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
3.8 KiB
Go
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)
|
|
}
|
|
}
|