This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/adr/ADR-010-auth-session.md
T
xcsandClaude Opus 5 bb22545bdf 路由表字面重复也掀桌子;死信没收件人时出声;票据横向范围与「权限只到端」写进 ADR
**I1:字面路由重复注册仍是静默覆盖 —— 与参数侧的掀桌子不对称。**
两条**字面**契约同方法同路径撞上时,后写覆盖先写,命中谁全看 `Object.values(contracts)`
的顺序。参数模版撞了会抛(理由是「命中谁只看声明顺序 = 静默判错权限」),而字面这一半
没设防:同一个病、同一个后果。把守卫从 59 处收成 1 处之后,注册表是**唯一**的授权
事实源,它的自检不该有半边缺口。`size` 一并从 `routes.length` 改成
`literal.size + patterns.length`(重复被拒之后两者相等,但语义上它说的是「表里有几条」)。
`lookup` 上补一句:它只认 HTTP_METHODS 那五个,**归一是调用方的职责**;测里钉了
`lookup('HEAD', …)` 仍是 undefined —— 将来有人把中间件那行归一挪走,这条分工还在。

**顺手两条:**
- 死信通知的收件人为空时不出声(`deadLetters.report`),而 container 的 governanceStaff
  对同一件事会往 stderr 喊一句 —— 后者的理由(把「出事了」无声丢掉最糟)在前者同样成立。
- ADR-010 状态行补「已被 ADR-012 取代」:ADR-012 说了取代,但先翻到 ADR-010 的人看不到。

**两条不修,写进文档:**
- 票据的**横向范围**进 ADR-012 与 debt:票据里没有用途,60 秒内它等于持票人的
  **全部读权限** —— 凭一枚为下载附件取的票据,同一分钟里读得了整张客户主数据。
  不是提权(那些读权限他本来就有),风险在留痕面(URL 走浏览器历史 / 反代日志 / Referer)。
  收敛做法记进 debt:签发时把用途绑进票据(scope ∈ {stream, file}),或中间件对认票据的
  路径设白名单。
- 「权限只到端」的影响面进 ADR-012 的「后果」:design 现在同时意味着装活动、跑测试台、
  传文件、探大模型;design|ops 意味着 `GET /api/gov/data/:domain/:object` 能翻整张客户
  主数据(**从前要 data:read 才行**)。半年后有人问「为什么设计的人看得到客户手机号」,
  ADR 里得有这句话在。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
2026-08-25 19:42:48 +08:00

81 lines
3.9 KiB
Markdown

# ADR-010 鉴权会话方案
状态:已接受(2026-07-27);已被 ADR-012 取代(2026-08-25)
背景出处:编码规范 §4「鉴权定调:单组织 cookie session —— 登录、会话与岗位权限校验中间件在
apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制」;
§12 留白清单「鉴权会话与凭证加密方案 —— 实现期再定,各建 ADR 后方可动工」。
## 问题
在此之前系统**没有登录、也没有登出**。中间件只做一件事:
```ts
const match = /(?:^|;\s*)jcai_account=([^;]+)/.exec(cookie);
c.set('account', match?.[1] ?? DEFAULT_LOGIN_ACCOUNT);
```
三个后果,前两个是缺功能,第三个是安全缺陷:
1. 没有登录口 —— 谁打开页面都是种子登录者冯兵,无法验多人协作
(A1 冯兵 → A3 林佩萱 → AP 丁子乔 的流转根本走不通)
2. 没有登出口 —— cookie 从未被写入,也就无从清除
3. **身份可任意伪造** —— cookie 是明文账号,浏览器里改一个字符即可冒充任何人,
包括审批人。「驳回必附意见」「完成只出自放行通过」这类规矩全部形同虚设:
规矩管的是**这个人**做了什么,而这个人是谁可以随手改写
## 决策
### 一、凭据:口令 + scrypt
`staff` 增列 `password_hash`。散列用 Node 内置 `crypto.scrypt`(N=16384, r=8, p=1),
格式 `scrypt$N$r$p$<salt-b64>$<hash-b64>`,比对走 `timingSafeEqual`。
**不引第三方散列库**:scrypt 是 Node 标准库自带的内存硬散列,参数与格式自描述,
零依赖即可满足 ≤50 人单组织的口令存储要求。
种子口令取自环境变量 `JCAI_SEED_PASSWORD`,缺省 `jcai-dev-2026`,播种时在 stdout 明示。
**源码里不写任何真实口令**。
### 二、会话:服务端会话表 + 不透明 token
新表 `sys.session`:`id`(即 token,32 字节随机 base64url)、`staff_id`、
`issued_at`、`expires_at`、`last_seen_at`。cookie `jcai_session`,
`HttpOnly; SameSite=Lax; Path=/`,有效期 12 小时,每次命中滑动续期。
**为什么不用签名 cookie(HMAC 自包含)**:登出必须**立刻**失效。签名 cookie 在到期前
无法作废,除非另建吊销表 —— 那还不如直接用会话表。会话表还顺带解决另一件事:
`org` 停用员工时可以一并踢掉他的在途会话,否则被停用的人凭旧 cookie 能继续办事。
### 三、归属:会话归 `org`,中间件归 app
会话是**身份**的一部分,而 `org` 已经拥有 staff / position / grant 与 `loadSession`。
所以 `login` / `logout` / `resolveSession` / `setPassword` 落 `org` 出口;
app 层只放中间件与两条路由 —— 与 §4「中间件在 app 层实现,权限判定读 `org` 出口」一致。
### 四、免鉴权白名单
只三条,其余一律 401:
| 路由 | 理由 |
|---|---|
| `GET /api/health` | 存活探针 |
| `POST /api/login` | 登录本身 |
| `POST /api/ingest/mail/:channelId` | **外界边界**:代替真实 IMAP 拉件,不是人的请求 |
`/api/stream`(SSE)此前完全无鉴权,现纳入鉴权 —— 它推的是「哪个目标动了」,
虽不含业务载荷,但目标 ID 本身即信息。
### 五、明文 `jcai_account` cookie 立即废止,不走三步弃用
三步弃用是给**契约变更**留的缓冲期。这一条是安全缺陷修复:保留兼容期
等于保留一条「改 cookie 即可冒充审批人」的旁路。同理,`DEFAULT_LOGIN_ACCOUNT`
不再作为请求身份的兜底,只保留其种子语义(播种时谁是第一个员工)。
## 后果
- 前端必须有登录页:`/api/session` 返回 401 即落登录页,登录成功后进原台面
- 开发期用 curl 打接口需要先 `POST /api/login` 拿 cookie,不能再靠缺省身份
- 口令重置目前只在种子期给出;治理台的「员工」面后续可加重置动作(未做,见 debt)
- 未做:失败次数限制 / 锁定、口令强度校验、首登强制改密。单组织 ≤50 人、
部署在内网的前提下先不做,列入 `docs/debt.md`