Files
wangjia 1d154bd627
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 20s
ci-pangolin / Lint — shellcheck (push) Successful in 51s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 23s
ci-pangolin / OpenAPI Sync Check (push) Successful in 1m10s
ci-pangolin / Flutter — analyze + test (push) Successful in 3m44s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m7s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 24s
ci-pangolin / Go — build + test (push) Failing after 1m0s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 40s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 6m9s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Successful in 42s
feat(admin): 后台登录支持「记住此设备」(免二次验证 + 保持登录)
常用设备(已在 mTLS 白名单内)每次都要输 TOTP + 30 分钟就掉线,体验差。
新增登录页「记住此设备」勾选:

- 勾选并成功登录(需完整 密码+TOTP)后,签发 30 天设备信任令牌(HttpOnly/
  Secure/SameSite=Strict cookie,Redis 存储绑定 admin ID),并把会话延到 30 天
  (持久 cookie + 服务端 TTL,滑动续期按会话自身 TTL)。
- 之后该设备重登只需 用户名+密码,**跳过 TOTP**;会话在有效期内保持登录。

安全不变量(均有测试覆盖):
- 密码永远必验——即便持有效信任令牌,密码错一律拒(只跳过第二因子,不跳过密码);
- 信任令牌绑定 admin,alice 的令牌不能给 bob 免 TOTP;
- 无令牌 + 空 TOTP 一律拒(未记住设备仍强制二次验证);
- 令牌过期/Redis 清空/未知令牌全部 fail-closed 回退到「要 TOTP」;
- TrustedDeviceTTL=0 关闭整功能(勾选无效)。

实现:新增 TrustedStore(Redis, trusted.go);Authenticator.LoginDevice
(旧 Login 保持签名,委托新方法,零行为变化);SessionStore.CreateWithTTL +
Session.TTLSeconds 支持持久会话按自身 TTL 滑动;handler 读 cookie/勾选、
按 Persistent 设长短会话 cookie、下发信任 cookie;登录页加勾选、TOTP 去
required。配置项 ADMIN_TRUSTED_DEVICE_TTL(默认 720h)。

测试:trusted_test(签发/校验/绑定/吊销/过期/禁用)、login_device_test
(跳过TOTP/仍需密码/绑定admin/无令牌需TOTP)、login_device_handler_test
(端到端 勾选→双cookie→凭信任cookie免TOTP、无信任空TOTP 401);
go test ./internal/admin 全绿,go vet 净。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-24 09:52:56 +08:00

183 lines
5.7 KiB
Go

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()
}