Compare commits

...

2 Commits

Author SHA1 Message Date
wangjia 1d154bd627 feat(admin): 后台登录支持「记住此设备」(免二次验证 + 保持登录)
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
2026-07-24 09:52:56 +08:00
wangjia f819a77d83 fix(admin): 登录审计记录真实来源 IP(经本机反代时取 XFF 末跳)
管理后台经 caddy mTLS 网关(同机 loopback 反代 127.0.0.1:9444)对外后,
LoginSubmit 里 hostOnly(r.RemoteAddr) 恒为 127.0.0.1,admin_login_ok/fail
的 ip 字段失去溯源价值。

新增 realIP():仅当 TCP 对端是 loopback(请求确实来自本机可信反代)才信
X-Forwarded-For,且取**最后一跳**(Caddy 把真实 TCP 对端追加在末位,更早的
段可被客户端伪造预置);直连、XFF 缺失或非法值一律回退 TCP 对端。

IPAllow 白名单中间件刻意不变——安全闸继续只看 RemoteAddr,不受任何代理头
影响(维持原注释声明的边界)。

测试:TestRealIP 七例(直连/伪造头/单跳/多跳/缺失/非法/IPv6);
go test ./internal/admin 全绿。(internal/store 迁移彩排 3 例失败为 HEAD
既有——主仓干净树复现,与本次无关。)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-24 08:34:57 +08:00
11 changed files with 625 additions and 30 deletions
+69 -19
View File
@@ -31,11 +31,14 @@ type Authenticator struct {
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.
// 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,
@@ -45,58 +48,105 @@ func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cf
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 "", nil, fmt.Errorf("admin.Login lock check: %w", lerr)
return LoginResult{}, fmt.Errorf("admin.Login lock check: %w", lerr)
}
if locked {
a.sec.LoginLocked(ctx, username, remoteIP)
return "", nil, ErrLockedOut
return LoginResult{}, ErrLockedOut
}
admin, gerr := a.store.GetAdminByUsername(ctx, username)
if gerr != nil && !errors.Is(gerr, ErrAdminNotFound) {
return "", nil, fmt.Errorf("admin.Login lookup: %w", gerr)
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 "", nil, ErrInvalidCredentials
return LoginResult{}, ErrInvalidCredentials
}
secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc)
if derr != nil {
a.recordFail(ctx, username)
a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt")
return "", nil, ErrInvalidCredentials
}
if !totp.Validate(secret, code, a.now(), 1) {
a.recordFail(ctx, username)
a.sec.LoginFail(ctx, username, remoteIP, "bad_totp")
return "", nil, 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 "", nil, fmt.Errorf("admin.Login update: %w", uerr)
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)
}
sid, sess, serr := a.sessions.Create(ctx, admin.ID, admin.Username)
if serr != nil {
return "", nil, serr
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 sid, sess, nil
return res, nil
}
func (a *Authenticator) isLocked(ctx context.Context, username string) (bool, error) {
+15
View File
@@ -44,6 +44,12 @@ type Config struct {
// CookieSecure controls the Secure attribute on the session cookie.
// Defaults to true; only disabled explicitly for local/dev over plain HTTP.
CookieSecure bool
// TrustedDeviceTTL is how long a "记住此设备" trust lasts. Within this window
// the device (a) skips the TOTP second factor on re-login and (b) keeps a
// persistent session of the same length. Password is ALWAYS still required.
// Zero disables the feature (checkbox has no effect).
TrustedDeviceTTL time.Duration
}
// defaultInternalCIDRs are the loopback and private/ULA ranges allowed by
@@ -66,6 +72,7 @@ var defaultInternalCIDRs = []string{
// ADMIN_LOGIN_FAIL_MAX int (default 5)
// ADMIN_LOGIN_LOCK Go duration (default 15m)
// ADMIN_COOKIE_INSECURE "1" disables Secure flag (dev only)
// ADMIN_TRUSTED_DEVICE_TTL Go duration (default 720h = 30d; 0 disables)
func FromEnv() (*Config, error) {
c := &Config{
Listen: getEnvDefault("ADMIN_LISTEN", "127.0.0.1:9443"),
@@ -73,6 +80,14 @@ func FromEnv() (*Config, error) {
LoginFailMax: 5,
LoginLockDuration: 15 * time.Minute,
CookieSecure: os.Getenv("ADMIN_COOKIE_INSECURE") != "1",
TrustedDeviceTTL: 30 * 24 * time.Hour,
}
if v := os.Getenv("ADMIN_TRUSTED_DEVICE_TTL"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("config: ADMIN_TRUSTED_DEVICE_TTL %q invalid: %w", v, err)
}
c.TrustedDeviceTTL = d
}
if err := validateListen(c.Listen); err != nil {
+39 -4
View File
@@ -61,9 +61,15 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
code := strings.TrimSpace(r.PostFormValue("totp"))
ip := hostOnly(r.RemoteAddr)
remember := r.PostFormValue("remember") != ""
ip := realIP(r) // 经本机 caddy 反代时取 XFF 末跳,否则 TCP 对端(见 mw_ipallow.go)
sid, _, err := h.auth.Login(r.Context(), username, password, code, ip)
var trustToken string
if c, cerr := r.Cookie(TrustedDeviceCookieName); cerr == nil {
trustToken = c.Value
}
res, err := h.auth.LoginDevice(r.Context(), username, password, code, ip, trustToken, remember)
if err != nil {
flash := "用户名、密码或动态验证码有误"
if err == ErrLockedOut {
@@ -73,7 +79,14 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) {
h.render.render(w, "login", pageData{Flash: flash})
return
}
h.setSessionCookie(w, sid)
if res.Persistent {
h.setSessionCookieTTL(w, res.SID, h.cfg.TrustedDeviceTTL)
} else {
h.setSessionCookie(w, res.SID)
}
if res.NewTrustToken != "" {
h.setTrustedCookie(w, res.NewTrustToken)
}
http.Redirect(w, r, "/", http.StatusFound)
}
@@ -394,6 +407,14 @@ func (h *Handlers) writeAudit(ctx context.Context, actor, action, target, metaJS
}
func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) {
h.setSessionCookieTTL(w, sid, h.cfg.SessionTTL)
}
// setSessionCookieTTL sets the session cookie with an explicit Max-Age. For a
// persistent ("记住此设备") session ttl is the long trust-TTL; otherwise the
// short idle default. Max-Age <= 0 would make it a session cookie, so ttl must
// be positive here.
func (h *Handlers) setSessionCookieTTL(w http.ResponseWriter, sid string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sid,
@@ -401,7 +422,21 @@ func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) {
HttpOnly: true,
Secure: h.cfg.CookieSecure,
SameSite: http.SameSiteStrictMode,
MaxAge: int(h.cfg.SessionTTL.Seconds()),
MaxAge: int(ttl.Seconds()),
})
}
// setTrustedCookie stores the device-trust token (HttpOnly, Secure, Strict) so
// this device can skip TOTP on future logins for the trust TTL.
func (h *Handlers) setTrustedCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: TrustedDeviceCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: h.cfg.CookieSecure,
SameSite: http.SameSiteStrictMode,
MaxAge: int(h.cfg.TrustedDeviceTTL.Seconds()),
})
}
@@ -0,0 +1,114 @@
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)
}
}
+114
View File
@@ -0,0 +1,114 @@
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)
}
}
+26
View File
@@ -3,6 +3,7 @@ package admin
import (
"net"
"net/http"
"strings"
)
// IPAllow is middleware that rejects any request whose source IP is not within
@@ -58,3 +59,28 @@ func hostOnly(remoteAddr string) string {
}
return host
}
// realIP returns the client IP for **audit logging** (admin_login_ok 等)。
//
// 与上面 IPAllow 的取址原则刻意不同:白名单闸继续只看 TCP 对端(不可伪造);
// 而审计日志要的是"人从哪来"——经本机反代(caddy mTLS 网关在同机回环上反代
// 127.0.0.1:9444)时 RemoteAddr 恒为 loopback,没有溯源价值。仅当 TCP 对端是
// loopback(即请求确实来自本机可信反代)才信 X-Forwarded-For,且取**最后一跳**
// (Caddy 把真实 TCP 对端追加在末位;更早的段可被客户端伪造预置,不可信)。
func realIP(r *http.Request) string {
peer := hostOnly(r.RemoteAddr)
ip := net.ParseIP(peer)
if ip == nil || !ip.IsLoopback() {
return peer
}
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
return peer
}
parts := strings.Split(xff, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if net.ParseIP(last) == nil {
return peer
}
return last
}
+37
View File
@@ -0,0 +1,37 @@
package admin
import (
"net/http/httptest"
"testing"
)
// realIP:仅当 TCP 对端为 loopback(本机可信反代,如 caddy mTLS 网关)时才信
// X-Forwarded-For 的最后一跳;其余情况一律用 TCP 对端,防止伪造头污染审计。
func TestRealIP(t *testing.T) {
cases := []struct {
name string
remoteAddr string
xff string
want string
}{
{"直连无代理", "203.0.113.7:52011", "", "203.0.113.7"},
{"直连时伪造 XFF 不可信", "203.0.113.7:52011", "1.2.3.4", "203.0.113.7"},
{"经本机反代取 XFF 最后一跳", "127.0.0.1:38200", "198.51.100.23", "198.51.100.23"},
{"多跳取最后一跳(前段可伪造)", "127.0.0.1:38200", "6.6.6.6, 198.51.100.23", "198.51.100.23"},
{"本机反代但无 XFF 回退 loopback", "127.0.0.1:38200", "", "127.0.0.1"},
{"XFF 非法值回退", "127.0.0.1:38200", "not-an-ip", "127.0.0.1"},
{"IPv6 loopback 反代", "[::1]:38200", "198.51.100.23", "198.51.100.23"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/login", nil)
r.RemoteAddr = c.remoteAddr
if c.xff != "" {
r.Header.Set("X-Forwarded-For", c.xff)
}
if got := realIP(r); got != c.want {
t.Errorf("realIP(remote=%s, xff=%q) = %q, want %q", c.remoteAddr, c.xff, got, c.want)
}
})
}
}
+26 -5
View File
@@ -25,6 +25,9 @@ type Session struct {
Username string `json:"username"`
CSRFToken string `json:"csrf"`
CreatedAt time.Time `json:"created_at"`
// TTLSeconds, when > 0, overrides the store's default sliding idle TTL for
// this session (used by "记住此设备" persistent sessions). 0 = use default.
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
}
// SessionStore persists admin sessions in Redis with a sliding idle TTL.
@@ -41,9 +44,15 @@ func NewSessionStore(rdb *redis.Client, ttl time.Duration) *SessionStore {
return &SessionStore{rdb: rdb, ttl: ttl}
}
// Create starts a new session for the given admin and returns the opaque
// session id (to be set as the cookie value).
// Create starts a new session with the store's default sliding idle TTL.
func (s *SessionStore) Create(ctx context.Context, adminID int64, username string) (string, *Session, error) {
return s.CreateWithTTL(ctx, adminID, username, 0)
}
// CreateWithTTL starts a new session. When ttl > 0 the session uses that TTL
// (both the initial expiry and the per-request slide) instead of the store
// default — this is how "记住此设备" keeps a device logged in for e.g. 30 days.
func (s *SessionStore) CreateWithTTL(ctx context.Context, adminID int64, username string, ttl time.Duration) (string, *Session, error) {
sid, err := randToken(32)
if err != nil {
return "", nil, err
@@ -58,12 +67,24 @@ func (s *SessionStore) Create(ctx context.Context, adminID int64, username strin
CSRFToken: csrf,
CreatedAt: time.Now().UTC(),
}
if ttl > 0 {
sess.TTLSeconds = int64(ttl / time.Second)
}
if err := s.save(ctx, sid, sess); err != nil {
return "", nil, err
}
return sid, sess, nil
}
// slideTTL is the TTL to (re)apply to a session: its own override if set,
// otherwise the store default.
func (s *SessionStore) slideTTL(sess *Session) time.Duration {
if sess != nil && sess.TTLSeconds > 0 {
return time.Duration(sess.TTLSeconds) * time.Second
}
return s.ttl
}
// Get loads a session and slides its TTL forward. Returns (nil, nil) when the
// session is absent or expired.
func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) {
@@ -81,8 +102,8 @@ func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) {
if err := json.Unmarshal(val, &sess); err != nil {
return nil, fmt.Errorf("admin.SessionStore.Get unmarshal: %w", err)
}
// Slide the idle timeout.
if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.ttl).Err(); err != nil {
// Slide the idle timeout (persistent sessions slide by their own TTL).
if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.slideTTL(&sess)).Err(); err != nil {
return nil, fmt.Errorf("admin.SessionStore.Get expire: %w", err)
}
return &sess, nil
@@ -101,7 +122,7 @@ func (s *SessionStore) save(ctx context.Context, sid string, sess *Session) erro
if err != nil {
return fmt.Errorf("admin.SessionStore.save: %w", err)
}
if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.ttl).Err(); err != nil {
if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.slideTTL(sess)).Err(); err != nil {
return fmt.Errorf("admin.SessionStore.save set: %w", err)
}
return nil
+3 -2
View File
@@ -12,9 +12,10 @@
{{if .Flash}}<div class="error">{{.Flash}}</div>{{end}}
<label>用户名<input type="text" name="username" autocomplete="username" required autofocus></label>
<label>密码<input type="password" name="password" autocomplete="current-password" required></label>
<label>动态验证码 (TOTP)<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6" required></label>
<label>动态验证码 (TOTP)<input type="text" name="totp" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" maxlength="6"></label>
<label class="checkbox"><input type="checkbox" name="remember" value="1"> 记住此设备(30 天内免动态码、保持登录)</label>
<button type="submit">登录</button>
<p class="hint">仅限内网 / SSH 隧道访问</p>
<p class="hint">仅限白名单设备(客户端证书)访问。已记住的设备可留空动态码</p>
</form>
</body>
</html>{{end}}
+74
View File
@@ -0,0 +1,74 @@
package admin
import (
"context"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// TrustedDeviceCookieName is the cookie holding a device-trust token.
const TrustedDeviceCookieName = "admin_trusted"
// trustedKeyPrefix namespaces device-trust tokens in Redis.
const trustedKeyPrefix = "admin:trusted:"
// TrustedStore records "记住此设备" device-trust tokens in Redis.
//
// A trusted token lets a device SKIP THE TOTP SECOND FACTOR on re-login
// (password is still always required). Each token is opaque (32 bytes of
// entropy), bound to one admin id, and expires after ttl — so a flushed
// Redis, an expired token, or a mismatched admin all fail closed to
// "TOTP required".
type TrustedStore struct {
rdb *redis.Client
ttl time.Duration
}
// NewTrustedStore wires a TrustedStore. A ttl <= 0 disables the feature:
// Issue returns an empty token and Check always returns false.
func NewTrustedStore(rdb *redis.Client, ttl time.Duration) *TrustedStore {
return &TrustedStore{rdb: rdb, ttl: ttl}
}
// Enabled reports whether device-trust is active.
func (s *TrustedStore) Enabled() bool { return s != nil && s.rdb != nil && s.ttl > 0 }
// Issue mints a new trust token bound to adminID and stores it with the TTL.
// Returns "" (no error) when the feature is disabled.
func (s *TrustedStore) Issue(ctx context.Context, adminID int64) (string, error) {
if !s.Enabled() {
return "", nil
}
tok, err := randToken(32)
if err != nil {
return "", err
}
if err := s.rdb.Set(ctx, trustedKeyPrefix+tok, strconv.FormatInt(adminID, 10), s.ttl).Err(); err != nil {
return "", err
}
return tok, nil
}
// Check reports whether tok is a live trust token bound to adminID.
// Any miss (disabled, empty, unknown, expired, wrong admin) returns false.
func (s *TrustedStore) Check(ctx context.Context, tok string, adminID int64) bool {
if !s.Enabled() || tok == "" {
return false
}
v, err := s.rdb.Get(ctx, trustedKeyPrefix+tok).Result()
if err != nil {
return false
}
return v == strconv.FormatInt(adminID, 10)
}
// Revoke deletes a trust token (e.g. on explicit logout-all). A no-op when
// disabled or empty.
func (s *TrustedStore) Revoke(ctx context.Context, tok string) error {
if !s.Enabled() || tok == "" {
return nil
}
return s.rdb.Del(ctx, trustedKeyPrefix+tok).Err()
}
+108
View File
@@ -0,0 +1,108 @@
package admin
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func newTestTrustedStore(t *testing.T) (*TrustedStore, *miniredis.Miniredis) {
t.Helper()
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("miniredis: %v", err)
}
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
return NewTrustedStore(rdb, 30*24*time.Hour), mr
}
func TestTrustedStore_IssueThenCheck(t *testing.T) {
ts, _ := newTestTrustedStore(t)
ctx := context.Background()
tok, err := ts.Issue(ctx, 7)
if err != nil {
t.Fatalf("Issue: %v", err)
}
if tok == "" {
t.Fatal("Issue returned empty token")
}
// 正确 admin 命中
if !ts.Check(ctx, tok, 7) {
t.Error("Check should accept the token for the issuing admin")
}
// 令牌绑定 admin:换个 admin id 不认
if ts.Check(ctx, tok, 8) {
t.Error("Check must reject a token bound to a different admin")
}
// 未知令牌不认
if ts.Check(ctx, "bogus-token", 7) {
t.Error("Check must reject an unknown token")
}
// 空令牌不认
if ts.Check(ctx, "", 7) {
t.Error("Check must reject an empty token")
}
}
func TestTrustedStore_Revoke(t *testing.T) {
ts, _ := newTestTrustedStore(t)
ctx := context.Background()
tok, _ := ts.Issue(ctx, 7)
if !ts.Check(ctx, tok, 7) {
t.Fatal("precondition: token should be valid")
}
if err := ts.Revoke(ctx, tok); err != nil {
t.Fatalf("Revoke: %v", err)
}
if ts.Check(ctx, tok, 7) {
t.Error("Check must reject a revoked token")
}
}
func TestTrustedStore_Expires(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("miniredis: %v", err)
}
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
ts := NewTrustedStore(rdb, time.Hour)
ctx := context.Background()
tok, _ := ts.Issue(ctx, 7)
if !ts.Check(ctx, tok, 7) {
t.Fatal("precondition: token valid before expiry")
}
mr.FastForward(2 * time.Hour) // 超过 TTL
if ts.Check(ctx, tok, 7) {
t.Error("Check must reject an expired token (fail-closed)")
}
}
// TTL<=0 视为功能关闭:Issue 返回空、Check 恒 false。
func TestTrustedStore_Disabled(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("miniredis: %v", err)
}
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
ts := NewTrustedStore(rdb, 0)
ctx := context.Background()
tok, err := ts.Issue(ctx, 7)
if err != nil {
t.Fatalf("Issue: %v", err)
}
if tok != "" {
t.Error("disabled store should issue empty token")
}
if ts.Check(ctx, "anything", 7) {
t.Error("disabled store should never trust")
}
}