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
常用设备(已在 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
183 lines
5.7 KiB
Go
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()
|
|
}
|