diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index ef3ee591..f3ffc11b 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -45,7 +45,7 @@ func (app *BootstrapApp) setupServices() error { labelProvider = dockerService } - accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps) + accessControlsService := service.NewAccessControlsService(app.log, app.config, &labelProvider) app.services.accessControlService = accessControlsService oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 40969b83..9effa70a 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -101,7 +101,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { clientIP := c.ClientIP() - if controller.auth.IsBypassedIP(clientIP, acls) { + if controller.acls.IsIPBypassed(clientIP, acls) { controller.setHeaders(c, acls) c.JSON(200, gin.H{ "status": 200, @@ -110,13 +110,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls) - - if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource") - controller.handleError(c, proxyCtx) - return - } + authEnabled := controller.acls.IsAuthEnabled(proxyCtx.Path, acls) if !authEnabled { controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication") @@ -128,7 +122,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - if !controller.auth.CheckIP(clientIP, acls) { + if !controller.acls.IsIPAllowed(clientIP, acls) { queries, err := query.Values(UnauthorizedQuery{ Resource: strings.Split(proxyCtx.Host, ".")[0], IP: clientIP, @@ -165,7 +159,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { } if userContext.Authenticated { - userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls) + userAllowed := controller.acls.IsUserAllowed(*userContext, acls) if !userAllowed { controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource") @@ -205,9 +199,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { var groupOK bool if userContext.IsOAuth() { - groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls) + groupOK = controller.acls.IsInOAuthGroup(*userContext, acls) } else { - groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls) + groupOK = controller.acls.IsInLDAPGroup(*userContext, acls) } if !groupOK { diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index 12c3c9f1..220a4569 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -24,33 +24,6 @@ func TestProxyController(t *testing.T) { cfg, runtime := test.CreateTestConfigs(t) - acls := map[string]model.App{ - "app_path_allow": { - Config: model.AppConfig{ - Domain: "path-allow.example.com", - }, - Path: model.AppPath{ - Allow: "/allowed", - }, - }, - "app_user_allow": { - Config: model.AppConfig{ - Domain: "user-allow.example.com", - }, - Users: model.AppUsers{ - Allow: "testuser", - }, - }, - "ip_bypass": { - Config: model.AppConfig{ - Domain: "ip-bypass.example.com", - }, - IP: model.AppIP{ - Bypass: []string{"10.10.10.10"}, - }, - }, - } - const browserUserAgent = ` Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36` @@ -391,7 +364,7 @@ func TestProxyController(t *testing.T) { broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx) authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker) - aclsService := service.NewAccessControlsService(log, nil, acls) + aclsService := service.NewAccessControlsService(log, cfg, nil) for _, test := range tests { t.Run(test.description, func(t *testing.T) { diff --git a/internal/model/config.go b/internal/model/config.go index f5376af2..1379a29a 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -24,6 +24,9 @@ func NewDefaultConfiguration() *Config { SessionMaxLifetime: 0, // disabled LoginTimeout: 300, // 5 minutes LoginMaxRetries: 3, + ACLS: ACLSConfig{ + Policy: "allow", + }, }, UI: UIConfig{ Title: "Tinyauth", @@ -114,6 +117,7 @@ type AuthConfig struct { LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` + ACLS ACLSConfig `description:"ACLs configuration." yaml:"acls"` } type UserAttributes struct { @@ -223,6 +227,10 @@ type OIDCClientConfig struct { Name string `description:"Client name in UI." yaml:"name"` } +type ACLSConfig struct { + Policy string `description:"ACL policy for allow-by-default or deny-by-defaut, available options are allow and deny default is allow." yaml:"policy"` +} + // ACLs type Apps struct { diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 34700ea7..992ed85a 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -1,44 +1,83 @@ package service import ( + "regexp" "strings" "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils/logger" ) +type AccessControlPolicy string + +const ( + PolicyAllow AccessControlPolicy = "allow" + PolicyBlock AccessControlPolicy = "block" +) + +func accessControlPolicyFromString(s string) (AccessControlPolicy, bool) { + switch strings.ToLower(s) { + case "allow": + return PolicyAllow, true + case "block": + return PolicyBlock, true + default: + return "", false + } +} + type LabelProvider interface { GetLabels(appDomain string) (*model.App, error) } type AccessControlsService struct { log *logger.Logger + config model.Config labelProvider *LabelProvider - static map[string]model.App + policy AccessControlPolicy } func NewAccessControlsService( log *logger.Logger, - labelProvider *LabelProvider, - static map[string]model.App) *AccessControlsService { - return &AccessControlsService{ + config model.Config, + labelProvider *LabelProvider) *AccessControlsService { + + service := AccessControlsService{ log: log, + config: config, labelProvider: labelProvider, - static: static, } + + policy, ok := accessControlPolicyFromString(config.Auth.ACLS.Policy) + + if !ok { + log.App.Warn().Str("policy", config.Auth.ACLS.Policy).Msg("Invalid ACL policy in config, defaulting to 'allow'") + service.policy = PolicyAllow + } + + if policy == PolicyAllow { + log.App.Debug().Msg("Using 'allow' ACL policy: access to apps will be allowed by default unless explicitly blocked") + } else { + log.App.Debug().Msg("Using 'block' ACL policy: access to apps will be blocked by default unless explicitly allowed") + } + + service.policy = policy + + return &service } -func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App { +func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App { var appAcls *model.App - for app, config := range acls.static { + for app, config := range service.config.Apps { if config.Config.Domain == domain { - acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain") + service.log.App.Debug().Str("name", app).Msg("Found matching container by domain") appAcls = &config break // If we find a match by domain, we can stop searching } if strings.SplitN(domain, ".", 2)[0] == app { - acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name") + service.log.App.Debug().Str("name", app).Msg("Found matching container by app name") appAcls = &config break // If we find a match by app name, we can stop searching } @@ -46,20 +85,193 @@ func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App { return appAcls } -func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { +func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { // First check in the static config - app := acls.lookupStaticACLs(domain) + app := service.lookupStaticACLs(domain) if app != nil { - acls.log.App.Debug().Msg("Using static ACLs for app") + service.log.App.Debug().Msg("Using static ACLs for app") return app, nil } // If we have a label provider configured, try to get ACLs from it - if acls.labelProvider != nil { - return (*acls.labelProvider).GetLabels(domain) + if service.labelProvider != nil { + return (*service.labelProvider).GetLabels(domain) } // no labels return nil, nil } + +func (service *AccessControlsService) IsUserAllowed(context model.UserContext, acls *model.App) bool { + if acls == nil { + return service.policyResult(true) + } + + if context.Provider == model.ProviderOAuth { + service.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist") + return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email) + } + + if acls.Users.Block != "" { + service.log.App.Debug().Msg("Checking users block list") + if utils.CheckFilter(acls.Users.Block, context.GetUsername()) { + return false + } + } + + service.log.App.Debug().Msg("Checking users allow list") + return utils.CheckFilter(acls.Users.Allow, context.GetUsername()) +} + +func (service *AccessControlsService) IsInOAuthGroup(context model.UserContext, acls *model.App) bool { + if acls == nil { + return true + } + + if !context.IsOAuth() { + service.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check") + return false + } + + if _, ok := model.OverrideProviders[context.OAuth.ID]; ok { + service.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check") + return true + } + + for _, userGroup := range context.OAuth.Groups { + if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) { + service.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched") + return true + } + } + + service.log.App.Debug().Msg("No groups matched") + return false +} + +func (service *AccessControlsService) IsInLDAPGroup(context model.UserContext, acls *model.App) bool { + if acls == nil { + return true + } + + if !context.IsLDAP() { + service.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check") + return false + } + + for _, userGroup := range context.LDAP.Groups { + if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) { + service.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched") + return true + } + } + + service.log.App.Debug().Msg("No groups matched") + return false +} + +func (service *AccessControlsService) IsAuthEnabled(uri string, acls *model.App) bool { + if acls == nil { + return true + } + + if acls.Path.Block != "" { + regex, err := regexp.Compile(acls.Path.Block) + + if err != nil { + service.log.App.Error().Err(err).Msg("Failed to compile block regex") + return true + } + + if !regex.MatchString(uri) { + return false + } + } + + if acls.Path.Allow != "" { + regex, err := regexp.Compile(acls.Path.Allow) + + if err != nil { + service.log.App.Error().Err(err).Msg("Failed to compile allow regex") + return true + } + + if regex.MatchString(uri) { + return false + } + } + + return true +} + +func (service *AccessControlsService) IsIPAllowed(ip string, acls *model.App) bool { + if acls == nil { + return service.policyResult(true) + } + + // Merge the global and app IP filter + blockedIps := append(service.config.Auth.IP.Block, acls.IP.Block...) + allowedIPs := append(service.config.Auth.IP.Allow, acls.IP.Allow...) + + for _, blocked := range blockedIps { + res, err := utils.FilterIP(blocked, ip) + if err != nil { + service.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") + continue + } + if res { + service.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access") + return false + } + } + + for _, allowed := range allowedIPs { + res, err := utils.FilterIP(allowed, ip) + if err != nil { + service.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") + continue + } + if res { + service.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access") + return true + } + } + + if len(allowedIPs) > 0 { + service.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access") + return false + } + + service.log.App.Debug().Str("ip", ip).Msg("IP not in block or allow list, allowing access") + return service.policyResult(true) +} + +func (service *AccessControlsService) IsIPBypassed(ip string, acls *model.App) bool { + if acls == nil { + return false + } + + for _, bypassed := range acls.IP.Bypass { + res, err := utils.FilterIP(bypassed, ip) + if err != nil { + service.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") + continue + } + if res { + service.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication") + return true + } + } + + service.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication") + return false +} + +func (service *AccessControlsService) policyResult(result bool) bool { + if service.policy == PolicyAllow { + return result + } else { + return !result + } +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index a721aa2b..99115df7 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "net/http" - "regexp" "strings" "sync" "time" @@ -18,7 +17,6 @@ import ( "slices" - "github.com/gin-gonic/gin" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" @@ -454,171 +452,6 @@ func (auth *AuthService) LDAPAuthConfigured() bool { return auth.ldap != nil } -func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool { - if acls == nil { - return true - } - - if context.Provider == model.ProviderOAuth { - auth.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist") - return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email) - } - - if acls.Users.Block != "" { - auth.log.App.Debug().Msg("Checking users block list") - if utils.CheckFilter(acls.Users.Block, context.GetUsername()) { - return false - } - } - - auth.log.App.Debug().Msg("Checking users allow list") - return utils.CheckFilter(acls.Users.Allow, context.GetUsername()) -} - -func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, acls *model.App) bool { - if acls == nil { - return true - } - - if !context.IsOAuth() { - auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check") - return false - } - - if _, ok := model.OverrideProviders[context.OAuth.ID]; ok { - auth.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check") - return true - } - - for _, userGroup := range context.OAuth.Groups { - if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) { - auth.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched") - return true - } - } - - auth.log.App.Debug().Msg("No groups matched") - return false -} - -func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext, acls *model.App) bool { - if acls == nil { - return true - } - - if !context.IsLDAP() { - auth.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check") - return false - } - - for _, userGroup := range context.LDAP.Groups { - if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) { - auth.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched") - return true - } - } - - auth.log.App.Debug().Msg("No groups matched") - return false -} - -func (auth *AuthService) IsAuthEnabled(uri string, acls *model.App) (bool, error) { - if acls == nil { - return true, nil - } - - // Check for block list - if acls.Path.Block != "" { - regex, err := regexp.Compile(acls.Path.Block) - - if err != nil { - return true, err - } - - if !regex.MatchString(uri) { - return false, nil - } - } - - // Check for allow list - if acls.Path.Allow != "" { - regex, err := regexp.Compile(acls.Path.Allow) - - if err != nil { - return true, err - } - - if regex.MatchString(uri) { - return false, nil - } - } - - return true, nil -} - -func (auth *AuthService) CheckIP(ip string, acls *model.App) bool { - if acls == nil { - return true - } - - // Merge the global and app IP filter - blockedIps := append(auth.config.Auth.IP.Block, acls.IP.Block...) - allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...) - - for _, blocked := range blockedIps { - res, err := utils.FilterIP(blocked, ip) - if err != nil { - auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") - continue - } - if res { - auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access") - return false - } - } - - for _, allowed := range allowedIPs { - res, err := utils.FilterIP(allowed, ip) - if err != nil { - auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") - continue - } - if res { - auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access") - return true - } - } - - if len(allowedIPs) > 0 { - auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access") - return false - } - - auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default") - return true -} - -func (auth *AuthService) IsBypassedIP(ip string, acls *model.App) bool { - if acls == nil { - return false - } - - for _, bypassed := range acls.IP.Bypass { - res, err := utils.FilterIP(bypassed, ip) - if err != nil { - auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") - continue - } - if res { - auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication") - return true - } - } - - auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication") - return false -} - func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) { auth.ensureOAuthSessionLimit() diff --git a/internal/test/test.go b/internal/test/test.go index 73ff5d38..51b482ff 100644 --- a/internal/test/test.go +++ b/internal/test/test.go @@ -48,6 +48,32 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) { Enabled: true, Path: filepath.Join(tempDir, "resources"), }, + Apps: map[string]model.App{ + "app_path_allow": { + Config: model.AppConfig{ + Domain: "path-allow.example.com", + }, + Path: model.AppPath{ + Allow: "/allowed", + }, + }, + "app_user_allow": { + Config: model.AppConfig{ + Domain: "user-allow.example.com", + }, + Users: model.AppUsers{ + Allow: "testuser", + }, + }, + "ip_bypass": { + Config: model.AppConfig{ + Domain: "ip-bypass.example.com", + }, + IP: model.AppIP{ + Bypass: []string{"10.10.10.10"}, + }, + }, + }, } passwd, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)