Files
pangolin/server/internal/admin/login_device_test.go
T
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

115 lines
4.1 KiB
Go

package admin
import (
"context"
"crypto/rand"
"testing"
"time"
"github.com/wangjia/pangolin/server/internal/totp"
)
// newTrustAuth builds an Authenticator with device-trust ENABLED (30d).
func newTrustAuth(t *testing.T) (*Authenticator, *fakeStore, []byte) {
t.Helper()
rdb, _ := newTestRedis(t)
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
store := newFakeStore()
cfg := &Config{
SecretKey: key, LoginFailMax: 3, LoginLockDuration: time.Minute,
SessionTTL: 30 * time.Minute, TrustedDeviceTTL: 30 * 24 * time.Hour,
}
sessions := NewSessionStore(rdb, cfg.SessionTTL)
sec := NewSecurityLog(store, nil)
return NewAuthenticator(store, sessions, rdb, cfg, sec), store, key
}
// 勾选「记住此设备」成功登录 → 返回可用于下次跳过 TOTP 的信任令牌。
func TestLoginDevice_RememberIssuesTrust(t *testing.T) {
auth, store, key := newTrustAuth(t)
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
code, _ := totp.Code(secret, time.Now().UTC())
ctx := context.Background()
res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true)
if err != nil {
t.Fatalf("login: %v", err)
}
if res.NewTrustToken == "" {
t.Fatal("remember=true should issue a trust token")
}
if !res.Persistent {
t.Error("remember=true should mark session persistent")
}
// 下次:带该令牌 + 空 TOTP 也能登录(跳过二次验证)
res2, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", res.NewTrustToken, false)
if err != nil {
t.Fatalf("trusted re-login should skip TOTP: %v", err)
}
if res2.SID == "" {
t.Fatal("no session on trusted re-login")
}
}
// 不勾选:不签发令牌,且 TOTP 仍必填。
func TestLoginDevice_NoRememberRequiresTOTP(t *testing.T) {
auth, store, key := newTrustAuth(t)
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
code, _ := totp.Code(secret, time.Now().UTC())
ctx := context.Background()
res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", false)
if err != nil {
t.Fatalf("login: %v", err)
}
if res.NewTrustToken != "" {
t.Error("remember=false must not issue a trust token")
}
// 无令牌 + 空 TOTP → 拒
if _, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", "", false); err != ErrInvalidCredentials {
t.Errorf("untrusted device with empty TOTP should fail, got %v", err)
}
}
// 铁律:即便持有效信任令牌,密码错误一律拒(只跳过 TOTP,不跳过密码)。
func TestLoginDevice_TrustNeverSkipsPassword(t *testing.T) {
auth, store, key := newTrustAuth(t)
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
code, _ := totp.Code(secret, time.Now().UTC())
ctx := context.Background()
res, _ := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true)
if res.NewTrustToken == "" {
t.Fatal("precondition: expected trust token")
}
if _, err := auth.LoginDevice(ctx, "alice", "WRONG", "", "127.0.0.1", res.NewTrustToken, false); err != ErrInvalidCredentials {
t.Errorf("trusted device must still require correct password, got %v", err)
}
}
// 信任令牌绑定 admin:换个用户名不认(令牌属 alice,拿去登 bob 无效)。
func TestLoginDevice_TrustBoundToAdmin(t *testing.T) {
auth, store, key := newTrustAuth(t)
sa := newTestAdmin(t, store, key, "alice", "alice-pass")
_ = sa
// bob:另一个 admin,不同 ID
hash, _ := HashPassword("bob-pass")
bsecret, _ := totp.GenerateSecret()
benc, _ := EncryptSecret(key, bsecret)
store.admins["bob"] = &Admin{ID: 2, Username: "bob", PwHash: hash, TOTPSecretEnc: benc, Status: "active"}
ctx := context.Background()
acode, _ := totp.Code(sa, time.Now().UTC())
ares, _ := auth.LoginDevice(ctx, "alice", "alice-pass", acode, "127.0.0.1", "", true)
if ares.NewTrustToken == "" {
t.Fatal("precondition: alice trust token")
}
// 用 alice 的令牌 + 空 TOTP 登 bob → 应要求 TOTP(令牌对 bob 无效)
if _, err := auth.LoginDevice(ctx, "bob", "bob-pass", "", "127.0.0.1", ares.NewTrustToken, false); err != ErrInvalidCredentials {
t.Errorf("alice's trust token must not skip TOTP for bob, got %v", err)
}
}