feat: store ldap group results in cache

This commit is contained in:
Stavros
2026-01-16 17:50:04 +02:00
parent 4fc18325a0
commit 95ed36db8b
6 changed files with 77 additions and 41 deletions

View File

@@ -21,9 +21,9 @@ func NewTinyauthCmdConfiguration() *config.Config {
Address: "0.0.0.0", Address: "0.0.0.0",
}, },
Auth: config.AuthConfig{ Auth: config.AuthConfig{
SessionExpiry: 3600, SessionExpiry: 86400, // 1 day
SessionMaxLifetime: 0, SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, LoginTimeout: 300, // 5 minutes
LoginMaxRetries: 3, LoginMaxRetries: 3,
}, },
UI: config.UIConfig{ UI: config.UIConfig{
@@ -32,8 +32,9 @@ func NewTinyauthCmdConfiguration() *config.Config {
BackgroundImage: "/background.jpg", BackgroundImage: "/background.jpg",
}, },
Ldap: config.LdapConfig{ Ldap: config.LdapConfig{
Insecure: false, Insecure: false,
SearchFilter: "(uid=%s)", SearchFilter: "(uid=%s)",
GroupCacheTTL: 900, // 15 minutes
}, },
Log: config.LogConfig{ Log: config.LogConfig{
Level: "info", Level: "info",

View File

@@ -67,6 +67,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
LoginMaxRetries: app.config.Auth.LoginMaxRetries, LoginMaxRetries: app.config.Auth.LoginMaxRetries,
SessionCookieName: app.context.sessionCookieName, SessionCookieName: app.context.sessionCookieName,
IP: app.config.Auth.IP, IP: app.config.Auth.IP,
LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL,
}, dockerService, services.ldapService, queries) }, dockerService, services.ldapService, queries)
err = authService.Init() err = authService.Init()

View File

@@ -67,14 +67,15 @@ type UIConfig struct {
} }
type LdapConfig struct { type LdapConfig struct {
Address string `description:"LDAP server address." yaml:"address"` Address string `description:"LDAP server address." yaml:"address"`
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"` AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"`
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"` 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 { type LogConfig struct {

View File

@@ -67,9 +67,16 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
goto basic 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 var ldapGroups []string
if userSearch.Type == "ldap" { if cookie.Provider == "ldap" {
ldapUser, err := m.auth.GetLdapUser(userSearch.Username) ldapUser, err := m.auth.GetLdapUser(userSearch.Username)
if err != nil { if err != nil {
@@ -86,7 +93,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
Username: cookie.Username, Username: cookie.Username,
Name: cookie.Name, Name: cookie.Name,
Email: cookie.Email, Email: cookie.Email,
Provider: userSearch.Type, Provider: cookie.Provider,
IsLoggedIn: true, IsLoggedIn: true,
LdapGroups: strings.Join(ldapGroups, ","), LdapGroups: strings.Join(ldapGroups, ","),
}) })

View File

@@ -19,6 +19,11 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
type LdapGroupsCache struct {
Groups []string
Expires time.Time
}
type LoginAttempt struct { type LoginAttempt struct {
FailedAttempts int FailedAttempts int
LastAttempt time.Time LastAttempt time.Time
@@ -36,24 +41,28 @@ type AuthServiceConfig struct {
LoginMaxRetries int LoginMaxRetries int
SessionCookieName string SessionCookieName string
IP config.IPConfig IP config.IPConfig
LDAPGroupsCacheTTL int
} }
type AuthService struct { type AuthService struct {
config AuthServiceConfig config AuthServiceConfig
docker *DockerService docker *DockerService
loginAttempts map[string]*LoginAttempt loginAttempts map[string]*LoginAttempt
loginMutex sync.RWMutex ldapGroupsCache map[string]*LdapGroupsCache
ldap *LdapService loginMutex sync.RWMutex
queries *repository.Queries ldapGroupsMutex sync.RWMutex
ldap *LdapService
queries *repository.Queries
} }
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries) *AuthService { func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries) *AuthService {
return &AuthService{ return &AuthService{
config: config, config: config,
docker: docker, docker: docker,
loginAttempts: make(map[string]*LoginAttempt), loginAttempts: make(map[string]*LoginAttempt),
ldap: ldap, ldapGroupsCache: make(map[string]*LdapGroupsCache),
queries: queries, 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) { 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) groups, err := auth.ldap.GetUserGroups(userDN)
if err != nil { if err != nil {
return config.LdapUser{}, err 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{ return config.LdapUser{
DN: userDN, DN: userDN,
Groups: groups, Groups: groups,

View File

@@ -4,8 +4,6 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"slices"
"strings"
"sync" "sync"
"time" "time"
@@ -148,11 +146,13 @@ func (ldap *LdapService) GetUserDN(username string) (string, error) {
} }
func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) { func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {
escapedUserDN := ldapgo.EscapeFilter(userDN)
searchRequest := ldapgo.NewSearchRequest( searchRequest := ldapgo.NewSearchRequest(
ldap.config.BaseDN, ldap.config.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false, ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
"(objectclass=groupOfUniqueNames)", fmt.Sprintf("(&(objectclass=groupOfUniqueNames)(uniquemember=%s))", escapedUserDN),
[]string{"uniquemember"}, []string{"dn"},
nil, nil,
) )
@@ -167,22 +167,21 @@ func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {
groupDNs := []string{} groupDNs := []string{}
for _, entry := range searchResult.Entries { for _, entry := range searchResult.Entries {
memberAttributes := entry.GetAttributeValues("uniquemember") groupDNs = append(groupDNs, entry.DN)
// 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)
}
} }
// Should work for most ldap providers?
groups := []string{} groups := []string{}
for _, groupDN := range groupDNs { // I guess it should work for most ldap providers
groupDN = strings.TrimPrefix(groupDN, "cn=") for _, dn := range groupDNs {
parts := strings.SplitN(groupDN, ",", 2) rdnParts, err := ldapgo.ParseDN(dn)
if len(parts) > 0 { if err != nil {
groups = append(groups, parts[0]) 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 return groups, nil