Archived
用契约(packages/contracts/src/api/*.ts + org.ts)重新生成路由归属总表,与 API 文档 §6 逐行比对 (方法/路径/access 三者):零差异,95 条契约路由与文档现状完全一致,不改路由表。 §7 的六个 schema(Staff/Position/Principal/RoleAccess/RosterSyncOutcome/InFlightWork/WorkGroup/ ApiError)逐个核对字段名与可选性,与 org.ts 一致,不改。前端对接说明 §2 的十个文件指针全部存在。 Keycloak 侧前置(serviceAccountsEnabled 的 client、audience mapper)本轮未就位,示例响应仍按契约 构造 —— 两份文档末尾补记这一状态,留给前置就位后换真实响应。
206 lines
13 KiB
Markdown
206 lines
13 KiB
Markdown
# 统一鉴权中心(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(后端环境变量 `KEYCLOAK_CLIENT_ID`,建议 `api-gateway`);
|
||
后端**不看 `azp`**,用哪个前端 client 取的 token 都行 —— 没有 aud 会全线 401,后端的 401 message 会点名让管理员加 audience mapper。
|
||
- 每个请求头带 `Authorization: Bearer …`。`GET /api/me` 回答两件事:我是谁、我能进哪几个端(`ends`)。
|
||
- **权限只到端**:`design`(设计中心 `/design`)/ `ops`(运营中心 `/ops`)/ `workbench`(工作台 `/`)。
|
||
入口亮不亮、页面能不能进,只看 `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 ∋ api-gateway)
|
||
▼
|
||
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 用的「AI 账号」现在是 Keycloak 里的服务账号(`bpos-e2e`),工作台测试时用它的 token。
|
||
|
||
## 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 的总表,一致。
|
||
本文里出现的响应示例(`GET /api/me`、票据、403 detail)与 API 文档同源,同样按契约构造 ——
|
||
换真实响应要 Keycloak 侧前置 1 与 4(服务账号 client、audience mapper)先就位,本轮未就位,留给前置就位后去 API 文档 §8 换。
|