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/前端对接说明-统一鉴权中心.md
T
xcs 0f1e53d114 文档:两份前端文档按真环境核对 —— clientId 实测为 task-dispatcher,示例换成实测响应
设计阶段写的两份对外文档,Task 12 只按契约核过,没按真环境核过。这次跑通之后逐条对:
① clientId 写的是「建议 api-gateway」——实际这个 realm 里 api-gateway 的 mapper 对我们不适用,
   本部署是 task-dispatcher(两个前端 client 都已挂好指向它的 audience mapper,实测确认);
② e2e 账号写的是 bpos-e2e,实际用的是 task-dispatcher 的服务账号,且它只有 service+user
   两个角色 → 只进得了工作台,要跑测试台得另配;
③ 「ends 为空」原本像个假想边界,实际 service 角色就是——没配进「角色→端」表的角色一律不加端;
④ §8 的响应示例从「按契约构造」换成实测响应(/api/me 的 JIT 建档结果、403 的真 message、
   aud 不对时的 401、票据的真形状);
⑤ 两处「核对状态」改写:前置已就位、整条链真环境验过,并写明仍未实测的那一件——用真人 token 走一遍。
2026-08-26 10:55:58 +08:00

214 lines
14 KiB
Markdown
Raw 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.
# 统一鉴权中心(Keycloak)—— 前端对接说明
> 后端已按 ADR-012 改成**资源服务器**:不再有登录口、会话 cookie 与岗位授权;
> 每个请求带 `Authorization: Bearer <access_token>`,后端本地验签、按「角色 → 端」判权。
> 这份说明只讲**前端要改什么、后端给了什么**。浏览器怎么登录、怎么取 / 刷 token,
> 照公司统一的《Keycloak 前端接入手册》,本文不重复。
> 接口的字段与状态码以 `docs/API文档-鉴权与人员.md` 与 `packages/contracts/src/api/*.ts` 为准。
## 0. 一分钟看懂
- 后端**不做登录**。前端(公司已在 Keycloak `corp` realm 建好两个前端 client:`web-portal` 用户端
`jcai-bpos.akhr-ai.com` / `web-console` 管理端 `jcai-bpos-opt.akhr-ai.com`,PKCE)自己拿 token。
- token 的 `aud` 必须含本后端的 clientId —— **本部署是 `task-dispatcher`**(后端环境变量 `KEYCLOAK_CLIENT_ID`);
`web-portal` 与 `web-console` 都已挂好指向它的 audience mapper(2026-08-26 实测确认),前端不用为此做任何事;
后端**不看 `azp`**,用哪个前端 client 取的 token 都行 —— 没有 aud 会全线 401,后端的 401 message 会点名让管理员加 audience mapper。
- 每个请求头带 `Authorization: Bearer …`。`GET /api/me` 回答两件事:我是谁、我能进哪几个端(`ends`)。
- **权限只到端**:`design`(设计中心 `/design`)/ `ops`(运营中心 `/ops`)/ `workbench`(工作台 `/`)。
当前 realm 里的角色不止 `admin` / `user` 两种(服务账号还持有 `service`),而**没配进「角色 → 端」表的角色一律不加端** ——
所以 `ends: []` 不是假想的边界,是真会遇到的(§3 那一段说了怎么呈现)。
入口亮不亮、页面能不能进,只看 `ends`;进去之后哪个接口能调,后端按每条路由的归属自己判(§6 总表)。
- 两条带不了 Authorization 头的路(`EventSource` 与 `<a href download>`)用 **60 秒票据**:
`GET /api/auth/ticket` → 把 `ticket` 放到那条 GET 的 `?ticket=` 上。
- 401 = 该重新登录(或刷 token);403 = 没权限,重登没用;410 = 这条路由退役了,看 §7 的替代。
## 1. 请求链
```
浏览器(SPA,PKCE)──登录/刷新 token──▶ https://keycloak.akhr-ai.com/realms/corp
│
│ Authorization: Bearer <access_token>(aud ∋ task-dispatcher)
▼
apps/api 鉴权中间件(唯一一处)
① 路由归属:契约里每条路由自带 access(public / any / 端)
② 验签:RS256 · iss · aud · 30s 容差(本地,不回源)
③ 名册即时更新:第一次见到这个 sub 就建档(岗位「未分配」)
④ 角色 × 角色→端表 → ends;端不够 → 403
⑤ 路由拿到 principal(staff / position / roles / ends)
```
## 2. 改动清单(按 `apps/web` 的文件)
| 文件 | 现在 | 改成 |
|---|---|---|
| `shared/typed-fetch.ts` | `credentials: 'same-origin'`,靠 cookie | 每个请求加 `Authorization: Bearer <token>`;401 → 刷一次 token 重试,仍 401 → 落登录;403 → 抛给页面提示(带 `detail.need` / `detail.ends`),**不要**跳登录 |
| `app/main.tsx`(`Gate`) | `call(sessionRoute)`,401 → `LoginPage` | 没 token → 走手册的登录跳转;有 token → `call(meRoute)`;`authed` = `me` 200;401 仍落登录 |
| `features/auth/internal/LoginPage.tsx` | 账号 + 口令表单 `POST /api/login` | 删除表单;换成「跳转到统一登录」(手册的 `login()`);`/api/login` 现回 410 |
| `shared/gov/surfaces.ts` | `canSeeSurface(session, surface)` 读 `session.grants` × `SURFACE_FNS` | `canSeeSurface(me, surface) = me.ends.includes(surface)`;`SURFACE_FNS` 删除 |
| `app/gov-shell.tsx` / `app/app-shell.tsx` | `session.data?.staff.name`;登出 `call(logoutRoute)` | `me.data?.staff.name`;登出 = 丢掉本地 token + Keycloak end-session(手册);`/api/logout` 现回 410 |
| `app/subscribe.ts` | `new EventSource('/api/stream')` | 先 `GET /api/auth/ticket`,再 `new EventSource('/api/stream?ticket=…')`;断线重连要**重新取票据**(§5) |
| `features/desk-task/internal/ViewRegion.tsx` + `FileViewer.tsx` | `<a href="/api/desk/tasks/:id/file?…&raw=1" download>` | 点击前现取票据拼到 `&ticket=`,或 `fetch`(带头)→ `blob:` URL(§5) |
| `features/gov-staff/*` + `shared/gov/gov-api.ts` | 建人 / 停用 / 停用前置检查 / 岗位授权 | 四条已退役(410);换成:同步名册、指派岗位、在途工作、角色 → 端(§7);`useGovGrants` 删 |
| 类型清理 | `Session` `sessionSchema` `grant*` `GovernanceFunction*` `disableCheckSchema` | 这轮契约里标了 `@deprecated`,web 编译照过;前端接入完成时一并从 `@contracts` 删(§12.4 三步之第三步) |
不用动的:`ui-kit` 的 `ViewContainer`、各 feature 的业务调用(路由契约的路径 / 参数 / 出参一个没改,只是每条多了 `access`)、
Vite 的 `/api` 反代、SSE 事件的形状(`ready` / `touch` / `ping`)。
## 3. `GET /api/me` 与「端」
```json
{
"staff": {
"id": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
"account": "chaihao",
"name": "柴浩",
"email": "chaihao@akhr-ai.com",
"positionId": "pm",
"state": "active",
"roles": ["admin"]
},
"position": { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" },
"roles": ["admin"],
"ends": ["design", "ops", "workbench"]
}
```
- `staff.id` 就是 Keycloak 的 `sub`。所有原来拿 `staff.id` 的地方(负责人、卡的收件人、工作组成员)语义不变,值变成 UUID。
- `staff.roles` 是 realm 角色(签发时的快照;`default-roles-corp` / `offline_access` / `uma_authorization` 已滤掉)。
- `ends` 是**并集**:角色命中「角色 → 端」表的每一行取并集;顺序固定 `design, ops, workbench`。
- `ends` 可以是 `[]`(角色在表里一行都没命中)—— `/api/me` 仍 200,别的路由 403。
这时候页面该说「你的角色(xxx)还没有被授权进入任何一端,请联系运营中心」,不要落登录页。
- 岗位 `position.code` 有两个特殊值:`NONE`(未分配,新人第一次登录就是它)、`TEST`(测试账号)。
403 的 `detail` 告诉你差的是哪个端、他能进哪几个:
```json
{
"code": "forbidden",
"message": "进入「运营中心」需要授权;你的角色(user)可进:工作台",
"detail": { "need": ["ops"], "ends": ["workbench"] }
}
```
## 4. token 怎么带、失败怎么处理
| 状态 | code | 意思 | 前端动作 |
|---|---|---|---|
| 401 | `unauthenticated` | 没带 token / 过期 / 签名不对 / aud 不含本服务(message 会说清哪一种) | 先刷一次 token 重试;仍 401 → 清本地 token,走登录 |
| 403 | `forbidden` | token 对,但账号已在鉴权中心停用,或这条路由的端不在 `ends` 里 | 提示,不重登;停用的 message 会说「已停用」 |
| 410 | `gone` | 路由随统一鉴权中心退役 | 改调 message 里点名的替代路由(§7) |
| 409 | `conflict` | 角色 → 端改完会没人能进运营中心(锁死保护) | 把 message 原样给用户看 |
细节:
- access token 默认 5 分钟;刷新由前端管(手册),后端不参与。后端验签有 30 秒时钟容差。
- 后端每个请求都验签,是纯本地运算(公钥缓存 10 分钟),不用担心「带 token 会慢」。
- 撤了角色,旧 token 到期前仍按旧角色算(最坏 5 分钟);账号停用经运营中心「同步名册」后即时 403。
- 不要把 token 放进 URL(票据除外,见 §5)、不要放进 localStorage 以外的地方由后端读 —— 后端只认 `Authorization` 头。
## 5. 票据:SSE 与下载
`EventSource` 与 `<a href download>` 带不了请求头,所以后端给了一条**只对 GET 有效**的路:
```
GET /api/auth/ticket Authorization: Bearer …
→ 200 { "ticket": "v1.OGYzYzFhMmI….1756112460.k5v…", "expiresAt": "2026-08-25T09:01:00.000Z" }
```
- 票据 60 秒有效、只在 **GET** 的 `?ticket=` 上认;POST / PUT / DELETE 带了也当没带(401)。
- 票据里没有权限:后端拿它对应的 sub 回库现算,停用 / 改角色即时生效。
- 同时带了 `Authorization` 头和票据时,以头为准。
### SSE
```ts
// app/subscribe.ts 的改法(示意)
async function openStream(onTouch: (t: Touch) => void, onOpen: () => void): Promise<() => void> {
const { ticket } = await call<{ ticket: string; expiresAt: string }>(authTicketRoute);
const source = new EventSource(`/api/stream?ticket=${encodeURIComponent(ticket)}`);
source.addEventListener('touch', (e) => onTouch(JSON.parse((e as MessageEvent<string>).data)));
source.addEventListener('open', onOpen);
// 票据只活 60 秒:断线后浏览器自动重连用的还是旧 URL,后端会 401,
// 而 EventSource 收到非 200 就不再自动重连 —— 所以 error 时要自己关掉、重新取票据、重建
source.addEventListener('error', () => {
source.close();
setTimeout(() => void openStream(onTouch, onOpen), 1000);
});
return () => source.close();
}
```
### 下载原件
两种都行,任选其一:
1. 点击前现取票据:`href = \`/api/desk/tasks/${id}/file?address=…&key=…&raw=1&ticket=${encodeURIComponent(ticket)}\``
—— 票据 60 秒,**别在渲染时预取**,要在 `onClick` 里取完再 `window.location.assign(href)`(或动态创建 `<a>` 点一下)。
2. `fetch(href, { headers: { authorization } })` → `blob()` → `URL.createObjectURL` → `<a download>` 点一下 → `revokeObjectURL`。
不用票据,但大文件会先进内存。
`FileViewer` 里「预览」那条路本来就是 `fetch`(经 `typed-fetch`),带头即可;只有「下载原件」那枚 `<a download>` 需要上面两选一。
## 6. 谁能进哪个端:入口与页面
- 入口亮灯:`canSeeSurface(me, 'design')` / `'ops'`;工作台入口同理看 `'workbench'`(从前工作台是默认进得去的,现在也要看 `ends`)。
- 深链保护:进入 `/design` 或 `/ops` 时若 `ends` 不含该端,落到能进的第一个端;一个都不能进 → §3 的「未授权」页。
- 页面内的按钮**不用**再按功能藏:后端不再有「task:read / task:govern」这种粒度,同一端内的接口对能进该端的人全开。
某些**读**接口两端都能调(`GET /api/gov/staff` 等八条,见 API 文档 §6),
写接口只归一端(触发器的启停 / 节点负责人归运营;定义类归设计)。
## 7. 退役路由与替代
| 退役(410) | 替代 |
|---|---|
| `POST /api/login` | 手册的登录跳转(Keycloak) |
| `POST /api/logout` | 丢本地 token + Keycloak end-session |
| `GET /api/session` | `GET /api/me` |
| `POST /api/gov/staff`(建人) | 人来自鉴权中心:首次登录自动建档;或 `POST /api/gov/staff/sync` 同步名册 |
| `GET /api/gov/staff/:id/disable-check` | `GET /api/gov/staff/:id/in-flight`(只读:此人身上还挂着什么) |
| `POST /api/gov/staff/:id/disable` | 在 Keycloak 停用账号 → `POST /api/gov/staff/sync` |
| `GET|POST /api/gov/positions/:id/grants` | `GET|PUT|DELETE /api/gov/access/roles[/:role]`(角色 → 端) |
运营中心 · 员工 那一页建议长这样:
- **名册**:`GET /api/gov/staff` 照旧(每人多了 `email` / `roles`;`positions` 里多了 `NONE` 未分配、`TEST` 测试);
「同步名册」按钮 → `POST /api/gov/staff/sync`,`configured:false` 就把 `message` 原样摆出来(别摆一排 0);
每人一个岗位下拉 → `PUT /api/gov/staff/:id/position`;「在途工作」→ `GET /api/gov/staff/:id/in-flight`。
- **角色 → 端**:`GET /api/gov/access/roles` 出 `roles`(已配置的行)与 `knownRoles`(名册上见过的 ∪ 已配置的);
每行三个勾(设计中心 / 运营中心 / 工作台)→ `PUT /api/gov/access/roles/:role` body `{ ends }`;
删行 → `DELETE`;409 时把 message 摆出来(「改完没人能进运营中心」)。
没在表里的 `knownRoles` 项要能一键「加进来」(等于 PUT 一行)。
- **工作组**:`GET|PUT /api/gov/work-groups` 不变。
## 8. 本地开发
- 后端启动必须配 `KEYCLOAK_URL` `KEYCLOAK_REALM` `KEYCLOAK_CLIENT_ID`(缺一拒绝启动,会打印缺哪个);
可选 `KEYCLOAK_PROVISIONING_CLIENT_ID/_SECRET`(同步名册)、`JCAI_TEST_ACCOUNTS`(测试账号用户名)。
- Vite 的 `/api` 反代不变;浏览器直连 `keycloak.akhr-ai.com` 取 token,与后端无关(跨域由 Keycloak 的 Web Origins 管,手册有写)。
- 想在没前端的情况下打接口:用服务账号拿 token(见 API 文档 §8 的 curl),或让管理员临时给 `web-portal` 开 Direct Access Grants 用密码取一枚(手册 §6.1,用完关掉)。
- 试跑 / e2e 用的是 **`task-dispatcher` 的服务账号**(`service-account-task-dispatcher`)。
注意它当前只有 `service` + `user` 两个 realm 角色 → 只进得了工作台;要跑测试台(设计端)得在运营中心
给 `service` 配上 design,或给该账号加 `admin`。
## 9. 接入完成的判据
- [ ] 未登录打开任一页 → 跳统一登录;登录回来 `GET /api/me` 200,首页按 `ends` 落到能进的端
- [ ] `ends = []` 的账号看到「未授权」页,不是登录页,也不是白屏
- [ ] 三个入口的亮灯与 `ends` 一致;深链到没权限的端会被挡回
- [ ] token 过期(等 5 分钟)后操作不报错:刷新后重试成功
- [ ] SSE 连上、断线 61 秒后能自动重连(新票据)
- [ ] 「下载原件」拿到的是真字节(`.docx` 能被 Word 打开)
- [ ] 运营中心 · 员工:同步名册 / 指派岗位 / 角色 → 端三样能用;409 与 `configured:false` 的话原样可见
- [ ] 登出后再访问 → 401 → 登录页;旧 token 不再被任何地方发出
- [ ] `apps/web` 里不再 import `loginRoute` / `logoutRoute` / `sessionRoute` / `Session` / `grantSchema` / `SURFACE_FNS`
## 10. 本文与真实契约的核对状态
Task 12 已用契约把本文核过一遍:§2 的十个文件指针全部存在(`apps/web` 本轮未动,指针原样有效);
§7 的退役路由与替代逐条对过 `docs/API文档-鉴权与人员.md` §6 的总表,一致。
**2026-08-26 补:整条链已在真环境跑通并实测。** Keycloak 侧前置已就位(`task-dispatcher` 及两个前端 client
的 audience mapper 齐备),后端以 `KEYCLOAK_CLIENT_ID=task-dispatcher` 启动、自检通过。实测验过的行为:
验签 → claims 归一 → 首次登录 JIT 建档(落 `NONE / 未分配` 岗)→ 角色→端 → 端判定;工作台 200、运营中心 403(带 `detail`);
票据签发后 `?ticket=` 真连上 SSE、同一张票据用在 POST 上被拒;`HEAD` 与 `GET` 判定一致。
本文与 API 文档 §8 的响应示例现在都是**实测响应**,不再是按契约构造的样例。