018a77b13a
接续 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
292 lines
25 KiB
Markdown
292 lines
25 KiB
Markdown
# 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 登录页内完成 |
|
||
| 2FA(TOTP / 短信 / 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 token;IdP 拒绝则作废(保留 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_id,QR 与批准用
|
||
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` 查行。
|
||
|
||
> RS256(IdP access token)已退出浏览器每请求热路径:浏览器登录(含 OIDC)一律持自签 token,OIDC 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=session,SESSION_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` 抽象,毫发无损**。折中被牢牢圈在“身份 / 会话层”,应用主体不动。这是本计划改造面可控、可分里程碑安全落地的根本保证。
|