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
226 lines
6.7 KiB
Go
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
|
|
}
|