PAYATHON 2026

Spring Security 对接 Entra 时为何找不到 preferred_username?

数据阿明

完成登录时,业务用户名不必固定改成 email,仍可以使用 ID token 中的 preferred_username。建议将 provider 的 user-name-attribute 配置为 sub,先让默认 UserInfo 加载成功,再从 ID token 读取 preferred_username。如果需要让 OidcUser.getName() 返回该值,可以在自定义 OidcUserService 中重新构造用户对象。

通过自定义 OidcUserService 映射 Entra 的 roles 是合适的扩展方式,但要处理缺失值,并保留需要的原有 authorities。

为什么 token 中有属性,Spring 仍然报缺失?

这里涉及两份数据:

  • ID token:OIDC 登录时返回的身份令牌,可以包含 preferred_username、roles 等 claims。
  • UserInfo 响应:使用 access token 调用 https://graph.microsoft.com/oidc/userinfo 得到的用户信息。

Entra 的 UserInfo endpoint 返回固定范围的用户信息。给 token 添加 Optional Claim 不会让 UserInfo 响应增加该字段,也不能通过 Optional Claim 自定义 UserInfo 的返回内容。Microsoft UserInfo 文档

对于题目描述的调用路径,OidcUserService 会委托 DefaultOAuth2UserService 获取 UserInfo。后者构造用户对象时,会按照 user-name-attribute 检查 UserInfo attributes。如果配置为 preferred_username,而响应中没有该属性,就会在组合 ID token 和 UserInfo 之前失败。Spring Security 用户服务说明、DefaultOAuth2UserService 实现

OAuth2UserRequestEntityConverter 负责构造请求;发送请求、处理响应和构造用户对象由用户服务完成。

题目只说明“token 中存在该字段”,还需要确认它存在于本次登录返回的 ID token 中。某个 access token 包含该字段,并不能保证 OidcUser 能读取到它。

先将 UserInfo 的用户名属性设为 sub

将以下配置合并到现有 YAML 中,保留原来的 registration、issuer-uri 和资源服务器配置:

spring:
  security:
    oauth2:
      client:
        provider:
          entra:
            user-name-attribute: sub

sub 是 OIDC 的主体标识。Spring 的 OIDC 用户服务会检查 UserInfo 的 sub 是否存在,以及是否与 ID token 的 sub 一致。不要为了绕过用户名错误而删除这项校验。OidcUserService 实现

题目中的 YAML 没有显示层级缩进。实际配置必须保持正确缩进,但不能仅凭粘贴内容认定运行环境存在缩进问题。

如果只需要在页面展示用户名,完成这一步后可以直接读取:

String preferredUsername = oidcUser.getIdToken()
    .getClaimAsString("preferred_username");

这不要求 oidcUser.getName() 也返回同一个值。

同时设置 principal name 并映射角色

如果业务代码依赖 getName() 返回 preferred_username,可以在默认加载完成后创建一个 DefaultOidcUser。以下方法可放入现有安全配置类中,相关类型需添加对应 import。

@Bean
public OAuth2UserService<OidcUserRequest, OidcUser> entraOidcUserService() {
    OidcUserService delegate = new OidcUserService();

    return userRequest -> {
        OidcUser user = delegate.loadUser(userRequest);

        Set<GrantedAuthority> authorities =
            new HashSet<>(user.getAuthorities());

        Object rolesClaim = user.getIdToken().getClaims().get("roles");
        if (rolesClaim instanceof Collection<?>) {
            for (Object role : (Collection<?>) rolesClaim) {
                if (role instanceof String
                        && !((String) role).trim().isEmpty()) {
                    authorities.add(
                        new SimpleGrantedAuthority("ROLE_" + role)
                    );
                }
            }
        }

        String preferredUsername = user.getIdToken()
            .getClaimAsString("preferred_username");

        String nameAttribute = "sub";
        if (preferredUsername != null
                && !preferredUsername.trim().isEmpty()) {
            nameAttribute = "preferred_username";
        }

        return new DefaultOidcUser(
            authorities,
            user.getIdToken(),
            user.getUserInfo(),
            nameAttribute
        );
    };
}

provider 配置中仍使用 sub,让 delegate.loadUser() 先完成加载,再选择最终 OidcUser 的 name attribute。如果 ID token 没有 preferred_username,就继续使用 sub。

再显式接入登录流程:

@Bean
public SecurityFilterChain filterChainWithOAuth2Security(
        HttpSecurity http,
        OAuth2UserService<OidcUserRequest, OidcUser> entraOidcUserService
) throws Exception {
    configureStandardHttpSecurity(http)
        .oauth2Login(oauth -> oauth
            .userInfoEndpoint(userInfo -> userInfo
                .oidcUserService(entraOidcUserService)
            )
        )
        .oauth2ResourceServer(oauth -> oauth
            .jwt(Customizer.withDefaults())
        );

    return http.build();
}

configureStandardHttpSecurity(http) 沿用题目中的现有方法。此方案已经处理角色,无需再挂载原来的 userAuthoritiesMapperForEntra()。Spring Security 支持通过 userInfoEndpoint().oidcUserService(...) 接入自定义 OIDC 用户服务。Spring Security 扩展配置

原来的角色读取方式有什么问题?

原代码只从 UserInfo 读取角色:

((OidcUserAuthority) authority).getUserInfo().getClaims()

Entra 的应用角色应检查登录 ID token 中的 roles,不能假定 UserInfo 会返回。原代码还有两个潜在的空值问题:getUserInfo() 可能为 null,而 roles 缺失时调用 roles.stream() 也会失败。

更新后的 oidcUser.getClaims() 使用组合后的 claims,因此能够读到 ID token 中的角色,这是方案能够工作的原因之一。仍建议明确角色来源,并避免直接把未经检查的对象强制转换为 List<String>。

示例将 Entra 的角色值 Admin 映射为 ROLE_Admin,可对应:

.hasRole("Admin")

或:

.hasAuthority("ROLE_Admin")

需要与实际角色值的大小写和应用的权限命名约定保持一致。

email 可以作为替代方案吗?

如果 UserInfo 确实返回 email,改用它可以解决当前的属性缺失错误。但不能据此假定所有用户都有 email,也不宜将其作为长期不变的账户标识。

preferred_username 可能是邮箱、电话号码或其他用户名,并且可以变化;name 也可能变化且不保证唯一。应用内账户关联可使用与 issuer 配套的 sub;需要跨应用关联 Entra 用户时,再根据业务范围考虑 tid 与 oid。显示名称与账户主键应分别处理。Microsoft ID token claims 说明

上面的角色映射只作用于 oauth2Login()。同一应用通过 oauth2ResourceServer().jwt() 接受 Bearer token 时,走的是另一条认证路径,不会调用这个 OIDC 用户服务。如果 API 也要使用 roles,需要另行配置 JWT authorities 转换。由于问题未提供具体依赖版本和异常堆栈,实际排查时还应核对项目版本下的调用路径。

备注:内容仅供参考。