在 Spring Authorization Server 中将 Authorities 作为自定义 Claim 添加到 JWT Access Token 中。

1、概览

在许多情况下,可以在 JWT Access Token 添加自定义 Claim,从而在 Token Payload 中包含更多信息。

本文将带你了解如何在 Spring Authorization Server 中为 JWT Access Token 添加资源所有者授权。

2、Spring Authorization Server

Spring Authorization Server 是 Spring 生态系统中的一个新项目,旨在为 Spring 应用提供授权服务器支持。它通过 Spring 编程模型简化 OAuth 2.0 和 OpenID Connect(OIDC) 授权服务器的实现。

2.1、Maven 依赖

首先,在 pom.xml 中导入 spring-boot-starter-webspring-boot-starter-securityspring-boot-starter-testspring-security-oauth2-authorization-server 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.5.4</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>2.5.4</version>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-authorization-server</artifactId>
    <version>0.2.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <version>2.5.4</version>
</dependency>

或者,可以在 pom.xml 文件中添加 spring-boot-starter-oauth2-authorization-server 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
    <version>3.2.0</version>
</dependency>

2.2、项目设置

配置 Spring Authorization Server 来签发 Access Token。

为了简单起见,这里使用 “Spring Security Oauth 授权服务器” 应用。

假设我们使用的是 GitHub 上的 授权服务器项目

3、为 JWT Access Token 添加基本的自定义 Claim

在基于 Spring Security OAuth2 的应用中,可以通过自定义授权服务器中的令牌创建流程,为 JWT Access Token 添加自定义 Claim。这种类型的 Claim 可用于在 JWT 中注入附加信息,然后由资源服务器或身份认证和授权流程中的其他组件使用。

3.1、添加基本的自定义 Claim

可以使用 OAuth2TokenCustomizer<JWTEncodingContext> Bean 将自定义 Claim 添加到 Access Token 中。通过使用它,授权服务器签发的每个 Access Token 都将填充自定义 Claim。

DefaultSecurityConfig 类中添加 OAuth2TokenCustomizer Bean:

@Bean
@Profile("basic-claim")
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
    return (context) -> {
      if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
        context.getClaims().claims((claims) -> {
          claims.put("claim-1", "value-1");
          claims.put("claim-2", "value-2");
        });
      }
    };
}

OAuth2TokenCustomizer 接口是 Spring Security OAuth2 库的一部分,用于自定义 OAuth 2.0 Token。在本例中,它专门用于在编码过程中自定义 JWT Token。

传递给 jwtTokenCustomizer() Bean 的 lambda 表达式定义了自定义逻辑。context 参数代表 Token 编码过程中的 JwtEncodingContext

首先,使用 context.getTokenType() 方法检查正在处理的 Token 是否是 Access Token。然后,使用 context.getClaims() 方法获取与正在构建的 JWT 相关的 Claim。最后,向 JWT 添加自定义 Claim。

在本例中,添加了两个 Claim(claim-1claim-2)和相应的值(value-1value-2)。

3.2、测试自定义 Claim

在测试中,使用 client_credentials Grant Type。

首先,将 AuthorizationServerConfig 中的 client_credentials Grant Type 定义为 RegisteredClient 对象中的 authorized Grant Type:

@Bean
public RegisteredClientRepository registeredClientRepository() {
    RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
      .clientId("articles-client")
      .clientSecret("{noop}secret")
      .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
      .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
      .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
      .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
      .redirectUri("http://127.0.0.1:8080/login/oauth2/code/articles-client-oidc")
      .redirectUri("http://127.0.0.1:8080/authorized")
      .scope(OidcScopes.OPENID)
      .scope("articles.read")
      .build();

    return new InMemoryRegisteredClientRepository(registeredClient);
}

然后,在 CustomClaimsConfigurationTest 类中创建一个测试用例:

@ActiveProfiles(value = "basic-claim")
public class CustomClaimsConfigurationTest {

    private static final String ISSUER_URL = "http://localhost:";
    private static final String USERNAME = "articles-client";
    private static final String PASSWORD = "secret";
    private static final String GRANT_TYPE = "client_credentials";

    @Autowired
    private TestRestTemplate restTemplate;

    @LocalServerPort
    private int serverPort;

    @Test
    public void givenAccessToken_whenGetCustomClaim_thenSuccess() throws ParseException {
        String url = ISSUER_URL + serverPort + "/oauth2/token";
        HttpHeaders headers = new HttpHeaders();
        headers.setBasicAuth(USERNAME, PASSWORD);
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", GRANT_TYPE);
        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);
        ResponseEntity<TokenDTO> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, TokenDTO.class);

        SignedJWT signedJWT = SignedJWT.parse(response.getBody().getAccessToken());
        JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet();
        Map<String, Object> claims = claimsSet.getClaims();

        assertEquals("value-1", claims.get("claim-1"));
        assertEquals("value-2", claims.get("claim-2"));
    } 
    
    static class TokenDTO {
        @JsonProperty("access_token")
        private String accessToken;
        @JsonProperty("token_type")
        private String tokenType;
        @JsonProperty("expires_in")
        private String expiresIn;
        @JsonProperty("scope")
        private String scope;

        public String getAccessToken() {
            return accessToken;
        }
    }
}

上述的测试逻辑如下:

  • 首先为 OAuth2 令牌端点创建一个 URL。
  • 发送一个 POST 请求到 token 端点,并获取包含 TokenDTO 类的响应。这里,创建了一个 HTTP 请求实体,其中包含 Header(Basic Authentication)和参数(Grant Type)。
  • 使用 SignedJWT 类从响应中解析 Access Token。此外,还从 JWT 中提取 Claim 并将其存储在 Map<String, Object> 中。
  • 使用 JUnit 断言证明 JWT 中的指定 Claim 具有预期值。

该测试确认了 Token 编码流程工作正常,并生成了预期的 Claim。

还可以使用 curl 命令获取 Access Token :

curl --request POST \
  --url http://localhost:9000/oauth2/token \
  --header 'Authorization: Basic YXJ0aWNsZXMtY2xpZW50OnNlY3JldA==' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data grant_type=client_credentials

如上,凭证编码为包含 client ID 和 client secret 的 Base64 字符串,以单个冒号 : 分隔。

现在,可以使用 Profile basic-claim 运行 Spring Boot 应用。

获得 Access Token 后,在 jwt.io 对其进行解码,可以在 Token Body 中找到测试 claim:

{
  "sub": "articles-client",
  "aud": "articles-client",
  "nbf": 1704517985,
  "scope": [
    "articles.read",
    "openid"
  ],
  "iss": "http://auth-server:9000",
  "exp": 1704518285,
  "claim-1": "value-1",
  "iat": 1704517985,
  "claim-2": "value-2"
}

可以看到,测试 Claim 的价值符合预期。

4、将 Authorities 作为自定义 Claim 添加到 JWT Access Token 中

将 Authorities(权限)添加为自定义 Claim 到 JWT Access Token 中,通常是在 Spring Boot 应用中确保安全和管理访问的关键方面。权限通常由 Spring Security 中的 GrantedAuthority 对象表示,表示用户被允许执行的操作或角色。通过将这些权限作为自定义 Claim 包含在 JWT Access Token 中,为资源服务器提供了一种方便且标准化的方式来理解用户的权限。

4.1、将 Authorities 添加为自定义 Claim

使用一个简单的内存用户配置,在 DefaultSecurityConfig 类中设置一组权限:

@Bean
UserDetailsService users() {
    UserDetails user = User.withDefaultPasswordEncoder()
      .username("admin")
      .password("password")
      .roles("USER")
      .build();
    return new InMemoryUserDetailsManager(user);
}

创建一个用户名为 admin、密码为 password、角色为 USER 的用户。

在 Access Token 中用这些 Authorities 填充自定义 Claim:

@Bean
@Profile("authority-claim")
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(@Qualifier("users") UserDetailsService userDetailsService) {
    return (context) -> {
      UserDetails userDetails = userDetailsService.loadUserByUsername(context.getPrincipal().getName());
      Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
      context.getClaims().claims(claims ->
         claims.put("authorities", authorities.stream().map(authority -> authority.getAuthority()).collect(Collectors.toList())));
    };
}

首先,定义一个实现 OAuth2TokenCustomizer<JwtEncodingContext> 接口的 lambda 函数。该函数会在编码过程中自定义 JWT。

然后,从注入的 UserDetailsService 中获取与当前 Principal(用户)相关的 UserDetails 对象。Principal 的 name 通常是用户名。

然后,检索与该用户相关联的 GrantedAuthority 对象集合。

最后,从 JwtEncodingContext 中检索 JWT Claim 并应用自定义。其中包括在 JWT 中添加名为 authorities 的自定义 Claim。此外,该 Claim 还包含从与用户关联的 GrantedAuthority 对象中获取的权限字符串列表。

4.2、测试 Authorities Claim

配置了授权服务器后,进行测试。使用 GitHub 上的客户端-服务器项目

创建一个 REST API 客户端,从 Access Token 中获取 Claim 列表:

@GetMapping(value = "/claims")
public String getClaims(
  @RegisteredOAuth2AuthorizedClient("articles-client-authorization-code") OAuth2AuthorizedClient authorizedClient
) throws ParseException {
    SignedJWT signedJWT = SignedJWT.parse(authorizedClient.getAccessToken().getTokenValue());
    JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet();
    Map<String, Object> claims = claimsSet.getClaims();
    return claims.get("authorities").toString();
}

@RegisteredOAuth2AuthorizedClient 注解用于 Spring Boot Controller 方法中,表示该方法希望 OAuth 2.0 授权客户端以指定的 client ID 注册。在本例中,client ID 是 articles-client-authorization-code

使用 authority-claim Profile 运行 Spring Boot 应用。

现在,打开浏览器并尝试访问 http://127.0.0.1:8080/claims 页面,会被自动重定向到 http://auth-server:9000/login URL 下的 OAuth 服务器登录页面。

提供正确的用户名和密码后,授权服务器会将我们重定向到请求的 URL,即 Claim 列表。

5、总结

总之,向 JWT Access Token 添加自定义 Claim 的功能提供了一种强大的机制,可根据应用的特定需求定制 Token,并增强身份认证和授权系统的整体安全性和功能。

本文介绍了如何在 Spring Authorization Server 中为 JWT Access Token 添加自定义 Claim 和用户授权(Authorities)。


Ref:https://www.baeldung.com/spring-jwt-access-tokens-authorities-custom-claims