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 trusted *TrustedStore trustTTL time.Duration // now is overridable in tests. now func() time.Time } // NewAuthenticator wires an Authenticator. Device-trust ("记住此设备") is // enabled when cfg.TrustedDeviceTTL > 0. 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, trusted: NewTrustedStore(rdb, cfg.TrustedDeviceTTL), trustTTL: cfg.TrustedDeviceTTL, now: func() time.Time { return time.Now().UTC() }, } } // LoginResult is the outcome of a device-aware login. type LoginResult struct { SID string Session *Session // NewTrustToken is non-empty when the caller ticked "记住此设备" and a fresh // device-trust token was issued — the handler sets it as the trust cookie. NewTrustToken string // Persistent is true when this login should use a long-lived (trust-TTL) // session + cookie instead of the short idle default. Persistent bool } // 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) { res, lerr := a.LoginDevice(ctx, username, password, code, remoteIP, "", false) return res.SID, res.Session, lerr } // LoginDevice is the device-aware login: username + password are ALWAYS // required; the TOTP second factor is skipped only when trustToken is a live // trust token bound to this admin ("记住此设备"). When remember is set, a fresh // trust token is issued and the session is made persistent (trust-TTL long). // // Security invariant: a trust token never substitutes for the password — a // wrong password fails regardless of trust. func (a *Authenticator) LoginDevice(ctx context.Context, username, password, code, remoteIP, trustToken string, remember bool) (LoginResult, error) { locked, lerr := a.isLocked(ctx, username) if lerr != nil { return LoginResult{}, fmt.Errorf("admin.Login lock check: %w", lerr) } if locked { a.sec.LoginLocked(ctx, username, remoteIP) return LoginResult{}, ErrLockedOut } admin, gerr := a.store.GetAdminByUsername(ctx, username) if gerr != nil && !errors.Is(gerr, ErrAdminNotFound) { return LoginResult{}, 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 LoginResult{}, ErrInvalidCredentials } // Second factor: skip only for a device already trusted by THIS admin. if !a.trusted.Check(ctx, trustToken, admin.ID) { secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc) if derr != nil { a.recordFail(ctx, username) a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt") return LoginResult{}, ErrInvalidCredentials } if !totp.Validate(secret, code, a.now(), 1) { a.recordFail(ctx, username) a.sec.LoginFail(ctx, username, remoteIP, "bad_totp") return LoginResult{}, 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 LoginResult{}, fmt.Errorf("admin.Login update: %w", uerr) } persistent := remember && a.trusted.Enabled() var ( sid string sess *Session serr error ) if persistent { sid, sess, serr = a.sessions.CreateWithTTL(ctx, admin.ID, admin.Username, a.trustTTL) } else { sid, sess, serr = a.sessions.Create(ctx, admin.ID, admin.Username) } if serr != nil { return LoginResult{}, serr } res := LoginResult{SID: sid, Session: sess, Persistent: persistent} if remember { if tok, terr := a.trusted.Issue(ctx, admin.ID); terr == nil { res.NewTrustToken = tok } } a.sec.LoginOK(ctx, username, remoteIP) return res, 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() }