Keycloak 身份平台:从 Realm 接入到高可用、迁移与退出
凌晨发布后,前端登录成功,API 却持续返回 401。浏览器拿到的令牌里 iss 是公网域名,资源服务配置的却是容器内地址;值班同学反复重建用户、刷新密钥缓存,故障仍然存在。真正需要核对的是一条完整证据链:请求进入哪个 realm、哪个 client 发起授权、令牌由哪个 issuer 签发、资源服务从哪个 JWKS 地址取到哪把公钥,以及反向代理是否改变了 Keycloak 对外部地址的判断。
另一个常见现场发生在员工离职之后:目录账号已经禁用,Keycloak 中的历史会话、离线令牌和服务账号凭据却仍可继续访问。身份平台不是一张登录页,而是持续保存用户、客户端、角色、认证流、会话、密钥和联邦连接的安全控制面。部署能启动只是起点,团队还要证明授权按预期发生、拒绝不会旁路、故障可以定位、状态可以恢复,并且旧平台能够被完整退役。
先把 Realm 看成安全隔离根
Keycloak 是身份提供方、OAuth 2.0 授权服务器、OpenID Connect 提供方和 SAML 身份提供方。它把用户认证、应用注册、令牌签发、会话、角色与外部身份源集中管理,适合多个应用需要统一登录、协议联邦和可审计访问控制的团队。若需求只是单元测试中签一个 JWT,完整 Keycloak 会增加数据库、升级和安全维护成本,轻量测试签发器更合适。
realm 是最重要的隔离边界。用户、组、角色、client、认证流、身份提供方、用户联邦、密钥和会话都归属于某个 realm;master realm 用于管理服务器,不应承载业务应用。一个 client 表示向 Keycloak 请求认证或令牌的应用或服务:浏览器应用通常是 public client,服务端应用可以安全保存 secret,机器调用则可使用 service account。角色可以属于 realm,也可以属于 client;协议 mapper 决定角色、组和属性怎样进入令牌声明。
一次 OIDC 请求会沿下面的状态链运行:浏览器进入 realm 的授权端点,client 与 redirect URI 被精确匹配,认证流执行 cookie、密码、MFA 或外部 IdP 步骤,成功后建立用户会话并签发授权码;client 再用授权码和 PKCE verifier 换取令牌。资源服务不会回问登录页面,而是根据 discovery、issuer、audience、有效期和 JWKS 签名独立判断令牌。任何一层的对象选错,最终都可能只表现为一个相同的 401。
从开发容器走到可持久化部署
官方容器的 start-dev 适合个人验证,它放宽生产检查并使用开发默认值,不能直接成为共享环境基线。下面以 Keycloak 26.7.0 产品版本作为可复现实验基线,使用 PostgreSQL 保存 realm 状态;实施时应从官方文档入口确认目标发行线,再固定镜像 digest,并按受支持配置矩阵核对数据库、JDK 和平台组合。
# compose.yaml
services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KC_DB_PASSWORD:?set KC_DB_PASSWORD}
volumes:
- keycloak-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 5s
timeout: 3s
retries: 20
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
command: start
depends_on:
postgres:
condition: service_healthy
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KC_DB_PASSWORD:?set KC_DB_PASSWORD}
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USERNAME:?set KC_ADMIN_USERNAME}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?set KC_ADMIN_PASSWORD}
KC_HOSTNAME: http://localhost:8080
KC_HTTP_ENABLED: "true"
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:9000:9000"
volumes:
keycloak-db:把三个密码放入只存在于本机的 .env 或秘密管理器,然后执行 docker compose up -d。KC_BOOTSTRAP_ADMIN_* 只负责数据库尚无初始管理员时的引导,不是长期轮换接口;数据库已有状态后改环境变量,不会重置管理员。管理端口 9000 只绑定回环地址,业务端口也只为本机实验开放。真实入口应使用 HTTPS、固定 hostname、受信代理和独立管理网络,具体生产检查见生产配置指南与hostname 配置。
Kubernetes/OpenShift 上可使用 Keycloak Operator 管理实例、滚动状态和生成资源,但 Operator 不代管生产数据库。平台团队仍需交付数据库、TLS Secret、备份和网络策略;Keycloak CR 的 Ready 也只能证明工作负载达到控制器条件,不能替代真实登录与令牌验证。Operator 的安装方式和 CR 字段应从官方 Operator 指南读取,避免复制已经变化的 CRD 示例。
第一组正反实验:从 Discovery 证明签发链
先在管理控制台创建 engineering realm,再创建启用 service account 的 confidential client inventory-api,保存新生成的 client secret。不要在命令历史中写真实值,实验 shell 通过环境变量读取:
export KC_BASE_URL=http://localhost:8080
export KC_REALM=engineering
export KC_CLIENT_ID=inventory-api
read -rsp 'client secret: ' KC_CLIENT_SECRET; export KC_CLIENT_SECRET; echo
curl -fsS "$KC_BASE_URL/realms/$KC_REALM/.well-known/openid-configuration" \
| jq '{issuer, token_endpoint, jwks_uri}'
TOKEN_JSON=$(curl -fsS -X POST \
"$KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/token" \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$KC_CLIENT_ID" \
--data-urlencode "client_secret=$KC_CLIENT_SECRET")
jq '{token_type, expires_in, scope, access_token_present:(.access_token|length>0)}' <<<"$TOKEN_JSON"
unset KC_CLIENT_SECRET TOKEN_JSON正向结果应同时满足:discovery 的 issuer 等于浏览器和资源服务共同认可的固定地址;token_endpoint 与 jwks_uri 位于同一 realm;响应存在非空 access_token,但终端没有打印令牌原文。这个实验验证的是机器身份,不能替代浏览器 Authorization Code + PKCE。
反向实验把 realm 改成不存在的值,并把 client secret 改成错误值。未知 realm 应为 404,错误凭据应返回 OAuth invalid_client,两者不能回退到 master 或其他 client:
test "$(curl -sS -o /dev/null -w '%{http_code}' \
"$KC_BASE_URL/realms/not-a-realm/.well-known/openid-configuration")" = "404"
curl -sS -o /tmp/keycloak-error.json -w 'status=%{http_code}\n' -X POST \
"$KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/token" \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$KC_CLIENT_ID" \
--data-urlencode 'client_secret=definitely-wrong'
jq '{error, error_description}' /tmp/keycloak-error.json
rm -f /tmp/keycloak-error.json如果错误 secret 仍能换到令牌,应立即检查是否调用了另一个环境、另一个 realm 或另一个 client,而不是继续调整密码。正反结果共同固定了“哪个 issuer 接受哪个客户端身份”。
配置对象怎样改变认证与授权
redirect URI 是授权码可返回的位置,不是提示性字符串。生产 client 应登记精确的 HTTPS 回调;宽泛的 * 会把授权码送往攻击者可控页面。Web Origin 控制浏览器跨域,与 redirect URI 解决的问题不同。public client 不能保守 secret,应强制 Authorization Code + PKCE S256;服务端 confidential client 才持有 secret,并应关闭不需要的 direct access grant。
认证流由 execution 和 requirement 组成。把 OTP 从 ALTERNATIVE 改成 REQUIRED 会改变所有绑定该 flow 的登录;直接修改内建 flow 会让升级和回滚困难,稳妥做法是复制、命名、绑定,再用测试 client 验证。Required Action 发生在用户级生命周期,例如首次修改密码或注册 WebAuthn,它可能让“凭据正确”仍停在交互页面。排障时要区分认证失败、动作未完成与授权不足。
用户联邦把 LDAP/目录连接为用户来源,Identity Provider broker 则把外部 OIDC/SAML IdP 接到登录流程。两者都会涉及属性 mapper、首次登录流、账号链接和同步策略。IMPORT、只读或可写模式决定用户属性由谁负责;周期同步并不等于即时离职撤销。团队必须定义权威身份源、唯一标识、冲突处理、禁用传播时限,以及已有 Keycloak 会话和 offline token 的撤销动作。
签名密钥的 kid 会进入 JWT header。轮换时应先发布新公钥、开始用新私钥签名,并让旧公钥保留到旧令牌自然失效或完成强制撤销;立刻删除旧 key 会制造全站 401,长期保留泄漏私钥又扩大风险窗口。资源服务应缓存 JWKS,但要在未知 kid 时刷新,并限制刷新风暴。
把 Keycloak 接进真实项目
资源服务应从 issuer 启动,而不是手写多个端点。以 Spring Security 为例,配置只保存公开 issuer,不保存管理员密码或 client secret:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${OIDC_ISSUER_URI:http://localhost:8080/realms/engineering}启动时框架读取 discovery 和 JWKS,并校验 iss、签名、时间声明;业务还应显式校验 audience,并把 realm role 或 client role 映射为本地权限。角色名称相同不代表业务语义相同,建议在 mapper 和代码中固定 claim 路径,例如 realm_access.roles 或 resource_access.<client>.roles,再用允许与拒绝两个集成测试保护映射。
浏览器应用注册为 public client,使用 Authorization Code + PKCE S256。前端只保存 authority、client ID 和回调地址,不能打包 secret。BFF 模式则由服务端保存会话 cookie 和令牌,浏览器不直接接触 refresh token;此时要同时治理 cookie 的 Secure、HttpOnly、SameSite、CSRF 和退出回调。无论哪种模式,流水线都应自动断言 discovery issuer、回调地址、aud、角色 claim 和 logout 行为。
第二组正反实验:精确回调与 Issuer 不可混用
为浏览器 client inventory-web 登记 http://localhost:5173/callback,然后生成 PKCE 参数并发起正向授权。命令会打开登录页,完成登录后回调应只携带短生命授权码:
VERIFIER=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 \
| openssl base64 -A | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)
printf '%s\n' \
"$KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/auth?client_id=inventory-web&response_type=code&scope=openid&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fcallback&code_challenge_method=S256&code_challenge=$CHALLENGE&state=$STATE"负向请求只把回调改成带尾斜杠的 /callback/。它必须在签发授权码前被 Keycloak 拒绝,且不能把浏览器重定向到未登记地址:
curl -sS -D - -o /dev/null \
"$KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/auth?client_id=inventory-web&response_type=code&scope=openid&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fcallback%2F&code_challenge_method=S256&code_challenge=$CHALLENGE&state=$STATE"
unset VERIFIER CHALLENGE STATE再做 issuer 反例:浏览器通过 https://login.example.com 登录,而资源服务配置 http://keycloak:8080。即使后端能从容器地址下载 JWKS,令牌的 iss 仍是外部地址,严格校验必须失败。修复是配置所有参与方都认可的固定 hostname 与代理头,不是关闭 issuer 校验。这个实验能把 DNS 可达性与协议身份分开。
用故障证据代替反复试配置
遇到 401,先离线读取 JWT header 与 payload,记录 kid、alg、iss、aud、azp、exp 和角色 claim,不记录完整令牌。随后读取同一 issuer 的 discovery 与 JWKS:找不到 kid 是密钥发布或缓存问题;iss 不同是 hostname/环境问题;签名正确但 aud 不含 API 是 client scope/mapper 问题;角色缺失才进入权限映射排查。
浏览器停在 Keycloak 页面时,优先看事件类型、client ID、redirect URI、认证流 execution 和 Required Action。数据库错误表现为 readiness 失败、连接池耗尽或事务异常;节点间会话问题则常在单 Pod 正常、负载均衡后随机失败,需同时核对路由、JGroups/Infinispan 视图和会话指标。健康端点只证明进程与依赖达到相应状态,不证明某个 realm 的登录、刷新和退出可用。
审计事件应记录成功/失败类型、realm、client、主体稳定标识、来源网络和关联 ID,但过滤 authorization code、Cookie、access/refresh token、密码和用户敏感属性。生产排障禁止把完整 token 粘贴到在线解码网站;可在受控终端只解码必要声明,并在结束后销毁临时文件。
HA 与容量从数据库、缓存和密钥开始
典型单站生产形态是负载均衡器后至少两个 Keycloak 实例,共享高可用关系数据库,并通过 Infinispan/JGroups 协调登录失败计数、认证会话和集群缓存。多副本只消除应用进程单点;数据库故障、错误 schema 迁移、网络分区、共享密钥丢失和入口证书失效仍会让全站不可用。缓存配置指南解释了嵌入式缓存与集群发现的运行边界。
容量不能只看每秒登录数。需要同时基线化活跃用户会话、离线会话、client 数、realm 数、LDAP 同步批次、token endpoint 延迟、数据库连接和查询、JVM heap/GC、缓存重平衡、事件保留与密钥轮换。压测至少包含登录、refresh、client credentials、userinfo/logout 和节点摘除;稳定标准应来自业务 SLO 与连续趋势,不套用演示阈值。
跨站架构会引入同步数据库、外部 Infinispan、站点延迟、故障切换和重同步成本。官方高可用架构入口对多站拓扑有严格假设,不能把它扩展为任意多地域 active-active。团队缺乏数据库、缓存和身份故障演练能力时,单站多可用区或托管身份服务通常比复杂跨站更可控。
权限、凭证与敏感身份数据
管理权限要从超级管理员拆开:平台管理员管理实例,realm 管理员管理租户对象,应用 owner 只维护自己的 client,审计主体只读事件。Operator 命名空间中,普通开发者不应读取 initial admin Secret、数据库 Secret 或修改 Keycloak CR。服务账号按应用独立创建,角色只授予资源端真正需要的动作,禁止共享万能 client。
管理员密码、client secret、registration token、offline token、LDAP bind 密码、IdP secret、TLS/签名私钥、数据库凭据和备份都进入秘密管理系统。Git 中只保存 Secret 引用、对象声明和非敏感 mapper;realm export 可能携带哈希凭据与 client secret,必须按敏感资产扫描、加密、限时保留。日志、工单、截图和 CI artifact 只记录 secret 名称、版本、指纹与 owner。
用户属性同样是敏感数据。只把业务所需的最少 claim 放进 token,避免把部门、手机号、组全量路径等信息广播给所有 client。离职流程不仅禁用用户,还要撤销会话、offline token、应用密码和外部 IdP 链接,并验证资源服务不再接受旧身份。
成本、备份、升级与安全退出
开源镜像没有座席费,不代表身份平台免费。长期成本来自数据库与备份、CPU/内存、跨站缓存、负载均衡与证书、邮件/MFA、日志索引、主题与扩展兼容、目录同步、值班和恢复演练。选型时应把应用数量、协议类型、用户规模、审计保留、团队能力和退出工时放入总成本,而不是只比较登录页功能。
realm export 适合迁移和声明审查,但不包含事件、持久会话、工作流状态与 revoked token,也不保证在线一致性,不能替代数据库原生备份。恢复演练要在隔离数据库中恢复备份与匹配版本,验证 realm、client、用户、角色、flow、IdP、mapper、登录、refresh 和 logout。导入导出的限制可查官方说明。
升级前固定镜像、数据库、扩展、主题和客户端兼容矩阵,备份数据库与部署声明,在隔离环境执行 schema 迁移和正反实验。只有满足官方兼容性检查的版本组合才考虑滚动升级;数据库已经迁移后,简单把镜像改回旧版不是回滚。失败恢复应同时恢复旧安装资产和升级前数据库备份,详见升级指南与滚动兼容性检查。
从旧 IdP 迁移时先建立双信任,映射稳定主体标识和角色,逐批迁移 client 与回调,观察旧新签发量,再撤销旧 secret、旧 redirect URI、旧 IdP 连接和会话。彻底退出前导出审计所需证据、验证数据库与备份保留策略、删除 DNS/TLS/负载均衡规则、回收服务账号和 Operator 权限,并证明旧 issuer 已不再被任何资源服务信任。
个人实验可用 docker compose down -v --remove-orphans 清除容器和数据库卷;执行前必须确认 Compose project 只属于本次实验。共享环境不允许用删卷充当回滚,应按对象撤销 client/用户、轮换泄漏凭据、恢复数据库备份并重新跑 discovery、登录、refresh、拒绝和 logout 验证。
