Archived
.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
1066 lines
39 KiB
Markdown
1066 lines
39 KiB
Markdown
# 统一鉴权中心(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 |
|