package auth import ( "context" "crypto/sha256" "encoding/hex" "fmt" "strings" "time" "github.com/google/uuid" "github.com/redis/go-redis/v9" ) // LockoutTracker counts consecutive failed logins per email and trips a // lockout flag after a threshold. The lock is keyed by user_id (once we // know it from the email) so that resetting the password — which we do via // /auth/reset-password — can clear it cleanly. // // Why two keys? The failure counter must work even when the email maps to // no user (otherwise an attacker probing addresses just gets unlimited // tries). The lock flag only exists once we've matched an actual account. type LockoutTracker struct { client *redis.Client threshold int window time.Duration // how long failures linger before counters reset prefix string } func NewLockoutTracker(client *redis.Client, threshold int, window time.Duration) *LockoutTracker { return &LockoutTracker{ client: client, threshold: threshold, window: window, prefix: "auth", } } func (t *LockoutTracker) failKey(email string) string { h := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email)))) return fmt.Sprintf("%s:login_fail:%s", t.prefix, hex.EncodeToString(h[:])) } func (t *LockoutTracker) lockKey(uid uuid.UUID) string { return fmt.Sprintf("%s:locked:%s", t.prefix, uid.String()) } // IsLocked reports whether the given user's account is currently locked. func (t *LockoutTracker) IsLocked(ctx context.Context, uid uuid.UUID) (bool, error) { if t == nil || t.client == nil { return false, nil } v, err := t.client.Exists(ctx, t.lockKey(uid)).Result() if err != nil { return false, err } return v > 0, nil } // RecordFailure increments the failure counter for the email and, if it // crosses the threshold, sets the lock flag for the given user id. // Returns (locked, error). A nil userID is fine — the counter still ticks // up so probing nonexistent accounts is also rate-limited. func (t *LockoutTracker) RecordFailure(ctx context.Context, email string, userID *uuid.UUID) (bool, error) { if t == nil || t.client == nil { return false, nil } key := t.failKey(email) n, err := t.client.Incr(ctx, key).Result() if err != nil { return false, err } if n == 1 { _ = t.client.Expire(ctx, key, t.window).Err() } if int(n) >= t.threshold && userID != nil { // Keep the lock until password reset clears it. 7-day fallback TTL // so a permanently abandoned account doesn't pile up forever. if err := t.client.Set(ctx, t.lockKey(*userID), "1", 7*24*time.Hour).Err(); err != nil { return false, err } return true, nil } return false, nil } // ClearForUser drops both the lock flag and any in-flight failure counter // for the user's email. Called from /auth/reset-password. func (t *LockoutTracker) ClearForUser(ctx context.Context, uid uuid.UUID, email string) error { if t == nil || t.client == nil { return nil } pipe := t.client.Pipeline() pipe.Del(ctx, t.lockKey(uid)) pipe.Del(ctx, t.failKey(email)) _, err := pipe.Exec(ctx) return err } // ClearOnSuccess drops only the failure counter — used after a successful // login to forgive prior typos. func (t *LockoutTracker) ClearOnSuccess(ctx context.Context, email string) { if t == nil || t.client == nil { return } _ = t.client.Del(ctx, t.failKey(email)).Err() }