Appearance
Keycloak 用户存储 SPI 深度实战:多数据库用户联邦与凭证验证
作者: 必码 | bima.cc
前言
在企业级身份与访问管理(IAM)基础设施的构建过程中,Keycloak 凭借其完善的 OIDC/OAuth2.0 协议支持、灵活的 SPI(Service Provider Interface)扩展机制以及活跃的社区生态,已经成为全球范围内最受欢迎的开源 IAM 解决方案之一。然而,对于实际落地项目的开发者而言,Keycloak 最核心的挑战并非协议对接或令牌管理,而是企业现有用户体系与 Keycloak 的深度集成——这是每个 Keycloak 项目必须面对的第一道关卡,也是决定项目成败的关键因素。
为什么需要 UserStorageProvider?
在企业环境中,用户数据通常已经存在于各种外部系统中:关系型数据库(MySQL、Oracle、SQL Server)、国产数据库(达梦、金仓、OceanBase、GaussDB)、LDAP 目录服务、HR 系统等。面对这些已有的用户数据源,企业通常有两种集成策略:
策略一:数据导入(Data Import)
将外部用户数据批量导入 Keycloak 的内置数据库。这种方式看似简单,但存在致命缺陷:
- 数据同步问题:外部系统中的用户变更(密码修改、账户禁用、属性更新)无法实时反映到 Keycloak 中,需要建立复杂的同步机制。
- 数据一致性风险:多数据源之间的数据不一致会导致认证失败、权限混乱等严重问题。
- 维护成本高昂:同步脚本、定时任务、冲突处理、异常恢复——这些都是持续的技术债务。
- 安全合规风险:密码数据的复制和传输增加了安全攻击面,在等保、GDPR 等合规场景下面临审计风险。
策略二:用户联邦(User Federation)
通过 Keycloak 的 UserStorageProvider SPI,在认证时实时从外部数据源读取用户信息并验证凭证。这种方式的优势是显而易见的:
- 实时性:用户信息的任何变更立即生效,无需同步延迟。
- 单一数据源:外部系统仍然是用户数据的唯一权威来源,消除了数据不一致的风险。
- 低侵入性:无需修改外部系统的任何代码或数据结构,通过配置即可完成集成。
- 安全合规:密码数据始终存储在原始系统中,不经过任何中间环节。
为什么本文选择 UserStorageProvider 作为深度实战的主题? 因为在实际项目中,UserStorageProvider 是 Keycloak SPI 扩展中使用频率最高、技术复杂度最大、与企业需求结合最紧密的扩展类型。一个设计良好的 UserStorageProvider 需要同时处理用户查找、凭证验证、分页查询、多数据库适配等多个技术维度,是检验 Keycloak SPI 开发能力的最佳试金石。
本文的技术定位
本文基于一个实际生产级的 Keycloak 扩展项目——CustomUserStorageProvider,该项目实现了从外部数据库实时读取用户信息并验证凭证的完整功能,支持 MySQL、SQL Server、Oracle、达梦、金仓、OceanBase、GaussDB 七种数据库。我们将从 SPI 架构的核心原理出发,逐步深入到项目设计、核心实现、数据库方言抽象、凭证验证、查询优化、生产部署等关键环节。
本文的写作目标是:不仅告诉你"怎么做",更要让你理解"为什么这样做"。每一个技术决策背后都有其架构层面的考量,每一个代码片段都经过生产环境的验证。
适合的读者
- 正在评估或实施 Keycloak 用户联邦方案的技术决策者和架构师。
- 负责将企业现有用户系统与 Keycloak 集成的开发工程师。
- 对 Keycloak SPI 机制感兴趣,希望深入理解其设计哲学的后端开发者。
- 需要支持国产数据库(信创环境)的 Keycloak 项目团队。
第一章 用户存储 SPI 架构深度解析
Keycloak 的用户存储 SPI(User Storage SPI)是其整个身份管理架构中最核心的扩展点之一。理解这个 SPI 的架构设计,是开发高质量 UserStorageProvider 的前提。本章将从接口体系、工厂模式、用户模型抽象、ID 映射机制四个维度,对 User Storage SPI 进行全面的架构解析。
1.1 UserStorageProvider 接口体系
Keycloak 的用户存储 SPI 采用了接口隔离原则(Interface Segregation Principle),将用户存储相关的功能拆分为多个细粒度的接口。Provider 实现类可以根据自身能力选择性地实现这些接口,Keycloak 运行时会根据实现的接口自动发现并调用相应的功能。
1.1.1 UserStorageProvider 顶层接口
UserStorageProvider 是所有用户存储提供者的顶层标记接口。它本身不定义任何方法,仅作为类型标识和生命周期管理的契约:
java
package org.keycloak.storage;
/**
* 用户存储提供者的顶层标记接口。
*
* 所有用户存储提供者都必须实现此接口。Keycloak 通过此接口识别
* 用户存储提供者,并管理其生命周期(创建、使用、关闭)。
*
* 设计理念:采用标记接口(Marker Interface)模式,而非定义
* 通用方法,遵循接口隔离原则。具体的用户操作能力由子接口定义。
*/
public interface UserStorageProvider extends Provider {
// Provider 接口定义了 close() 方法,用于资源清理
// public void close();
}为什么采用标记接口设计? 这是一个值得深入思考的架构决策。Keycloak 完全可以设计一个包含所有用户操作方法的"大接口",但它选择了标记接口 + 多个子接口的方案。这种设计的优势在于:
- 职责单一:每个子接口只关注一个维度的用户操作,接口契约清晰明确。
- 按需实现:Provider 可以只实现自己需要的接口,不需要为不需要的功能提供空实现。
- 能力发现:Keycloak 运行时可以通过
instanceof检查判断 Provider 具备哪些能力,从而调用相应的方法。 - 演进友好:新增功能只需要定义新的子接口,不会影响已有的 Provider 实现。
1.1.2 UserLookupProvider 用户查找
UserLookupProvider 定义了通过不同标识符查找用户的能力。这是几乎所有 UserStorageProvider 都会实现的核心接口:
java
package org.keycloak.storage.user;
/**
* 用户查找提供者接口。
*
* Keycloak 在认证流程、管理操作、权限校验等多个场景中需要
* 通过用户名、邮箱或 ID 查找用户。此接口定义了这些查找操作的契约。
*
* 实现要点:
* 1. 所有方法都应返回 null 表示用户不存在,而非抛出异常
* 2. 查找操作应该是高效的,因为它们在认证热路径上被频繁调用
* 3. 返回的 UserModel 应该是轻量级的,避免加载不必要的属性
*/
public interface UserLookupProvider {
/**
* 根据 Keycloak 内部 ID 查找用户。
*
* 这个 ID 的格式为 "f:componentId:externalId",其中:
* - "f" 表示联邦用户(federated user)
* - componentId 是 UserStorageProvider 的组件 ID
* - externalId 是外部系统中的用户唯一标识
*
* @param realm 当前 Realm
* @param id Keycloak 内部用户 ID
* @return 用户模型,未找到返回 null
*/
UserModel getUserById(RealmModel realm, String id);
/**
* 根据用户名查找用户。
*
* 这是登录认证流程中最常用的查找方法。
* Keycloak 在用户输入用户名后,会调用此方法查找对应用户。
*
* @param realm 当前 Realm
* @param username 用户名(通常不区分大小写,取决于具体实现)
* @return 用户模型,未找到返回 null
*/
UserModel getUserByUsername(RealmModel realm, String username);
/**
* 根据邮箱地址查找用户。
*
* 在邮箱登录、密码重置、邮箱验证等场景中使用。
* 注意:Keycloak 默认要求邮箱在 Realm 内唯一。
*
* @param realm 当前 Realm
* @param email 邮箱地址
* @return 用户模型,未找到返回 null
*/
UserModel getUserByEmail(RealmModel realm, String email);
}关于 getUserById 的 ID 格式,这是 User Storage SPI 中最容易引起困惑的设计之一。Keycloak 内部使用 StorageId 类来解析和生成这种格式的 ID。我们将在 1.4 节中详细讨论这个机制。
1.1.3 UserQueryProvider 用户查询
UserQueryProvider 定义了用户搜索和查询的能力,主要用于 Keycloak 管理控制台中的用户列表展示和搜索功能:
java
package org.keycloak.storage.user;
/**
* 用户查询提供者接口。
*
* 此接口定义了用户搜索、分页、计数等查询操作。
* 主要被 Keycloak 管理控制台和管理 API 使用。
*
* 设计考量:
* 1. 使用 Stream 而非 Collection 作为返回类型,支持大数据量下的懒加载
* 2. 分页参数(firstResult, maxResults)遵循 JPA 规范的命名约定
* 3. 搜索方法支持模糊匹配,具体匹配策略由实现决定
*/
public interface UserQueryProvider {
/**
* 根据搜索关键词查询用户。
*
* 搜索范围通常包括用户名、邮箱、姓名等字段。
* 支持分页,firstResult 为起始偏移量,maxResults 为最大返回数。
*
* @param realm 当前 Realm
* @param search 搜索关键词,可为 null 表示查询所有用户
* @param firstResult 分页起始位置(从 0 开始),可为 null
* @param maxResults 最大返回数量,可为 null 表示不限制
* @return 匹配的用户流
*/
Stream<UserModel> searchForUserStream(RealmModel realm,
String search,
Integer firstResult,
Integer maxResults);
/**
* 根据搜索参数查询用户。
*
* params 中可能包含以下键:
* - UserRepresentation.USERNAME:按用户名精确匹配
* - UserRepresentation.EMAIL:按邮箱精确匹配
* - UserRepresentation.FIRST_NAME:按名匹配
* - UserRepresentation.LAST_NAME:按姓匹配
*
* @param realm 当前 Realm
* @param params 搜索参数映射
* @param firstResult 分页起始位置
* @param maxResults 最大返回数量
* @return 匹配的用户流
*/
Stream<UserModel> searchForUserStream(RealmModel realm,
Map<String, String> params,
Integer firstResult,
Integer maxResults);
/**
* 获取用户总数。
*
* Keycloak 管理控制台在用户列表页面需要显示总用户数。
* 此方法应返回高效的 COUNT 查询结果,而非加载所有用户后计数。
*
* @param realm 当前 Realm
* @return 用户总数
*/
int getUsersCount(RealmModel realm);
/**
* 获取指定组的成员。
*
* 当用户在外部系统中有组关系时,通过此方法查询组成员。
* 如果外部系统不支持组概念,可返回空流。
*
* @param realm 当前 Realm
* @param group 组模型
* @param firstResult 分页起始位置
* @param maxResults 最大返回数量
* @return 组成员用户流
*/
Stream<UserModel> getGroupMembersStream(RealmModel realm,
GroupModel group,
Integer firstResult,
Integer maxResults);
/**
* 获取指定角色的成员。
*
* 当用户在外部系统中有角色关系时,通过此方法查询角色成员。
* 如果外部系统不支持角色概念,可返回空流。
*
* @param realm 当前 Realm
* @param role 角色模型
* @param firstResult 分页起始位置
* @param maxResults 最大返回数量
* @return 角色成员用户流
*/
Stream<UserModel> getRoleMembersStream(RealmModel realm,
RoleModel role,
Integer firstResult,
Integer maxResults);
/**
* 根据用户属性搜索用户。
*
* 支持按自定义属性(如部门、职位等)搜索用户。
*
* @param realm 当前 Realm
* @param attrName 属性名称
* @param attrValue 属性值
* @return 匹配的用户流
*/
Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm,
String attrName,
String attrValue);
}为什么使用 Stream 而非 List? 这是 Keycloak 在设计 UserQueryProvider 时的一个重要决策。使用 Stream 的好处是:
- 延迟执行:Stream 允许在数据被实际消费时才执行查询,避免不必要的数据库访问。
- 内存效率:对于大量用户数据,Stream 可以逐条处理,不需要一次性加载所有结果到内存。
- 操作链式组合:调用方可以对 Stream 进行 filter、map、limit 等操作,实现灵活的数据处理。
1.1.4 CredentialInputValidator 凭证验证
CredentialInputValidator 是认证流程中最关键的接口之一,定义了凭证验证的能力:
java
package org.keycloak.credential;
/**
* 凭证输入验证器接口。
*
* 在用户登录认证流程中,Keycloak 收集用户输入的凭证(如密码)后,
* 会调用实现了此接口的 UserStorageProvider 来验证凭证的有效性。
*
* 认证流程中的调用时机:
* 1. 用户提交登录表单
* 2. Keycloak 通过 UserLookupProvider 找到用户
* 3. Keycloak 构造 CredentialInput 对象
* 4. 调用 CredentialInputValidator.supports() 判断是否支持该凭证类型
* 5. 调用 CredentialInputValidator.isValid() 验证凭证
*/
public interface CredentialInputValidator {
/**
* 判断是否支持验证指定类型的凭证。
*
* Keycloak 在调用 isValid() 之前会先调用此方法。
* 如果返回 false,Keycloak 会跳过此 Provider,尝试其他验证方式。
*
* @param credentialType 凭证类型标识符
* 常见值:PasswordCredentialModel.TYPE = "password"
* @return 是否支持该凭证类型的验证
*/
boolean supportsCredentialType(String credentialType);
/**
* 验证用户凭证是否有效。
*
* 此方法是认证流程的核心。实现时应注意:
* 1. 使用恒定时间比较(constant-time comparison)防止时序攻击
* 2. 不要在日志中记录密码明文
* 3. 验证失败时返回 false,而非抛出异常(避免信息泄露)
* 4. 考虑密码编码方式(明文、BCrypt、SCrypt 等)的兼容性
*
* @param realm 当前 Realm
* @param user 待验证的用户模型
* @param input 用户输入的凭证
* @return 凭证是否有效
*/
boolean isValid(RealmModel realm, UserModel user, CredentialInput input);
}关于时序攻击防护:这是凭证验证中一个容易被忽视但极其重要的安全点。如果使用 String.equals() 进行密码比较,攻击者可以通过测量比较操作的耗时来推断密码的逐位匹配情况。正确的做法是使用恒定时间比较算法,无论密码有多少位匹配,比较操作都花费相同的时间。
1.1.5 CredentialInputUpdater 凭证更新
CredentialInputUpdater 定义了凭证更新(如密码修改)的能力。对于只读的外部用户存储,通常不需要实现此接口:
java
package org.keycloak.credential;
/**
* 凭证输入更新器接口。
*
* 当外部用户存储支持写操作时(如允许通过 Keycloak 修改密码),
* 需要实现此接口。对于只读的用户联邦场景,可以不实现。
*
* 设计决策:将凭证验证和凭证更新分离为两个独立的接口,
* 使得只读 Provider 不需要提供更新方法的空实现。
*/
public interface CredentialInputUpdater {
/**
* 判断是否支持更新指定类型的凭证。
*/
boolean supportsCredentialType(String credentialType);
/**
* 更新用户凭证。
*
* @param realm 当前 Realm
* @param user 目标用户
* @param input 新的凭证
* @return 是否更新成功
*/
boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input);
/**
* 禁用指定类型的凭证。
*
* 在密码重置、账户锁定等场景中使用。
*/
void disableCredentialType(RealmModel realm, UserModel user, String credentialType);
/**
* 获取指定凭证类型的存储优先级。
*
* 当多个 Provider 都支持同一种凭证类型时,
* Keycloak 根据此优先级决定使用哪个 Provider。
*/
Stream<CredentialTypeMetadata> getCredentialMetadataStream(RealmModel realm, UserModel user);
}1.1.6 接口继承关系图
以下是 User Storage SPI 核心接口的继承关系:
┌─────────────────────────┐
│ Provider (标记接口) │
│ + close() │
└───────────┬─────────────┘
│ extends
┌───────────┴─────────────┐
│ UserStorageProvider │
│ (用户存储顶层接口) │
└───────────┬─────────────┘
│ implements
┌─────────────┬──────────────┬─────┴──────┬──────────────┐
│ │ │ │ │
┌─────────┴──────┐ ┌───┴────────┐ ┌───┴────────┐ ┌┴────────────┐ ┌┴──────────────┐
│ UserLookup │ │ UserQuery │ │ Credential │ │ Credential │ │ UserBulk │
│ Provider │ │ Provider │ │ Input │ │ Input │ │ Import │
│ │ │ │ │ Validator │ │ Updater │ │ Provider │
│ +getUserById() │ │ +search() │ │ │ │ │ │ │
│ +getUserBy │ │ +getCount()│ │ +supports()│ │ +update() │ │ +importUsers │
│ Username() │ │ +getGroup │ │ +isValid() │ │ +disable() │ │ FromRealm() │
│ +getUserBy │ │ Members() │ │ │ │ │ │ │
│ Email() │ │ +getRole │ │ │ │ │ │ │
│ │ │ Members() │ │ │ │ │ │ │
└────────────────┘ └────────────┘ └────────────┘ └─────────────┘ └───────────────┘
必须实现接口: 可选实现接口:
┌────────────────┐ ┌────────────────┐
│ UserLookup │ │ UserQuery │
│ Provider │ │ Provider │
└────────────────┘ │ Credential │
│ InputUpdater │
│ UserBulk │
│ ImportProvider │
└────────────────┘关键设计洞察:Keycloak 将 UserLookupProvider 设为必须实现的接口(因为 Keycloak 在几乎所有操作中都需要查找用户),而其他接口则是可选的。这种"必选 + 可选"的组合模式,既保证了核心功能的可用性,又为 Provider 实现提供了充分的灵活性。
1.2 UserStorageProviderFactory 工厂模式
Keycloak 的 SPI 体系采用了经典的工厂模式(Factory Pattern)。UserStorageProviderFactory 负责创建和管理 UserStorageProvider 实例。理解 Factory 的生命周期和配置机制,是正确实现 UserStorageProvider 的基础。
1.2.1 Factory 的生命周期
UserStorageProviderFactory 的生命周期由 Keycloak 容器管理,经历以下阶段:
┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐
│ init() │───>│postInit()│───>│ create() │───>│ close() │
│ 初始化 │ │ 后初始化 │ │ 创建Provider │ │ 销毁 │
└──────────┘ └──────────┘ └──────────────┘ └──────────┘
│ │ │ │
v v v v
全局执行一次 全局执行一次 每个组件配置 服务器关闭时
读取全局配置 所有SPI就绪后 执行一次 执行一次java
package org.keycloak.storage;
/**
* 用户存储提供者工厂接口。
*
* 工厂模式在 Keycloak SPI 体系中的职责:
* 1. 声明 Provider 的元数据(ID、配置属性)
* 2. 管理 Provider 的创建和初始化
* 3. 提供全局级别的资源管理
*
* 生命周期:
* - init():在 Keycloak 启动时调用,读取全局配置
* - postInit():在所有 SPI 初始化完成后调用
* - create():为每个组件配置创建 Provider 实例
* - close():在 Keycloak 关闭时调用,释放全局资源
*
* 注意:Factory 是单例的,在整个 Keycloak 实例生命周期中只有一个实例。
* 而 Provider 是多例的,每个组件配置对应一个 Provider 实例。
*/
public interface UserStorageProviderFactory<T extends UserStorageProvider>
extends ProviderFactory<T> {
/**
* 获取组件配置的元数据。
*
* 返回的配置属性列表将显示在 Keycloak 管理控制台的
* User Federation 配置页面中。管理员通过这些配置项
* 指定数据库连接信息、表名、列名映射等参数。
*
* 配置属性的顺序决定了在管理控制台中的显示顺序。
*
* @return 配置属性列表
*/
List<ProviderConfigProperty> getConfigProperties();
/**
* 创建 UserStorageProvider 实例。
*
* 每当 Keycloak 需要使用某个 User Federation 配置时,
* 会调用此方法创建对应的 Provider 实例。
*
* @param session Keycloak 会话,提供对各种服务的访问
* @param model 组件模型,包含管理员配置的所有参数
* @return 新创建的 Provider 实例
*/
T create(KeycloakSession session, ComponentModel model);
}1.2.2 getConfigProperties() 配置声明
getConfigProperties() 方法返回的 ProviderConfigProperty 列表,定义了 Provider 在管理控制台中的配置界面。每个配置属性包含以下信息:
java
// 配置属性示例
ProviderConfigProperty property = new ProviderConfigProperty(
"connectionUrl", // 属性名(代码中使用)
"Database Connection URL", // 显示名称(管理控制台中显示)
"JDBC connection URL for the external database", // 描述
ProviderConfigProperty.STRING_TYPE, // 类型
"jdbc:mysql://localhost:3306/mydb" // 默认值
);
// Keycloak 支持的配置类型
ProviderConfigProperty.STRING_TYPE // 字符串
ProviderConfigProperty.BOOLEAN_TYPE // 布尔值
ProviderConfigProperty.PASSWORD_TYPE // 密码(输入框会遮蔽)
ProviderConfigProperty.NUMBER_TYPE // 数字
ProviderConfigProperty.LIST_TYPE // 下拉列表
ProviderConfigProperty.MULTIVALUED_STRING_TYPE // 多值字符串配置属性的设计原则:
- 合理的默认值:为每个配置属性提供合理的默认值,降低配置复杂度。
- 清晰的描述:配置描述应该足够详细,让管理员不需要查阅文档就能理解其含义。
- 类型安全:使用正确的配置类型,Keycloak 会自动进行类型转换和验证。
- 敏感信息保护:密码类型的配置项在管理控制台中会以遮蔽方式显示,在数据库中也会加密存储。
1.2.3 create() 实例化策略
create() 方法是 Factory 的核心方法,负责根据组件配置创建 Provider 实例。以下是几种常见的实例化策略:
策略一:直接构造(Simple Construction)
最简单的策略,直接 new 一个 Provider 实例:
java
@Override
public CustomUserStorageProvider create(KeycloakSession session, ComponentModel model) {
return new CustomUserStorageProvider(session, model);
}策略二:依赖注入(Dependency Injection)
当 Provider 需要复杂的依赖时,可以在 Factory 中进行组装:
java
@Override
public CustomUserStorageProvider create(KeycloakSession session, ComponentModel model) {
// 从组件配置中读取参数
String dbType = model.getConfig().getFirst("dbType");
String connectionUrl = model.getConfig().getFirst("connectionUrl");
String username = model.getConfig().getFirst("username");
String password = model.getConfig().getFirst("password");
// 创建数据库方言
DatabaseDialect dialect = DatabaseDialectFactory.getDialect(dbType);
// 初始化连接池
DataSource dataSource = createDataSource(dialect, connectionUrl, username, password);
// 创建并返回 Provider
return new CustomUserStorageProvider(session, model, dataSource, dialect);
}策略三:实例缓存(Instance Caching)
当 Provider 的创建成本较高时,可以在 Factory 中缓存实例。但需要注意的是,Keycloak 已经管理了 Provider 的生命周期,通常不需要在 Factory 层面做额外的缓存。如果确实需要缓存,应该使用 Keycloak 的 Cache 机制。
1.3 UserModel 抽象体系
UserModel 是 Keycloak 中表示用户的核心抽象。理解 UserModel 的设计,对于正确实现 UserStorageProvider 至关重要。
1.3.1 UserModel 接口设计
UserModel 是一个包含大量方法的接口,定义了用户的完整数据模型:
java
package org.keycloak.models;
/**
* 用户模型接口。
*
* UserModel 是 Keycloak 中最复杂的模型接口之一,
* 包含了用户标识、属性、角色、组、凭证等维度的操作。
*
* 设计哲学:
* 1. 读写分离:每个属性都有 getter 和 setter
* 2. 集合操作:角色和组通过 add/remove 方法管理
* 3. 属性扩展:通过 getAttributes() 支持自定义属性
* 4. 不可变标识:ID 和 Username 在创建后通常不应修改
*/
public interface UserModel extends RoleMapperModel, UserCredentialModel {
// ===== 基本标识 =====
String getId();
String getUsername();
void setUsername(String username);
String getEmail();
void setEmail(String email);
String getFirstName();
void setFirstName(String firstName);
String getLastName();
void setLastName(String lastName);
// ===== 状态管理 =====
boolean isEnabled();
void setEnabled(boolean enabled);
boolean isEmailVerified();
void setEmailVerified(boolean verified);
Long getCreatedTimestamp();
void setCreatedTimestamp(Long timestamp);
// ===== 属性管理 =====
Map<String, List<String>> getAttributes();
List<String> getAttribute(String name);
void setAttribute(String name, List<String> values);
void removeAttribute(String name);
// ===== 角色管理(继承自 RoleMapperModel)=====
void grantRole(RoleModel role);
void revokeRole(RoleModel role);
boolean hasRole(RoleModel role);
// ===== 组管理 =====
void joinGroup(GroupModel group);
void leaveGroup(GroupModel group);
// ===== 服务账户 =====
String getServiceAccountClientLink();
void setServiceAccountClientLink(String clientInternalId);
// ===== 凭证管理 =====
// 继承自 UserCredentialModel
}1.3.2 内置实现 vs 自定义实现
Keycloak 提供了多个 UserModel 的内置实现,但在 UserStorageProvider 场景中,我们通常需要自定义实现:
| 实现类 | 适用场景 | 特点 |
|---|---|---|
UserAdapter | 包装 Keycloak 内部用户 | 代理模式,委托给内部存储 |
AbstractUserAdapter | 自定义用户的基类 | 提供默认实现,减少样板代码 |
AbstractUserAdapterFederatedStorage | 联邦存储用户 | 支持联邦属性存储 |
| 自定义实现 | 完全外部用户 | 完全控制用户数据来源 |
为什么推荐自定义实现? 在 UserStorageProvider 场景中,用户数据完全来自外部系统,使用 Keycloak 内置的 Adapter 会引入不必要的复杂度。自定义实现可以精确控制数据的加载和存储行为,避免与 Keycloak 内部存储产生耦合。
1.3.3 属性存储机制
UserModel 的属性存储是一个重要的设计考量。Keycloak 支持两种属性存储方式:
方式一:内置属性字段
email、firstName、lastName、enabled 等常用属性有专门的 getter/setter 方法。
方式二:通用属性映射
通过 getAttributes() 方法获取 Map<String, List<String>>,支持任意自定义属性。注意属性值是 List<String> 类型,即一个属性名可以对应多个值。
java
// 读取自定义属性
List<String> departments = user.getAttribute("department");
String department = departments.isEmpty() ? "" : departments.get(0);
// 设置自定义属性
user.setAttribute("department", Collections.singletonList("Engineering"));属性存储策略的选择:对于来自外部数据库的用户,建议将数据库中的字段映射到 UserModel 的内置属性字段(如 username、email),将额外的字段存储在通用属性映射中。这种策略既保持了与 Keycloak 管理控制台的兼容性,又支持了自定义扩展。
1.3.4 Adapter 模式在 UserModel 中的应用
Keycloak 内部广泛使用了 Adapter 模式来包装 UserModel。理解这个模式有助于理解 Keycloak 的用户管理架构:
┌─────────────────────────────────────────────────┐
│ Keycloak 调用方 │
│ (认证流程、管理 API、管理控制台等) │
└──────────────────────┬──────────────────────────┘
│ 调用 UserModel 接口方法
v
┌─────────────────────────────────────────────────┐
│ UserModelAdapter │
│ (适配器层,拦截方法调用) │
│ │
│ - 将写操作委托给 FederatedStorageManager │
│ - 将读操作委托给底层 UserModel │
│ - 合并本地属性和联邦属性 │
└──────────────────────┬──────────────────────────┘
│ 委托
v
┌─────────────────────────────────────────────────┐
│ 自定义 UserModel 实现 │
│ (如 CustomUserModel) │
│ │
│ - 从外部数据库加载数据 │
│ - 实现核心的 getter 方法 │
│ - setter 方法可以为空操作(只读模式) │
└─────────────────────────────────────────────────┘1.4 StorageId 与外部 ID 映射
StorageId 是 Keycloak 用户存储 SPI 中处理 ID 映射的核心工具类。理解 ID 映射机制,是正确实现 getUserById() 方法的前提。
1.4.1 ID 格式规范
Keycloak 为联邦用户(Federated User)定义了特殊的 ID 格式:
f:<componentId>:<externalId>
其中:
- f :固定前缀,表示联邦用户(federated)
- componentId:UserStorageProvider 组件的唯一标识符
- externalId :外部系统中的用户唯一标识
示例:
f:3a7f8b2c-1234-5678-abcd-ef0123456789:zhangsan
│ │ │
│ │ └── 外部系统中的用户名
│ └── Provider 组件 ID
└── 联邦用户前缀1.4.2 内部ID与外部ID的转换
Keycloak 提供了 StorageId 工具类来处理 ID 的解析和生成:
java
package org.keycloak.storage;
/**
* StorageId 工具类,用于解析和生成联邦用户的 ID。
*
* 使用示例:
*/
// 解析 ID,提取外部 ID
StorageId storageId = new StorageId(keycloakId);
String externalId = storageId.getExternalId(); // "zhangsan"
String componentId = storageId.getProviderId(); // "3a7f8b2c-..."
// 生成 Keycloak 格式的 ID
String keycloakId = StorageId.keycloakId(componentModel, externalId);
// 结果:"f:3a7f8b2c-1234-5678-abcd-ef0123456789:zhangsan"为什么需要这种 ID 映射? 这个设计解决了三个关键问题:
全局唯一性:Keycloak 内部需要为每个用户分配一个全局唯一的 ID。通过将 Provider 组件 ID 和外部 ID 组合,确保了即使不同 Provider 中的外部 ID 相同,Keycloak 内部 ID 也不会冲突。
来源追溯:通过 ID 中的组件 ID 部分,Keycloak 可以快速定位用户属于哪个 UserStorageProvider,从而将操作路由到正确的 Provider。
外部系统解耦:外部系统的 ID 格式不受 Keycloak 约束,可以是数字、UUID、用户名等任何格式。StorageId 提供了一个统一的映射层。
1.4.3 跨 Provider 的用户唯一性保证
当一个 Realm 中配置了多个 UserStorageProvider 时,Keycloak 通过以下机制保证用户的唯一性:
┌──────────────────────────────────────────────────────┐
│ Realm │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Provider A │ │ Provider B │ │
│ │ (MySQL) │ │ (LDAP) │ │
│ │ │ │ │ │
│ │ user: zhangsan │ │ user: zhangsan │ │
│ │ KC ID: │ │ KC ID: │ │
│ │ f:compA: │ │ f:compB: │ │
│ │ zhangsan │ │ zhangsan │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Keycloak 内部:两个不同的用户(ID 不同) │
│ 但在登录时可能产生歧义 │
└──────────────────────────────────────────────────────┘登录时的用户查找顺序:当用户输入用户名 "zhangsan" 登录时,Keycloak 会按照配置的优先级依次查询每个 Provider,直到找到匹配的用户。如果多个 Provider 中都存在同名用户,Keycloak 会使用第一个匹配的结果。这种行为可以通过管理控制台中的 Priority 配置来调整。
第二章 项目实战:CustomUserStorageProvider 设计与实现
本章将基于实际项目,详细讲解 CustomUserStorageProvider 的设计与实现。我们将从需求分析开始,逐步深入到架构设计、核心类实现和每个关键方法的代码解析。
2.1 需求分析
2.1.1 企业用户系统对接场景
在实际项目中,企业用户系统与 Keycloak 的集成通常面临以下典型场景:
场景一:遗留系统用户迁移
企业有一个运行多年的业务系统,用户数据存储在 MySQL 数据库中。现在需要引入 Keycloak 作为统一的身份认证平台,但不能影响现有系统的运行。用户联邦是最合适的方案——Keycloak 实时从 MySQL 读取用户信息,现有系统继续正常运行。
场景二:多系统用户统一认证
企业有多个业务系统,分别使用不同的用户存储(MySQL、Oracle、LDAP 等)。需要通过 Keycloak 实现统一认证入口,用户只需要记住一套凭证即可访问所有系统。
场景三:信创环境适配
在国产化替代(信创)场景中,企业需要将 Keycloak 与国产数据库(达梦、金仓、OceanBase、GaussDB)集成。这些数据库虽然兼容 SQL 标准,但在分页语法、驱动类名、数据类型等方面存在差异。
2.1.2 功能需求清单
基于上述场景,CustomUserStorageProvider 需要满足以下功能需求:
| 需求编号 | 需求描述 | 优先级 | 对应接口 |
|---|---|---|---|
| FR-001 | 通过用户名查找用户 | P0 | UserLookupProvider |
| FR-002 | 通过邮箱查找用户 | P0 | UserLookupProvider |
| FR-003 | 通过 Keycloak ID 查找用户 | P0 | UserLookupProvider |
| FR-004 | 验证用户密码 | P0 | CredentialInputValidator |
| FR-005 | 按关键词搜索用户(支持分页) | P1 | UserQueryProvider |
| FR-006 | 按参数搜索用户(支持分页) | P1 | UserQueryProvider |
| FR-007 | 获取用户总数 | P1 | UserQueryProvider |
| FR-008 | 支持 MySQL 数据库 | P0 | DatabaseDialect |
| FR-009 | 支持 SQL Server 数据库 | P0 | DatabaseDialect |
| FR-010 | 支持 Oracle 数据库 | P0 | DatabaseDialect |
| FR-011 | 支持达梦数据库 | P1 | DatabaseDialect |
| FR-012 | 支持金仓数据库 | P1 | DatabaseDialect |
| FR-013 | 支持 OceanBase 数据库 | P1 | DatabaseDialect |
| FR-014 | 支持 GaussDB 数据库 | P1 | DatabaseDialect |
| FR-015 | 可配置的表名和列名映射 | P0 | 配置属性 |
| FR-016 | 管理控制台配置界面 | P0 | ProviderConfigProperty |
2.1.3 非功能需求
| 需求类别 | 需求描述 | 指标 |
|---|---|---|
| 性能 | 用户查找响应时间 | < 100ms(P99) |
| 性能 | 用户搜索响应时间 | < 500ms(P99,1000用户) |
| 安全 | SQL 注入防护 | 使用 PreparedStatement |
| 安全 | 密码比较安全 | 恒定时间比较 |
| 兼容性 | 数据库兼容性 | 7种数据库 |
| 兼容性 | Keycloak 版本 | 17.x - 26.x |
| 可靠性 | 连接管理 | 支持连接池 |
| 可维护性 | 可扩展性 | 新增数据库方言 < 50行代码 |
2.2 架构设计
2.2.1 分层架构图
CustomUserStorageProvider 采用了清晰的分层架构:
┌──────────────────────────────────────────────────────────────┐
│ Keycloak Runtime │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Keycloak SPI 调用层 │ │
│ │ UserLookupProvider / UserQueryProvider / │ │
│ │ CredentialInputValidator │ │
│ └───────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┴────────────────────────────────┐ │
│ │ CustomUserStorageProvider │ │
│ │ (核心实现层:实现所有 SPI 接口方法) │ │
│ │ │ │
│ │ getUserByUsername() / getUserByEmail() / getUserById() │ │
│ │ isValid() / searchForUserStream() / getUsersCount() │ │
│ └───────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┴────────────────────────────────┐ │
│ │ CustomUserModel │ │
│ │ (用户模型层:封装外部用户数据) │ │
│ │ │ │
│ │ username / email / firstName / lastName / enabled │ │
│ │ attributes / roles / groups │ │
│ └───────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┴────────────────────────────────┐ │
│ │ DatabaseDialect 抽象层 │ │
│ │ (数据库方言层:屏蔽不同数据库的 SQL 差异) │ │
│ │ │ │
│ │ MySQL / SQLServer / Oracle / Dameng / Kingbase / │ │
│ │ OceanBase / GaussDB │ │
│ └───────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┴────────────────────────────────┐ │
│ │ JDBC 连接层 │ │
│ │ (数据访问层:管理数据库连接和 SQL 执行) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
---
> **版权声明:** 本文为必码(bima.cc)原创技术文章,仅供学习交流。
>
> 本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。
>
> 如需获取完整项目代码或技术支持,请访问 [bima.cc](https://bima.cc)。