Files
pangolin/server/internal/admin/login_device_handler_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
3.8 KiB
Go

package admin
import (
"crypto/rand"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/totp"
)
// newTrustEnv wires a full admin router with device-trust ENABLED and seeds
// one admin, returning the router and that admin's TOTP secret.
func newTrustEnv(t *testing.T) (http.Handler, string) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { rdb.Close() })
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
allow, _ := ParseCIDRs([]string{"127.0.0.0/8"})
cfg := &Config{
Listen: "127.0.0.1:9443", AllowCIDRs: allow, SecretKey: key,
SessionTTL: 30 * time.Minute, LoginFailMax: 3, LoginLockDuration: time.Minute,
CookieSecure: false, TrustedDeviceTTL: 30 * 24 * time.Hour,
}
store := newFakeStore()
secret := newTestAdmin(t, store, key, "alice", "s3cret-pass")
sessions := NewSessionStore(rdb, cfg.SessionTTL)
sec := NewSecurityLog(store, nil)
auth := NewAuthenticator(store, sessions, rdb, cfg, sec)
svc := Services{Codes: &fakeCodes{}, Lifecycle: &recordingLifecycle{ready: true}, Provision: &recordingProvision{ready: true}}
h, err := NewHandlers(cfg, store, sessions, auth, svc, sec, nil)
if err != nil {
t.Fatal(err)
}
return NewRouter(h, sessions, cfg, sec), secret
}
func cookieByName(resp *http.Response, name string) *http.Cookie {
for _, c := range resp.Cookies() {
if c.Name == name {
return c
}
}
return nil
}
func postLogin(t *testing.T, router http.Handler, form url.Values, cookies ...*http.Cookie) *http.Response {
t.Helper()
req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "127.0.0.1:5000"
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr.Result()
}
// 端到端:勾选记住 → 登录成功 → 同时下发会话 cookie 与信任 cookie;
// 随后仅凭信任 cookie + 空 TOTP 再次登录成功(免二次验证)。
func TestLoginHandler_RememberThenSkipTOTP(t *testing.T) {
router, secret := newTrustEnv(t)
code, _ := totp.Code(secret, time.Now().UTC())
resp := postLogin(t, router, url.Values{
"username": {"alice"}, "password": {"s3cret-pass"},
"totp": {code}, "remember": {"1"},
})
if resp.StatusCode != http.StatusFound {
t.Fatalf("remember login: status = %d, want 302", resp.StatusCode)
}
trust := cookieByName(resp, TrustedDeviceCookieName)
if trust == nil || trust.Value == "" {
t.Fatal("remember login should set a non-empty trusted cookie")
}
if trust.MaxAge <= 0 {
t.Errorf("trusted cookie MaxAge = %d, want > 0 (persistent)", trust.MaxAge)
}
sessCookie := cookieByName(resp, SessionCookieName)
if sessCookie == nil || sessCookie.MaxAge <= int((30 * time.Minute).Seconds()) {
t.Error("remember login should set a long-lived (persistent) session cookie")
}
// 第二次:只带信任 cookie,TOTP 留空 → 成功
resp2 := postLogin(t, router, url.Values{
"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {""},
}, &http.Cookie{Name: TrustedDeviceCookieName, Value: trust.Value})
if resp2.StatusCode != http.StatusFound {
t.Fatalf("trusted re-login: status = %d, want 302 (TOTP skipped)", resp2.StatusCode)
}
}
// 无信任 cookie + 空 TOTP → 401(二次验证仍强制)。
func TestLoginHandler_NoTrustEmptyTOTPRejected(t *testing.T) {
router, _ := newTrustEnv(t)
resp := postLogin(t, router, url.Values{
"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {""},
})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("empty TOTP without trust: status = %d, want 401", resp.StatusCode)
}
}