From 152d11e7a7fec1707810daf0e1c3dd0c1effc7d1 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 16 Oct 2026 23:01:50 +0300 Subject: [PATCH 1/6] refactor: use global lock for rate limit ops --- internal/controller/user_controller.go | 178 ++++++++++++---------- internal/middleware/context_middleware.go | 53 ++++--- internal/service/auth_service.go | 73 +++++---- 3 files changed, 177 insertions(+), 127 deletions(-) diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 65b12de0..479f8f05 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -11,6 +11,7 @@ import ( "github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils/logger" + "github.com/tinyauthapp/tinyauth/pkg/cache" "go.uber.org/dig" "github.com/gin-gonic/gin" @@ -72,49 +73,59 @@ func (controller *UserController) loginHandler(c *gin.Context) { controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt") - search, err := controller.auth.SearchUser(req.Username) + var search *model.UserSearch - if err != nil { - if errors.Is(err, service.ErrUserNotFound) { - controller.auth.DummyPasswordCheck(req.Password) - controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt") - controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found") + err = controller.auth.WithLoginAttempt(func(actions cache.CacheStoreActions[service.LoginAttempt]) error { + search, err = controller.auth.SearchUser(req.Username) + + if err != nil { + if errors.Is(err, service.ErrUserNotFound) { + controller.auth.DummyPasswordCheck(req.Password) + controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt") + controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return fmt.Errorf("user not found") + } + controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return fmt.Errorf("error searching for user") + } + + isLocked, remaining := controller.auth.IsAccountLocked(req.Username, actions) + + if isLocked { + controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts") + controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "account locked") + c.Writer.Header().Add("x-tinyauth-lock-locked", "true") + c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) + c.JSON(429, gin.H{ + "status": 429, + "message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remaining), + }) + return fmt.Errorf("account locked") + } + + if err = controller.auth.CheckUserPassword(*search, req.Password); err != nil { + controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt") + controller.auth.RecordLoginAttempt(req.Username, false, actions) + controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "invalid password") c.JSON(401, gin.H{ "status": 401, "message": "Unauthorized", }) - return + return fmt.Errorf("invalid password") } - controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt") - c.JSON(500, gin.H{ - "status": 500, - "message": "Internal Server Error", - }) - return - } - - isLocked, remaining := controller.auth.IsAccountLocked(req.Username) - if isLocked { - controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts") - controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "account locked") - c.Writer.Header().Add("x-tinyauth-lock-locked", "true") - c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) - c.JSON(429, gin.H{ - "status": 429, - "message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remaining), - }) - return - } + return nil + }) - if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil { - controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt") - controller.auth.RecordLoginAttempt(req.Username, false) - controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "invalid password") - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) + if err != nil { return } @@ -216,7 +227,10 @@ func (controller *UserController) loginHandler(c *gin.Context) { controller.log.AuditLoginSuccess(req.Username, search.Type.String(), c.ClientIP()) - controller.auth.RecordLoginAttempt(req.Username, true) + controller.auth.WithLoginAttempt(func(actions cache.CacheStoreActions[service.LoginAttempt]) error { + controller.auth.RecordLoginAttempt(req.Username, true, actions) + return nil + }) c.JSON(200, gin.H{ "status": 200, @@ -317,56 +331,66 @@ func (controller *UserController) totpHandler(c *gin.Context) { controller.log.App.Debug().Str("username", context.GetUsername()).Msg("TOTP verification attempt") - isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername()) + var user *model.LocalUser - if isLocked { - controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Account is locked due to too many failed TOTP attempts") - controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "account locked") - c.Writer.Header().Add("x-tinyauth-lock-locked", "true") - c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) - c.JSON(429, gin.H{ - "status": 429, - "message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remaining), - }) - return - } + err = controller.auth.WithLoginAttempt(func(actions cache.CacheStoreActions[service.LoginAttempt]) error { + isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername(), actions) - user := controller.auth.GetLocalUser(context.GetUsername()) + if isLocked { + controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Account is locked due to too many failed TOTP attempts") + controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "account locked") + c.Writer.Header().Add("x-tinyauth-lock-locked", "true") + c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) + c.JSON(429, gin.H{ + "status": 429, + "message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remaining), + }) + return fmt.Errorf("account locked") + } - if user == nil { - controller.log.App.Error().Str("username", context.GetUsername()).Msg("Local user not found during TOTP verification") - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } + user = controller.auth.GetLocalUser(context.GetUsername()) - ok := totp.Validate(req.Code, user.TOTPSecret) + if user == nil { + controller.log.App.Error().Str("username", context.GetUsername()).Msg("Local user not found during TOTP verification") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return fmt.Errorf("local user not found") + } - if !ok { - controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code during verification attempt") - controller.auth.RecordLoginAttempt(context.GetUsername(), false) - controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "invalid TOTP code") - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } + ok := totp.Validate(req.Code, user.TOTPSecret) - uuid, err := c.Cookie(controller.runtime.SessionCookieName) + if !ok { + controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code during verification attempt") + controller.auth.RecordLoginAttempt(context.GetUsername(), false, actions) + controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "invalid TOTP code") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return fmt.Errorf("invalid TOTP code") + } - if err == nil { - _, err = controller.auth.DeleteSession(c, uuid) - if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification") + uuid, err := c.Cookie(controller.runtime.SessionCookieName) + + if err == nil { + _, err = controller.auth.DeleteSession(c, uuid) + if err != nil { + controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification") + } + } else { + controller.log.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, cannot delete it") } - } else { - controller.log.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, cannot delete it") - } - controller.auth.RecordLoginAttempt(context.GetUsername(), true) + controller.auth.RecordLoginAttempt(context.GetUsername(), true, actions) + + return nil + }) + + if err != nil { + return + } sessionCookie := repository.Session{ Username: user.Username, diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 2cbb046e..6b920be4 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -12,6 +12,7 @@ import ( "github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils/logger" + "github.com/tinyauthapp/tinyauth/pkg/cache" "go.uber.org/dig" "github.com/gin-gonic/gin" @@ -253,34 +254,50 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri // any response headers to set. func (m *ContextMiddleware) basicAuth(username string, password string) (*model.UserContext, map[string]string, error) { headers := make(map[string]string) + errLocked := errors.New("account locked") userContext := new(model.UserContext) - locked, remaining := m.auth.IsAccountLocked(username) - if locked { - m.log.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", username, remaining) - headers["x-tinyauth-lock-locked"] = "true" - headers["x-tinyauth-lock-reset"] = time.Now().Add(time.Duration(remaining) * time.Second).Format(time.RFC3339) - return nil, headers, nil - } + var search *model.UserSearch - search, err := m.auth.SearchUser(username) + err := m.auth.WithLoginAttempt(func(actions cache.CacheStoreActions[service.LoginAttempt]) error { + locked, remaining := m.auth.IsAccountLocked(username, actions) - if err != nil { - if errors.Is(err, service.ErrUserNotFound) { - m.auth.DummyPasswordCheck(password) + if locked { + m.log.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", username, remaining) + headers["x-tinyauth-lock-locked"] = "true" + headers["x-tinyauth-lock-reset"] = time.Now().Add(time.Duration(remaining) * time.Second).Format(time.RFC3339) + return errLocked + } + + var err error + search, err = m.auth.SearchUser(username) + + if err != nil { + if errors.Is(err, service.ErrUserNotFound) { + m.auth.DummyPasswordCheck(password) + } + return fmt.Errorf("error searching for user: %w", err) } - return nil, nil, fmt.Errorf("error searching for user: %w", err) - } - err = m.auth.CheckUserPassword(*search, password) + err = m.auth.CheckUserPassword(*search, password) + + if err != nil { + m.auth.RecordLoginAttempt(username, false, actions) + return fmt.Errorf("invalid password for basic auth user: %w", err) + } + + m.auth.RecordLoginAttempt(username, true, actions) + + return nil + }) if err != nil { - m.auth.RecordLoginAttempt(username, false) - return nil, nil, fmt.Errorf("invalid password for basic auth user: %w", err) + if errors.Is(err, errLocked) { + return nil, headers, nil + } + return nil, nil, err } - m.auth.RecordLoginAttempt(username, true) - switch search.Type { case model.UserLocal: user := m.auth.GetLocalUser(username) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index a4cef1e1..47390cb6 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -267,12 +267,16 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) { }, nil } -func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { - if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 { +func (auth *AuthService) rateLimitEnabled() bool { + return auth.config.Auth.LoginMaxRetries > 0 && auth.config.Auth.LoginTimeout > 0 +} + +func (auth *AuthService) IsAccountLocked(identifier string, actions cache.CacheStoreActions[LoginAttempt]) (bool, int) { + if !auth.rateLimitEnabled() { return false, 0 } - attempt, exists := auth.caches.login.Get(identifier) + attempt, exists := actions.Get(identifier) if !exists { return false, 0 } @@ -285,46 +289,51 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { return false, 0 } -func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) { - if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 { +func (auth *AuthService) RecordLoginAttempt(identifier string, success bool, actions cache.CacheStoreActions[LoginAttempt]) { + if !auth.rateLimitEnabled() { return } - auth.caches.login.WithLock(func(actions cache.CacheStoreActions[LoginAttempt]) { - entry, ok := actions.Get(identifier) + entry, ok := actions.Get(identifier) - if !ok { - attempt := LoginAttempt{ - LastAttempt: time.Now(), - } - if !success { - attempt.FailedAttempts = 1 - if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries { - attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second) - auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts") - } + if !ok { + attempt := LoginAttempt{ + LastAttempt: time.Now(), + } + if !success { + attempt.FailedAttempts = 1 + if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries { + attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second) + auth.log.App.Warn().Str("identifier", identifier).Int("failed_attempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts") } - // match current tinyauth behavior which doesn't expire rate limits - actions.Set(identifier, attempt, 0) - return } + actions.Set(identifier, attempt, time.Duration(auth.config.Auth.LoginTimeout)*time.Second) + return + } - entry.LastAttempt = time.Now() + entry.LastAttempt = time.Now() - if success { - entry.FailedAttempts = 0 - entry.LockedUntil = time.Time{} - } else { - entry.FailedAttempts++ + if success { + entry.FailedAttempts = 0 + entry.LockedUntil = time.Time{} + } else { + entry.FailedAttempts++ - if entry.FailedAttempts >= auth.config.Auth.LoginMaxRetries { - entry.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second) - auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", entry.FailedAttempts).Msg("Account locked due to too many failed login attempts") - } + if entry.FailedAttempts >= auth.config.Auth.LoginMaxRetries { + entry.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second) + auth.log.App.Warn().Str("identifier", identifier).Int("failed_attempts", entry.FailedAttempts).Msg("Account locked due to too many failed login attempts") } + } + + actions.Set(identifier, entry, time.Duration(auth.config.Auth.LoginTimeout)*time.Second) +} - actions.Set(identifier, entry, 0) +func (auth *AuthService) WithLoginAttempt(f func(actions cache.CacheStoreActions[LoginAttempt]) error) error { + var err error + auth.caches.login.WithLock(func(actions cache.CacheStoreActions[LoginAttempt]) { + err = f(actions) }) + return err } // We could also directly access the policyEngine.effectToAccess but @@ -337,7 +346,7 @@ func (auth *AuthService) IsEmailWhitelisted(provider string, email string) bool } match, err := utils.CheckFilter(strings.Join(whitelist, ","), email) if err != nil { - if err == utils.ErrFilterEmpty { + if errors.Is(err, utils.ErrFilterEmpty) { return EffectAbstain } auth.log.App.Error().Err(err).Str("email", email).Msg("Failed to evaluate email whitelist filter, defaulting to deny") From 14988291ebe8c176ec92ba5a44c434af4fccc7cc Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 18 Oct 2026 18:50:40 +0300 Subject: [PATCH 2/6] refactor: use keyed locks for auth --- internal/controller/user_controller.go | 6 +-- internal/middleware/context_middleware.go | 2 +- internal/service/auth_service.go | 4 +- pkg/cache/cache_store.go | 53 +++++++++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 479f8f05..4dc9ec1a 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -75,7 +75,7 @@ func (controller *UserController) loginHandler(c *gin.Context) { var search *model.UserSearch - err = controller.auth.WithLoginAttempt(func(actions cache.CacheStoreActions[service.LoginAttempt]) error { + err = controller.auth.WithLoginAttempt(req.Username, func(actions cache.CacheStoreActions[service.LoginAttempt]) error { search, err = controller.auth.SearchUser(req.Username) if err != nil { @@ -227,7 +227,7 @@ func (controller *UserController) loginHandler(c *gin.Context) { controller.log.AuditLoginSuccess(req.Username, search.Type.String(), c.ClientIP()) - controller.auth.WithLoginAttempt(func(actions cache.CacheStoreActions[service.LoginAttempt]) error { + controller.auth.WithLoginAttempt(req.Username, func(actions cache.CacheStoreActions[service.LoginAttempt]) error { controller.auth.RecordLoginAttempt(req.Username, true, actions) return nil }) @@ -333,7 +333,7 @@ func (controller *UserController) totpHandler(c *gin.Context) { var user *model.LocalUser - err = controller.auth.WithLoginAttempt(func(actions cache.CacheStoreActions[service.LoginAttempt]) error { + err = controller.auth.WithLoginAttempt(context.GetUsername(), func(actions cache.CacheStoreActions[service.LoginAttempt]) error { isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername(), actions) if isLocked { diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 6b920be4..b679a252 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -259,7 +259,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. var search *model.UserSearch - err := m.auth.WithLoginAttempt(func(actions cache.CacheStoreActions[service.LoginAttempt]) error { + err := m.auth.WithLoginAttempt(username, func(actions cache.CacheStoreActions[service.LoginAttempt]) error { locked, remaining := m.auth.IsAccountLocked(username, actions) if locked { diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 47390cb6..5dd7e0b6 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -328,9 +328,9 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool, act actions.Set(identifier, entry, time.Duration(auth.config.Auth.LoginTimeout)*time.Second) } -func (auth *AuthService) WithLoginAttempt(f func(actions cache.CacheStoreActions[LoginAttempt]) error) error { +func (auth *AuthService) WithLoginAttempt(identifier string, f func(actions cache.CacheStoreActions[LoginAttempt]) error) error { var err error - auth.caches.login.WithLock(func(actions cache.CacheStoreActions[LoginAttempt]) { + auth.caches.login.WithKeyedLock(identifier, func(actions cache.CacheStoreActions[LoginAttempt]) { err = f(actions) }) return err diff --git a/pkg/cache/cache_store.go b/pkg/cache/cache_store.go index a23ca8ec..56b879fc 100644 --- a/pkg/cache/cache_store.go +++ b/pkg/cache/cache_store.go @@ -18,10 +18,17 @@ type cacheEntry[T any] struct { expiresAt *time.Time } +type keyedCacheMutex struct { + mu sync.Mutex + refs int +} + type CacheStore[T any] struct { cache map[string]cacheEntry[T] order []string mu sync.RWMutex + keyedMu sync.RWMutex + keyed sync.Map maxSize int } @@ -48,6 +55,52 @@ func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) { mutate(actions) } +// WithKeyedLock allows performing multiple operations on a single lock for a specific key. +// The provided mutate function receives a set of actions (Set, Get, Delete, Update) that +// can be used to manipulate the cache store within the locked context. +func (cs *CacheStore[T]) WithKeyedLock(key string, mutate func(actions CacheStoreActions[T])) { + cs.mu.RLock() + defer cs.mu.RUnlock() + cs.lockKeyed(key) + defer cs.unlockKeyed(key) + + actions := CacheStoreActions[T]{ + Set: cs.setCallback, + Get: cs.getCallback, + Delete: cs.deleteCallback, + Update: cs.updateCallback, + } + mutate(actions) +} + +func (cs *CacheStore[T]) lockKeyed(key string) { + cs.keyedMu.Lock() + + val, _ := cs.keyed.LoadOrStore(key, &keyedCacheMutex{}) + km := val.(*keyedCacheMutex) + km.refs++ + + cs.keyedMu.Unlock() + + km.mu.Lock() +} + +func (cs *CacheStore[T]) unlockKeyed(key string) { + cs.keyedMu.Lock() + + val, _ := cs.keyed.Load(key) + km := val.(*keyedCacheMutex) + + km.mu.Unlock() + km.refs-- + + if km.refs == 0 { + cs.keyed.Delete(key) + } + + cs.keyedMu.Unlock() +} + func (cs *CacheStore[T]) updateCallback(key string, value T, ttl time.Duration) bool { if currentEntry, exists := cs.cache[key]; exists { if currentEntry.expiresAt != nil && time.Now().After(*currentEntry.expiresAt) { From 9f60f6df91e74ffc8241192745cb593deb9a5a8b Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 18 Oct 2026 18:55:25 +0300 Subject: [PATCH 3/6] fix: do not count basic auth success for totp accounts --- internal/middleware/context_middleware.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index b679a252..18e1e3ab 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -286,7 +286,13 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. return fmt.Errorf("invalid password for basic auth user: %w", err) } - m.auth.RecordLoginAttempt(username, true, actions) + if search.Type == model.UserLocal { + user := m.auth.GetLocalUser(username) + + if user != nil && user.TOTPSecret != "" { + return fmt.Errorf("user with totp not allowed to login via basic auth: %s", username) + } + } return nil }) @@ -306,10 +312,6 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. return nil, nil, fmt.Errorf("user not found locally: %s", username) } - if user.TOTPSecret != "" { - return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", username) - } - userContext.Local = &model.LocalContext{ BaseContext: model.BaseContext{ Username: user.Username, From 41ad6179c1db048809d8ebc715fa630b47e53fb1 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 18 Oct 2026 23:25:51 +0300 Subject: [PATCH 4/6] fix: rabbit comments --- pkg/cache/cache_store.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/cache/cache_store.go b/pkg/cache/cache_store.go index 56b879fc..f354c159 100644 --- a/pkg/cache/cache_store.go +++ b/pkg/cache/cache_store.go @@ -40,6 +40,17 @@ func NewCacheStore[T any](maxSize int) *CacheStore[T] { } } +func wrapWithLock[T any](f T, mu *sync.RWMutex, ro bool) T { + if ro { + mu.RLock() + defer mu.RUnlock() + } else { + mu.Lock() + defer mu.Unlock() + } + return f +} + // WithLock allows performing multiple operations on a single lock. // The provided mutate function receives a set of actions (Set, Get, Delete, Update) that // can be used to manipulate the cache store within the locked context. @@ -59,16 +70,14 @@ func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) { // The provided mutate function receives a set of actions (Set, Get, Delete, Update) that // can be used to manipulate the cache store within the locked context. func (cs *CacheStore[T]) WithKeyedLock(key string, mutate func(actions CacheStoreActions[T])) { - cs.mu.RLock() - defer cs.mu.RUnlock() cs.lockKeyed(key) defer cs.unlockKeyed(key) actions := CacheStoreActions[T]{ - Set: cs.setCallback, - Get: cs.getCallback, - Delete: cs.deleteCallback, - Update: cs.updateCallback, + Set: wrapWithLock[func(key string, value T, ttl time.Duration)](cs.setCallback, &cs.keyedMu, false), + Get: wrapWithLock[func(key string) (T, bool)](cs.getCallback, &cs.keyedMu, true), + Delete: wrapWithLock[func(key string)](cs.deleteCallback, &cs.keyedMu, false), + Update: wrapWithLock[func(key string, value T, ttl time.Duration) bool](cs.updateCallback, &cs.keyedMu, false), } mutate(actions) } From 240936369bc7d718edbd9da17de2d93835a9faa1 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 19 Oct 2026 16:30:39 +0300 Subject: [PATCH 5/6] fix: remove useless lock wrapper --- pkg/cache/cache_store.go | 21 ++++----------- pkg/cache/cache_store_test.go | 48 ++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/pkg/cache/cache_store.go b/pkg/cache/cache_store.go index f354c159..1935b87f 100644 --- a/pkg/cache/cache_store.go +++ b/pkg/cache/cache_store.go @@ -40,17 +40,6 @@ func NewCacheStore[T any](maxSize int) *CacheStore[T] { } } -func wrapWithLock[T any](f T, mu *sync.RWMutex, ro bool) T { - if ro { - mu.RLock() - defer mu.RUnlock() - } else { - mu.Lock() - defer mu.Unlock() - } - return f -} - // WithLock allows performing multiple operations on a single lock. // The provided mutate function receives a set of actions (Set, Get, Delete, Update) that // can be used to manipulate the cache store within the locked context. @@ -68,16 +57,16 @@ func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) { // WithKeyedLock allows performing multiple operations on a single lock for a specific key. // The provided mutate function receives a set of actions (Set, Get, Delete, Update) that -// can be used to manipulate the cache store within the locked context. +// each acquire the store lock while the key lock is held for the entire callback. func (cs *CacheStore[T]) WithKeyedLock(key string, mutate func(actions CacheStoreActions[T])) { cs.lockKeyed(key) defer cs.unlockKeyed(key) actions := CacheStoreActions[T]{ - Set: wrapWithLock[func(key string, value T, ttl time.Duration)](cs.setCallback, &cs.keyedMu, false), - Get: wrapWithLock[func(key string) (T, bool)](cs.getCallback, &cs.keyedMu, true), - Delete: wrapWithLock[func(key string)](cs.deleteCallback, &cs.keyedMu, false), - Update: wrapWithLock[func(key string, value T, ttl time.Duration) bool](cs.updateCallback, &cs.keyedMu, false), + Set: cs.Set, + Get: cs.Get, + Delete: cs.Delete, + Update: cs.Update, } mutate(actions) } diff --git a/pkg/cache/cache_store_test.go b/pkg/cache/cache_store_test.go index 71e37bc8..19894cbf 100644 --- a/pkg/cache/cache_store_test.go +++ b/pkg/cache/cache_store_test.go @@ -356,6 +356,41 @@ func TestCacheStoreWithLock(t *testing.T) { assert.Equal(t, 200, value) } +func TestCacheStoreWithKeyedLock(t *testing.T) { + cs := NewCacheStore[int](0) + + const goroutines = 16 + const iterations = 100 + const keys = 4 + + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(goroutines) + + for g := range goroutines { + go func(g int) { + defer wg.Done() + <-start + key := strconv.Itoa(g % keys) + for range iterations { + cs.WithKeyedLock(key, func(actions CacheStoreActions[int]) { + value, _ := actions.Get(key) + actions.Set(key, value+1, 0) + }) + } + }(g) + } + + close(start) + wg.Wait() + + for k := range keys { + value, ok := cs.Get(strconv.Itoa(k)) + assert.True(t, ok) + assert.Equal(t, goroutines/keys*iterations, value) + } +} + // TestCacheStoreConcurrency exercises every locking path concurrently so the // race detector (go test -race) can flag unsynchronised access. func TestCacheStoreConcurrency(t *testing.T) { @@ -372,7 +407,7 @@ func TestCacheStoreConcurrency(t *testing.T) { defer wg.Done() for i := range iterations { key := strconv.Itoa((g*iterations + i) % 32) - switch i % 6 { + switch i % 9 { case 0: cs.Set(key, i, time.Minute) case 1: @@ -389,6 +424,17 @@ func TestCacheStoreConcurrency(t *testing.T) { actions.Set(key, v+1, time.Minute) } }) + case 6: + cs.WithKeyedLock(key, func(actions CacheStoreActions[int]) { + actions.Set(key, i, time.Minute) + value, _ := actions.Get(key) + actions.Update(key, value+1, time.Minute) + actions.Delete(key) + }) + case 7: + cs.Sweep() + case 8: + cs.Clear() } } }(g) From cacaea40b4ba3e37a2c08d5a570c444cee47edd0 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 19 Oct 2026 16:51:10 +0300 Subject: [PATCH 6/6] fix: record correct login attempt in context middleware --- internal/middleware/context_middleware.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 18e1e3ab..38955d17 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -294,6 +294,8 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model. } } + m.auth.RecordLoginAttempt(username, true, actions) + return nil })