# 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` 抽象，毫发无损**。折中被牢牢圈在“身份 / 会话层”，应用主体不动。这是本计划改造面可控、可分里程碑安全落地的根本保证。
