# 统一鉴权中心(Keycloak)—— 后端接入手册(Node.js) > **本文档的用途**:从零把一个 Node.js 后端服务接进统一鉴权中心, > 一步一步做到第一个受保护接口跑通。按第 1 步到第 12 步顺序执行, > 每步都写了"做完应该看到什么",对不上就别往下走。 > > **配套文档**:浏览器登录、前端 token 处理见《Keycloak 前端接入手册》。 > > **文档基准**:Keycloak 26.7.1,`jose` 5.10.0,Node.js 20+,核实日期 2026-08-12。 > 文中代码均用真实签名的 RS256 token 端到端测试通过。 > > **给 AI agent 的执行原则** > 1. 第 4 步的代码可直接落地,四个服务共用同一份,不要各写一套验签逻辑。 > 2. 任何写操作(建用户、改角色、删客户端)执行前先向人类确认,只读查询可直接执行。 > 3. 遇到本文档没写的 Admin API 端点,去 `https://www.keycloak.org/docs-api/latest/rest-api/` 查,不要猜。 > 4. 每步的"做完应该看到什么"是硬检查点,不要跳过。 --- ## 0. 环境信息 | 项 | 值 | |---|---| | Keycloak 地址 | `https://keycloak.akhr-ai.com` | | Realm | `corp` | | Issuer | `https://keycloak.akhr-ai.com/realms/corp` | | JWKS | `https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/certs` | 四个后端服务,各自一个 client: | 服务 | clientId | service account | client secret | |---|---|---|---| | API 网关 | `api-gateway` | ✓ | 有 | | 智能体服务 | `agent-service` | ✓ | 有 | | 任务分发服务 | `task-dispatcher` | ✓ | 有 | | 业务数据服务 | `data-service` | ✗ | **没有,也不需要**(见 13.2) | 两个前端(token 的来源): | | clientId | 要求的角色 | |---|---|---| | 用户端 `jcai-bpos.akhr-ai.com` | `web-portal` | `user` | | 管理端 `jcai-bpos-opt.akhr-ai.com` | `web-console` | `admin` | 角色模型(realm 级,所有服务共用同一套): ``` admin ──── user 管理员:组合包含 user user 普通用户;realm 默认角色,新建用户自动获得 ``` --- ## 1. 确认 Keycloak 侧已就绪 代码写得再对,Keycloak 那边没配好也验不过。先花 30 秒确认: ```bash curl -s https://keycloak.akhr-ai.com/realms/corp/.well-known/openid-configuration | jq -r .issuer ``` **做完应该看到**: ``` https://keycloak.akhr-ai.com/realms/corp ``` 出来是别的或报错,**先停下**,让管理员修 Keycloak 的 `KC_HOSTNAME`。 这种情况下后端怎么写都验不过,而且报错完全指不到这里。 再确认公钥能拉到: ```bash curl -s https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/certs | jq '.keys[0].kid' ``` 有输出即可。这是个**公开端点,不需要任何凭据**——后端验签只需要它。 --- ## 2. 装依赖,建目录 ```bash npm i jose express ``` > **不要用 `keycloak-connect`**(Node 官方适配器),已标记 DEPRECATED、不再维护。 > `jose` 是标准 JOSE 实现,`createRemoteJWKSet` 自带公钥缓存和密钥轮换处理。 目录结构(四个服务都一样): ``` your-service/ ├── .env ← 第 3 步 └── src/ ├── keycloak-auth.js ← 第 4 步,核心,四个服务共用 └── server.js ← 第 5 步 ``` `package.json` 里加 `"type": "module"`(下面代码用 ESM)。 --- ## 3. 写环境变量 **每个服务填自己的 `KEYCLOAK_CLIENT_ID`**,这决定了它校验 `aud` 时比对哪个值。填错会导致所有请求 401。 ```bash # api-gateway 的 .env KEYCLOAK_URL=https://keycloak.akhr-ai.com KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=api-gateway KEYCLOAK_CLIENT_SECRET=<管理台 Clients → api-gateway → Credentials 抄> PORT=8080 ``` ```bash # data-service 的 .env —— 没有 SECRET 是对的,不是配漏了 KEYCLOAK_URL=https://keycloak.akhr-ai.com KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=data-service PORT=8083 ``` `agent-service` / `task-dispatcher` 同 `api-gateway` 的形式,改 `CLIENT_ID` 和 `SECRET`。 **为什么 `data-service` 没有 secret**:secret 的用途是"向 Keycloak 证明自己是谁", 不是"验别人的 token"。验签只需要公钥,公钥是公开的。`data-service` 只验不调, 所以用不上。详见 13.2。 --- ## 4. 写 `src/keycloak-auth.js` 核心文件,四个服务共用一份。下面分五段讲,最后给完整版。 ### 4.1 远程公钥集 ```js import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from 'jose'; const KEYCLOAK_URL = process.env.KEYCLOAK_URL; const REALM = process.env.KEYCLOAK_REALM; const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID; export const ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`; const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), { cooldownDuration: 30_000, // 两次重新拉取之间至少间隔 30 秒 cacheMaxAge: 10 * 60_000, // 公钥缓存 10 分钟 timeoutDuration: 5_000, }); ``` `createRemoteJWKSet` 内部行为: - **首次用到时才拉取**,不是启动时 - 结果缓存 `cacheMaxAge` - 遇到缓存里没有的 `kid` 会自动重新拉,但受 `cooldownDuration` 限流 **密钥轮换你不用管。** Keycloak 换签名密钥后新 token 的 `kid` 是缓存里没有的, 库会自动重拉;`cooldownDuration` 防止恶意 token 把 Keycloak 打爆。 ### 4.2 验签 ```js export async function verifyAccessToken(token) { const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'], // 写死,绝不读 token header 里的 alg issuer: ISSUER, audience: CLIENT_ID, clockTolerance: 30, }); return payload; } ``` 四项校验,顺序即安全要点: | 校验 | 为什么 | |---|---| | `algorithms: ['RS256']` | **必须写死**。读 header 里的 `alg` 会被 `alg: none` 攻击绕过 | | `issuer` | 确认 token 来自本 realm,不是另一个 Keycloak 实例 | | `audience` | 确认这个 token 是**发给本服务**的 | | `clockTolerance: 30` | 容忍 30 秒时钟偏移,避免边界抖动 | **这一步是纯本地计算,不联网。** 公钥缓存后连续验 500 次实测约 57ms (平均 0.1ms/次),Keycloak 不在请求热路径上。 ### 4.3 Express 中间件 ```js function extractBearer(req) { const h = req.headers?.authorization || ''; if (!/^bearer /i.test(h)) return null; return h.slice(7).trim() || null; } export function requireAuth(options = {}) { const { optional = false } = options; return async function authMiddleware(req, res, next) { const token = extractBearer(req); if (!token) { if (optional) return next(); return res.status(401).json({ error: 'unauthorized', message: '缺少 Bearer token' }); } try { const p = await verifyAccessToken(token); req.auth = p; // 原始 payload req.user = { id: p.sub, // 稳定 UUID,业务表外键用它 username: p.preferred_username, email: p.email, name: p.name, roles: p.realm_access?.roles ?? [], groups: p.groups ?? [], // 服务账号 token 没有这个,默认空数组 isService: (p.preferred_username || '').startsWith('service-account-'), }; return next(); } catch (err) { if (optional) return next(); return res.status(401).json({ error: 'invalid_token', message: explain(err) }); } }; } ``` `req.user.groups` 默认成空数组很重要——**服务账号 token 没有 `groups`**, 按组过滤数据的代码不处理会崩。 ### 4.4 把 jose 的错误翻译成人话 排错时能省很多时间: ```js function explain(err) { if (err instanceof joseErrors.JWTExpired) { return 'token 已过期,用 refresh_token 换个新的'; } if (err instanceof joseErrors.JWTClaimValidationFailed) { if (err.claim === 'aud') { return `token 的 aud 不含 "${CLIENT_ID}" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper`; } if (err.claim === 'iss') { return `token 的 iss 与 "${ISSUER}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; } } if (err instanceof joseErrors.JWKSNoMatchingKey) { return 'JWKS 里找不到匹配的公钥 —— token 可能来自别的 realm,或 Keycloak 刚轮换过密钥'; } if (err instanceof joseErrors.JWSSignatureVerificationFailed) { return '签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'; } return err.message; } ``` ### 4.5 角色守卫 ```js /** 要求持有指定 realm 角色之一。admin 组合含 user,所以管理员能过 user 的检查 */ export function requireRealmRole(...roles) { return (req, res, next) => { const have = req.user?.roles ?? []; if (roles.some((r) => have.includes(r))) return next(); return res.status(403).json({ error: 'forbidden', message: `需要角色之一: ${roles.join(', ')};当前持有: ${have.join(', ') || '无'}`, }); }; } /** 按组过滤:groups 是完整路径数组,判断"属于某部门或其子部门"用前缀匹配 */ export function isInGroup(user, groupPath) { const base = groupPath.replace(/\/$/, ''); return (user?.groups ?? []).some((g) => g === base || g.startsWith(base + '/')); } ``` **401 和 403 必须分开**: | 状态码 | 含义 | 前端该怎么反应 | |---|---|---| | 401 | token 缺失/无效/过期 | 重新登录 | | 403 | token 有效但角色不够 | 提示"没有权限",重新登录没用 | ### 4.6 完整文件 ```js // src/keycloak-auth.js import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from 'jose'; const KEYCLOAK_URL = process.env.KEYCLOAK_URL; const REALM = process.env.KEYCLOAK_REALM; const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID; const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; export const ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`; const TOKEN_URL = `${ISSUER}/protocol/openid-connect/token`; const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), { cooldownDuration: 30_000, cacheMaxAge: 10 * 60_000, timeoutDuration: 5_000, }); export async function verifyAccessToken(token) { const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'], issuer: ISSUER, audience: CLIENT_ID, clockTolerance: 30, }); return payload; } function extractBearer(req) { const h = req.headers?.authorization || ''; if (!/^bearer /i.test(h)) return null; return h.slice(7).trim() || null; } function explain(err) { if (err instanceof joseErrors.JWTExpired) return 'token 已过期'; if (err instanceof joseErrors.JWTClaimValidationFailed) { if (err.claim === 'aud') return `token 的 aud 不含 "${CLIENT_ID}" —— 给签发方 client 加 audience mapper`; if (err.claim === 'iss') return `iss 与 "${ISSUER}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; } if (err instanceof joseErrors.JWKSNoMatchingKey) return 'JWKS 里找不到匹配的公钥'; if (err instanceof joseErrors.JWSSignatureVerificationFailed) return '签名校验失败'; return err.message; } export function requireAuth(options = {}) { const { optional = false } = options; return async function authMiddleware(req, res, next) { const token = extractBearer(req); if (!token) { if (optional) return next(); return res.status(401).json({ error: 'unauthorized', message: '缺少 Bearer token' }); } try { const p = await verifyAccessToken(token); req.auth = p; req.user = { id: p.sub, username: p.preferred_username, email: p.email, name: p.name, roles: p.realm_access?.roles ?? [], groups: p.groups ?? [], isService: (p.preferred_username || '').startsWith('service-account-'), }; return next(); } catch (err) { if (optional) return next(); return res.status(401).json({ error: 'invalid_token', message: explain(err) }); } }; } export function requireRealmRole(...roles) { return (req, res, next) => { const have = req.user?.roles ?? []; if (roles.some((r) => have.includes(r))) return next(); return res.status(403).json({ error: 'forbidden', message: `需要角色之一: ${roles.join(', ')};当前持有: ${have.join(', ') || '无'}`, }); }; } export function isInGroup(user, groupPath) { const base = groupPath.replace(/\/$/, ''); return (user?.groups ?? []).some((g) => g === base || g.startsWith(base + '/')); } // ───────── 服务账号:用于调用其它服务或 Admin API ───────── let _svcToken = null; let _svcExpireAt = 0; /** 用 client_credentials 拿代表"本服务自己"的 token,内部带缓存 */ export async function getServiceToken() { if (_svcToken && Date.now() < _svcExpireAt) return _svcToken; if (!CLIENT_SECRET) throw new Error('未配置 KEYCLOAK_CLIENT_SECRET,无法使用服务账号'); const resp = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: CLIENT_ID, client_secret: CLIENT_SECRET, }), }); if (!resp.ok) throw new Error(`获取服务账号 token 失败: ${resp.status} ${await resp.text()}`); const data = await resp.json(); _svcToken = data.access_token; _svcExpireAt = Date.now() + (data.expires_in - 60) * 1000; // 提前 60 秒过期 return _svcToken; } // ───────── 启动自检 ───────── /** 启动时调一次,把配置错误暴露在启动阶段而不是第一个请求 */ export async function checkKeycloakConnection() { const url = `${ISSUER}/.well-known/openid-configuration`; const resp = await fetch(url, { signal: AbortSignal.timeout(5000) }); if (!resp.ok) throw new Error(`拉取发现文档失败 (${resp.status}): ${url}`); const conf = await resp.json(); if (conf.issuer !== ISSUER) { throw new Error( `issuer 不一致!\n 期望: ${ISSUER}\n 实际: ${conf.issuer}\n` + ` → Keycloak 的 KC_HOSTNAME 配错了,或本服务的 KEYCLOAK_URL 写错了` ); } return { issuer: conf.issuer, clientId: CLIENT_ID }; } export const config = { KEYCLOAK_URL, REALM, CLIENT_ID, ISSUER, TOKEN_URL }; ``` --- ## 5. 挂到 Express ```js // src/server.js import express from 'express'; import { requireAuth, requireRealmRole, checkKeycloakConnection, config } from './keycloak-auth.js'; const app = express(); app.use(express.json()); // 健康检查必须放在鉴权之前 —— 让 k8s/负载均衡探活不需要 token app.get('/healthz', (req, res) => res.json({ ok: true })); // 公开接口 app.get('/api/public', (req, res) => res.json({ message: '不需要登录' })); // 只要求登录 app.get('/api/me', requireAuth(), (req, res) => { res.json({ user: req.user, tokenExpiresAt: new Date(req.auth.exp * 1000).toISOString(), }); }); // 普通用户能访问(admin 组合含 user,管理员也能过) app.get('/api/records', requireAuth(), requireRealmRole('user'), (req, res) => { res.json({ by: req.user.username, records: [] }); }); // 管理端专属 app.get('/api/admin/stats', requireAuth(), requireRealmRole('admin'), (req, res) => { res.json({ message: '管理员数据' }); }); const PORT = process.env.PORT || 8083; try { const info = await checkKeycloakConnection(); console.log('✓ Keycloak 连通'); console.log(' issuer:', info.issuer); console.log(' client:', info.clientId); } catch (e) { console.error('✗ Keycloak 连不上或配置有误:', e.message); process.exit(1); // 配置错了就别启动,免得跑起来每个请求都 401 还查不出原因 } app.listen(PORT, () => console.log(`✓ 服务已启动 http://localhost:${PORT}`)); ``` **做完应该看到**: ``` ✓ Keycloak 连通 issuer: https://keycloak.akhr-ai.com/realms/corp client: data-service ✓ 服务已启动 http://localhost:8083 ``` 启动就报 `issuer 不一致` 说明 `KEYCLOAK_URL` 或 realm 名写错了,改完再跑。 > 顶层 `await` 需要 ESM(`"type": "module"`)。 > 用 Express 4 的话异步中间件抛错不会自动进错误处理器——本文的中间件内部已经 > `try/catch` 兜住了,不受影响。 --- ## 6. 第一次运行:用真 token 打通 ### 6.1 取一个真实 token ```bash cd /opt/keycloak # 临时开密码模式(仅为调试取 token) WEB=$(sudo ./kc kcadm get clients -r corp -q clientId=web-portal --fields id --format csv --noquotes | tail -1) sudo ./kc kcadm update "clients/$WEB" -r corp -s directAccessGrantsEnabled=true TOKEN=$(curl -s -X POST \ "https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token" \ -d grant_type=password -d client_id=web-portal \ -d username=<你的测试账号> -d password='<密码>' -d scope=openid \ | jq -r .access_token) echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss, aud, azp, preferred_username, realm_access}' ``` **必须确认 `aud` 里有本服务的 clientId**,没有的话验签必 401——那是 Keycloak 侧 audience mapper 没配,改后端代码没用。 用完立刻关掉: ```bash sudo ./kc kcadm update "clients/$WEB" -r corp -s directAccessGrantsEnabled=false ``` > 报 `Account is not fully set up` 的话,是账号上挂着必输动作。 > Users → 该用户 → Details → **Required user actions** 清空,Credentials 里 > 重设一个非临时密码。 ### 6.2 打接口 ```bash curl -s http://localhost:8083/api/public | jq . curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8083/api/me | jq . curl -s http://localhost:8083/api/me | jq . # 无 token,应 401 ``` **做完应该看到**: | 请求 | 期望 | |---|---| | `/api/public` | 200 | | `/api/me` 带 token | 200,返回 `user` 对象含 `username` / `roles` | | `/api/me` 不带 token | 401 `{"error":"unauthorized"}` | | `/api/records` 带普通用户 token | 200 | | `/api/admin/stats` 带普通用户 token | **403**(不是 401) | 最后一条最关键——**403 说明验签通过了、只是角色不够**,整条链路是通的。 ### 6.3 这一步的三个常见卡点 | 现象 | 原因 | 处理 | |---|---|---| | 401 且提示 `aud 不含 xxx` | Keycloak 侧没给前端 client 配 audience mapper | 找管理员补,见 14.5 | | 401 且提示 `iss 与 ... 不符` | `KEYCLOAK_URL` 或 realm 名写错 | 对照第 0 节 | | 全部 401 且提示签名校验失败 | token 来自另一个 Keycloak 实例 | 确认取 token 和验 token 用的是同一个地址 | --- ## 7. 角色鉴权怎么用 ```js // 管理端专属接口 app.get('/api/admin/users', requireAuth(), requireRealmRole('admin'), handler); app.delete('/api/records/:id', requireAuth(), requireRealmRole('admin'), handler); // 普通接口(admin 组合含 user,管理员也能过) app.get('/api/records', requireAuth(), requireRealmRole('user'), handler); ``` **前端的角色判断不是安全措施。** 前端是 public client,代码在用户浏览器里, 能被绕过。管理端专属接口**必须**在后端加 `requireRealmRole('admin')`, 这是唯一真正的安全边界。 按部门过滤数据: ```js import { isInGroup } from './keycloak-auth.js'; app.get('/api/dept-docs', requireAuth(), requireRealmRole('user'), async (req, res) => { const all = await db.docs.findAll(); // 资源表上存 visible_to_group(组的完整路径),前缀匹配 res.json(all.filter((d) => isInGroup(req.user, d.visible_to_group))); }); ``` `groups` 是完整路径数组(如 `["/技术部", "/技术部/平台组"]`), 需要 Keycloak 侧配了 group membership mapper 才有。**服务账号 token 没有它**, 所以上面代码对服务调用会返回空——这是正确行为。 --- ## 8. 业务库怎么存用户 不要每次都问 Keycloak。首次见到这个 `sub` 就在自己库里落一条: ```js app.use(requireAuth(), async (req, res, next) => { let local = await db.users.findOne({ keycloak_sub: req.user.id }); if (!local) { local = await db.users.insert({ keycloak_sub: req.user.id, // 稳定 UUID,所有业务外键指向它 username: req.user.username, // 冗余一份仅供显示,不要当主键 created_at: new Date(), }); } req.localUser = local; next(); }); ``` **外键必须用 `sub`,不能用 username。** username 允许修改,`sub` 永不变。 用 username 做外键的话,用户改个名你的数据就对不上了。 --- ## 9. API 网关的写法(和下游不一样) 网关只做两件事:**验签拦截 + 原样转发**。不判业务角色,那是下游各自的事。 ```js // gateway.js —— KEYCLOAK_CLIENT_ID=api-gateway import express from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { requireAuth } from './keycloak-auth.js'; const app = express(); // 第一件事:剥掉客户端可能伪造的内部 header app.use((req, _res, next) => { for (const h of ['x-user-id', 'x-user-roles', 'x-internal-token']) delete req.headers[h]; next(); }); app.get('/healthz', (req, res) => res.json({ ok: true })); app.use('/api', requireAuth()); // 这里校验的 aud 是 api-gateway const forward = (target) => createProxyMiddleware({ target, changeOrigin: true, // 关键:原样带上 Authorization,下游会自己再验一遍 on: { proxyReq: (proxyReq, req) => { proxyReq.setHeader('Authorization', req.headers.authorization); }, }, }); app.use('/api/agent', forward(process.env.AGENT_SERVICE_URL)); app.use('/api/task', forward(process.env.TASK_SERVICE_URL)); app.use('/api/data', forward(process.env.DATA_SERVICE_URL)); app.listen(process.env.PORT || 8080); ``` ### 两条铁律 **1. 下游必须自己验签,不能信任网关。** 这是纵深防御:网关被绕过、内网被横向移动时,下游仍然安全。 验签是纯本地计算,成本可以忽略。 **2. 网关不要把解出的用户信息塞进自定义 header 让下游信任。** ```js // ❌ 错误做法 proxyReq.setHeader('X-User-Id', req.user.id); // 下游读 X-User-Id 就信 —— 等于把安全边界搬到了内网可伪造的地方 ``` 下游从自己验出来的 token 里读用户身份。 > 网关和下游能验通同一个 token,是因为前端 token 的 `aud` 同时包含四个后端 > (`web-portal` 上挂了四个 audience mapper)。网关校验 `aud` 含 `api-gateway`, > `data-service` 校验 `aud` 含 `data-service`,各查各的。 --- ## 10. 服务间调用 定时任务、后台作业这类脱离用户请求的场景,用服务账号拿代表"服务自己"的 token。 ```js import { getServiceToken } from './keycloak-auth.js'; async function callDataService(path) { const token = await getServiceToken(); // 内部有缓存,不会每次都换 const resp = await fetch(`${process.env.DATA_SERVICE_URL}${path}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) throw new Error(`调用失败 ${resp.status}`); return resp.json(); } ``` ### 服务账号 token 与用户 token 的三处不同 | claim | 用户 token | 服务账号 token | |---|---|---| | `sub` | 真实用户 UUID | 服务账号用户的 UUID | | `preferred_username` | 用户名 | `service-account-` | | `groups` | 用户所在组 | **不存在** | | `email` | 有 | 通常没有 | 下游收到时要注意: 1. **不要假设 `req.user.groups` 非空**,按组过滤的代码要处理空数组 2. **不要用 `sub` 去业务用户表查人**,会查不到 3. 判断是不是服务调用,**推荐给服务账号授予专门的 realm 角色**再判角色 —— `preferred_username` 的前缀只是 Keycloak 的命名约定,判角色更稳 **做完应该看到**:服务账号 token 解开后 `preferred_username` 是 `service-account-task-dispatcher`,`realm_access.roles` 是你给它挂的角色。 --- ## 11. 调 Admin API(管用户和角色) 需要一个有管理权限的服务账号。**不要用人的账号密码做自动化。** ### 11.1 一次性准备(人类执行) ```bash cd /opt/keycloak && sudo ./kc login sudo ./kc kcadm create clients -r master \ -s clientId=provisioning -s enabled=true -s publicClient=false \ -s standardFlowEnabled=false -s serviceAccountsEnabled=true PROV=$(sudo ./kc kcadm get clients -r master -q clientId=provisioning \ --fields id --format csv --noquotes | tail -1) sudo ./kc kcadm get "clients/$PROV/client-secret" -r master # 记下 secret SA=$(sudo ./kc kcadm get "clients/$PROV/service-account-user" -r master \ --fields id --format csv --noquotes | tail -1) sudo ./kc kcadm add-roles -r master --uid "$SA" \ --cclientid corp-realm --rolename manage-users --rolename view-users \ --rolename query-groups --rolename view-clients ``` > `corp-realm` 是 master realm 里自动生成的、代表 `corp` realm 的管理 client, > 命名规则是 `-realm`。 ### 11.2 Node 里调用 ```js // admin.js const KEYCLOAK_URL = process.env.KEYCLOAK_URL; const REALM = process.env.KEYCLOAK_REALM; const PROV_ID = process.env.PROVISIONING_CLIENT_ID; // provisioning const PROV_SECRET = process.env.PROVISIONING_CLIENT_SECRET; let _tok = null, _exp = 0; async function adminToken() { if (_tok && Date.now() < _exp) return _tok; const resp = await fetch(`${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: PROV_ID, client_secret: PROV_SECRET, }), }); if (!resp.ok) throw new Error(`取管理 token 失败: ${resp.status}`); const d = await resp.json(); _tok = d.access_token; _exp = Date.now() + (d.expires_in - 60) * 1000; return _tok; } /** 调 Admin REST API。path 相对于 /admin/realms/{realm} */ export async function adminFetch(path, { method = 'GET', body, ...rest } = {}) { const token = await adminToken(); const resp = await fetch(`${KEYCLOAK_URL}/admin/realms/${REALM}${path}`, { method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, ...rest, }); if (!resp.ok) throw new Error(`Admin API ${method} ${path} 失败: ${resp.status} ${await resp.text()}`); // 201 Created 没有响应体,新资源 ID 在 Location 头最后一段 const text = await resp.text(); if (!text) { const loc = resp.headers.get('location'); return loc ? { id: loc.split('/').pop() } : null; } return JSON.parse(text); } ``` ### 11.3 常用操作 ```js // 精确查用户(能用 exact 就别用 search,search 在大 realm 上很慢) const [u] = await adminFetch('/users?username=alice&exact=true'); // 建用户,201 的 Location 头带新 ID const { id } = await adminFetch('/users', { method: 'POST', body: { username: 'bob', email: 'bob@akhr-ai.com', enabled: true, emailVerified: true, credentials: [{ type: 'password', value: 'Init@2026', temporary: true }], }}); // 查实际生效的角色(composite 会展开组合角色) const roles = await adminFetch(`/users/${id}/role-mappings/realm/composite`); // 授予 admin 角色(body 是角色对象数组,不是名字数组) const adminRole = await adminFetch('/roles/admin'); await adminFetch(`/users/${id}/role-mappings/realm`, { method: 'POST', body: [adminRole] }); // 强制下线 —— 撤权后让其立即失效的唯一手段 await adminFetch(`/users/${id}/logout`, { method: 'POST' }); ``` **做完应该看到**:`adminFetch('/users?username=xxx&exact=true')` 返回一个数组。 403 的话是服务账号缺 realm-management 角色,对照 11.1 补。 --- ## 12. 上线前检查清单 - [ ] 每个服务的 `KEYCLOAK_CLIENT_ID` 是**自己的** clientId(填错会全线 401) - [ ] `data-service` 没配 `KEYCLOAK_CLIENT_SECRET`(正确,见 13.2) - [ ] secret 只在环境变量里,没进 Git、没写死在代码里 - [ ] 启动自检 `checkKeycloakConnection()` 已接上,配置错了直接退出 - [ ] `/healthz` 在鉴权中间件**之前**注册(否则探活会 401) - [ ] 管理端专属接口都加了 `requireRealmRole('admin')` - [ ] 401 和 403 分开返回,没有混用 - [ ] 网关剥掉了 `x-user-id` 之类的伪造 header - [ ] 下游服务各自验签,没有"信任网关"的代码 - [ ] 没有在请求热路径上调 introspect / userinfo / Admin API - [ ] 业务表外键用的是 `sub`,不是 username --- ## 13. 鉴权原理速查 ### 13.1 谁验什么 | 角色 | 要做的验证 | 不做什么 | |---|---|---| | 前端 | 读 `realm_access.roles` 决定显示什么 | **不做安全判断** | | API 网关 | 完整验签,拒绝无效 token,原样转发 | 不做业务权限判断 | | 下游三个服务 | **各自再完整验一遍签**,然后判角色 | 不假设"网关已经验过了" | ### 13.2 验 token 不需要 client secret **secret 的用途是"向 Keycloak 证明自己是谁",不是"验别人的 token"。** | 服务在做什么 | 需要 secret | |---|---| | 验别人递来的 Bearer token | ❌ | | 用 `client_credentials` 换自己的 token 去调别的服务 | ✅ | | 调 token introspection | ✅(但不推荐) | | 做 token exchange | ✅ | 所以 `data-service` 的 `.env` 里没有 `KEYCLOAK_CLIENT_SECRET` 是正确的, 不是配漏了。看到它没 secret 时不要自作主张配一个——不用的凭据多存一处 就多一个泄漏面。 ### 13.3 身份数据从哪来 —— 不要向 Keycloak 回源查 > **后端不向 Keycloak「要」用户身份。身份就在 token 里。** > 看到「验签通过后调 `/userinfo` 拿用户信息」这类写法,那是错的。 ``` 首次请求 客户端 ──Bearer eyJ...──► 服务 ├ 解 header 拿 kid,本地无此公钥 ├───► GET /certs ← 唯一一次网络往返 ├ 公钥存内存,缓存 10 分钟 └ 验签 → 校验 iss/aud/exp → 读 sub/roles/groups ≈ 50–200 ms 第二次及以后 客户端 ──Bearer eyJ...──► 服务 ├ kid 命中缓存 └ 纯本地运算 ≈ 0.1 ms,Keycloak 全程未被打扰 ``` **需要 token 里没有的字段时,改配 mapper,不要调接口。** 比如要用户工号:Clients → `web-portal` → Client scopes → `web-portal-dedicated` → Add mapper → By configuration → **User Attribute**, 配 `employee_id` → `employee_id`,Add to access token = On。 之后 `req.auth.employee_id` 直接可读,零网络开销。**两个前端都要配。** ### 13.4 撤权的时效性 本地验签的代价:**改了角色或禁用用户,现有 access token 在过期前仍然有效**。 access token 是 5 分钟,所以最坏滞后 5 分钟。 要立即生效调 `POST /admin/realms/corp/users/{id}/logout`, 它会作废该用户所有会话和 refresh token。 **不要为了消除这 5 分钟去每请求调 introspect**,那个代价大得多。 --- ## 14. Admin REST API 清单 基地址 `https://keycloak.akhr-ai.com/admin/realms/corp`, 带 `Authorization: Bearer <管理 token>`。 ### 14.1 三条必须记住的路径规则 1. **路径里的 `{client-uuid}` 是 client 的内部 UUID,不是 clientId 字符串。** 官方文档原文:`id of client (not client-id!)`。 先 `GET /clients?clientId=data-service` 拿 `[0].id`。 2. **创建成功返回 201,响应体为空,新资源 ID 在 `Location` 头最后一段。** 3. **列表接口默认 `max=100`**,必须分页。 ### 14.2 用户 | 操作 | 方法与路径 | |---|---| | 精确查 | `GET /users?username=alice&exact=true` | | 模糊搜索 | `GET /users?search=&first=0&max=50`(大 realm 上慢,能用 exact 就别用) | | 详情 | `GET /users/{user-id}` | | 创建 | `POST /users` → 201 + Location | | 更新 | `PUT /users/{user-id}`(全量覆盖,先 GET 再改再 PUT) | | 删除 | `DELETE /users/{user-id}` | | 设密码 | `PUT /users/{user-id}/reset-password` body `{"type":"password","value":"...","temporary":true}` | | 查会话 | `GET /users/{user-id}/sessions` | | **强制下线** | `POST /users/{user-id}/logout` | | 解除暴力锁定 | `DELETE /attack-detection/brute-force/users/{user-id}` | ### 14.3 组 | 操作 | 方法与路径 | |---|---| | 组树 | `GET /groups?briefRepresentation=false` | | 建顶层组 | `POST /groups` body `{"name":"技术部"}` | | 建子组 | `POST /groups/{parent-id}/children` | | 组成员 | `GET /groups/{group-id}/members?first=0&max=100` | | **加进组** | `PUT /users/{user-id}/groups/{group-id}`(无 body,204) | | 移出组 | `DELETE /users/{user-id}/groups/{group-id}` | | 查用户所属组 | `GET /users/{user-id}/groups` | ### 14.4 角色 | 操作 | 方法与路径 | |---|---| | 列 realm 角色 | `GET /roles` | | 建 realm 角色 | `POST /roles` body `{"name":"auditor"}` | | **查实际生效的 realm 角色** | `GET /users/{user-id}/role-mappings/realm/composite` | | 授 realm 角色 | `POST /users/{user-id}/role-mappings/realm` body 是**角色对象数组** | | 撤 realm 角色 | `DELETE /users/{user-id}/role-mappings/realm` | | 给**组**授角色 | 同上,`users/{id}` 换成 `groups/{id}` | `/composite` 会递归展开组合角色,返回"实际生效"的集合。 **排查权限问题永远看 `/composite`。** 注意它不包含通过组继承的角色, 要查完整生效集合需同时看用户所属组的角色。 ### 14.5 客户端与 mapper | 操作 | 方法与路径 | |---|---| | 按 clientId 查 | `GET /clients?clientId=data-service` → 取 `[0].id` | | 取 client secret | `GET /clients/{client-uuid}/client-secret` | | 重新生成 secret | `POST /clients/{client-uuid}/client-secret` | | 服务账号对应用户 | `GET /clients/{client-uuid}/service-account-user` | | 列 mapper | `GET /clients/{client-uuid}/protocol-mappers/models` | | 加 mapper | `POST /clients/{client-uuid}/protocol-mappers/models` | | **预览 token** | `GET /clients/{client-uuid}/evaluate-scopes/generate-example-access-token?userId=` | 最后一个在调 mapper 时极其有用:不用真登录就能看到 token 会变成什么样。 audience mapper 的 body: ```json { "name": "aud-data-service", "protocol": "openid-connect", "protocolMapper": "oidc-audience-mapper", "config": { "included.client.audience": "data-service", "access.token.claim": "true" } } ``` ### 14.6 服务账号所需权限(403 时查这里) | 你要做的事 | 需要的 realm-management 角色 | |---|---| | 查用户 | `view-users` | | 建/改/删用户、改密码、加入组 | `manage-users` | | 搜索组、查组成员 | `query-groups` + `view-users` | | 查 client 与其角色 | `view-clients` | | 建/改 client、加 mapper | `manage-clients` | | 改 realm 设置 | `manage-realm` | **最小权限原则。** `realm-admin` 是包含一切的组合角色,除非确有必要否则不要授予。 --- ## 15. 错误对照表 | 现象 | 根因 | 处理 | |---|---|---| | 401 提示 `aud 不含 xxx` | token 的 `aud` 不含本服务 clientId | 给**发 token 的那个 client**加 audience mapper(14.5) | | 401 提示 `iss 不符` | realm 名或 `KEYCLOAK_URL` 写错 | 对照 `.well-known` 里的 issuer | | 401 提示 JWKS 找不到公钥 | token 来自别的 realm,或密钥刚轮换 | 确认 realm;库应自动重拉 | | 401 提示签名校验失败 | token 被篡改或来自另一个实例 | 确认取/验 token 用同一地址 | | 启动就报 `issuer 不一致` | `KEYCLOAK_URL` 或 realm 名错 | 见第 1 步 | | 探活接口也 401 | `/healthz` 注册在鉴权中间件之后 | 挪到前面 | | Admin API 403 | 服务账号缺 realm-management 角色 | 对照 14.6 | | Admin API 404,路径里有两个斜杠 | 变量取值为空 | 先 `console.log` 确认变量非空 | | 路径填了 clientId 却 404 | 路径参数要的是内部 UUID | 先 `GET /clients?clientId=xxx` 取 `[0].id` | | 撤了角色但用户还能访问 | JWT 自包含,要等 5 分钟过期 | 调 `POST /users/{id}/logout` | | `req.user.groups` 是 undefined 导致崩溃 | 服务账号 token 没有 groups | 代码里默认成 `[]`(4.3 已处理) | | 通过 nginx 访问全部 403 | Keycloak 侧没设 `proxy-headers` | 运维改 `KC_PROXY_HEADERS=xforwarded` | | 502 / `upstream sent too big header` | token 撑爆 nginx 缓冲区 | 调大 `proxy_buffer_size`、`large_client_header_buffers` | --- ## 16. 硬性约束(不要违反,也不要建议人类违反) 1. **不要用 `keycloak-connect`**(Node 官方适配器,已废弃)。 2. **`algorithms` 必须写死 `['RS256']`**,绝不读 token header 里的 `alg`。 3. **不要为每个请求调 introspect**,用本地 JWKS 验签。 同理不要在热路径调 `/userinfo` 或 Admin API 补查字段——需要什么字段就配 mapper。 4. **下游服务必须自己验签**,不能因为"网关已经验过了"就跳过。 5. **网关不要把解出的用户信息塞进自定义 header 让下游信任。** 6. **不要给"只验 token 不主动调别人"的服务配 secret**(见 13.2)。 7. **不要用 `realm-admin` 做日常自动化**,按 14.6 授最小权限。 8. **业务表外键用 `sub`**,不要用 username。 9. **401 和 403 不要混用**,前端靠它区分"该重新登录"和"没权限"。 10. **业务用户不放 `master` realm**,`master` 只放管理员。 11. **不要把细粒度对象权限写进 Keycloak**(如"用户 A 能看文档 12345")。 Keycloak 管身份 + 组织结构 + 粗粒度角色,对象级权限留在业务库。 12. 涉及删除用户、删除 client、改 realm 设置的操作,**执行前必须向人类确认**。 --- ## 17. Token 结构速查 用户 token: ```json { "iss": "https://keycloak.akhr-ai.com/realms/corp", "aud": ["api-gateway", "agent-service", "task-dispatcher", "data-service", "account"], "sub": "8f3c1a2b-...", "exp": 1755000000, "azp": "web-portal", "preferred_username": "chaihao", "email": "chaihao@akhr-ai.com", "name": "张三", "groups": ["/技术部", "/技术部/平台组"], "realm_access": { "roles": ["user", "default-roles-corp"] } } ``` | claim | 含义 | 注意 | |---|---|---| | `sub` | 用户唯一 ID,**永不变** | 业务表存这个 | | `azp` | 哪个前端签发的 | 与 `aud` 不同,别混用 | | `aud` | 目标受众 | 默认不含后端,靠 audience mapper | | `groups` | 组完整路径数组 | 默认不存在,靠 group membership mapper;服务账号没有 | | `realm_access.roles` | realm 角色 | 只有 `user` / `admin` 两种 | --- ## 18. 官方文档索引 | 主题 | 链接 | |---|---| | Admin REST API 完整参考 | https://www.keycloak.org/docs-api/latest/rest-api/ | | 应用接入 OIDC | https://www.keycloak.org/securing-apps/oidc-layers | | Token Exchange | https://www.keycloak.org/securing-apps/token-exchange | | 服务器管理指南 | https://www.keycloak.org/docs/latest/server_admin/ | | jose 库文档 | https://github.com/panva/jose |