package admin import ( "context" "errors" "fmt" "time" "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/totp" ) // Login outcome sentinel errors. var ( // ErrInvalidCredentials is returned for any wrong username / password / // TOTP combination. It is deliberately generic to avoid user enumeration. ErrInvalidCredentials = errors.New("admin: invalid credentials") // ErrLockedOut is returned when the username is temporarily locked after // too many consecutive failures. ErrLockedOut = errors.New("admin: account temporarily locked") ) const loginFailKeyPrefix = "admin:loginfail:" // Authenticator performs two-factor admin login with failure rate-limiting. type Authenticator struct { store Store sessions *SessionStore rdb *redis.Client secret []byte failMax int lockDur time.Duration sec *SecurityLog // now is overridable in tests. now func() time.Time } // NewAuthenticator wires an Authenticator. func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cfg *Config, sec *SecurityLog) *Authenticator { return &Authenticator{ store: store, sessions: sessions, rdb: rdb, secret: cfg.SecretKey, failMax: cfg.LoginFailMax, lockDur: cfg.LoginLockDuration, sec: sec, now: func() time.Time { return time.Now().UTC() }, } } // Login validates username + password + TOTP and, on success, creates a // session and returns its id. Every failure is rate-limited and recorded as a // security event in the audit log (red line: admin records only security // events, never routine access). func (a *Authenticator) Login(ctx context.Context, username, password, code, remoteIP string) (sid string, sess *Session, err error) { locked, lerr := a.isLocked(ctx, username) if lerr != nil { return "", nil, fmt.Errorf("admin.Login lock check: %w", lerr) } if locked { a.sec.LoginLocked(ctx, username, remoteIP) return "", nil, ErrLockedOut } admin, gerr := a.store.GetAdminByUsername(ctx, username) if gerr != nil && !errors.Is(gerr, ErrAdminNotFound) { return "", nil, fmt.Errorf("admin.Login lookup: %w", gerr) } if admin == nil || admin.Status != "active" || !VerifyPassword(admin.PwHash, password) { a.recordFail(ctx, username) a.sec.LoginFail(ctx, username, remoteIP, "bad_password") return "", nil, ErrInvalidCredentials } secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc) if derr != nil { a.recordFail(ctx, username) a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt") return "", nil, ErrInvalidCredentials } if !totp.Validate(secret, code, a.now(), 1) { a.recordFail(ctx, username) a.sec.LoginFail(ctx, username, remoteIP, "bad_totp") return "", nil, ErrInvalidCredentials } // Success: clear counter, stamp login, create session. a.clearFail(ctx, username) if uerr := a.store.UpdateLastLogin(ctx, admin.ID, a.now()); uerr != nil { return "", nil, fmt.Errorf("admin.Login update: %w", uerr) } sid, sess, serr := a.sessions.Create(ctx, admin.ID, admin.Username) if serr != nil { return "", nil, serr } a.sec.LoginOK(ctx, username, remoteIP) return sid, sess, nil } func (a *Authenticator) isLocked(ctx context.Context, username string) (bool, error) { if a.rdb == nil { return false, nil } n, err := a.rdb.Get(ctx, loginFailKeyPrefix+username).Int() if errors.Is(err, redis.Nil) { return false, nil } if err != nil { return false, err } return n >= a.failMax, nil } func (a *Authenticator) recordFail(ctx context.Context, username string) { if a.rdb == nil { return } key := loginFailKeyPrefix + username pipe := a.rdb.Pipeline() pipe.Incr(ctx, key) pipe.Expire(ctx, key, a.lockDur) _, _ = pipe.Exec(ctx) } func (a *Authenticator) clearFail(ctx context.Context, username string) { if a.rdb == nil { return } _ = a.rdb.Del(ctx, loginFailKeyPrefix+username).Err() }