Ohhnews

分类导航

$ cd ..
Baeldung原文

在Spring Authorization Server中自定义OpenID Connect 1.0 UserInfo响应

#spring authorization server#openid connect#userinfo#oauth 2.0#自定义声明

[LOADING...]

1. 引言

对大多数应用而言,用户认证只是提供任何实际服务之前需要跨过的第一步。通常情况下,我们需要把用户标识与实现这些服务所必需的领域特定信息关联起来。

在本教程中,我们将展示如何自定义 Spring 授权服务器 UserInfo 端点的处理方式,使客户端应用能够以标准方式获取这些信息。

2. 什么是 OpenID Connect 1.0 UserInfo 端点?

OpenID Connect 规范在 OAuth 2.0 标准之上增加了一层身份层,使身份提供者(简称 IdP)能够以两种方式向客户端提供身份信息:

  • id_token:一个 JWT,作为认证流程的一部分交给客户端,其令牌载荷(payload)中包含一组用户属性——也称为声明(claims)
  • UserInfo 端点:一个可选端点,不过当前的主流实现大多支持它,它以 JSON 对象或经过签名和/或加密的 JWT 形式返回用户声明

两者在内容上相似,但在技术和语义上存在一些差异:

id_tokenUserInfo 端点
传递方式授权回调或令牌端点使用授权流程中获得的 access_token,向 IdP 发起 GET(推荐)或 POST 请求
必需声明iss(签发者)、aud(受众)、sub(主体)、exp(过期时间)、iat(签发时间)sub;当响应为签名 JWT 时,iss 与 aud 同样是必需项
安全属性JWT 签名,可选加密;可选的登录细节声明(acr、amr 等)签名与加密均为可选
主要用例与认证相关的最小化用户身份属性完整的个人资料信息

除必需声明之外,规范还定义了一组标准声明,在很多情况下这些声明足以填充应用账户的用户详细信息。关键在于我们何时使用哪种方式。

对于 id_token,其中的属性代表认证时刻的一个快照;一旦签发,要获取更新版本,唯一可靠且可移植的方式就是走完整的授权流程。例外情况是当 IdP 支持在响应 refresh_token 请求并返回新的 access_token 时一并返回 id_token。

相比之下,客户端应用可以随时使用 UserInfo 端点获取最新版本的数据,因此在实现“我的个人资料”页面或类似功能时,它成为首选。另一个相关场景是客户端应用调用后端资源服务器,而该服务器需要某个尚未包含在 access_token 中的用户资料属性。此时,后端使用收到的令牌查询 UserInfo 端点,并可能对其进行缓存,以避免后续调用产生额外延迟。

3. Spring 授权服务器的默认实现

我们已在之前的教程中介绍了 Spring 授权服务器(简称 SAS)的基础知识,因此这里不再详述。简要提醒一下:自 Spring Security 7 和 Spring Boot 4 起,SAS 不再是独立项目,而是这些框架中的标准模块。

以下是我们基于 WebMVC 的项目所需的 Maven 依赖:

$ xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
    <version>4.0.5</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-authorization-server-test</artifactId>
    <version>4.0.5</version>
    <scope>test</scope>
</dependency>

这些依赖的最新版本可在 Maven Central 获取:

我们先创建一个最小的 Spring Security 配置,看看“开箱即用”的效果:

$ java
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) {
    return http.oauth2AuthorizationServer(sas -> {
        http
          .securityMatcher(sas.getEndpointsMatcher())
          .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
          .exceptionHandling(exceptions ->
            exceptions.defaultAuthenticationEntryPointFor(
              new LoginUrlAuthenticationEntryPoint("/login"),
              createRequestMatcher()));
        sas.oidc(withDefaults());
    }).build();
}
@Bean
@Order(SecurityFilterProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) {
    http
      .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
      .formLogin(withDefaults());
    return http.build();
}

我们还需要一个测试客户端和用户,使用下面的 YAML 配置提供:

$ config
spring:
  security:
    user:
      name: user
      password: "{noop}password"
    oauth2:
      authorizationserver:
        client:
          client1:
            require-proof-key: false
            require-authorization-consent: false
            registration:
              client-name: "Client 1 - Issuer 1"
              client-id: client1
              client-secret: "{noop}secret1"
              client-authentication-methods:
                - client_secret_basic
              redirect-uris:
                - http://localhost:9090/login/oauth2/code/issuer1client1
              authorization-grant-types:
                - authorization_code
                - refresh_token
              scopes:
                - openid
                - email
                - profile
                - account

该测试客户端可在线获取,它通过以下步骤获取 UserInfo 响应:

  • 获取并解析 OIDC 发现文档,以找到所需的端点
  • 启动认证流程(其中包含自定义的 account 作用域),并使用测试凭据提交登录表单
  • 使用授权码获取 access 和 id 令牌
  • 使用访问令牌获取 userinfo 数据

运行该测试会发现,唯一可用的声明是 sub,它包含登录应用时使用的用户名。这符合预期,因为这是底层认证机制所提供的唯一信息。

4. UserInfo 服务实现

在添加任何声明之前,我们需要为其定义一个数据来源。思路是将信息获取环节与 UserInfo 响应定制解耦。这种做法还带来一个额外好处:很容易复用该服务来自定义 id_token 的生成。

我们的服务包含一个方法,给定用户名后返回一个包含待添加属性的 record:

$ java
@Service
public class UserInfoService {
    static final Map<String, UserInfo> users = Map.of(
        "user", new UserInfo(
        "user",
        "Alice Smith",
        "Alice",
        "Smith",
        "alice.smith@example.com",
        true,
        Locale.forLanguageTag("en-US"),
        "female",
        LocalDate.of(1990, 1, 1),
        ZoneId.of("America/New_York"),
        UUID.randomUUID(),
        Instant.now(),
        Instant.now(),
        Instant.now().plus(60, java.time.temporal.ChronoUnit.DAYS)
        )
    );
    public Optional<UserInfo> getUserInfoByUsername(String username) {
        return Optional.ofNullable(users.get(username));
    }
    public record UserInfo(
        String username,
        String name,
        String givenName,
        String familyName,
        String email,
        boolean emailVerified,
        Locale locale,
        String gender,
        LocalDate birthdate,
        ZoneId zoneId,
        UUID accountId,
        Instant createdAt,
        Instant updatedAt,
        Instant accountExpiresAt
    ) {}
}

这里的实现只是在静态 map 中查找用户名并返回关联的信息 record。在真实场景中,实现会使用某种仓库来获取这些信息,例如数据库、LDAP 或 REST 服务。

我们还为该类添加了 @Service 注解,使 Spring 能自动将其加入应用上下文。这样一来,我们就可以在需要的地方注入该服务,下一节将会看到这一点。

5. UserInfo 映射

要将 UserInfoService 与 SAS 配合使用,我们必须实现一个 Function,把 OidcUserInfoAuthenticationContext 实例映射为 OidcUserInfo 对象。SAS 提供的 OidcUserInfoAuthenticationContext 包含以下字段:

  • authentication:与当前请求关联的有效 Authentication。我们可以用 getName() 获取与该请求关联的用户名
  • authorization:用于生成发送给客户端的访问令牌的 OAuth 2.0 授权
  • accessToken:收到的访问令牌,若我们想访问下游受保护资源,它会很有用

该 authorization 包含客户端已获授权的作用域,我们据此在构建最终 OidcUserInfo 实例时筛选从 UserInfoService 返回的信息。

UserInfoMapper 类实现了这一逻辑:

$ java
public class UserInfoMapper implements Function<OidcUserInfoAuthenticationContext, OidcUserInfo> {
    private final UserInfoService userInfoService;
    // ... constructor omitted
    @Override
    public OidcUserInfo apply(OidcUserInfoAuthenticationContext context) {
        var auth = context.getAuthentication();
        var subject = auth.getName();
        var scopes = context.getAuthorization().getAuthorizedScopes();
        var userInfo = userInfoService.getUserInfoByUsername(subject);
        if ( userInfo.isEmpty() || scopes.isEmpty() || (scopes.size() == 1 && scopes.contains("openid")) ) {
            return OidcUserInfo.builder()
              .subject(subject)
              .build();
        }
        var user = userInfo.get();
        var b = OidcUserInfo.builder().subject(subject);
        if ( scopes.contains("email") ) {
            b.email(user.email())
              .emailVerified(user.emailVerified());
        }
        if( scopes.contains("profile") ) {
            b.name(user.name())
              .givenName(user.givenName())
              .familyName(user.familyName())
              .locale(user.locale().toLanguageTag())
              .gender(user.gender())
              .birthdate(user.birthdate().toString())
              .zoneinfo(user.zoneId().toString())
              .preferredUsername(user.username())
              .claim(StandardClaimNames.UPDATED_AT, user.updatedAt().getEpochSecond());
        }
        if ( scopes.contains("account") ) {
            b.claim("account_id", user.accountId().toString())
              .claim("created_at", user.createdAt().getEpochSecond())
              .claim("account_expires_at", user.accountExpiresAt().getEpochSecond());
        }
        return b.build();
    }
}

请注意,对于 updated_at 声明,我们没有使用 builder 的 updatedAt 方法。原因是 Spring(至少在当前版本中)会将该声明输出为字符串而非 JSON 数字。作为变通,我们使用了通用的 claim() 方法,它接受任意对象作为声明值。

这里的固定作用域列表很小,因此可以用简单的“if”语句来构建 OidcUserInfo 实例。如果作用域集合更大,就需要更有结构化的方式。例如,我们可以使用一个从作用域到“声明填充器”的映射,由后者负责把用户值对象中的属性映射到 OidcUserInfo。

6. 注册 UserInfo 映射

我们需要做的最后一步,是用自定义映射器配置 SAS。这要在授权服务器 DSL 中可访问的 oidc 配置器里完成:

$ java
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, UserInfoMapper userInfoMapper) {
    return http.oauth2AuthorizationServer(sas -> {
      http
        .securityMatcher(sas.getEndpointsMatcher())
        .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
        .exceptionHandling(exceptions ->
          exceptions.defaultAuthenticationEntryPointFor(
            new LoginUrlAuthenticationEntryPoint("/login"),
            createRequestMatcher()));
      if (enableCustomUserInfo) {
        sas.oidc(oidc -> oidc
          .userInfoEndpoint(userInfo -> userInfo
            .userInfoMapper(userInfoMapper)));
      }
      else {
        sas.oidc(withDefaults());
      }
    }).build();
}

仅出于示例/测试目的,我们使用特性开关(enableCustomUserInfo)有条件地配置了自定义映射器。这样我们就可以通过 customuserinfo.enabled 属性快速启用/禁用该功能,并比较结果。

7. 测试完整流程

我们用一个单元测试来结束本教程的编码工作,该测试会走完整个认证流程,并使用得到的访问令牌查询 UserInfo 端点。

完整代码可在线获取,与这里展示的内容基本一致,区别如下:

  • 我们确保 customuserinfo.enabled 属性被设置为 true
  • 末尾增加了额外的断言语句,用于检查自定义声明是否存在

要更好地理解 SAS 的内部运作,一个好办法是在 IDE 中运行该单元测试,并在关键位置设置断点。其中之一就是 UserInfoService 的实现。当测试执行到 getUserInfoByUsername() 方法时,我们可以沿着调用栈回溯到 OidcUserInfoEndpointFilter,在那里就能看到认证/授权各组件是如何协同工作的。

8. 其他可用的定制方式

SAS 的定制能力并不止于添加额外声明,尽管这是最常见的场景。

与其他端点的情况一样,oidc DSL 允许开发者完全控制端点的处理过程。虽然本教程未涉及,我们还是简要介绍一下:

  • userInfoRequestConverter:添加自定义请求转换器,将 HttpServletRequest 转换为 Authentication
  • userInfoRequestConverters:允许我们添加、删除或修改认证转换器列表
  • authenticationProvider:向用于认证请求的可用提供者列表中添加自定义认证提供者
  • authenticationProviders:允许我们添加、删除或修改认证提供者
  • userInfoResponseHandler:替换用于生成最终发送给客户端的响应的逻辑
  • errorResponseHandler:替换在出错时用于生成发送给客户端响应的逻辑

这些方法合在一起,可以满足与请求处理相关的任何自定义需求。例如,如果我们想加密响应或使用自定义认证方式,就可以使用相应的方法用自定义处理替换标准处理流程。## 9. 结论

在本教程中,我们展示了如何自定义 Spring Authorization Server 的 UserInfo 端点,使响应中包含从开发者提供的服务中获取的额外声明。

一如既往,完整源代码可在 GitHub 上获取。