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
T
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

39 KiB
Raw Blame History

统一鉴权中心(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 秒确认:

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。 这种情况下后端怎么写都验不过,而且报错完全指不到这里。

再确认公钥能拉到:

curl -s https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/certs | jq '.keys[0].kid'

有输出即可。这是个公开端点,不需要任何凭据——后端验签只需要它。


2. 装依赖,建目录

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。

# 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
# 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 远程公钥集

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 验签

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 中间件

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 的错误翻译成人话

排错时能省很多时间:

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 角色守卫

/** 要求持有指定 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 完整文件

// 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

// 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

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 没配,改后端代码没用。

用完立刻关掉:

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 打接口

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. 角色鉴权怎么用

// 管理端专属接口
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'), 这是唯一真正的安全边界。

按部门过滤数据:

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 就在自己库里落一条:

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 网关的写法(和下游不一样)

网关只做两件事:验签拦截 + 原样转发。不判业务角色,那是下游各自的事。

// 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 让下游信任。

// ❌ 错误做法
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。

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 一次性准备(人类执行)

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 里调用

// 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 常用操作

// 精确查用户(能用 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:

{ "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:

{
  "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