用契约(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)本轮未就位,示例响应仍按契约 构造 —— 两份文档末尾补记这一状态,留给前置就位后换真实响应。
13 KiB
统一鉴权中心(Keycloak)—— 前端对接说明
后端已按 ADR-012 改成资源服务器:不再有登录口、会话 cookie 与岗位授权; 每个请求带
Authorization: Bearer <access_token>,后端本地验签、按「角色 → 端」判权。 这份说明只讲前端要改什么、后端给了什么。浏览器怎么登录、怎么取 / 刷 token, 照公司统一的《Keycloak 前端接入手册》,本文不重复。 接口的字段与状态码以docs/API文档-鉴权与人员.md与packages/contracts/src/api/*.ts为准。
0. 一分钟看懂
- 后端不做登录。前端(公司已在 Keycloak
corprealm 建好两个前端 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 与「端」
{
"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 告诉你差的是哪个端、他能进哪几个:
{
"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
// 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();
}
下载原件
两种都行,任选其一:
- 点击前现取票据:
href = \/api/desk/tasks/${id}/file?address=…&key=…&raw=1&ticket=${encodeURIComponent(ticket)}`—— 票据 60 秒,**别在渲染时预取**,要在onClick里取完再window.location.assign(href)(或动态创建` 点一下)。 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 /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/:rolebody{ ends }; 删行 →DELETE;409 时把 message 摆出来(「改完没人能进运营中心」)。 没在表里的knownRoles项要能一键「加进来」(等于 PUT 一行)。 - 工作组:
GET|PUT /api/gov/work-groups不变。
8. 本地开发
- 后端启动必须配
KEYCLOAK_URLKEYCLOAK_REALMKEYCLOAK_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/me200,首页按ends落到能进的端 ends = []的账号看到「未授权」页,不是登录页,也不是白屏- 三个入口的亮灯与
ends一致;深链到没权限的端会被挡回 - token 过期(等 5 分钟)后操作不报错:刷新后重试成功
- SSE 连上、断线 61 秒后能自动重连(新票据)
- 「下载原件」拿到的是真字节(
.docx能被 Word 打开) - 运营中心 · 员工:同步名册 / 指派岗位 / 角色 → 端三样能用;409 与
configured:false的话原样可见 - 登出后再访问 → 401 → 登录页;旧 token 不再被任何地方发出
apps/web里不再 importloginRoute/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 换。