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 a4716c8632 文档:前端对接说明与 API 文档按真实契约核对(路由归属总表与 schema 均已核对一致,补记示例来源)
用契约(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)本轮未就位,示例响应仍按契约
构造 —— 两份文档末尾补记这一状态,留给前置就位后换真实响应。
2026-08-25 18:51:13 +08:00

13 KiB
Raw Blame History

统一鉴权中心(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 与「端」

{
  "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();
}

下载原件

两种都行,任选其一:

  1. 点击前现取票据:href = \/api/desk/tasks/${id}/file?address=…&key=…&raw=1&ticket=${encodeURIComponent(ticket)}`—— 票据 60 秒,**别在渲染时预取**,要在onClick里取完再window.location.assign(href)(或动态创建 ` 点一下)。
  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 /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 换。