鉴权并入 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:
+70
-72
@@ -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 tokens:iOS 快捷指令用的长效 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 token(HS256,密钥派生自
|
||||
// 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"
|
||||
|
||||
Reference in New Issue
Block a user