Keycloak-SSO-RBAC实战
Keycloak 从零到部署:两个 Node.js 站点实现 SSO 单点登录 + RBAC 角色权限控制实战
写在前面:本文是一篇完整的技术实战文档,配套一个可直接运行的 Node.js 演示项目。从 Keycloak 服务搭建、Realm/Client/角色/用户配置,到代码实现 SSO 和 RBAC 角色权限控制,最后到常见坑点排查,全部基于真实跑通的代码演示,文末附带完整源码。
关键词:Keycloak、SSO、OIDC、ROPC、RBAC、JWT、Token 传递、Node.js、Express
目录
一、为什么需要 SSO
想象一下这个场景:
你公司有 5 个内部系统:CRM、ERP、OA、数据中台、运维平台。每个系统都有自己的账号体系。新员工入职要在 5 个系统各注册一次,离职时还要在 5 个系统各删一次。更糟的是,每个系统密码规则不一样,密码三个月过期一次,用户要在 5 个系统各改一次……
SSO(Single Sign-On,单点登录)就是来解决这个问题的:一次登录,所有系统通行。Keycloak 是这个领域最主流的开源方案之一。
二、本次实战的技术栈
| 类别 | 选型 | 版本 |
|---|---|---|
| 身份认证服务 | Keycloak | 26.7.0 |
| 数据库 | MySQL | 5.7+ |
| 部署方式 | Docker | 26.x |
| 演示站点 | Node.js + Express | 18+ |
| 模板引擎 | EJS | 3.1 |
| 认证模式 | ROPC(Resource Owner Password Credentials) | - |
| SSO 实现方式 | Token 传递(URL 参数) | - |
| 权限控制 | RBAC(Realm Roles + Client Roles) | - |
为什么选 ROPC 而不是标准的 Authorization Code 模式?
标准 OIDC 流程要求用户必须跳转到 Keycloak 登录页。一些企业内网系统有自己的品牌设计要求,希望保留自有登录页。ROPC 允许后端用用户名密码直接调用 Keycloak 的/token接口换 Token,体验上更灵活。本次实战就采用这种方式。
三、Keycloak 服务搭建
3.1 Docker 部署(MySQL 后端)
通过 Docker 启动 Keycloak,外挂 MySQL 作为后端存储。生产环境推荐这样配置——重启 Keycloak 容器数据不丢。
提示:如果想用本地数据库,先创建好
keycloak数据库及对应的账号密码,确保 Keycloak 能正常连接。
docker run -d \
--name keycloak \
-p 8080:8080 \
-e KC_DB=mysql \
-e KC_DB_URL="jdbc:mysql://192.168.1.101:13306/keycloak?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai" \
-e KC_DB_USERNAME=root \ # 数据库账号
-e KC_DB_PASSWORD=1234567890 \ # 数据库密码
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ # 初始化管理员账号
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ # 初始化管理员密码
-e KC_HTTP_ENABLED=true \
-e KC_HOSTNAME=192.168.1.101 \ # 桥接模式下必须配置,否则访问不了
-e KC_HOSTNAME_STRICT=false \
-e KC_HOSTNAME_STRICT_BACKCHANNEL=false \
-e KC_HOSTNAME_PORT=8080 \
quay.io/keycloak/keycloak:26.7.0 \
start-dev # 开发模式,允许 HTTP 访问
参数说明:
| 参数 | 作用 |
|---|---|
KC_HOSTNAME |
必须设为外部可访问的 IP,否则 Keycloak 生成的回调地址会指向容器内部,无法访问 |
KC_HTTP_ENABLED=true |
允许 HTTP,生产环境应设为 false 并配置 HTTPS |
start-dev |
开发模式启动,正式部署用 start 并加 --optimized |
启动成功后访问 http://192.168.1.101:8080,看到登录页就说明 OK 了:
3.2 控制台汉化
进入 Keycloak Admin Console:http://192.168.1.101:8080/admin,用初始管理员账号 admin/admin 登录。
汉化步骤:
- 进入「管理领域 → Realm settings → Localization」标签
- 启用「Internationalization」
- 在 Supported locales 中添加「Chinese (Simplified)」
- 保存

图 3-1:汉化配置界面,按图示数字依次点击
保存后重新登录,右上角就可以看到中文选项了:

图 3-2:登录页面右上角的中文切换
3.3 创建领域(Realm)
领域(Realm) 是 Keycloak 中的顶层租户单位,用于多公司/多业务的隔离。一个 Realm 下有独立的用户、客户端、角色体系。
进入「管理领域(Manage realms)」→「创建领域」,输入名称 hunter(可按需取名)。本演示项目就用 hunter 作为 Realm 名。

Realm 创建完成后会自动跳转到 hunter 域。所有后续操作默认都在 hunter 域下进行。
3.4 创建客户端(Client)
客户端(Client) 代表的是一个应用系统或服务。每个需要登录的子平台都要在 Keycloak 里注册为一个 Client。
点击左侧「客户端 → 创建客户端」:

填写如下信息:
- 客户端 ID:
test-service(对应站点 A) - 名称:自定义,比如"测试GDO项目"
- 客户端类型:OpenID Connect
点「下一步」配置能力(Capabilities),只需要保留 Standard flow 和 Direct access grants(ROPC 用的就是这个)。
注意:Standard flow 是标准 OIDC 跳转授权。如果只用 ROPC,可以把它关掉。但如果之后想改回标准 OIDC 跳转,建议保留。

切换到「客户端详情 → 重定向 URL」标签:
此处 URL 配置是给「Standard flow」(授权码模式)跳转用的。如果只走 ROPC 可不配。但如果选择 OIDC 跳转授权,需要在这里配置好对应的站点回调地址,比如:
http://localhost:3001/callbackhttp://localhost:3002/callback

按相同步骤再创建一个客户端 test-service2(对应站点 B)。
完成后「客户端列表」可以看到:

图 3-3:客户端列表,可以看到 test-service 和 test-service2
进入客户端的「凭据(Credentials)」标签,可以获取 Client Secret。这个密钥在 ROPC 模式中后端调用 Token 接口时要用到:
test-service: tfnOj0ARJrIzs46i14BCHQaO3b5RzjUMAReea2tqTheZAvANVihZAmmoPg1Tc7zmur9jvNhgISRSm84vzxRyGU
test-service2: bqn3HUdPY34JMaQhlLQzvENchW4UU9Q1d6sePo6qbSv1byRYuJUvTiHZvebT5FDtTvxh9i54dhrIHSWmSK5D8B
3.5 创建客户端角色
如果想让用户访问对应客户端,配置逻辑是:
创建客户端 → 创建客户端角色 → 创建领域用户 → 给用户进行角色映射
进入 test-service 客户端的「角色(Roles)」标签页 → 创建角色,比如 test-service-user:

同样为 test-service2 创建角色 test-service2-user。
角色创建完后可以在角色列表看到:

3.6 创建领域用户
3.6.1 添加用户
左侧菜单「用户管理 → Add user」:

填入用户名(用户名在 Realm 内唯一):

3.6.2 初始化登录密码
进入用户详情 → 「凭据(Credentials)」标签 → 设置密码:

关键一步:把「临时(Temporary)」开关关闭,否则用户下次登录会被强制要求改密码:

3.6.3 配置角色映射
进入用户详情 → 「角色映射(Role mapping)」标签页。这里有两个角色类型来源:
- 领域角色(Realm roles):跨所有客户端的全局角色(如 admin/user/viewer)
- 客户端角色(Client roles):某个具体客户端独有的角色
默认显示的是领域角色。切换到「客户端角色」下拉框,选中 test-service:


勾选需要分配的角色:

如果要实现同一账号可以同时访问两个客户端,就两个 Client 角色都勾选。
3.7 关键配置:让 Token 包含角色信息
⚠️ 这一步非常关键但容易忽略!如果不做,Token 里不会有角色信息,后端就无法进行 RBAC 权限判断。
如果不主动配置,用户登录后默认返回的 ID Token 是不会包含角色信息的,尤其是 realm_access 中的角色字段。
进入「 客户端范围(Client scopes)标签 → 搜索roles」:

点击进入roles详情,再点击「映射(Mappers)」配置,找到 realm roles:

把「添加到访问令牌(Add to access token)」和「添加到用户信息(Add to userinfo)」两个选项都启用:
在这里插入图片描述
启用后,Keycloak 在用户登录成功后,会把角色信息一并写到 Access Token 和 userinfo 接口中。
3.8 常见疑问解答
Q:为什么我创建的用户却登录不了 Keycloak 控制面板?
A:首先你必须在 master 这个默认领域内创建用户并配置相应权限才能访问。

进入用户详情 →「角色映射」标签,给用户分配相应权限:

你可以自定义领域角色,例如创建 developer:

两种角色分配方式:
方式一:客户端角色直接灵活分配

方式二:领域角色勾选(如 admin)

两种方式任选其一,配置完就可以正常登录账号了。
四、Node.js 项目实现
4.1 项目结构
keycloak-sso-demo/
├── lib/
│ ├── auth.js # 认证核心模块(ROPC、introspect、RBAC)
│ ├── config.js # 双站点配置
│ └── server.js # Express 路由工厂
├── views/ # EJS 模板
│ ├── header.ejs
│ ├── footer.ejs
│ ├── login-form.ejs # 自有登录页
│ ├── home.ejs # 首页
│ ├── profile.ejs # 个人资料 + Token + RBAC
│ ├── admin.ejs # 管理面板(需 admin 角色)
│ └── forbidden.ejs # 403 无权限
├── site-a/
│ └── index.js # 站点 A 启动入口
├── site-b/
│ └── index.js # 站点 B 启动入口
├── doc-images/ # Word 指引中的截图
├── .env # Keycloak 配置
└── package.json
package.json 关键依赖:
{
"type": "module",
"scripts": {
"start": "concurrently -n \"SiteA,SiteB\" -c \"blue,green\" \"npm:start:a\" \"npm:start:b\""
},
"dependencies": {
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
"express": "^4.21.2",
"express-session": "^1.19.0"
}
}
4.2 配置文件
.env:
# Keycloak 服务配置
KEYCLOAK_URL=http://192.168.1.101:8080
KEYCLOAK_REALM=hunter
# 站点 A
SITE_A_NAME=站点 A
SITE_A_CLIENT_ID=test-service
SITE_A_CLIENT_SECRET=tfnOj0ARJrIzs46i14BCHQaO3b5RzjUMAReea2tqTheZAvANVihZAmmoPg1Tc7zmur9jvNhgISRSm84vzxRyGU
SITE_A_PORT=3001
# 站点 B
SITE_B_NAME=站点 B
SITE_B_CLIENT_ID=test-service2
SITE_B_CLIENT_SECRET=bqn3HUdPY34JMaQhlLQzvENchW4UU9Q1d6sePo6qbSv1byRYuJUvTiHZvebT5FDtTvxh9i54dhrIHSWmSK5D8B
SITE_B_PORT=3002
APP_BASE_URL=http://localhost
SESSION_SECRET=your-random-secret
lib/config.js:
'use strict';
import dotenv from 'dotenv';
dotenv.config();
const SITES = {
a: {
id: 'a',
name: process.env.SITE_A_NAME || '站点 A',
clientId: process.env.SITE_A_CLIENT_ID || 'test-service',
clientSecret: process.env.SITE_A_CLIENT_SECRET || '',
port: parseInt(process.env.SITE_A_PORT, 10) || 3001,
color: '#4f46e5',
// ... 主题色
},
b: {
id: 'b',
name: process.env.SITE_B_NAME || '站点 B',
clientId: process.env.SITE_B_CLIENT_ID || 'test-service2',
clientSecret: process.env.SITE_B_CLIENT_SECRET || '',
port: parseInt(process.env.SITE_B_PORT, 10) || 3002,
color: '#059669',
},
};
export function getSiteConfig(siteId) {
const site = SITES[siteId];
const otherSite = SITES[siteId === 'a' ? 'b' : 'a'];
const baseUrl = process.env.APP_BASE_URL || 'http://localhost';
const keycloakUrl = (process.env.KEYCLOAK_URL || 'http://localhost:8080').replace(/\/+$/, '');
const realm = process.env.KEYCLOAK_REALM || 'master';
return {
site: { ...site, url: `${baseUrl}:${site.port}` },
otherSite: { ...otherSite, url: `${baseUrl}:${otherSite.port}` },
keycloak: {
url: keycloakUrl,
realm,
issuerBaseURL: `${keycloakUrl}/realms/${realm}`,
accountUrl: `${keycloakUrl}/realms/${realm}/account`,
},
sessionSecret: process.env.SESSION_SECRET || 'fallback-secret',
};
}
export { SITES };
4.3 核心:ROPC 自有登录页
登录表单 views/login-form.ejs:
<div class="login-container">
<div class="login-card">
<h1><%= site.name %></h1>
<p class="login-subtitle">请输入您的 Keycloak 账号密码</p>
<% if (error) { %>
<div class="alert alert-error">
<strong>登录失败</strong><br/>
<%= error %>
</div>
<% } %>
<form method="POST" action="/login">
<input type="hidden" name="returnTo" value="<%= returnTo || '/' %>" />
<input type="text" name="username" placeholder="用户名" required />
<input type="password" name="password" placeholder="密码" required />
<button type="submit">登录</button>
</form>
</div>
</div>
后端 ROPC 调用 lib/auth.js:
/**
* 用用户名密码换取 Token(ROPC password grant)
*/
export async function passwordGrantLogin(keycloak, clientId, clientSecret, username, password) {
const tokenUrl = `${keycloak.url}/realms/${keycloak.realm}/protocol/openid-connect/token`;
const body = new URLSearchParams({
grant_type: 'password',
client_id: clientId,
client_secret: clientSecret,
username,
password,
scope: 'openid profile email',
});
const resp = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = await resp.json();
if (!resp.ok) {
const msg = data.error_description || data.error || `HTTP ${resp.status}`;
throw new Error(msg);
}
return data;
}
Express 路由 lib/server.js 处理登录请求:
app.post('/login', async (req, res) => {
const { username, password, returnTo } = req.body;
try {
const tokenSet = await passwordGrantLogin(
keycloak, site.clientId, site.clientSecret, username, password
);
// 【关键】Client 权限校验
// ROPC 登录时 Keycloak 总会用请求的 client_id 作为 azp,
// 所以不能靠 azp/aud 判断用户是否被分配了该 Client 的角色。
// 唯一可靠依据:resource_access[clientId].roles 是否非空。
const decoded = decodeJwt(tokenSet.access_token);
const clientRoles = decoded?.payload?.resource_access?.[site.clientId]?.roles || [];
if (clientRoles.length === 0) {
return res.render('login-form', {
error: `账号 "${username}" 没有分配 "${site.clientId}" 的访问权限,无法登录 ${site.name}。`,
username, returnTo, site, otherSite, keycloak,
});
}
// 存 Token 到 session
req.session.tokens = {
access_token: tokenSet.access_token,
id_token: tokenSet.id_token || null,
refresh_token: tokenSet.refresh_token || null,
expires_at: tokenSet.expires_at || null,
};
res.redirect(returnTo || '/');
} catch (err) {
res.render('login-form', {
error: err.message,
username, returnTo, site, otherSite, keycloak,
});
}
});
4.4 核心:SSO Token 传递
登录后,首页会有一个「免密跳转到站点 B」的按钮。这里用最简单的 Token 传递实现:把 access_token 作为 URL 参数,跳到站点 B 的 /sso-callback:
views/home.ejs:
<a href="<%= otherSite.url %>/sso-callback?token=<%= accessToken %>&returnTo=/"
class="btn btn-primary">
免密跳转到 <%= otherSite.name %> →
</a>
⚠️ 这种方式只在演示场景使用。生产环境 Token 不能放 URL(会被日志服务器、浏览器历史记录等留存)。实际部署应该用 Cookie 共享或加密 token 短时效方案。
4.5 关键:Token 验证双保险
站点 B 的 /sso-callback 路由要做两件事:
- Client 权限门控:这个 Token 有没有当前 Client 的角色
- Token 有效性验证:Token 没过期、签名合法
app.get('/sso-callback', async (req, res) => {
const token = req.query.token;
const returnTo = req.query.returnTo || '/';
if (!token) {
return res.status(400).send('SSO 缺少 Token');
}
try {
// 先解码 JWT
const decoded = decodeJwt(token);
const tokenResourceAccess = decoded?.payload?.resource_access || {};
const tokenAzp = decoded?.payload?.azp || '';
const tokenAud = Array.isArray(decoded?.payload?.aud)
? decoded.payload.aud
: [decoded?.payload?.aud].filter(Boolean);
// ====== Client 权限门控 ======
const clientRoles = tokenResourceAccess[site.clientId]?.roles || [];
const hasClientAccess =
clientRoles.length > 0 || tokenAud.includes(site.clientId) || tokenAzp === site.clientId;
if (!hasClientAccess) {
return res.status(403).send(`
<h1>SSO 访问被拒绝</h1>
<p>该账号没有 <strong>${site.clientId}</strong> 的访问权限,无法免密登录 <strong>${site.name}</strong>。</p>
<div style="background:#f5f5f5;padding:12px;border-radius:8px;">
Token 签发 Client (azp): ${tokenAzp || '(无)'}<br>
Token 包含的 Client 权限: ${Object.keys(tokenResourceAccess).join(', ') || '(无)'}<br>
Token audience: ${tokenAud.join(', ') || '(无)'}<br>
目标 Client: ${site.clientId}
</div>
`);
}
// ====== Token 有效性验证 ======
// 方案 1:调 Keycloak introspect 验证
const introspection = await introspectToken(
keycloak, site.clientId, site.clientSecret, token
);
if (introspection && introspection.active) {
req.session.tokens = {
access_token: token,
id_token: null,
refresh_token: null,
expires_at: introspection.exp || null,
};
return res.redirect(returnTo);
}
// 方案 2:introspect 失败时降级为本地 JWT 验证
const expectedIssuer = `${keycloak.url}/realms/${keycloak.realm}`;
const localPayload = verifyTokenLocally(token, expectedIssuer, site.clientId);
if (localPayload) {
req.session.tokens = {
access_token: token,
id_token: null,
refresh_token: null,
expires_at: localPayload.exp || null,
};
return res.redirect(returnTo);
}
return res.status(401).send('SSO Token 无效');
} catch (err) {
res.status(500).send('SSO 认证失败: ' + err.message);
}
});
为什么要用「introspect + 本地 JWT 验证」双保险?
| 验证方式 | 优点 | 缺点 |
|---|---|---|
introspect 调 Keycloak /token/introspect 接口 |
Keycloak 端权威判定(未过期、未吊销) | 网络抖动或 Keycloak 跨 Client 配置问题时会返回 active:false,SSO 失败 |
| 本地 JWT 验证(验 exp + iss + Client 角色) | 不依赖网络,性能好 | 不能识别服务端吊销的 Token |
实战中遇到的问题:当 A 站点登录拿到的 Token 拿到 B 站点的 SSO callback 时,Keycloak 偶尔会返回 active: false(跨 Client 验证、缓存、配置差异等原因)。所以加了本地 JWT 验证作为 fallback。
4.6 关键:Client 级别权限隔离
这是一个隐藏的坑,必须额外检查。
在 SSO 场景下,用户在 A 站点的 Token 拿来访问 B 站点时,要确保这个用户确实有 B 站点对应 Client 的角色——而不是只要 Token 没过期、签发者对就放行。
如果用户的 Token 只分配了 test-service 的角色,但拿去 SSO test-service2,应该被拒绝!
lib/auth.js 中的 verifyTokenLocally 函数做了这个检查:
export function verifyTokenLocally(token, expectedIssuer, clientId) {
const decoded = decodeJwt(token);
if (!decoded || !decoded.payload) return null;
const { payload } = decoded;
const now = Math.floor(Date.now() / 1000);
// 1. 检查过期时间
if (payload.exp && payload.exp < now) return null;
// 2. 检查签发者
if (expectedIssuer && payload.iss !== expectedIssuer) return null;
// 3. 检查 Client 级别访问权限
if (clientId) {
const resourceAccess = payload.resource_access || {};
const clientRoles = resourceAccess[clientId]?.roles || [];
const aud = Array.isArray(payload.aud) ? payload.aud : (payload.aud ? [payload.aud] : []);
const hasClientAccess =
clientRoles.length > 0 || aud.includes(clientId) || payload.azp === clientId;
if (!hasClientAccess) {
console.log(`[local-verify] Token 无权访问 Client: ${clientId}`);
return null;
}
}
return payload;
}
⚠️ 注意:在 SSO 场景下
azp === clientId是一个充分条件(因为 Token 来自 A 站点,azp 是 A 的 clientId,不会匹配 B 的 clientId)。但在 ROPC 登录场景下 Keycloak 总会把请求方的 client_id 写入 azp,所以azp永远匹配——这种情况下必须只看resource_access[clientId].roles是否非空。
4.7 RBAC 角色权限控制
Keycloak 角色有两层:
| 层级 | JWT 字段 | 含义 |
|---|---|---|
| Realm 角色 | realm_access.roles |
全域共享(如 admin / user / developer) |
| Client 角色 | resource_access[clientId].roles |
某 Client 独占(如 test-service:editor) |
4.7.1 提取角色
export function extractRoles(token, clientId) {
const decoded = decodeJwt(token);
if (!decoded?.payload) {
return { realmRoles: [], clientRoles: [], allRoles: [], resourceAccess: {} };
}
const payload = decoded.payload;
const realmRoles = payload.realm_access?.roles || [];
const resourceAccess = payload.resource_access || {};
const clientRoles = resourceAccess[clientId]?.roles || [];
// 合并去重
const allRoles = [...new Set([...realmRoles, ...clientRoles])];
return { realmRoles, clientRoles, allRoles, resourceAccess };
}
export function hasRole(roleData, requiredRole) {
if (!requiredRole) return true;
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
return roles.some(r => roleData.allRoles.includes(r));
}
4.7.2 路由守卫
export function requireRole(role, clientId) {
return (req, res, next) => {
if (!req.oidc?.isAuthenticated()) {
return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
}
const roleData = extractRoles(req.oidc.accessToken, clientId);
if (hasRole(roleData, role)) {
req.roleData = roleData;
next();
} else {
// 渲染 403 页面
res.status(403).render('forbidden', {
requiredRole: Array.isArray(role) ? role.join(' / ') : role,
userRoles: roleData.allRoles,
// ...
});
}
};
}
使用示例(在 server.js):
// 受保护的个人资料页(任意已登录用户)
app.get('/profile', requireAuth(), (req, res) => {
// ...
});
// 管理面板(必须有 admin 角色)
app.get('/admin', requireAuth(), requireRole('admin', site.clientId), (req, res) => {
// 角色检查通过,进入管理面板
});
4.7.3 角色→权限映射
const ROLE_PERMISSIONS = {
'admin': {
label: '管理员',
level: 'realm',
permissions: ['查看所有用户', '管理用户', '管理角色', '查看管理面板', '修改系统配置'],
color: '#dc2626',
},
'user': {
label: '普通用户',
level: 'realm',
permissions: ['查看个人资料', '编辑个人信息', '访问公开页面'],
color: '#2563eb',
},
'developer': {
label: '开发者',
level: 'realm',
permissions: ['查看个人资料', '访问 API 文档', '查看调试信息'],
color: '#7c3aed',
},
// 自定义角色默认
'_default': {
label: '自定义角色',
level: 'client',
permissions: ['基础访问'],
color: '#64748b',
},
};
export function getUserPermissions(roleData) {
const result = [];
for (const role of roleData.realmRoles) {
const meta = ROLE_PERMISSIONS[role] || { ...ROLE_PERMISSIONS._default, label: role, level: 'realm' };
result.push({ role, ...meta });
}
for (const role of roleData.clientRoles) {
const meta = { ...ROLE_PERMISSIONS._default, label: role, level: 'client' };
if (role.toLowerCase().includes('admin')) {
meta.permissions = ['Client 级管理操作', '查看管理面板'];
meta.color = '#dc2626';
}
result.push({ role, ...meta });
}
return result;
}
4.7.4 API 接口
// 获取当前用户角色
app.get('/api/roles', requireAuth(), (req, res) => {
const roleData = extractRoles(req.oidc.accessToken, site.clientId);
const permissions = getUserPermissions(roleData);
res.json({
site: site.name,
username: req.oidc.user?.preferred_username,
realmRoles: roleData.realmRoles,
clientRoles: roleData.clientRoles,
allRoles: roleData.allRoles,
resourceAccess: roleData.resourceAccess,
permissions,
});
});
// 检查是否拥有某个角色
app.get('/api/check-role', requireAuth(), (req, res) => {
const role = req.query.role;
const roleData = extractRoles(req.oidc.accessToken, site.clientId);
res.json({
role,
granted: hasRole(roleData, role),
userRoles: roleData.allRoles,
});
});
五、运行效果
🔗 演示项目完整源码:https://gitee.com/taoKing666/keycloak-sso-rbac
欢迎 Clone / Star,本地跑通本文所有效果。
启动两个站点:
npm install
npm start
输出类似:
+---------------------------------------------+
| 站点 A 已启动
+---------------------------------------------+
| 地址: http://localhost:3001
| Client: test-service
| Realm: hunter
| Keycloak: http://xxx:8080
| 模式: ROPC 自有登录页
| SSO回调: http://localhost:3001/sso-callback
+---------------------------------------------+
+---------------------------------------------+
| 站点 B 已启动
+---------------------------------------------+
| 地址: http://localhost:3002
| Client: test-service2
+---------------------------------------------+
5.1 登录页
访问 http://localhost:3001/login:
![登录页 - 站点 A 自有登录表单,主题色 #4f46e5(靛蓝色)]
可以看到,这是站点自有的登录表单(不是 Keycloak 的登录页)。用户输入 Keycloak 账号密码。
5.2 首页(已登录状态)
用 hunter 账号(已分配两个 Client 角色)登录后:
- 顶部显示「admin」角色徽章(如果是 admin 用户)
- 「当前角色与权限」卡片:列出 realm/client 角色及对应权限
- 「管理面板」按钮:仅 admin 用户可见
- 「免密跳转到 站点 B」按钮:Token 传递
未登录用户看到的是 SSO 演示步骤说明。
5.3 个人资料页
/profile 页面:
- 用户信息卡(姓名、邮箱等)
- ID Token Claims 详情
- Access Token Claims 详情(含
iss、aud、exp、azp、realm_access) - RBAC 角色权限详情:按 Realm/Client 分类展示每个角色的权限
- 各 Client 角色映射(
resource_access) - 原始 JWT 字符串
5.4 管理面板(admin 专属)
访问 /admin:
- admin 用户:进入面板,可看到所有用户角色列表、权限详情
- 非 admin 用户:被
requireRole中间件拦截,返回 403 页面
5.5 SSO 跨站跳转
在 A 站点点击「免密跳转到 站点 B」:
- 浏览器跳转到
http://localhost:3002/sso-callback?token=xxx&returnTo=/ - 站点 B 解码 Token,检查 Client 权限
- 调 Keycloak introspect 验证(失败则降级本地 JWT 验证)
- 通过则建本地会话,跳转到指定页
如果用户只在 test-service 分配了角色,但用同一 Token 去 SSO test-service2:
该账号没有 test-service2 的访问权限,无法免密登录 站点 B。
Token 签发 Client (azp): test-service
Token 包含的 Client 权限: test-service, account
Token audience: ["account"]
目标 Client: test-service2
六、踩坑记录
坑 1:Token URL 角色为空
现象:用户已分配 Client 角色,Token 里却看不到。
根因:忘了在 Client Scopes → roles → 映射器中启用「Add to access token」和「Add to userinfo」。
解决:参考本文 3.7 节。
坑 2:SSO 跳转显示 Token 无效
现象:A 站点登录后 Token 拿到 B 站点 SSO callback 报 Token 已过期或无效。
根因:Keycloak 的 introspect 端点对跨 Client 验证有时返回 active:false,但 Token 实际是有效的。
解决:增加本地 JWT 验证作为 fallback(检查 exp + iss + Client 权限),参考 4.5 节。
坑 3:SSO 跨 Client 权限绕过
现象:用户只分配了 test-service 客户端角色,但用同一 Token 居然能 SSO 进 test-service2。
根因:之前的校验逻辑只检查了 exp 和 iss,没检查 Token 是否包含目标 Client 的角色。
解决:参考 4.6 节。检查 resource_access[clientId].roles + aud + azp 三者之一即可。
坑 4:ROPC 登录时校验条件被绕过
现象:SSO 跳转已经做了 Client 校验,但 ROPC 直接登录却没有。
根因:ROPC 登录时 Keycloak 总会把请求方的 client_id 写入 azp,所以 azp === clientId 永远成立,单靠它形同虚设。
解决:ROPC 登录逻辑只能靠 resource_access[clientId].roles 做权限判断。
坑 5:Master 域用户登录控制面板失败
现象:在 master 之外的其他 Realm 创建用户,但用户要登录 http://keycloak:8080/admin 控制面板报错。
根因:只有 master 域的用户能登录 Admin Console。
解决:去 master 域创建用户并分配权限,或直接给现有用户添加 manage-realm 角色。
坑 6:临时密码导致反复要求重置
现象:创建用户登录后,Keycloak 立刻要求改密码。
根因:用户创建时默认启用「临时密码」。
解决:在 Credentials 标签页设置密码时,把「临时」开关关闭。
七、写在最后
完整代码
所有源代码 + 配置文件 + Word 配置指引原图都已整理在演示项目中:
📦 Gitee 仓库地址:https://gitee.com/taoKing666/keycloak-sso-rbac
keycloak-sso-demo/
├── lib/
│ ├── auth.js # 认证核心(ROPC、introspect、RBAC)
│ ├── config.js # 双站点配置
│ └── server.js # Express 路由工厂
├── views/ # EJS 模板
├── site-a/index.js
├── site-b/index.js
├── doc-images/ # Word 指引截图
├── .env # 配置
└── package.json
适用场景
本方案适合:
- ✅ 内网多系统集成
- ✅ 已有用户体系想统一登录
- ✅ 各子系统希望保留自有登录界面(品牌定制)
- ✅ 需要细粒度的 Client 级别权限隔离
- ✅ 需要 RBAC 角色权限控制
不适合:
- ❌ 跨互联网的多租户 SaaS(需要更复杂的 OIDC 配置)
- ❌ 移动端 App 集成(ROPC 不推荐,App 一般用 Auth Code + PKCE)
后续可扩展方向
- MFA(多因素认证):TOTP、短信、WebAuthn
- 社交登录:微信、企业微信、钉钉登录
- 密码自助管理:用户自己找回/修改密码
- 审计日志:用户登录、Token 签发等可追溯
- Keycloak 集群部署:生产级高可用
- 统一角色管理 API:对接 HR 系统,自动同步用户和角色
完整接口列表
本次实调用到的 3 个 Keycloak OIDC 标准端点:
| 函数 | 端点路径 | OIDC 规范名称 |
|---|---|---|
passwordGrantLogin |
/protocol/openid-connect/token |
token_endpoint |
introspectToken |
/protocol/openid-connect/token/introspect |
introspection_endpoint |
revokeToken |
/protocol/openid-connect/logout |
end_session_endpoint |
完整的端点发现可访问:http://xxxx:8080/realms/hunter/.well-known/openid-configuration
相关参考
- Keycloak 官方文档:https://www.keycloak.org/docs/latest/securing_apps/
- OIDC Discovery 规范:RFC 8414
- OAuth 2.0 Token Introspection:RFC 7662
- OIDC 核心规范:OpenID Connect Core 1.0
作者:基于真实项目代码撰写,建议在 Keycloak 26.x + Node.js 18+ 环境下运行。
如果本文对你有帮助,欢迎点赞、收藏、关注三连! 有任何疑问或改进建议,欢迎评论区讨论 👇
更多推荐



所有评论(0)