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/keycloak-backend-guide.md
xcsandClaude Fable 5 f52edd5383 CI/CD 四件与两份文档入库 —— debt.md 早已写「已接」而文件一直没跟踪
.gitea/workflows/ci.yml · tools/ci.mjs · tools/cd.mjs · docs/部署.md:推主干跑五道门禁 +
从空库起的集成测试 + 三份构建,全绿后部署到 runner 那台机(机密一律 secrets.*,机器相关
一律 vars.*,文件里不写死)。debt.md 在 9848e2f 就点名了这几个文件,远端却从未有过 .gitea。

docs/keycloak-backend-guide.md:ADR-012 引为出处的后端接入手册(只有 clientId 与「有/没有」,
无密钥)。docs/数据域字段说明-E04-I01.md:设计中心数据域那一屏的字段说明。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01712YeSo5fTfkCSPZHBiniq
2026-08-26 14:34:19 +08:00

1066 lines
39 KiB
Markdown
Raw Permalink 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)—— 后端接入手册(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-<clientId>` |
| `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名>-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=<kw>&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=<uid>` |
最后一个在调 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 |