PAYATHON 2026

Keycloak 25:UserStorageProvider 如何区分 IdP 与 username 登录查询?

技术老齐

结论

建议按以下分工处理:

  • UserStorageProvider 负责加载用户:getUserByUsername 按 username 查询,getUserById 按外部系统的稳定用户 ID 查询。
  • IdP 登录时的 claim 由 broker 扩展点处理。如果需要在加载已关联用户之前取得 claim,可以实现自定义 IdentityProviderMapper,使用 preprocessFederatedIdentity。

只在 First Broker Login Flow 中处理,无法覆盖已有身份关联的后续登录。Post Login Flow 可以执行后续校验,但此时关联用户已经加载,无法阻止此前的 username API 调用。

以下调用顺序依据 Keycloak 25.0.6 源码。使用其他 25.x 补丁版本时,应核对对应版本的实现。

为什么首次登录有效,后续登录又走回 provider

IdentityBrokerService.authenticated() 中与此问题相关的调用顺序如下:

取得 BrokeredIdentityContext
    ↓
调用 IdP 的 preprocessFederatedIdentity
    ↓
调用各个 IdP mapper 的 preprocessFederatedIdentity
    ↓
getUserByFederatedIdentity(...)
    ├─ 没有找到关联用户:进入 First Broker Login Flow
    └─ 找到关联用户:校验、更新用户,然后进入配置的 Post Login Flow

mapper 的预处理发生在关联用户查询之前,First Broker Login Flow 和 Post Login Flow 都无法替代这一执行时机。Keycloak 25.0.6 broker 实现

对于外部存储用户,UserStorageManager.getUserByFederatedIdentity() 会先取得关联记录中的 Keycloak 用户 ID,再调用 getUserById(realm, id) 加载用户。用户存储查询实现

Keycloak 按 ID 加载用户时,并不要求 provider 再按 username 查询。如果 getUserById() 最终调用了 username API,原因在于当前 provider 的实现。

先让 getUserById 脱离 username 查询

管理操作、会话恢复等路径也可能调用 getUserById(),这些场景没有当前 IdP 响应。因此,即使没有 broker claim,该方法也需要能加载同一个用户。

对于使用标准 StorageId 的外部用户,可以在 ID 的外部部分保存业务系统中不可变的用户标识:

f:<provider-component-id>:<external-user-id>

构造和解析 ID 时,使用 StorageId.keycloakId()、StorageId.externalId(),不要自行拆分字符串。Keycloak 25.0.6 StorageId API

provider 的两个查询方法可以这样实现。其中,customerApi、Customer 和 toUserModel 都是需要自行实现的业务代码:

@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
    Customer customer = customerApi.findByUsername(username);
    return customer == null ? null : toUserModel(realm, customer);
}

@Override
public UserModel getUserById(RealmModel realm, String id) {
    if (!componentModel.getId().equals(StorageId.providerId(id))) {
        return null;
    }

    String externalUserId = StorageId.externalId(id);
    Customer customer = customerApi.findById(externalUserId);

    return customer == null ? null : toUserModel(realm, customer);
}

两种查询返回的 UserModel 必须使用相同的 ID 规则:

@Override
public String getId() {
    return StorageId.keycloakId(componentModel, customer.id());
}

这样,普通登录仍按 username 查询,后续 IdP 登录加载关联用户时就不会再调用 username API。

如果已有用户的 external ID 保存的是 username,修改 ID 规则会影响现有身份关联和会话,需要安排迁移,不能直接替换。

在用户加载前读取 IdP claim

自定义 mapper 可以继承 AbstractIdentityProviderMapper,覆盖 preprocessFederatedIdentity。该方法接收 BrokeredIdentityContext,可以直接处理 broker 登录数据,无需在 storage provider 内猜测登录来源。Mapper API

对于题目中的 OIDC UserInfo,可以直接读取 OIDCIdentityProvider.USER_INFO。以下代码仅包含预处理方法,省略了 mapper 的元数据方法、注册和外部 API 客户端实现:

@Override
public void preprocessFederatedIdentity(
        KeycloakSession session,
        RealmModel realm,
        IdentityProviderMapperModel mapperModel,
        BrokeredIdentityContext context) {

    Object raw = context.getContextData()
            .get(OIDCIdentityProvider.USER_INFO);

    if (!(raw instanceof JsonNode)) {
        throw new IdentityBrokerException("UserInfo is unavailable");
    }

    JsonNode claim = ((JsonNode) raw).get("customer_ref");

    if (claim == null
            || !claim.isTextual()
            || claim.asText().isBlank()) {
        throw new IdentityBrokerException("Missing customer_ref claim");
    }

    String providerAlias = context.getIdpConfig().getAlias();

    Customer customer = customerApi.findByIdpClaim(
            providerAlias, claim.asText());

    if (customer == null) {
        throw new IdentityBrokerException("Customer not found");
    }

    // 自定义的当前请求数据,供 storage provider 按需复用。
    session.setAttribute(
            "com.example.customer.brokerLookup",
            new BrokerLookup(realm.getId(), providerAlias, customer));
}

customer_ref 是示例 claim。代码假设当前 OIDC provider 已成功取得 UserInfo,并将其放入 broker context。其他协议或自定义 IdP 实现需要按实际数据结构读取。

如果使用 AbstractClaimMapper.getClaimValue(context, claimName),需要注意:Keycloak 25.0.6 会依次搜索 Access Token、ID Token、UserInfo。需要严格使用 UserInfo 中的字段时,直接读取 USER_INFO 更明确。Claim 读取实现

mapper 需要注册为 IdentityProviderMapper 扩展,并在目标 IdP 的 Mappers 中添加对应配置,才会参与该 IdP 的登录处理。

如果还要省去按 ID 查询的 API 调用

可以让 getUserById() 复用预处理阶段查到的 Customer。KeycloakSession 的 setAttribute()、getAttribute() 可用于当前 session 内的数据传递。KeycloakSession API

复用前必须确认结果与目标用户匹配,不能仅凭当前是 IdP 登录就返回同一个用户:

// 示意逻辑,放在 getUserById 中。
BrokerLookup lookup = (BrokerLookup) session.getAttribute(
        "com.example.customer.brokerLookup");

if (lookup != null
        && realm.getId().equals(lookup.realmId())
        && externalUserId.equals(lookup.customer().id())) {
    return toUserModel(realm, lookup.customer());
}

// 没有匹配的请求结果时,仍按稳定 ID 加载。
Customer customer = customerApi.findById(externalUserId);

生产实现还应限定目标 storage component 和允许的 IdP alias。一个请求可能加载多个用户,因此不能把当前登录者的数据用于其他用户的查询。

这种数据传递由自定义扩展实现,UserLookupProvider 本身没有登录来源参数。请求标记不存在时,也不能据此判断当前是普通 username 登录。

同时,必须校验 claim 查询到的客户与当前 federated identity 关联的用户一致:

  • 首次关联时,在 First Broker Login Flow 中完成匹配和关联校验。
  • 已有关联时,可在配置的 Post Login Flow 中读取 broker context,核对 claim 对应的稳定客户 ID 与当前用户 ID。

Post Login Flow 经过重定向后,不能再依赖之前 KeycloakSession 中的 attribute。应从该流程的 broker context 重新取得所需数据,或设计明确的跨请求传递方式。

各扩展点的职责和限制

扩展点适合的用途对当前问题的限制
First Broker Login Flow首次匹配、创建用户、建立身份关联已有关联时不会按首次登录路径执行
Post Login Flow每次 broker 登录后的业务检查、关联一致性校验用户已经加载,不能阻止此前的查询
Mapper preprocessFederatedIdentity加载关联用户前读取 claim、调用业务 API、准备请求数据不会自动替换已保存的用户关联
Mapper updateBrokeredUser更新已有用户的属性或映射结果用户已经加载,且要考虑同步模式
User Storage SPI按 username 或稳定 ID 加载用户查询接口没有 IdP claim 参数

首次 broker 流程完成后,以及已有用户的 broker 登录路径中,都有进入 Post Login Flow 的对应入口。后置流程实现

实现时还需处理以下两种情况:

  1. 用户缓存命中时,storage provider 的查询方法可能不会执行。如果要求每次 IdP 回调都调用 claim API,应把调用放在 broker 扩展点,不能只放在 getUserById() 中。
  2. claim 缺失、API 超时或身份不一致时,应明确失败。不要通过返回 null、删除关联或修改 context.getId() 来强迫系统重新执行首次登录流程,也不要静默退回 username 查询。

建议先实现按稳定 ID 加载用户,并在 broker 阶段处理 claim。确实需要减少重复查询时,再加入当前请求结果的复用逻辑。

备注:内容仅供参考。