鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移

后端(委托 Auth Broker,路径 A):
- 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest()
- internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等

统一会话模型“委派设备会话”(Delegated Device Sessions):
- 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表
- 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备
- 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定
- 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行

Web 前端:
- 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh
- 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环
- 去 OIDC PKCE / step-up(删 oauth.callback / stepUp)

桌面客户端(Wails):
- loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置

iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥):
- 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库)
- 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId

实时 / 健壮性:
- presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线)
- Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热)

配置 / 删旧栈:
- config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效)
- 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传)
- 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
2026-06-26 02:07:11 +08:00
parent c79b176b87
commit 10cf36ecee
104 changed files with 7533 additions and 5318 deletions
+70 -72
View File
@@ -27,35 +27,38 @@ type Config struct {
// 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"`
// PublicURL 是部署的公开源(scheme://host),如 https://drop.commilitia.net。
// 用于拼扫码登录的 QR 链接、作 Web Push 的默认联系标识。dev 留空时回退到请求自身
// 的 Host。
PublicURL string `koanf:"public_url"`
HS256Secret string `koanf:"hs256_secret"`
// Auth Broker 接入(路径 A)。cdrop 把身份与会话生命周期委托给 broker:每个受控
// 请求在边缘由 broker /verify(经 Caddy)鉴权并注入 X-Auth-* 头,cdrop 直接信任;
// 扫码批准时经 broker 内部 API 委托签发会话,cdrop 不再自签。
// BrokerBaseURL —— broker 内网地址(如 http://broker-internal-ip:8080),在 docker
// 内网直连(绝不经公网反代,以保 /internal/* 与 /refresh 可达)。
// BrokerInternalKey —— broker /internal/* 端点的共享密钥(作 X-Internal-Key 发送),
// prod 必填。
// BrokerApp —— 本应用在 broker apps 注册表里的 key(默认 "cdrop")。
BrokerBaseURL string `koanf:"broker_base_url"`
BrokerInternalKey string `koanf:"broker_internal_key"`
BrokerApp string `koanf:"broker_app"`
// 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"`
// BrokerPublicURL is the broker's PUBLIC origin (e.g. https://sso.commilitia.net)
// — where a browser is sent to log in via global SSO. Distinct from BrokerBaseURL
// (the internal address cdrop's server-to-server calls use). GET /api/auth/login
// 302-redirects here; empty disables that redirect (native-only deployments).
BrokerPublicURL string `koanf:"broker_public_url"`
// 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"`
// 按档令牌 TTL(秒):cdrop 经 broker 委托签发会话时请求的 access / refresh 寿命。
// full=可信设备(access 短、refresh 一周、滑动);guest=扫码借用(同样的短 access、
// refresh 一天)。两档都在 broker 的 app 上限内(access 3600 / refresh 604800),
// clampTTL 不会压它们。broker /verify 每请求查活,短 access 只决定续期频率、不影响
// 吊销即时性。
FullAccessTTLSeconds int `koanf:"full_access_ttl_seconds"`
FullRefreshTTLSeconds int `koanf:"full_refresh_ttl_seconds"`
GuestAccessTTLSeconds int `koanf:"guest_access_ttl_seconds"`
GuestRefreshTTLSeconds int `koanf:"guest_refresh_ttl_seconds"`
// Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/).
// When both fields are set, /api/calls/credentials returns short-lived
@@ -83,31 +86,26 @@ type Config struct {
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"`
// 扫码登录(QR):新设备出码、已登录设备扫码批准,经 Auth Broker 委托签发会话。
// QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503。QRRequestTTLSeconds 是一条扫码
// 请求的存活秒数(默认 120)。批准后铸的会话寿命取上面的按档 TTL(full / guest
QRLoginEnabled bool `koanf:"qr_login_enabled"`
QRRequestTTLSeconds int `koanf:"qr_request_ttl_seconds"`
}
// 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.
// QRLoginOn reports whether scan-login is enabled and the broker it delegates
// minting to is configured. Without BrokerBaseURL cdrop can't mint a session on
// approval, so QR stays off regardless of the flag.
func (c *Config) QRLoginOn() bool {
return c.QRLoginEnabled && c.SessionSecret != ""
return c.QRLoginEnabled && c.BrokerBaseURL != ""
}
// BrokerAppOrDefault returns the configured broker app key, defaulting to "cdrop".
func (c *Config) BrokerAppOrDefault() string {
if c.BrokerApp != "" {
return c.BrokerApp
}
return "cdrop"
}
// Load reads config from optional ./config.yaml then overrides with CDROP_* env.
@@ -119,18 +117,15 @@ func Load() (*Config, error) {
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)
k.Set("broker_app", "cdrop")
k.Set("full_access_ttl_seconds", 900)
k.Set("full_refresh_ttl_seconds", 604800)
k.Set("guest_access_ttl_seconds", 900)
k.Set("guest_refresh_ttl_seconds", 86400)
if _, err := os.Stat("config.yaml"); err == nil {
if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
@@ -166,24 +161,27 @@ func (c *Config) validate() error {
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 == "" {
// cdrop delegates identity + session lifecycle to the Auth Broker (path A):
// without its internal address and shared key, QR-approve can't mint and the
// service is non-functional. Refuse to boot rather than half-run.
if c.BrokerBaseURL == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " +
"(comma-separated OAuth client_ids); refusing to start")
"CDROP_AUTH_MODE=prod requires CDROP_BROKER_BASE_URL " +
"(the Auth Broker internal address); 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 == "" {
if c.BrokerInternalKey == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " +
"(keys web-session refresh_token encryption); refusing to start")
"CDROP_AUTH_MODE=prod requires CDROP_BROKER_INTERNAL_KEY " +
"(shared secret for the broker /internal/* API); refusing to start")
}
// PublicURL fixes the deployment origin the CSRF Origin check compares against
// (deriveSiteOrigin → sameOrigin). Without it sameOrigin fails OPEN, silently
// disabling the only cdrop-side CSRF guard on the state-changing POST endpoints
// (device-session mint, logout). Require it in prod so the guard never fails open.
if c.PublicURL == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_PUBLIC_URL " +
"(the deployment origin, used for the CSRF check); refusing to start")
}
default:
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
@@ -212,7 +210,7 @@ func (c *Config) VAPIDSubjectOrDefault() string {
if c.VAPIDSubject != "" {
return c.VAPIDSubject
}
if u, err := url.Parse(c.OIDCRedirectURI); err == nil && u.Scheme != "" && u.Host != "" {
if u, err := url.Parse(c.PublicURL); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host
}
return "https://drop.commilitia.net"