Keycloak 从零到部署:两个 Node.js 站点实现 SSO 单点登录 + RBAC 角色权限控制实战

写在前面:本文是一篇完整的技术实战文档,配套一个可直接运行的 Node.js 演示项目。从 Keycloak 服务搭建、Realm/Client/角色/用户配置,到代码实现 SSO 和 RBAC 角色权限控制,最后到常见坑点排查,全部基于真实跑通的代码演示,文末附带完整源码。

关键词:Keycloak、SSO、OIDC、ROPC、RBAC、JWT、Token 传递、Node.js、Express

📌 演示项目源码地址https://gitee.com/taoKing666/keycloak-sso-rbac


目录


一、为什么需要 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 登录。

汉化步骤:

  1. 进入「管理领域 → Realm settings → Localization」标签
  2. 启用「Internationalization」
  3. 在 Supported locales 中添加「Chinese (Simplified)」
  4. 保存
    在这里插入图片描述

图 3-1:汉化配置界面,按图示数字依次点击

保存后重新登录,右上角就可以看到中文选项了:

在这里插入图片描述

图 3-2:登录页面右上角的中文切换

3.3 创建领域(Realm)

领域(Realm) 是 Keycloak 中的顶层租户单位,用于多公司/多业务的隔离。一个 Realm 下有独立的用户、客户端、角色体系。

进入「管理领域(Manage realms)」→「创建领域」,输入名称 hunter(可按需取名)。本演示项目就用 hunter 作为 Realm 名。

在这里插入图片描述

Realm 创建完成后会自动跳转到 hunter 域。所有后续操作默认都在 hunter 域下进行。

3.4 创建客户端(Client)

客户端(Client) 代表的是一个应用系统或服务。每个需要登录的子平台都要在 Keycloak 里注册为一个 Client。

点击左侧「客户端 → 创建客户端」:

![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/c0eb2fe65e674fc59a1a9b78563d1ab9.png

填写如下信息:

  • 客户端 IDtest-service(对应站点 A)
  • 名称:自定义,比如"测试GDO项目"
  • 客户端类型:OpenID Connect

点「下一步」配置能力(Capabilities),只需要保留 Standard flowDirect access grants(ROPC 用的就是这个)。

注意:Standard flow 是标准 OIDC 跳转授权。如果只用 ROPC,可以把它关掉。但如果之后想改回标准 OIDC 跳转,建议保留。

在这里插入图片描述

切换到「客户端详情 → 重定向 URL」标签:

此处 URL 配置是给「Standard flow」(授权码模式)跳转用的。如果只走 ROPC 可不配。但如果选择 OIDC 跳转授权,需要在这里配置好对应的站点回调地址,比如:

  • http://localhost:3001/callback
  • http://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

![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/4dd87d76785e4016bddc7c0c409e68a8.png

把「添加到访问令牌(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 路由要做两件事:

  1. Client 权限门控:这个 Token 有没有当前 Client 的角色
  2. 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 详情(含 issaudexpazprealm_access
  • RBAC 角色权限详情:按 Realm/Client 分类展示每个角色的权限
  • 各 Client 角色映射(resource_access
  • 原始 JWT 字符串

5.4 管理面板(admin 专属)

访问 /admin

  • admin 用户:进入面板,可看到所有用户角色列表、权限详情
  • 非 admin 用户:被 requireRole 中间件拦截,返回 403 页面

5.5 SSO 跨站跳转

在 A 站点点击「免密跳转到 站点 B」:

  1. 浏览器跳转到 http://localhost:3002/sso-callback?token=xxx&returnTo=/
  2. 站点 B 解码 Token,检查 Client 权限
  3. 调 Keycloak introspect 验证(失败则降级本地 JWT 验证)
  4. 通过则建本地会话,跳转到指定页

如果用户只在 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

根因:之前的校验逻辑只检查了 expiss,没检查 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)

后续可扩展方向

  1. MFA(多因素认证):TOTP、短信、WebAuthn
  2. 社交登录:微信、企业微信、钉钉登录
  3. 密码自助管理:用户自己找回/修改密码
  4. 审计日志:用户登录、Token 签发等可追溯
  5. Keycloak 集群部署:生产级高可用
  6. 统一角色管理 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 26.x + Node.js 18+ 环境下运行。

如果本文对你有帮助,欢迎点赞、收藏、关注三连! 有任何疑问或改进建议,欢迎评论区讨论 👇

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐