Files
Commilitia-Drop/internal/config/config.go
T
admin 90a3790a98 扫码登录:cdrop 自签会话原语 + 薄账户层 + 受限访客 + 2FA step-up
身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层:
自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。

后端 · 自签会话原语
- web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc
  会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类
- cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、
  shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分
- requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token
- /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP

后端 · 扫码登录(internal/httpapi/qr.go)
- qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper
- 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备
  (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准)
- 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL

后端 · 薄账户层与 2FA step-up
- accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key /
  显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联
- step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地
  换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制

前端(web/)
- 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell
  与聚珍排版管线、零新全局样式
- step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、
  独立 state key)后带 step_up_code/verifier 调 approve
- 三语 i18n qr.*;新增 qrcode 依赖

修复 · clipboard sweeper(早已提交的损坏)
- ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移
  bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行
  期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效)
- 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR
  转为正常清理(cleared count=1)

构建
- .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建
- Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络
  构建时传区域镜像即可,仓库不固化区域值)

文档
- 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example /
  compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim)

测试
- 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny /
  step-up 门)、extractIdentity、web_sessions 升级迁移
2026-06-21 22:43:36 +08:00

220 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
const envPrefix = "CDROP_"
type Config struct {
AuthMode string `koanf:"auth_mode"`
DevToken string `koanf:"dev_token"`
DBPath string `koanf:"db_path"`
Listen string `koanf:"listen"`
// DeviceTTLHours is the maximum age of a row in `devices` before the
// background sweeper deletes it. Brief §2 sets the refresh_token sliding
// window at 196h (≈8 days) — devices must not outlive that. Default 196h.
DeviceTTLHours int `koanf:"device_ttl_hours"`
// OIDC settings. Generic OAuth/OIDC provider compatible (Casdoor included).
OIDCAuthorizeURL string `koanf:"oidc_authorize_url"`
OIDCTokenURL string `koanf:"oidc_token_url"`
OIDCJWKSURL string `koanf:"oidc_jwks_url"`
OIDCIssuer string `koanf:"oidc_issuer"`
// OIDCAudience accepts a comma-separated list. A token passes when its `aud`
// matches any one entry — this lets one backend serve several OAuth clients
// that carry different audiences (e.g. the web app and the desktop client).
// Empty disables audience checking; a single value behaves as before.
OIDCAudience string `koanf:"oidc_audience"`
OIDCClientID string `koanf:"oidc_client_id"`
OIDCClientSecret string `koanf:"oidc_client_secret"`
OIDCRedirectURI string `koanf:"oidc_redirect_uri"`
OIDCScopes string `koanf:"oidc_scopes"`
HS256Secret string `koanf:"hs256_secret"`
// SessionSecret keys the AES-256-GCM encryption of browser refresh_tokens at
// rest in web_sessions (the "passwordless re-login" feature). Any-length
// high-entropy string; it's SHA-256'd into a 32-byte key. The key lives only
// here (container env), never in the DB file, so an exfiltrated SQLite file
// can't be decrypted. Required in prod — refuse to boot without it.
SessionSecret string `koanf:"session_secret"`
// Shortcut tokensiOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期
// (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。
// 需配 HS256Secret 才启用,否则签发端点返回 503。
ShortcutTokenTTLDays int `koanf:"shortcut_token_ttl_days"`
ShortcutMaxPerUser int `koanf:"shortcut_max_per_user"`
// Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/).
// When both fields are set, /api/calls/credentials returns short-lived
// TLS-capable TURN creds (turns:turn.cloudflare.com:5349); otherwise the
// endpoint serves a STUN-only fallback so dev / no-CF deploys still work.
CFTurnKeyID string `koanf:"cf_turn_key_id"`
CFTurnAPIToken string `koanf:"cf_turn_api_token"`
// Clipboard:单条覆写式同步。MaxBytes 限制单次写入字节数;DebounceSec 是
// 收到 PUT 后等待"无新写入稳定窗口"的秒数,到期才广播 SSE。同窗口内的
// 后续 PUT 直接刷新 generation 让旧广播取消。
ClipboardMaxBytes int `koanf:"clipboard_max_bytes"`
ClipboardDebounceSec int `koanf:"clipboard_debounce_sec"`
// ClipboardTTLSec 是云剪贴板内容的存活秒数。因为无法可靠区分密码与普通
// 文本(密码.app / 浏览器扩展复制都不打敏感标记),改以短 TTL 限制暴露面:
// 内容超过 TTL 后 GET 返回空,后台 sweeper 亦清除其 content。0 = 不过期。
ClipboardTTLSec int `koanf:"clipboard_ttl_sec"`
// Web Push (RFC 8291/8292):浏览器 / PWA 在页面关闭时仍能收到系统通知。
// 公私钥是自签的 VAPID 密钥对(与 Apple/Google 账号无关),用 `just vapid-keygen`
// 生成一次后固定——更换会使所有既有订阅失效。两者皆设才启用推送,缺一即整体
// 关闭(订阅端点返回 503,事件回退为「仅在线设备收 SSE」)。Subject 是 VAPID
// 要求的联系标识(mailto: 或站点 URL);留空时回退为部署站点 URL。
VAPIDPublicKey string `koanf:"vapid_public_key"`
VAPIDPrivateKey string `koanf:"vapid_private_key"`
VAPIDSubject string `koanf:"vapid_subject"`
// 扫码登录(AUTH.md §4+ cdrop 自签会话(§3)。cdrop 为「没有 IdP refresh_token」
// 的会话(扫码批准的设备、受限访客借用)自签短效 access tokenHS256,密钥派生自
// SessionSecret)。QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503;其余为各 TTL。
QRLoginEnabled bool `koanf:"qr_login_enabled"`
QRRequestTTLSeconds int `koanf:"qr_request_ttl_seconds"`
QRGuestTTLSeconds int `koanf:"qr_guest_ttl_seconds"`
QRPersistTTLHours int `koanf:"qr_persist_ttl_hours"`
SessionTokenTTLSeconds int `koanf:"session_token_ttl_seconds"`
// StepUpEnabled 给敏感动作(批准扫码设备)加 provider 再认证门(AUTH.md §6):开启后
// qr/approve 必须带一份新鲜的 prompt=login 授权码,后端就地换取 id_token、验签并校验
// auth_time 在窗口内。默认关。StepUpMaxAgeSeconds 是该新鲜度窗口(秒),默认 300。
StepUpEnabled bool `koanf:"step_up_enabled"`
StepUpMaxAgeSeconds int `koanf:"step_up_max_age_seconds"`
// AccountMatchClaim 是写入 accounts.match_key 的 JWT claim,供管理员开启「迁移
// 标记」时跨 provider 关联同一账号(AUTH.md §2.1/§7)。默认 email。
AccountMatchClaim string `koanf:"account_match_claim"`
}
// QRLoginOn reports whether scan-login is enabled and the self-signed session
// machinery it relies on is keyable (SessionSecret present). Without the secret
// cdrop can't sign session tokens, so QR stays off regardless of the flag.
func (c *Config) QRLoginOn() bool {
return c.QRLoginEnabled && c.SessionSecret != ""
}
// Load reads config from optional ./config.yaml then overrides with CDROP_* env.
// Validates dev/prod invariants; returns error if invariants are violated.
func Load() (*Config, error) {
k := koanf.New(".")
k.Set("auth_mode", "prod")
k.Set("db_path", "./cdrop.db")
k.Set("listen", ":8080")
k.Set("device_ttl_hours", 196)
k.Set("oidc_scopes", "openid profile email")
k.Set("clipboard_max_bytes", 65536)
k.Set("clipboard_debounce_sec", 3)
k.Set("shortcut_token_ttl_days", 365)
k.Set("shortcut_max_per_user", 10)
k.Set("qr_login_enabled", true)
k.Set("qr_request_ttl_seconds", 120)
k.Set("qr_guest_ttl_seconds", 3600)
k.Set("qr_persist_ttl_hours", 168)
k.Set("session_token_ttl_seconds", 900)
k.Set("account_match_claim", "email")
k.Set("step_up_max_age_seconds", 300)
if _, err := os.Stat("config.yaml"); err == nil {
if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
return nil, fmt.Errorf("load config.yaml: %w", err)
}
}
envProvider := env.Provider(".", env.Opt{
Prefix: envPrefix,
TransformFunc: func(key, value string) (string, any) {
return strings.ToLower(strings.TrimPrefix(key, envPrefix)), value
},
})
if err := k.Load(envProvider, nil); err != nil {
return nil, fmt.Errorf("load env: %w", err)
}
cfg := &Config{}
if err := k.Unmarshal("", cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) validate() error {
switch c.AuthMode {
case "dev":
if c.DevToken == "" {
return errors.New("CDROP_AUTH_MODE=dev requires CDROP_DEV_TOKEN; refusing to start")
}
case "prod":
// Audience MUST be set in prod (R6). With it empty, RS256 audience
// checking is skipped entirely, so ANY token the IdP mints for ANY
// application sharing this JWKS would validate here — a user of a
// sibling Casdoor app could impersonate a cdrop user. The web + desktop
// client_ids go in CDROP_OIDC_AUDIENCE (comma-separated); refuse to boot
// without it rather than run wide open.
if c.OIDCAudience == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " +
"(comma-separated OAuth client_ids); refusing to start")
}
// SessionSecret keys at-rest encryption of browser refresh_tokens. Empty
// would either disable passwordless re-login or, worse, tempt a plaintext
// fallback; require it so the encryption path is always live in prod.
if c.SessionSecret == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " +
"(keys web-session refresh_token encryption); refusing to start")
}
default:
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
}
// Web Push is optional, but a half-configured pair is always an operator
// mistake: with only one key set, every push send would fail at runtime
// while the feature looks "on". Fail fast instead of silently degrading.
if (c.VAPIDPublicKey == "") != (c.VAPIDPrivateKey == "") {
return errors.New(
"CDROP_VAPID_PUBLIC_KEY and CDROP_VAPID_PRIVATE_KEY must be set together " +
"(run `just vapid-keygen`); set neither to disable Web Push")
}
return nil
}
// PushEnabled reports whether Web Push is configured (both VAPID keys present).
func (c *Config) PushEnabled() bool {
return c.VAPIDPublicKey != "" && c.VAPIDPrivateKey != ""
}
// VAPIDSubjectOrDefault returns the VAPID contact identity. Push services want a
// way to reach the operator; the configured value wins, else the deployment's
// own origin (derived from the OIDC redirect URI), else the product URL.
func (c *Config) VAPIDSubjectOrDefault() string {
if c.VAPIDSubject != "" {
return c.VAPIDSubject
}
if u, err := url.Parse(c.OIDCRedirectURI); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host
}
return "https://drop.commilitia.net"
}