Files
admin 018a77b13a 登录子系统:OIDC 统一自签 token + 吊销即时生效 + 设备并入会话列表 + 应用内扫码 + 禁则修复
接续 90a3790(扫码 A0+A1+B + step-up 雏形)。蓝本见 AUTH.md。

OIDC 统一到 cdrop 自签 token
- /auth/exchange 先 JWKS 验 id_token 签名(自签 token 的唯一信任锚)再签 cdrop
  自签 HS256 access token(绑 sid),不再把 IdP RS256 下发浏览器;验签失败仅告警
  并回退 RS256(会话仍建、下次 refresh 自愈),登录不中断
- /auth/refresh oidc 路径仍打 IdP 轮换 refresh_token + 探测 IdP 侧吊销,但同样回
  自签 token;createWebSession 改返回行 id
- verify 链保留 RS256 分支仅供桌面端 loopback(其即时吊销留后续)

吊销即时生效 + 被吊销设备自知回退
- verifySelfToken 每请求按 sid 查 web_sessions 行,行删即拒 → 吊销在被吊销设备
  下一次请求即生效(不等 TTL),oidc / self / guest 一致
- 前端仅在「服务端以 401 确证会话失效」时 forceLogout 清登录态、回登录页;短期连接
  失败(5xx / 网络)一律保留登录态(refreshTokens 改三态 refreshed/invalid/transient,
  cookieRefresh 改 discriminated 结果)
- __root 反应式守卫:prod 下失登录态且非认证路由即跳登录页;新增 auth.sessionLost.* 三语

设备列表并入会话列表 + 孤儿自动清除
- GET /api/auth/sessions 统一 web_sessions 与原生客户端设备(桌面 / iOS,自 devices
  登记表呈现,native=true,离线也列);同名不重复列出,排除 shortcut 与无会话 browser
- 吊销网页会话连带删其设备登记(无同名在用会话时);原生「登出」走
  DELETE /api/devices/{name}(同要求 step-up)
- 新增 DeleteOrphanBrowserDevices(browser 型且无存活 web_session),接入设备清扫器(1h)

应用内扫码 + 聚珍崩溃 / CJK 禁则修复
- 登录页内置 getUserMedia + jsqr 扫码,不再外跳系统应用(避免开错浏览器)
- 修聚珍(cjk-autospace)MutationObserver 与 React 重渲染同子树冲突致的 insertBefore
  崩溃:扫码 / 显码 / 批准三状态机页整页跳过聚珍(data-jz-skip)
- 修流动正文未跑 CJK 禁则(jinze 为段落级、须 opt-in):设置 / 快捷指令 / 桌面页一批
  hint 补 data-jz-level="paragraph" + justify,短标签 / 状态仍留文本级

step-up 完善
- auth_time 改可选(Casdoor 不下发,原强制致死循环);stepped_up_at 按会话绑定、
  StepUpMaxAgeSeconds 窗口内不复要求;新增 POST /api/auth/stepup
- 批准页 persist 编进 step-up returnTo 防整页跳转丢失;scope 随档绑定(信任=full /
  仅此次=guest),默认偏安全的「仅此次」

文档与测试
- AUTH.md:折中架构、accounts 薄表、令牌架构、扫码流程、step-up、§4.4 统一会话列表
- 新增 Go 测试:OIDC 自签验证、verifySelfToken 吊销即拒、会话吊销连带删设备、
  统一会话列表(含原生 / 不含 shortcut / 不重复)、孤儿清扫、删设备需 step-up
2026-06-22 11:55:02 +08:00

292 lines
25 KiB
Markdown
Raw Permalink 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.
# cdrop 账户与登录实施计划
> 内置账户层 · OAuth 为账户来源与凭证提供者 · cdrop 自维护薄账户数据与自签会话 · 扫码快登优先
> 状态:**规划中**。本文是折中架构定稿后的实施蓝本——把边界、数据模型、令牌架构、扫码流程、里程碑讲清楚,按里程碑分批落地。
> 关联:根 `README.md``internal/jwtauth`(认证中间件 / verify 链 / JWKS)、`internal/httpapi/session.go`(浏览器免重登 session)、`internal/httpapi/shortcut.go`HS256 scoped token)、`internal/db/migrations`。记忆 `cdrop-state` / `cdrop-next-phase` / `cdrop-security-scan`。
---
## 0. 定位、折中架构与关键决策
### 折中:OAuth 为体的凭证、cdrop 为体的会话
三方在 2026-06-21 收敛到一个折中位置,介于“OIDC 锚定身份”(原方案)与“内置账号系统取代 OIDC”(全反转)之间:
1. **身份来源仍是 OAuth provider**(当前自建 Casdoor)。`user_id` 继续等于 OIDC `sub`,cdrop 不引入内部 id 间接层。
2. **凭证机制全部委派给 provider**——账号密码、Passkey、2FA、密码重置、注册,cdrop 一律**不实现**。Casdoor 本就提供这些,cdrop 发起 OAuth 跳转即可复用。
3. **cdrop 只在其上自维护薄薄一层**:一张 `accounts` 表(cdrop 侧账户数据,不含任何凭证)+ 一套**自签会话令牌**(脱离 IdP refresh 流程,供扫码 / 访客 / 持久设备会话使用)。
4. **产品化时加一个内建、可启用的 OAuth 后端**。cdrop 消费侧保持“只认一个 OIDC provider”,该后端是 drop-in:今天 provider 是外部 Casdoor,将来翻开关变成内建的。账号密码 / Passkey / 2FA 那些机制届时才进 cdrop 代码库,但被隔离在 provider 模块里。
一句话:**凭证是 provider 的事,会话是 cdrop 的事,账户数据两边各持一薄层。**
### 决策记录
- **决策 A — 折中架构(已定)**:见上。否决“全反转”,因其令 cdrop 成为凭证保管者(argon2id / 防爆破 / 锁定 / 枚举防御 / 无邮件密码重置),攻击面与维护成本过高;这些 Casdoor 已成熟承担。
- **决策 B — `user_id` 继续 = OIDC `sub`,不引内部 id**。新增薄 `accounts` 表(键在 `sub`),其中存一个**稳定匹配键**(默认取 JWT 的 `email` claim)。日常切换 OAuth provider 即账号失效——**运维须知此前提**。仅当管理员显式启用“后端迁移标记”时,cdrop 才在新源登录时按该匹配键自动关联回同一账号(受控窗口,非日常自动 link,规避账号接管)。
- **决策 C — 2FA 委派给 provider**。cdrop 不自存任何第二因子密钥,仅保留敏感动作的 **step-up 钩子**(要求 provider 再认证),默认关闭。
- **决策 D — 扫码方向 = 陌生设备显码、已登录手机扫码批准**(微信 / WhatsApp 网页版模型)。陌生设备常无摄像头但有屏,最贴合“临时借设备传文件”。
- **决策 E — 扫码会话两档,scope 与持久性绑定**。批准时二选一:“信任此设备(完整会话,7 天滑动)”——授予 scope=full、kind=self,可完整使用本账号(自有设备用);“仅此次(受限访客,1 小时)”——授予 scope=guest、kind=guest,能收发文件与消息,但不能改账号、不能再批准别的设备、不能签发长效 token(陌生/借用设备用)。默认偏向更安全的“仅此次”。(原设计两档皆 guest、仅持久性不同;2026-06-21 实测后改为 scope 随档绑定,消除“信任却受限”的语义矛盾。)
> 本文取代 `.scratch/auth-inversion-diff.html` 中按“全反转”写的 §四 大半——那五个“凭证保管者”决策(无邮件重置 / 开放注册 / argon2id 等)在折中下随委派 provider 而消散。
---
## 1. 设计边界:cdrop 建什么 / 委派什么
| 能力 | 归属 | 说明 |
|---|---|---|
| 账号密码登录 | **provider** | Casdoor 登录页提供;cdrop 只发起 OIDC PKCE 跳转 |
| Passkey / WebAuthn 登录 | **provider** | 同上,provider 登录页内完成 |
| 2FATOTP / 短信 / passkey-as-2FA | **provider** | cdrop 仅保留 step-up 钩子(决策 C |
| 密码重置 / 找回 | **provider** | Casdoor 邮件流;cdrop 无 SMTP 设施,不介入 |
| 注册 / 开放注册策略 | **provider** | Casdoor 注册配置 |
| 账号枚举 / 防爆破 / 锁定 | **provider** | 凭证侧防护全在 provider |
| **扫码快速登录** | **cdrop** | 唯一的纯新增功能(§4 |
| **自签会话令牌** | **cdrop** | 脱离 IdP refresh,供扫码 / 访客 / 持久会话(§3) |
| **cdrop 侧账户数据** | **cdrop** | `accounts` 薄表:匹配键 / 显示名 / 头像 / 角色缓存(§2) |
| 设备列表 / 在线状态 | **cdrop** | 已有,沿用,键在 `user_id` |
| 会话 / 设备管理与吊销 | **cdrop** | 管理 cdrop 自签会话(§3、§4) |
**Provider 抽象边界**cdrop 认证消费侧(`/api/auth/config`、PKCE exchange、JWKS 验签)只依赖“一个 OIDC provider”的标准面,不内嵌任何 provider 专有逻辑。这是内建后端(§7)能非破坏性接入的前提。
---
## 2. 数据模型
### 2.1 新增 `accounts`(薄账户数据层)
```sql
CREATE TABLE IF NOT EXISTS accounts (
user_id TEXT PRIMARY KEY, -- = OIDC sub,与既有各表的 user_id 一致
match_key TEXT NOT NULL DEFAULT '', -- 稳定匹配键(默认 email claim),供迁移标记关联
display_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
roles TEXT NOT NULL DEFAULT '', -- OIDC groups claim 缓存(逗号分隔),admin 判定用
provider TEXT NOT NULL DEFAULT '', -- 来源标识(如 "casdoor"),迁移时区分新旧源
created_at INTEGER NOT NULL,
last_login_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_match_key ON accounts (match_key);
```
- **写入时机**OIDC exchange 成功后 upsert(捕获 `match_key` / 显示名 / 头像 / `roles`,刷新 `last_login_at`)。
- **不含任何凭证**——密码 / 第二因子全在 provider。
- `match_key` 仅在“迁移标记”开启时被读作 join key(§7),日常不参与登录判定。
### 2.2 泛化 `web_sessions` → 支持自签会话
既有 `web_sessions` 只承载“绑定 IdP refresh_token 的浏览器会话”。扫码 / 访客会话拿不到 IdP refresh_token,需泛化。新增列(`bootstrap.go` 幂等 ALTER 补列,既有行视为 `kind='oidc'``scope='full'`):
```sql
ALTER TABLE web_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'oidc'; -- oidc | self | guest
ALTER TABLE web_sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'; -- full | guest
ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''; -- 扫码批准者的 session id(审计 / 连带吊销)
-- refresh_token 改为“可空语义”:self / guest 会话无 IdP refresh_token,存空串
```
| kind | refresh_token | refresh 行为 | 用途 |
|---|---|---|---|
| `oidc` | AES-GCM 密文 | 向 IdP 续 + 自签 access tokenIdP 拒绝则作废(保留 IdP 吊销传播) | 正常浏览器登录 |
| `self` | 空 | 仅按 session 行自签 access token,不触 IdP | 桌面 / 持久设备(未来)、扫码“信任” |
| `guest` | 空 | 同 `self`,但 `scope='guest'` 受限 | 扫码“仅此次” / 受限借用设备 |
### 2.3 新增 `login_requests`(扫码)
```sql
CREATE TABLE IF NOT EXISTS login_requests (
id TEXT PRIMARY KEY, -- request_idQR 与批准用
poll_secret TEXT NOT NULL, -- SHA-256(新设备私有轮询密钥),QR 不含,仅新设备持有
approval_code TEXT NOT NULL, -- 短码,随 QR 给批准方
status TEXT NOT NULL, -- pending | approved | denied | consumed | expired
new_device_name TEXT NOT NULL,
new_device_type TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
request_ip TEXT NOT NULL DEFAULT '',
approver_user_id TEXT NOT NULL DEFAULT '', -- 批准后填
grant_scope TEXT NOT NULL DEFAULT '', -- full | guest
grant_persist TEXT NOT NULL DEFAULT '', -- once | persist
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, -- 短 TTL(默认 120s
approved_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires_at);
```
后台 reaper 清 `expires_at < now`(复用 `RunWebSessionReaper` 同款 goroutine)。
### 2.4 不动的表
`devices` / `shortcut_tokens` / `transfer_sessions` / `clipboard_state` / `push_subscriptions` 全部只键在 `user_id`,**一字不改**。这是折中改造面可控的根本原因——应用主体被 `user_id` 抽象隔离。
---
## 3. 令牌与会话架构
### 3.1 cdrop 自签 access token
现有架构:cookie`web_sessions`,窄 `Path=/api/auth`)→ `/auth/refresh` 签发 access token → JS 内存持有 → 作 Bearer 打 `/api/*`。折中改动**只在“access token 由谁签”**:从“IdP 签的 RS256”改为“**cdrop 自签的 HS256**”。
- **签名密钥**:从 `SESSION_SECRET` 派生独立 HMAC 密钥(`DeriveSessionTokenKey(SESSION_SECRET)`,与既有 `DeriveHS256Key(HS256Secret)` 区分密钥域)。`SESSION_SECRET` 在 prod 本就强制非空,无新增必填密钥。
- **载荷**`{ sub: user_id, sid: session_id, typ: "session", scope: "full"|"guest", iat, exp }`。短 TTL(默认 900s)。
- **校验**:中间件新增 `verifySelfToken` 分支(§9)。靠 `typ:"session"` + 独立签名密钥与 scoped shortcut token`typ` 缺省、`scope:"clipboard"`、走 `shortcut_tokens` 查吊销)区分,互不串验。
- **吊销 / 撤销(即时)**:长效凭证是 `web_sessions` 行;access token 短命且**绑定 `sid`**。`verifySelfToken` 每请求按 `sid` 查 session 行——行被删除(吊销)即当场拒绝,故吊销在被吊销设备的**下一次请求**即生效,而非等一个 TTL。`oidc` 会话 refresh 仍打 IdP,保留 IdP 侧登出 / 吊销传播。
- **被吊销设备的感知与回退**:服务端拒绝之外,前端把“访问被拒且续期也被拒”视作会话不可恢复 → 就地清空登录态并回到登录页(`net/api.ts``forceLogout` + `__root` 反应式守卫)。**离线设备**于下一次使用(请求 401 或开机 cookie 续期失败)得知;**在线设备**经 `Hub.Kick` 关流 → 约 1s 后重连撞 401 得知。两路殊途同归到 `verifySelfToken``sid` 查行。
> RS256IdP access token)已退出浏览器每请求热路径:浏览器登录(含 OIDC)一律持自签 tokenOIDC exchange 时验 `id_token` 签名一次(§5)作唯一信任锚,refresh 仍打 IdP 轮换 refresh_token 但同样回自签 token。中间件保留 RS256 分支仅供**桌面端**(自跑 loopback PKCE、直带 IdP token);桌面端即时吊销留后续。
### 3.2 受限能力门(guest 会话)
复用既有 `rejectScoped`(挡 scoped shortcut token 进非 clipboard 路由)的同款思路,新增 `requireFullSession` 守卫。`scope:"guest"` 的会话:
- **放行**`/clipboard``/transfer/*``/relay/*``/message``/hub/*``/devices`(看列表)、`/me``/push/*`
- **拒绝**`/auth/qr/approve`(再批准设备)、`/shortcut/*`(签长效 token)、`/devices/{name}` DELETE 别的设备、未来账号设置 / 会话管理。
能力矩阵随路由分组实现,集中在 `server.go` 中间件层(§9)。
---
## 4. 扫码登录(优先里程碑 B)
陌生设备显码、已登录手机扫码批准。**三方密钥分离**是安全核心:QR 可被偷拍,故 QR 里只放“能发起批准请求”的信息,而会话只投递给持有私有轮询密钥的原始新设备。
### 4.1 流程
1. **新设备发起**`POST /api/auth/qr/start { device_name, device_type }`(公开、限流)→ 服务端建 `login_requests` 行,返回 `{ request_id, poll_secret, qr_payload }`
- `poll_secret`:新设备**私有**,仅此响应返回一次,QR 不含。
- `qr_payload``request_id` + `approval_code`,编码进二维码展示。
2. **新设备轮询**`GET /api/auth/qr/status?request_id=…`HTTP header 带 `poll_secret`(公开、限流)。`pending` 时长轮询 / SSE 挂起等待。
3. **手机扫码**:扫得 `qr_payload` → 打开 `/link?request_id=…&code=…`。**须已登录**(否则先走正常 OAuth 登录)。页面显示“批准 <设备名>·<类型>·<IP 粗粒度> 登入你的账号?”,可选“信任此设备 7 天”或“仅此次 1 小时”,再点【批准】/【拒绝】。
4. **手机批准**`POST /api/auth/qr/approve { request_id, approval_code, scope, persist }`**完整会话鉴权 + `requireFullSession`**,可选 step-up)。服务端校验请求 `pending` 且未过期、`approval_code` 匹配 → 建 `web_sessions` 行(`user_id=批准者``kind=self|guest``scope``expires_at``persist``granted_by=批准者 sid`)→ 标 `login_requests``approved`
5. **新设备领取**:下一次 `qr/status` 轮询(凭 `poll_secret`)见 `approved` → 响应 `Set-Cookie`(新设备自己的 TLS 连接)下发 session cookie + 返回首枚 access token → 标 `consumed`(单次)。
**手机全程只发送批准、从不接触新设备的 session 密钥**;新设备的 cookie 只经其自身轮询连接下发。
### 4.2 安全要点
- `approval_code` / `poll_secret` 高熵随机;`login_requests` 短 TTL(默认 120s),`consumed` / `expired` 后不可复用。
- 偷拍 QR 者:无 `poll_secret`(领不到会话)、未登录(批不了准),双重失败。
- 访客会话受 §3.2 能力门约束;`granted_by` 支持“吊销我批准过的借用设备”与连带吊销。
- 批准动作可挂 step-up(§6)。
### 4.3 端点小结
| 方法 路径 | 鉴权 | 作用 |
|---|---|---|
| `POST /api/auth/qr/start` | 公开 + 限流 | 新设备建请求,返 `request_id` / `poll_secret` / `qr_payload` |
| `GET /api/auth/qr/status` | `poll_secret``X-Poll-Secret` 头)+ 限流 | 新设备长轮询,批准后**在本响应** Set-Cookie + 返回 access token,并标 `consumed`(单次) |
| `GET /api/auth/qr/request` | 完整会话 + `approval_code` | 批准方读新设备信息(名 / 类型 / 来源 IP)以渲染确认页 |
| `POST /api/auth/qr/approve` | 完整会话 + `requireFullSession` | 手机批准,记录 approver / scope / persist(会话在新设备领取时才建) |
| `POST /api/auth/qr/deny` | 完整会话 | 手机拒绝 |
前端:新设备“显码页”+ 手机“批准页”+ 设备 / 会话管理(列出 `granted_by` 借用设备、可吊销)。
### 4.4 登录会话列表 = 全部设备(统一视图 + 自动清除)
「登录会话」面板收敛到“一台设备 = 一条会话”:列表即当前登录此账号的全部设备。既往“手动清除设备”入口已撤,导致被吊销设备长期滞留——本节即为此消除。
- **统一列表**`GET /api/auth/sessions`):`web_sessions`(浏览器 oidc / self / guest、扫码)**并入**原生客户端设备(桌面 / iOS,`type ∈ {macos, windows, linux, ios}`)。原生客户端用 IdP 令牌、不入 `web_sessions`,故从设备登记表 `devices`(即决策里说的“等价表”)呈现:`id` 形如 `device:<设备名>``kind` 取设备类型、`native=true`。同名已有会话的设备不重复列出;无会话的 `browser` 型(孤儿)与 `shortcut` 型(自有面板)不在此列;过期会话已隐藏。这满足“原生设备也在会话列表登记”,且离线原生设备照常显示(`devices` 留有 `last_seen`)。
- **吊销即清设备**:吊销网页会话(`DELETE /api/auth/sessions/{id}`)在删会话行后**连带删除其设备登记**(除非另有同名在用会话),再 Kick + 广播 presence → 被吊销设备即时从所有列表消失。原生客户端的“登出”改走 `DELETE /api/devices/{name}`(同样要求 step-up),删设备 + Kick;该设备若仍在线,会在下次请求经中间件重新登记(原生**即时**吊销需客户端配合,留后续)。
- **孤儿自动清除**:设备清扫器(每 1h)除按 `last_seen` TTL 清陈旧设备外,新增清除“`browser` 型且无存活 `web_session`”的孤儿(会话已撤 / 过期但设备行滞留);`DeleteOrphanBrowserDevices` 一条 SQL 完成,原生 / shortcut 设备无 `web_session` 属正常、仅受 TTL 清扫。即时路径(吊销)+ 周期路径(清扫)合起来让设备列表与会话列表持续对齐。
---
## 5. 账号密码 / Passkey 登录(委派 provider
几乎零 cdrop 新增——复用既有 OIDC PKCE
- cdrop `/api/auth/config` 已暴露 `authorize_url` 等;前端“登录”按钮发起跳转,**Casdoor 登录页**呈现账号密码 / Passkey / 2FA 表单。
- 回调 → `/api/auth/exchange`(既有)→ 解析 token。**折中下的唯一安全强化**:`id_token` 必须在此**验签**(JWKS)——这是 cdrop 信任 OIDC 的唯一时刻(原方案故意不验、靠后续每请求 JWKS 兜底;自签 access token 后那层兜底消失)。
- exchange 成功 → upsert `accounts`(§2.1,捕获 `match_key` 等)→ 建 `kind='oidc'` 会话 → 自签首枚 access token。
“确保 Casdoor 启用了账号密码 / Passkey / 2FA”是部署配置项,非 cdrop 代码。
---
## 6. 2FA(委派 + step-up 钩子,已实装)
- **默认**2FA 在 provider 完成(Casdoor TOTP / passkey-as-2FA),cdrop 不感知、不存密钥。
- **step-up 钩子**(决策 C,默认关,`CDROP_STEP_UP_ENABLED`;新鲜度窗口 `CDROP_STEP_UP_MAX_AGE_SECONDS`,默认 300):开启后,批准扫码设备(`qr/approve`)这类敏感动作要求一次**现场再认证**。机制:前端先跑一次带 `prompt=login` / `max_age=0` 的新鲜 PKCE,把拿到的授权码 + verifier 交给 `qr/approve`;后端**就地**用它向 IdP 换 `id_token`JWKS 验签 + 校验 `sub` 等于调用者,方才放行(provider 若开了 2FA,即在这次 prompt=login 往返中强制)。`auth_time` 为**可选**——OIDC 仅在 IdP 选择遵循 `max_age` 时才要求它,**Casdoor 不下发**;有则强制 `StepUpMaxAgeSeconds` 窗口,无则以「单次短寿授权码刚换出」作为新鲜度界(否则会因缺 `auth_time` 而恒拒、陷死循环,2026-06-21 实测修正)。失败返回 `403 step_up_required`。授权码一次性、不可重放;cdrop 不存任何第二因子,也不留 step-up 服务端状态——证明随这一次批准请求过期。
- `qr/request` 响应回带 `step_up` 标志,供批准页判断是否需先再认证。
- passkey 作为“快速登录”的补充:陌生设备场景下扫码优于 passkey(passkey 设备绑定,陌生设备上没有),故 passkey 由 provider 承担、不进 cdrop 自建范围。
---
## 7. 内建 OAuth 后端(产品化,远期)
目标:自托管者不必跑外部 Casdoor。设计为 **drop-in provider**,不改 cdrop 消费侧。
- **形态**:cdrop 内嵌(或同容器旁挂)一个最小 OIDC provider 模块,提供账号密码 / Passkey / 2FA / 注册 / 重置。凭证机制届时才进 cdrop 代码库,但**隔离在 provider 模块**,主体仍只认标准 OIDC 面。
- **启用**`CDROP_BUILTIN_OAUTH_ENABLED=true``OIDC_*` 指向内建端点。
- **迁移标记**(决策 B):管理员置 `CDROP_OIDC_MIGRATION_MODE=true` 后,新源登录时按 `accounts.match_key`(默认 email)匹配既有账号并**自动改键关联**(把旧 `sub` 的数据迁到新 `sub`,或登记别名)。受控、可关闭,非日常自动 link。
- 本里程碑不在此展开内建后端内部设计——仅锁定“provider 抽象边界”这一前提,确保现在的实现不堵死将来。
---
## 8. 配置项(“均可配置关闭”)
| 键 | 默认 | 作用 |
|---|---|---|
| `CDROP_QR_LOGIN_ENABLED` | `true` | 扫码登录总开关(关闭则 `qr/*` 返 404 / 503 |
| `CDROP_QR_REQUEST_TTL_SECONDS` | `120` | 二维码 / 登录请求有效期 |
| `CDROP_QR_GUEST_TTL_SECONDS` | `3600` | “仅此次”访客会话 TTL |
| `CDROP_QR_PERSIST_TTL_HOURS` | `168` | “信任此设备”持久会话 TTL(与设备 TTL 一致) |
| `CDROP_SESSION_TOKEN_TTL_SECONDS` | `900` | cdrop 自签 access token TTL |
| `CDROP_SESSION_SECRET` | (prod 必填) | 既有;现额外派生自签会话签名密钥 |
| `CDROP_STEP_UP_ENABLED` | `false` | 批准扫码设备等敏感动作要求 provider 再认证 |
| `CDROP_STEP_UP_MAX_AGE_SECONDS` | `300` | step-up 的 `auth_time` 新鲜度窗口(秒) |
| `CDROP_ACCOUNT_MATCH_CLAIM` | `email` | `accounts.match_key` 取哪个 JWT claim |
| `CDROP_BUILTIN_OAUTH_ENABLED` | `false` | 远期:启用内建 OAuth 后端 |
| `CDROP_OIDC_MIGRATION_MODE` | `false` | 远期:按 `match_key` 关联跨源账号 |
账号密码 / Passkey / 2FA / 注册 / 重置的开关在 **provider**Casdoor)侧,非 cdrop env。
---
## 9. 认证中间件 `verify()` 扩展
现链:`dev → HS256(scoped shortcut) → RS256(OIDC)`。折中后:
```
verify(token):
dev 模式 → verifyDev
自签会话 HS256 → verifySelfToken ← 新增(typ=sessionSESSION_SECRET 派生密钥)
scoped shortcut HS256 → verifyHS256 ← 既有(typ 缺省,HS256Secret 派生,查 shortcut_tokens
OIDC RS256 → verifyRS256 ← 既有,保留作兼容(桌面直带 IdP token)
```
- 凭据来源新增:cookie(既有,`/auth/refresh` 用)之外,自签 access token 作 Bearer 走 `verifySelfToken`
- 路由守卫两层叠加:`requireScope("clipboard")`(既有,scoped token);`requireFullSession`(新增,挡 `scope=guest` 进敏感路由,§3.2)。
- `accounts.roles` 缓存供 admin 判定(既有“admin 仅 groups claim”语义保留,读 `accounts.roles` 而非每次解 token)。
---
## 10. 里程碑
| 里程碑 | 内容 | 阶段 |
|---|---|---|
| **A0** 自签会话原语 | `SESSION_SECRET` 派生会话签名密钥;自签 access token 签发 / 校验(`verifySelfToken`);`web_sessions``kind` / `scope` / `granted_by` 列,refresh 按 `kind` 分流;既有 `oidc` 会话**零行为变化** | 阶段一基座 |
| **A1** `accounts` 薄表 | 建表;exchange 时 upsert`match_key` / 显示名 / 头像 / `roles`);`id_token` exchange 时验签;`/me`、admin 判定改读 `accounts` | 阶段一 |
| **B** 扫码登录(优先) | `login_requests` 表;`qr/start` `qr/status` `qr/approve` `qr/deny`;访客 / 持久会话;`requireFullSession` 能力门;前端显码页 + 批准页 + 设备 / 会话吊销 | 阶段一(端到端,drop.commilitia.net 即可上线) |
| **C** 2FA step-up 钩子 | 敏感动作要求 provider 再认证(`prompt=login` / `max_age`);默认关 | 阶段三 |
| **D** 内建 OAuth 后端 | provider 抽象 drop-in;凭证机制隔离模块;迁移标记按 `match_key` 关联 | 远期 / 产品化 |
> 折中下“阶段二(账号密码登录)”几乎无 cdrop 工作量——账号密码 / Passkey 本就是 provider 提供,A1 的 `accounts` 落地即覆盖其 cdrop 侧需求。重心在阶段一的 **A0 + A1 + B**:自签会话原语 + 薄账户表 + 扫码,先在现有 Casdoor 部署上端到端可用,且对老用户零行为变化。
---
## 11. 安全要点
- **`id_token` 必须 exchange 时验签**——自签 access token 后,原“每请求 JWKS 兜底”消失,OIDC 信任收敛到这唯一一刻。
- **吊销即时**`verifySelfToken` 每请求按 `sid``web_sessions` 行,删行即当场拒绝,被吊销设备下一次请求即失效(不依赖 TTL);前端据 401 + 续期失败回退登录页(§3.1)。自签 access token 短 TTL(默认 900s)只是续期节律,长效凭证只在 `web_sessions` 行。
- **扫码三密钥分离**:QR 仅含批准信息,会话只投私有 `poll_secret` 持有者;偷拍 QR 双重失败。
- **访客会话能力门**`scope=guest` 服务端强制受限(§3.2),不依赖前端自律。
- **迁移标记受控**:跨源 `match_key` 关联仅在管理员显式开启时生效,非日常自动 link,规避 email 接管。
- **密钥域分离**:自签会话密钥(`SESSION_SECRET` 派生)与 scoped shortcut 密钥(`HS256Secret` 派生)不同域,互不串验。
- **限流**`qr/start` / `qr/status` 纳入既有 `/api/auth` 限流桶(60/min per-IP)或单独更严桶。
---
## 12. 不变的部分
设备模型、SSE Hub、传输状态机、剪贴板、Web Push、cookie 机制、AES-256-GCM 落盘、striped refresh 锁、HS256 scoped shortcut 基建——**全部只认 `user_id` 抽象,毫发无损**。折中被牢牢圈在“身份 / 会话层”,应用主体不动。这是本计划改造面可控、可分里程碑安全落地的根本保证。