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
115 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|