我在 Authing.cn 中建立了一个 Brickverse 用户池:
image.png

在这个用户池下创建了两个应用,一个名为 brickverse,一个名为“哈德韦的个人小程序”。
image.png

第一个应用的访问链接是: https://brickverse.net,第二个应用,虽然是小程序,但也提供了网页访问方式: https://taro.jefftian.dev

之前也写了一个 user-service 服务以供 https://brickverse.net 使用,通过 spring-security-oauth2 来保护这个服务,后来将它升级到了 spring-boot-starter-oauth2-resource-server,具体细节见:《升级 spring-security-oauth2 到 spring-boot-starter-oauth2-resource-serve - Jeff Tian的文章 - 知乎 》。这时,user-service 是资源服务,而 spring-security-oauth2 也好,spring-boot-starter-oauth2-resource-server 也好,都只是一个 Java 包,是一个具体的程序。要保护资源服务,还得需要一个授权服务,这个授权服务,我就使用 Authing.cn,具体来说,是我在 Authing.cn 上创建的 brickverse 应用。

image.png
而这个服务需要配置授权服务器,我的配置如下:
image.png

今天,我想让我的个人小程序,同样可以使用这个 user-service。如果直接调用,哪怕是已经登录过了,接口也会回复 401。原因很简单,我的个人小程序,在登录时,连接的是 Authing.cn 中的另一个 app,即“哈德韦的个人小程序”。
image.png

因此,需要扩展 user-service 的安全组件,能够支持 uniheart(我给“哈德韦的个人小程序”app 在 Authing.cn 中起的名字) 公钥端点的 jwt token,即通过某个字段(比如 Issuer)来判断是否是受支持的,是则尝试解开 token,否则直接报错就行。

image.png

测试先行

说干就干,先加两个测试,第一个测试表明无效的令牌调用不了接口,第二个测试使用一个 uniheart token 调用接口,期待通过(现在这个用例会失败,因为还没有实现)。

java @Test public void testGraphQLEndpointsWorksWithWrongTokenForPublicAPIs() throws Exception { var exception = assertThrows(com.auth0.jwt.exceptions.JWTDecodeException.class, () -> mockMvc.perform(MockMvcRequestBuilders.post(/graphql).content(friendListQuery).header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).header(HttpHeaders.AUTHORIZATION, Bearer 123)).andExpect(MockMvcResultMatchers.status().isBadRequest()).andReturn());

    assertEquals(The token was expected to have 3 parts, but got 1., exception.getMessage());
}

@Test
public void testGraphQLEndpointsWorksWithUniheartToken() throws Exception {
    var uniheartToken = eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkNGTDZUZXN0X2xYZ0RHN1drSkphRVE3azd0Q0dzSEdua2xscy1vT3FqeUEifQ.eyJ1cGRhdGVkX2F0IjoiMjAyMy0wNC0xOVQxODoxMDo0NS4xNTRaIiwiYWRkcmVzcyI6eyJjb3VudHJ5IjoiIiwicG9zdGFsX2NvZGUiOm51bGwsInJlZ2lvbiI6bnVsbCwiZm9ybWF0dGVkIjpudWxsfSwicGhvbmVfbnVtYmVyX3ZlcmlmaWVkIjp0cnVlLCJwaG9uZV9udW1iZXIiOiIxMzA2MTkxMDI3MyIsImxvY2FsZSI6InpoX0NOIiwiem9uZWluZm8iOm51bGwsImJpcnRoZGF0ZSI6bnVsbCwiZ2VuZGVyIjoiTSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZW1haWwiOm51bGwsIndlYnNpdGUiOm51bGwsInBpY3R1cmUiOiJodHRwczovL3RoaXJkd3gucWxvZ28uY24vbW1vcGVuL3ZpXzMyL1g1TFE3b2tibjdqZFlMTFFmNGljaWJpYVNjMHljT01FZ29qNk5TRnVLVWJLSlcwcHVpYXpvYTI4NGZzVFBCSUdCTEhmT0tpYlcwN3h4Uk9GNHN1Q2Q1alBpYjF3LzEzMiIsInByb2ZpbGUiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6bnVsbCwibmlja25hbWUiOiLlk4jlvrfpn6bwn5SlIiwibWlkZGxlX25hbWUiOm51bGwsImZhbWlseV9uYW1lIjpudWxsLCJnaXZlbl9uYW1lIjpudWxsLCJuYW1lIjpudWxsLCJzdWIiOiI2Mzg5ZDQ4MzFkYjE2ZTU0MWRjOTQzYTMiLCJleHRlcm5hbF9pZCI6bnVsbCwidW5pb25pZCI6Im8wcHFFNkppNVhCRVJ4YkQ5XzJmWUpGZ1FXc00iLCJ1c2VybmFtZSI6IuWTiOW-t-mfpvCflKUiLCJkYXRhIjp7InR5cGUiOiJ1c2VyIiwidXNlclBvb2xJZCI6IjYyMDA5N2I2OWE5ZGFiNWU5NjdkMGM0NCIsImFwcElkIjoiNjIwMDk3YjdmN2M5NjQyMTBiOGY3NDMxIiwiaWQiOiI2Mzg5ZDQ4MzFkYjE2ZTU0MWRjOTQzYTMiLCJ1c2VySWQiOiI2Mzg5ZDQ4MzFkYjE2ZTU0MWRjOTQzYTMiLCJfaWQiOiI2Mzg5ZDQ4MzFkYjE2ZTU0MWRjOTQzYTMiLCJwaG9uZSI6IjEzMDYxOTEwMjczIiwiZW1haWwiOm51bGwsInVzZXJuYW1lIjoi5ZOI5b636Z-m8J-UpSIsInVuaW9uaWQiOiJvMHBxRTZKaTVYQkVSeGJEOV8yZllKRmdRV3NNIiwib3BlbmlkIjoibzFwOUg0MG12dUJiN18zTFJFRXE0TlZXVVdnQSIsImNsaWVudElkIjoiNjIwMDk3YjY5YTlkYWI1ZTk2N2QwYzQ0In0sInVzZXJwb29sX2lkIjoiNjIwMDk3YjY5YTlkYWI1ZTk2N2QwYzQ0IiwiYXVkIjoiNjIwMDk3YjdmN2M5NjQyMTBiOGY3NDMxIiwiZXhwIjoxNjg0MDQ4OTc2LCJpYXQiOjE2ODI4MzkzNzYsImlzcyI6Imh0dHBzOi8vdW5paGVhcnQuYXV0aGluZy5jbi9vaWRjIn0.D_UyVFD89dNzBtAgx-M9fTJlMTGxq_95cVOBN8fsD_0eQxSltr0Pktd25IdPB09JxZQBIEFb9f-maQXOq7YH2agSMk6IoNtO69TR4LNl8cGuIbkbI35jl7SGig9moK55luKnY5QjOWlUUQcTb7C0fVvBkx7K9wmuF8sVukHg8t_ComRNrhFxtqQuLWeXJSLhbLxMQzEta0Ofsu9paPzP0HPpVGmzYsS2FvkF7z4laRiz0JpyC01hw3i70qfFfOPUsxWUuSqUw_juc7heqMEFrJz2hO3c8oVovnMp48v2i-LWN8yRMbTDhsUmd9Ny0wovGdMhNFtMZ3cOhwFPp1bK6A;

    mockMvc.perform(MockMvcRequestBuilders.post(/graphql).content(friendListQuery).header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).header(HttpHeaders.AUTHORIZATION, Bearer  + uniheartToken)).andExpect(MockMvcResultMatchers.status().isOk());
}

你可能会问,还应有一个测试,传入 brickverse token 时,接口可以调通。但是这个测试在之前添加 brickverse 应用时,就已经写了,我们只需要在稍后改完代码后,保证它仍然是通过的就行了。

第二个测试中先使用了一个硬编码的 uniheart token,后续可以替换成一个活的。
image.png

要让这两个测试都通过,需要让安全组件能够针对 token 的 iss 做分支判断,不同的 iss,走不同的逻辑。最简单的想法,是给 spring-boot-starter-oauth2-resource-server 配置多个 jwk-set-uri,从而不写一行代码。遗憾的是,spring-boot-starter-oauth2-resource-server 并不支持这样做,只能自己写一点代码了。

添加自定义的 jwt decoder

首先添加一个新文件: src/main/java/com/brickpets/user/security/BrickverseUniHeartJwtDecoder.java:

java package com.brickpets.user.security;

import com.auth0.jwt.JWT; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.security.oauth2.jwt.NimbusJwtDecoderJwkSupport;

public class BrickverseUniHeartJwtDecoder implements JwtDecoder { @Value(${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}) private String jwkSetUri;

@Override
public Jwt decode(String token) throws JwtException {
    var jwt = JWT.decode(token);
    String issuer = jwt.getIssuer();

    if (https://brickverse.authing.cn/oidc.equals(issuer)) {
        return new NimbusJwtDecoderJwkSupport(jwkSetUri).decode(token);
    }

    return new NimbusJwtDecoderJwkSupport(https://uniheart.authing.cn/oidc/.well-known/jwks.json).decode(token);
}

}

这里主要就是从 token 中取出 issuer,如果是 brickverse 来的,就直接调用之前的配置。如果不是,就当做 uniheart token 来尝试(目前先写死 uniheart 的 jwks url)。

使用 Bean 将自定义 jwt decoder 注入

接着改造一下之前的 src/main/java/com/brickpets/user/config/CustomWebSecurityConfigurerAdapter.java 文件,将新增的 jwt decoder 放进去:

diff package com.brickpets.user.config;

import com.brickpets.user.filters.TokenRemover;

  • import com.brickpets.user.security.BrickverseUniHeartJwtDecoder;

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity;

  • import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
  • import org.springframework.security.oauth2.jwt.JwtDecoder;

import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;

@Configuration public class CustomWebSecurityConfigurerAdapter {

  • @Bean
  • public JwtDecoder jwtDecoder() {
  •    return new BrickverseUniHeartJwtDecoder();
    
  • }
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers(/user/preference/**).authenticated()
            .anyRequest().permitAll()
            .and()
            .csrf().disable()
  •            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt).addFilterBefore(new TokenRemover(), AbstractPreAuthenticatedProcessingFilter.class);
    
  •            .addFilterBefore(new TokenRemover(), AbstractPreAuthenticatedProcessingFilter.class)
    
  •            .oauth2ResourceServer().jwt().decoder(jwtDecoder())
      ;
    
    
      return http.build();
    

以上就是通过 Bean 的方式将我们新加的 jwt decoder 注入到了应用中去。

有了以上的改动,测试就全通过了。提供代码上线,现在 https://taro.jefftian.dev 也可以使用 user-service 了!