Files
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

226 lines
6.7 KiB
Go

package admin
import (
"encoding/base64"
"encoding/hex"
"fmt"
"net"
"os"
"strings"
"time"
)
// Config holds all configuration for the admin backend listener.
//
// Security invariants enforced here (see doc/02 §1 and doc/06 §2 红线
// "管理后台不得暴露公网"):
// - Listen must never bind 0.0.0.0 / :: / an empty host; only a concrete
// loopback or internal address is accepted.
// - AllowCIDRs defaults to loopback + RFC1918 / ULA internal ranges only.
type Config struct {
// Listen is the admin HTTP listen address, e.g. "127.0.0.1:9443".
Listen string
// AllowCIDRs is the IP allowlist applied by mw_ipallow. A request whose
// source address is not contained in any of these networks is rejected
// with 403 before any handler runs.
AllowCIDRs []*net.IPNet
// SecretKey is the 32-byte key (AES-256) used to encrypt TOTP secrets at
// rest and to sign session/CSRF tokens' opaque ids are random, not signed.
SecretKey []byte
// SessionTTL is the sliding idle timeout for an admin session.
SessionTTL time.Duration
// LoginFailMax is the number of consecutive failed logins (per username)
// before the account is temporarily locked.
LoginFailMax int
// LoginLockDuration is how long a username stays locked after hitting
// LoginFailMax.
LoginLockDuration time.Duration
// 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
// default — the admin port must only be reachable over SSH tunnel / intranet.
var defaultInternalCIDRs = []string{
"127.0.0.0/8",
"::1/128",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
}
// FromEnv builds a Config from environment variables, applying safe defaults.
//
// ADMIN_LISTEN listen address (default 127.0.0.1:9443)
// ADMIN_ALLOW_CIDRS comma-separated allowlist(default internal ranges)
// ADMIN_SECRET_KEY hex/base64 32-byte key (required)
// ADMIN_SESSION_TTL Go duration (default 30m)
// 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"),
SessionTTL: 30 * time.Minute,
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 {
return nil, err
}
cidrs := os.Getenv("ADMIN_ALLOW_CIDRS")
var raw []string
if strings.TrimSpace(cidrs) == "" {
raw = defaultInternalCIDRs
} else {
raw = splitTrim(cidrs)
}
nets, err := ParseCIDRs(raw)
if err != nil {
return nil, err
}
c.AllowCIDRs = nets
key, err := parseSecretKey(os.Getenv("ADMIN_SECRET_KEY"))
if err != nil {
return nil, err
}
c.SecretKey = key
if v := os.Getenv("ADMIN_SESSION_TTL"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("config: ADMIN_SESSION_TTL: %w", err)
}
c.SessionTTL = d
}
if v := os.Getenv("ADMIN_LOGIN_LOCK"); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return nil, fmt.Errorf("config: ADMIN_LOGIN_LOCK: %w", err)
}
c.LoginLockDuration = d
}
if v := os.Getenv("ADMIN_LOGIN_FAIL_MAX"); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
return nil, fmt.Errorf("config: ADMIN_LOGIN_FAIL_MAX must be a positive integer")
}
c.LoginFailMax = n
}
return c, nil
}
// validateListen rejects any address that would expose the admin port on a
// public/wildcard interface. This is the code-level guard behind the red line.
func validateListen(addr string) error {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("config: ADMIN_LISTEN %q invalid: %w", addr, err)
}
host = strings.TrimSpace(host)
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
return fmt.Errorf("config: ADMIN_LISTEN must bind a concrete internal address, not a wildcard (%q)", addr)
}
ip := net.ParseIP(host)
if ip == nil {
// A hostname (e.g. an internal DNS name) is permitted; we cannot resolve
// here, but we have rejected the obvious wildcard forms above.
return nil
}
if ip.IsUnspecified() {
return fmt.Errorf("config: ADMIN_LISTEN must not be the unspecified address (%q)", addr)
}
return nil
}
// ParseCIDRs parses a list of CIDR strings into *net.IPNet.
func ParseCIDRs(raw []string) ([]*net.IPNet, error) {
nets := make([]*net.IPNet, 0, len(raw))
for _, r := range raw {
r = strings.TrimSpace(r)
if r == "" {
continue
}
// Allow bare IPs by appending the host-route mask.
if !strings.Contains(r, "/") {
if strings.Contains(r, ":") {
r += "/128"
} else {
r += "/32"
}
}
_, n, err := net.ParseCIDR(r)
if err != nil {
return nil, fmt.Errorf("config: invalid CIDR %q: %w", r, err)
}
nets = append(nets, n)
}
if len(nets) == 0 {
return nil, fmt.Errorf("config: empty IP allowlist")
}
return nets, nil
}
func parseSecretKey(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, fmt.Errorf("config: ADMIN_SECRET_KEY is required (32-byte hex or base64)")
}
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
return nil, fmt.Errorf("config: ADMIN_SECRET_KEY must decode to exactly 32 bytes (hex or base64)")
}
func getEnvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func splitTrim(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}