From 95ed36db8b4aaf140b1ed444ec0d2967ef3e65cb Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 16 Jan 2026 17:50:04 +0200 Subject: [PATCH] feat: store ldap group results in cache --- cmd/tinyauth/tinyauth.go | 11 ++--- internal/bootstrap/service_bootstrap.go | 1 + internal/config/config.go | 17 ++++---- internal/middleware/context_middleware.go | 11 ++++- internal/service/auth_service.go | 49 ++++++++++++++++++----- internal/service/ldap_service.go | 29 +++++++------- 6 files changed, 77 insertions(+), 41 deletions(-) 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/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 9ab4a00..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 { diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index eb56498..4d392c8 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -67,9 +67,16 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { 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 userSearch.Type == "ldap" { + if cookie.Provider == "ldap" { ldapUser, err := m.auth.GetLdapUser(userSearch.Username) if err != nil { @@ -86,7 +93,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { Username: cookie.Username, Name: cookie.Name, Email: cookie.Email, - Provider: userSearch.Type, + Provider: cookie.Provider, IsLoggedIn: true, LdapGroups: strings.Join(ldapGroups, ","), }) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 54de520..a6391cd 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, } } @@ -132,12 +141,30 @@ func (auth *AuthService) GetLocalUser(username string) config.User { } func (auth *AuthService) GetLdapUser(userDN string) (config.LdapUser, error) { + auth.ldapGroupsMutex.Lock() + entry, exists := auth.ldapGroupsCache[userDN] + auth.ldapGroupsMutex.Unlock() + + 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, diff --git a/internal/service/ldap_service.go b/internal/service/ldap_service.go index 57e7144..d1856da 100644 --- a/internal/service/ldap_service.go +++ b/internal/service/ldap_service.go @@ -4,8 +4,6 @@ import ( "context" "crypto/tls" "fmt" - "slices" - "strings" "sync" "time" @@ -148,11 +146,13 @@ func (ldap *LdapService) GetUserDN(username string) (string, error) { } 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, - "(objectclass=groupOfUniqueNames)", - []string{"uniquemember"}, + fmt.Sprintf("(&(objectclass=groupOfUniqueNames)(uniquemember=%s))", escapedUserDN), + []string{"dn"}, nil, ) @@ -167,22 +167,21 @@ func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) { groupDNs := []string{} for _, entry := range searchResult.Entries { - memberAttributes := entry.GetAttributeValues("uniquemember") - // no need to escape username here, if it's malicious it won't match anything - if slices.Contains(memberAttributes, userDN) { - groupDNs = append(groupDNs, entry.DN) - } + groupDNs = append(groupDNs, entry.DN) } - // Should work for most ldap providers? groups := []string{} - for _, groupDN := range groupDNs { - groupDN = strings.TrimPrefix(groupDN, "cn=") - parts := strings.SplitN(groupDN, ",", 2) - if len(parts) > 0 { - groups = append(groups, parts[0]) + // 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