Skip to content
All notes

Dubbo 集成 Spring Security 和 OAuth2

记录 Dubbo REST 请求接入 Spring Security 和 OAuth2 令牌校验的示例。

Dubbo 集成 Spring Security 和 OAuth2

这份记录围绕授权服务器和 Dubbo 资源服务搭建示例,并测试携带令牌与未携带令牌的请求。

角色与集成点

Spring Security 提供认证和访问控制能力。OAuth2 中,授权服务器颁发访问令牌,客户端携带令牌访问资源服务器,资源服务器验证令牌和访问权限。

Dubbo 侧通过实现 Filter 和 org.apache.dubbo.rpc.protocol.tri.rest.filter.RestExtension,再注册 SPI,把校验接到 REST 请求链路。

授权服务器

示例使用默认安全配置,再配置授权服务器和端点信息。具体端点与默认安全行为以项目使用的 Spring Authorization Server 版本为准。

@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

    return http.build();
}

private static final String HOST = System.getProperty("authorization.address", "localhost");

String issuer = "http://" + HOST + ":9000";

@Bean
public RegisteredClientRepository registeredClientRepository() {
    RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("49fd8518-12eb-422b-9264-2bae0ab89f66") //configure the client id
            .clientSecret("{noop}H3DTtm2fR3GRAdr4ls1mcg") // configure the client secret
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("http://localhost:9000/oauth2/token") // configure the redirect uri
            .scope("openid")
            .scope("read")
            .scope("write")
            .build();

    return new InMemoryRegisteredClientRepository(registeredClient);
}

@Bean
public AuthorizationServerSettings authorizationServerSettings() {
    return AuthorizationServerSettings.builder()
            .issuer(issuer) // set the address of the authorization server
            .build();
}

资源服务器

OAuthFilter 实现过滤接口,并通过 @Activate 激活。doFilter 校验请求中的令牌,成功后继续处理,否则拒绝访问。

@Override
public void init(FilterConfig filterConfig) {
    // Initialize the JwtDecoder and obtain the public key from the configured authorization server URL for decoding the JWT
    jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
    // Initialize JwtAuthenticationConverter to convert JWT
    jwtAuthenticationConverter = new JwtAuthenticationConverter();
    JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
}

@Override
public String[] getPatterns() {
    return new String[] {"/**"}; // Intercept all requests
}

@Override
public void doFilter(
        ServletRequest servletRequest,
        ServletResponse servletResponse,
        FilterChain filterChain) throws IOException {
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    HttpServletResponse response = (HttpServletResponse) servletResponse;
    String authorization = request.getHeader("Authorization");
    if (authorization != null && authorization.startsWith("Bearer ")) {
        String jwtToken = authorization.substring("Bearer ".length());
        // Decode the JWT token
        try {
            Jwt jwt = jwtDecoder.decode(jwtToken);
            jwtAuthenticationConverter.convert(jwt);
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
        }

    } else {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing JWT token");
    }

}

@Override
public int getPriority() {
    return -200;
}

发现授权服务器与公钥

授权服务器元数据提供 jwks_uri,资源服务器通过该地址获取用于验签的公钥。

// 20241228173022
// http://localhost:9000/.well-known/oauth-authorization-server

{
  "issuer": "http://localhost:9000",
  "authorization_endpoint": "http://localhost:9000/oauth2/authorize",
  "device_authorization_endpoint": "http://localhost:9000/oauth2/device_authorization",
  "token_endpoint": "http://localhost:9000/oauth2/token",
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post",
    "client_secret_jwt",
    "private_key_jwt"
  ],
  "jwks_uri": "http://localhost:9000/oauth2/jwks",
  "response_types_supported": [
    "code"
  ],
  "grant_types_supported": [
    "authorization_code",
    "client_credentials",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code"
  ],
  "revocation_endpoint": "http://localhost:9000/oauth2/revoke",
  "revocation_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post",
    "client_secret_jwt",
    "private_key_jwt"
  ],
  "introspection_endpoint": "http://localhost:9000/oauth2/introspect",
  "introspection_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post",
    "client_secret_jwt",
    "private_key_jwt"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ]
}
// 20241228173230
// http://localhost:9000/oauth2/jwks

{
  "keys": [
    {
      "kty": "RSA",
      "e": "AQAB",
      "kid": "8e37caeb-1505-48b6-8fe2-2f49796b6a07",
      "n": "ljHIB4ZEP8nxu5Wurn97Kf35SuwLzQE5WcASzXT7qUxQkRHNRTAqjVUxHpwiEh7_6h-dO8-VTEcAsoifSsSR3ry949V5iXUPqcw2RtOANkb2jcIYwKvGrJvFikNWsU5R9pTzNj8JL-UizRSqLfYfBEsfYx6CZowaALbTUUmx0LzBcXmnvOaxS2IgJ6pD5CDJWyTD62dQeZTBxMeGvBvJi8y7yhu_ANzivEkbnx-QogRCBzwSqpAzAe4DDbaU0iAbBrDZT17uynlSpiLbN0RnsyiD3X6RDSKr7PyG_2rI_wkqDfiV4RqoIvOYwQHH27zNzO5tC8k_sGeOVK1_ydAEuw"
    }
  ]
}

客户端在 Authorization 中发送的是访问令牌,不是私钥。对于这里的非对称签名 JWT,资源服务器用公钥验证签名;令牌验证还需要检查签发者、接收方、有效期和所需权限,不能只凭签名通过就放行。

测试记录

测试参数:

private final String clientId = "49fd8518-12eb-422b-9264-2bae0ab89f66";
private final String clientSecret = "H3DTtm2fR3GRAdr4ls1mcg";

private static final String OAUTH2HOST = System.getProperty("authorization.address", "localhost");
private static final String HOST = System.getProperty("resource.address", "localhost");

携带令牌访问:

@Test
public void testGetUserEndpoint() {
    String credentials = clientId + ":" + clientSecret;
    String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());

    // build RestClient request
    RestClient restClient = RestClient.builder().build();
    String url = "http://" + OAUTH2HOST + ":9000/oauth2/token";

    try {
        // make a post request
        String response = restClient.post()
                .uri(url)
                .header(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials)
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
                .body("grant_type=client_credentials&scope=read")
                .retrieve()
                .body(String.class);

        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(response);
        String accessToken = jsonNode.get("access_token").asText();

        // Use the access token to authenticate the request to the /user endpoint
        assert accessToken != null;
        String userUrl = "http://" + HOST + ":50051/hello/sayHello/World";
        try {
            String userResponse = restClient.get()
                    .uri(userUrl)
                    .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
                    .retrieve()
                    .body(String.class);

            assertEquals("\"Hello, World\"", userResponse, "error");
        } catch (RestClientResponseException e) {
            System.err.println("Error Response: " + e.getResponseBodyAsString());
            Assertions.fail("Request failed with response: " + e.getResponseBodyAsString());
        }

    } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
}

未携带有效令牌访问:

@Test
public void testGetUserEndpointWithInvalidToken() {
    String invalidAccessToken = "invalid_token";
    RestClient restClient = RestClient.builder().build();
    String userUrl = "http://" + HOST + ":50051/hello/sayHello/World";

    try {
        restClient.get()
                .uri(userUrl)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidAccessToken)
                .retrieve()
                .body(String.class);

        Assertions.fail("Request should have failed with an invalid token");
    } catch (RestClientResponseException e) {
        System.err.println("Error Response: " + e.getResponseBodyAsString());
        assertEquals(401, e.getStatusCode().value(), "Expected 401 Unauthorized status");
    }
}