From 4926e53409e10e0627724184b437df9463540353 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sat, 17 Jan 2026 20:03:29 +0200 Subject: [PATCH] feat: ldap group acls (#590) * wip * refactor: remove useless session struct abstraction * feat: retrieve and store groups from ldap provider * chore: fix merge issue * refactor: rework ldap group fetching logic * feat: store ldap group results in cache * fix: review nitpicks * fix: review feedback --- .zed/debug.json | 13 +++ Makefile | 5 + cmd/tinyauth/tinyauth.go | 11 +- frontend/src/pages/login-page.tsx | 6 +- internal/bootstrap/app_bootstrap.go | 14 ++- internal/bootstrap/service_bootstrap.go | 1 + internal/config/config.go | 41 +++---- internal/controller/context_controller.go | 2 - .../controller/context_controller_test.go | 7 +- internal/controller/oauth_controller.go | 5 +- internal/controller/proxy_controller.go | 22 +++- internal/controller/proxy_controller_test.go | 9 +- internal/controller/user_controller.go | 18 +-- internal/controller/user_controller_test.go | 6 +- internal/middleware/context_middleware.go | 54 +++++++-- internal/service/auth_service.go | 110 ++++++++++++++---- internal/service/ldap_service.go | 44 ++++++- sqlc.yml | 2 + 18 files changed, 280 insertions(+), 90 deletions(-) create mode 100644 .zed/debug.json diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 0000000..767b3a0 --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,13 @@ +[ + { + "label": "Attach to remote Delve", + "adapter": "Delve", + "mode": "remote", + "remotePath": "/tinyauth", + "request": "attach", + "tcp_connection": { + "host": "127.0.0.1", + "port": 4000, + }, + }, +] diff --git a/Makefile b/Makefile index a7d526e..ccf9735 100644 --- a/Makefile +++ b/Makefile @@ -62,3 +62,8 @@ develop: # Production prod: docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans + +# SQL +.PHONY: sql +sql: + sqlc generate diff --git a/cmd/tinyauth/tinyauth.go b/cmd/tinyauth/tinyauth.go index c1e652d..8293262 100644 --- a/cmd/tinyauth/tinyauth.go +++ b/cmd/tinyauth/tinyauth.go @@ -21,9 +21,9 @@ func NewTinyauthCmdConfiguration() *config.Config { Address: "0.0.0.0", }, Auth: config.AuthConfig{ - SessionExpiry: 3600, - SessionMaxLifetime: 0, - LoginTimeout: 300, + SessionExpiry: 86400, // 1 day + SessionMaxLifetime: 0, // disabled + LoginTimeout: 300, // 5 minutes LoginMaxRetries: 3, }, UI: config.UIConfig{ @@ -32,8 +32,9 @@ func NewTinyauthCmdConfiguration() *config.Config { BackgroundImage: "/background.jpg", }, Ldap: config.LdapConfig{ - Insecure: false, - SearchFilter: "(uid=%s)", + Insecure: false, + SearchFilter: "(uid=%s)", + GroupCacheTTL: 900, // 15 minutes }, Log: config.LogConfig{ Level: "info", diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index e2efdd6..962ce38 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -50,10 +50,12 @@ export const LoginPage = () => { const redirectUri = searchParams.get("redirect_uri"); const oauthProviders = providers.filter( - (provider) => provider.id !== "username", + (provider) => provider.id !== "local" && provider.id !== "ldap", ); const userAuthConfigured = - providers.find((provider) => provider.id === "username") !== undefined; + providers.find( + (provider) => provider.id === "local" || provider.id === "ldap", + ) !== undefined; const oauthMutation = useMutation({ mutationFn: (provider: string) => diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index 20355fc..f1c4b0b 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -144,10 +144,18 @@ func (app *BootstrapApp) Setup() error { return configuredProviders[i].Name < configuredProviders[j].Name }) - if services.authService.UserAuthConfigured() { + if services.authService.LocalAuthConfigured() { configuredProviders = append(configuredProviders, controller.Provider{ - Name: "Username", - ID: "username", + Name: "Local", + ID: "local", + OAuth: false, + }) + } + + if services.authService.LdapAuthConfigured() { + configuredProviders = append(configuredProviders, controller.Provider{ + Name: "LDAP", + ID: "ldap", OAuth: false, }) } diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index e68c2f2..b656f84 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -67,6 +67,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er LoginMaxRetries: app.config.Auth.LoginMaxRetries, SessionCookieName: app.context.sessionCookieName, IP: app.config.Auth.IP, + LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL, }, dockerService, services.ldapService, queries) err = authService.Init() diff --git a/internal/config/config.go b/internal/config/config.go index 62e06c9..907f046 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,14 +67,15 @@ type UIConfig struct { } type LdapConfig struct { - Address string `description:"LDAP server address." yaml:"address"` - BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` - BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` - BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` - Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` - SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` - AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"` - AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"` + Address string `description:"LDAP server address." yaml:"address"` + BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` + BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` + BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` + Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` + SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` + AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"` + AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"` + GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"` } type LogConfig struct { @@ -138,28 +139,22 @@ type User struct { TotpSecret string } +type LdapUser struct { + DN string + Groups []string +} + type UserSearch struct { Username string Type string // local, ldap or unknown } -type SessionCookie struct { - UUID string - Username string - Name string - Email string - Provider string - TotpPending bool - OAuthGroups string - OAuthName string - OAuthSub string -} - type UserContext struct { Username string Name string Email string IsLoggedIn bool + IsBasicAuth bool OAuth bool Provider string TotpPending bool @@ -167,6 +162,7 @@ type UserContext struct { TotpEnabled bool OAuthName string OAuthSub string + LdapGroups string } // API responses and queries @@ -195,6 +191,7 @@ type App struct { IP AppIP `description:"IP access configuration." yaml:"ip"` Response AppResponse `description:"Response customization." yaml:"response"` Path AppPath `description:"Path access configuration." yaml:"path"` + LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap"` } type AppConfig struct { @@ -211,6 +208,10 @@ type AppOAuth struct { Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"` } +type AppLDAP struct { + Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups"` +} + type AppIP struct { Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index 3c6f008..58651a1 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -21,7 +21,6 @@ type UserContextResponse struct { OAuth bool `json:"oauth"` TotpPending bool `json:"totpPending"` OAuthName string `json:"oauthName"` - OAuthSub string `json:"oauthSub"` } type AppContextResponse struct { @@ -90,7 +89,6 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { OAuth: context.OAuth, TotpPending: context.TotpPending, OAuthName: context.OAuthName, - OAuthSub: context.OAuthSub, } if err != nil { diff --git a/internal/controller/context_controller_test.go b/internal/controller/context_controller_test.go index 1a28e54..227705e 100644 --- a/internal/controller/context_controller_test.go +++ b/internal/controller/context_controller_test.go @@ -16,8 +16,8 @@ import ( var controllerCfg = controller.ContextControllerConfig{ Providers: []controller.Provider{ { - Name: "Username", - ID: "username", + Name: "Local", + ID: "local", OAuth: false, }, { @@ -40,8 +40,9 @@ var userContext = config.UserContext{ Name: "testuser", Email: "test@example.com", IsLoggedIn: true, + IsBasicAuth: false, OAuth: false, - Provider: "username", + Provider: "local", TotpPending: false, OAuthGroups: "", TotpEnabled: false, diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index 8dfd7a2..019bae7 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -7,6 +7,7 @@ import ( "time" "github.com/steveiliop56/tinyauth/internal/config" + "github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils/tlog" @@ -188,10 +189,10 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { username = user.PreferredUsername } else { tlog.App.Debug().Msg("No preferred username from OAuth provider, using pseudo username") - username = strings.Replace(user.Email, "@", "_", -1) + username = strings.Replace(user.Email, "@", "_", 1) } - sessionCookie := config.SessionCookie{ + sessionCookie := repository.Session{ Username: username, Name: name, Email: user.Email, diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index bb62712..bbb1946 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -173,7 +173,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { tlog.App.Trace().Interface("context", userContext).Msg("User context from request") - if userContext.Provider == "basic" && userContext.TotpEnabled { + if userContext.IsBasicAuth && userContext.TotpEnabled { tlog.App.Debug().Msg("User has TOTP enabled, denying basic auth access") userContext.IsLoggedIn = false } @@ -212,11 +212,17 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { return } - if userContext.OAuth { - groupOK := controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups) + if userContext.OAuth || userContext.Provider == "ldap" { + var groupOK bool + + if userContext.OAuth { + groupOK = controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups) + } else { + groupOK = controller.auth.IsInLdapGroup(c, userContext, acls.LDAP.Groups) + } if !groupOK { - tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements") + tlog.App.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User groups do not match resource requirements") if req.Proxy == "nginx" || !isBrowser { c.JSON(403, gin.H{ @@ -251,7 +257,13 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { c.Header("Remote-User", utils.SanitizeHeader(userContext.Username)) c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name)) c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) - c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) + + if userContext.Provider == "ldap" { + c.Header("Remote-Groups", utils.SanitizeHeader(userContext.LdapGroups)) + } else if userContext.Provider != "local" { + c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) + } + c.Header("Remote-Sub", utils.SanitizeHeader(userContext.OAuthSub)) controller.setHeaders(c, acls) diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index cc6a3e4..a5cbb5b 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -143,11 +143,11 @@ func TestProxyHandler(t *testing.T) { // Test logged in user c := gin.CreateTestContextOnly(recorder, router) - err := authService.CreateSessionCookie(c, &config.SessionCookie{ + err := authService.CreateSessionCookie(c, &repository.Session{ Username: "testuser", Name: "testuser", Email: "testuser@example.com", - Provider: "username", + Provider: "local", TotpPending: false, OAuthGroups: "", }) @@ -164,7 +164,7 @@ func TestProxyHandler(t *testing.T) { Email: "testuser@example.com", IsLoggedIn: true, OAuth: false, - Provider: "username", + Provider: "local", TotpPending: false, OAuthGroups: "", TotpEnabled: false, @@ -192,8 +192,9 @@ func TestProxyHandler(t *testing.T) { Name: "testuser", Email: "testuser@example.com", IsLoggedIn: true, + IsBasicAuth: true, OAuth: false, - Provider: "basic", + Provider: "local", TotpPending: false, OAuthGroups: "", TotpEnabled: true, diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 73478fa..35d60d3 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/steveiliop56/tinyauth/internal/config" + "github.com/steveiliop56/tinyauth/internal/repository" "github.com/steveiliop56/tinyauth/internal/service" "github.com/steveiliop56/tinyauth/internal/utils" "github.com/steveiliop56/tinyauth/internal/utils/tlog" @@ -112,11 +112,11 @@ func (controller *UserController) loginHandler(c *gin.Context) { if user.TotpSecret != "" { tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification") - err := controller.auth.CreateSessionCookie(c, &config.SessionCookie{ + err := controller.auth.CreateSessionCookie(c, &repository.Session{ Username: user.Username, Name: utils.Capitalize(req.Username), Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain), - Provider: "username", + Provider: "local", TotpPending: true, }) @@ -138,11 +138,15 @@ func (controller *UserController) loginHandler(c *gin.Context) { } } - sessionCookie := config.SessionCookie{ + sessionCookie := repository.Session{ Username: req.Username, Name: utils.Capitalize(req.Username), Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain), - Provider: "username", + Provider: "local", + } + + if userSearch.Type == "ldap" { + sessionCookie.Provider = "ldap" } tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") @@ -248,11 +252,11 @@ func (controller *UserController) totpHandler(c *gin.Context) { controller.auth.RecordLoginAttempt(context.Username, true) - sessionCookie := config.SessionCookie{ + sessionCookie := repository.Session{ Username: user.Username, Name: utils.Capitalize(user.Username), Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.config.CookieDomain), - Provider: "username", + Provider: "local", } tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go index ef10487..fedc98c 100644 --- a/internal/controller/user_controller_test.go +++ b/internal/controller/user_controller_test.go @@ -204,7 +204,7 @@ func TestTotpHandler(t *testing.T) { Email: "totpuser@example.com", IsLoggedIn: false, OAuth: false, - Provider: "username", + Provider: "local", TotpPending: true, OAuthGroups: "", TotpEnabled: true, @@ -267,7 +267,7 @@ func TestTotpHandler(t *testing.T) { Email: "totpuser@example.com", IsLoggedIn: false, OAuth: false, - Provider: "username", + Provider: "local", TotpPending: true, OAuthGroups: "", TotpEnabled: true, @@ -290,7 +290,7 @@ func TestTotpHandler(t *testing.T) { Email: "totpuser@example.com", IsLoggedIn: false, OAuth: false, - Provider: "username", + Provider: "local", TotpPending: false, OAuthGroups: "", TotpEnabled: false, diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 22749a7..4d392c8 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -49,7 +49,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { Username: cookie.Username, Name: cookie.Name, Email: cookie.Email, - Provider: "username", + Provider: "local", TotpPending: true, TotpEnabled: true, }) @@ -58,22 +58,44 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } switch cookie.Provider { - case "username": + case "local", "ldap": userSearch := m.auth.SearchUser(cookie.Username) - if userSearch.Type == "unknown" || userSearch.Type == "error" { + if userSearch.Type == "unknown" { tlog.App.Debug().Msg("User from session cookie not found") m.auth.DeleteSessionCookie(c) goto basic } + if userSearch.Type != cookie.Provider { + tlog.App.Warn().Msg("User type from session cookie does not match user search type") + m.auth.DeleteSessionCookie(c) + c.Next() + return + } + + var ldapGroups []string + + if cookie.Provider == "ldap" { + ldapUser, err := m.auth.GetLdapUser(userSearch.Username) + + if err != nil { + tlog.App.Error().Err(err).Msg("Error retrieving LDAP user details") + c.Next() + return + } + + ldapGroups = ldapUser.Groups + } + m.auth.RefreshSessionCookie(c) c.Set("context", &config.UserContext{ Username: cookie.Username, Name: cookie.Name, Email: cookie.Email, - Provider: "username", + Provider: cookie.Provider, IsLoggedIn: true, + LdapGroups: strings.Join(ldapGroups, ","), }) c.Next() return @@ -155,20 +177,32 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { Username: user.Username, Name: utils.Capitalize(user.Username), Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.config.CookieDomain), - Provider: "basic", + Provider: "local", IsLoggedIn: true, TotpEnabled: user.TotpSecret != "", + IsBasicAuth: true, }) c.Next() return case "ldap": tlog.App.Debug().Msg("Basic auth user is LDAP") + + ldapUser, err := m.auth.GetLdapUser(basic.Username) + + if err != nil { + tlog.App.Debug().Err(err).Msg("Error retrieving LDAP user details") + c.Next() + return + } + c.Set("context", &config.UserContext{ - Username: basic.Username, - Name: utils.Capitalize(basic.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.config.CookieDomain), - Provider: "basic", - IsLoggedIn: true, + Username: basic.Username, + Name: utils.Capitalize(basic.Username), + Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.config.CookieDomain), + Provider: "ldap", + IsLoggedIn: true, + LdapGroups: strings.Join(ldapUser.Groups, ","), + IsBasicAuth: true, }) c.Next() return diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index cddb613..0083993 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -19,6 +19,11 @@ import ( "golang.org/x/crypto/bcrypt" ) +type LdapGroupsCache struct { + Groups []string + Expires time.Time +} + type LoginAttempt struct { FailedAttempts int LastAttempt time.Time @@ -36,24 +41,28 @@ type AuthServiceConfig struct { LoginMaxRetries int SessionCookieName string IP config.IPConfig + LDAPGroupsCacheTTL int } type AuthService struct { - config AuthServiceConfig - docker *DockerService - loginAttempts map[string]*LoginAttempt - loginMutex sync.RWMutex - ldap *LdapService - queries *repository.Queries + config AuthServiceConfig + docker *DockerService + loginAttempts map[string]*LoginAttempt + ldapGroupsCache map[string]*LdapGroupsCache + loginMutex sync.RWMutex + ldapGroupsMutex sync.RWMutex + ldap *LdapService + queries *repository.Queries } func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries) *AuthService { return &AuthService{ - config: config, - docker: docker, - loginAttempts: make(map[string]*LoginAttempt), - ldap: ldap, - queries: queries, + config: config, + docker: docker, + loginAttempts: make(map[string]*LoginAttempt), + ldapGroupsCache: make(map[string]*LdapGroupsCache), + ldap: ldap, + queries: queries, } } @@ -70,12 +79,12 @@ func (auth *AuthService) SearchUser(username string) config.UserSearch { } if auth.ldap != nil { - userDN, err := auth.ldap.Search(username) + userDN, err := auth.ldap.GetUserDN(username) if err != nil { tlog.App.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP") return config.UserSearch{ - Type: "error", + Type: "unknown", } } @@ -131,6 +140,41 @@ func (auth *AuthService) GetLocalUser(username string) config.User { return config.User{} } +func (auth *AuthService) GetLdapUser(userDN string) (config.LdapUser, error) { + if auth.ldap == nil { + return config.LdapUser{}, errors.New("LDAP service not initialized") + } + + auth.ldapGroupsMutex.RLock() + entry, exists := auth.ldapGroupsCache[userDN] + auth.ldapGroupsMutex.RUnlock() + + if exists && time.Now().Before(entry.Expires) { + return config.LdapUser{ + DN: userDN, + Groups: entry.Groups, + }, nil + } + + groups, err := auth.ldap.GetUserGroups(userDN) + + if err != nil { + return config.LdapUser{}, err + } + + auth.ldapGroupsMutex.Lock() + auth.ldapGroupsCache[userDN] = &LdapGroupsCache{ + Groups: groups, + Expires: time.Now().Add(time.Duration(auth.config.LDAPGroupsCacheTTL) * time.Second), + } + auth.ldapGroupsMutex.Unlock() + + return config.LdapUser{ + DN: userDN, + Groups: groups, + }, nil +} + func (auth *AuthService) CheckPassword(user config.User, password string) bool { return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil } @@ -190,7 +234,7 @@ func (auth *AuthService) IsEmailWhitelisted(email string) bool { return utils.CheckFilter(strings.Join(auth.config.OauthWhitelist, ","), email) } -func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.SessionCookie) error { +func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *repository.Session) error { uuid, err := uuid.NewRandom() if err != nil { @@ -300,20 +344,20 @@ func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error { return nil } -func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, error) { +func (auth *AuthService) GetSessionCookie(c *gin.Context) (repository.Session, error) { cookie, err := c.Cookie(auth.config.SessionCookieName) if err != nil { - return config.SessionCookie{}, err + return repository.Session{}, err } session, err := auth.queries.GetSession(c, cookie) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return config.SessionCookie{}, fmt.Errorf("session not found") + return repository.Session{}, fmt.Errorf("session not found") } - return config.SessionCookie{}, err + return repository.Session{}, err } currentTime := time.Now().Unix() @@ -324,7 +368,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, if err != nil { tlog.App.Error().Err(err).Msg("Failed to delete session exceeding max lifetime") } - return config.SessionCookie{}, fmt.Errorf("session expired due to max lifetime exceeded") + return repository.Session{}, fmt.Errorf("session expired due to max lifetime exceeded") } } @@ -333,10 +377,10 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, if err != nil { tlog.App.Error().Err(err).Msg("Failed to delete expired session") } - return config.SessionCookie{}, fmt.Errorf("session expired") + return repository.Session{}, fmt.Errorf("session expired") } - return config.SessionCookie{ + return repository.Session{ UUID: session.UUID, Username: session.Username, Email: session.Email, @@ -349,8 +393,12 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, }, nil } -func (auth *AuthService) UserAuthConfigured() bool { - return len(auth.config.Users) > 0 || auth.ldap != nil +func (auth *AuthService) LocalAuthConfigured() bool { + return len(auth.config.Users) > 0 +} + +func (auth *AuthService) LdapAuthConfigured() bool { + return auth.ldap != nil } func (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool { @@ -393,6 +441,22 @@ func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserConte return false } +func (auth *AuthService) IsInLdapGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool { + if requiredGroups == "" { + return true + } + + for userGroup := range strings.SplitSeq(context.LdapGroups, ",") { + if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) { + tlog.App.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched") + return true + } + } + + tlog.App.Debug().Msg("No groups matched") + return false +} + func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) { // Check for block list if path.Block != "" { diff --git a/internal/service/ldap_service.go b/internal/service/ldap_service.go index fb03778..d1856da 100644 --- a/internal/service/ldap_service.go +++ b/internal/service/ldap_service.go @@ -116,7 +116,7 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) { return ldap.conn, nil } -func (ldap *LdapService) Search(username string) (string, error) { +func (ldap *LdapService) GetUserDN(username string) (string, error) { // Escape the username to prevent LDAP injection escapedUsername := ldapgo.EscapeFilter(username) filter := fmt.Sprintf(ldap.config.SearchFilter, escapedUsername) @@ -145,6 +145,48 @@ func (ldap *LdapService) Search(username string) (string, error) { return userDN, nil } +func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) { + escapedUserDN := ldapgo.EscapeFilter(userDN) + + searchRequest := ldapgo.NewSearchRequest( + ldap.config.BaseDN, + ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectclass=groupOfUniqueNames)(uniquemember=%s))", escapedUserDN), + []string{"dn"}, + nil, + ) + + ldap.mutex.Lock() + defer ldap.mutex.Unlock() + + searchResult, err := ldap.conn.Search(searchRequest) + if err != nil { + return []string{}, err + } + + groupDNs := []string{} + + for _, entry := range searchResult.Entries { + groupDNs = append(groupDNs, entry.DN) + } + + groups := []string{} + + // I guess it should work for most ldap providers + for _, dn := range groupDNs { + rdnParts, err := ldapgo.ParseDN(dn) + if err != nil { + return []string{}, err + } + if len(rdnParts.RDNs) == 0 || len(rdnParts.RDNs[0].Attributes) == 0 { + return []string{}, fmt.Errorf("invalid DN format: %s", dn) + } + groups = append(groups, rdnParts.RDNs[0].Attributes[0].Value) + } + + return groups, nil +} + func (ldap *LdapService) BindService(rebind bool) error { // Locks must not be used for initial binding attempt if rebind { diff --git a/sqlc.yml b/sqlc.yml index 77b3a71..b9cf1ea 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -19,3 +19,5 @@ sql: go_type: "string" - column: "sessions.oauth_sub" go_type: "string" + - column: "sessions.ldap_groups" + go_type: "string"