定义与基本概念

Spring Security 是一个基于 Spring 框架的安全框架,为 Java 企业级应用提供了全面的安全解决方案,涵盖了身份验证(用户登录)、授权(访问控制)、加密和防止常见的安全漏洞等功能。它可以集成到各种类型的 Spring 应用中,包括 Web 应用(如 Spring MVC、Spring Boot)和非 Web 应用。

OAuth2(开放授权 2.0)是一个开放标准的授权框架,旨在解决不同应用之间的授权问题,允许用户在不向第三方应用透露自己的用户名和密码的情况下,授权第三方应用访问他们存储在另一个服务提供商上的资源。它通过使用令牌Token来代表用户的授权,使得资源服务器能够验证请求是否被授权。

核心角色与流程

  • 资源所有者:通常是用户,拥有受保护的资源。

  • 资源服务器:存储资源的服务器,需要对请求进行授权验证。

  • 客户端:请求访问资源的应用程序。

  • 授权服务器:负责验证资源所有者的身份,并发放令牌给客户端

客户端请求资源所有者授权,资源所有者同意后,授权服务器向客户端发放令牌,客户端携带令牌访问资源服务器,资源服务器验证令牌的有效性后提供资源。

Dubbo 的协同

同时低版本 javax 和高版本 jakarta servlet APIjakarta API 优先级更高,只需要引入jar即可使用HttpServletRequestHttpServletResponse作为参数

使用 Filter 扩展: 实现 Filter 接口和 org.apache.dubbo.rpc.protocol.tri.rest.filter.RestExtension 接口,然后注册SPI

主要流程

由于 OAuth2 需要授权服务器,资源服务器,客户端,为了简化案例,就写授权服务器和资源服务器。

AuthorizationServer

采用默认配置

@Bean  
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {  
    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);  
  
    return http.build();  
}
1. 端点访问控制
  • 授权端点(/authorize)
    • 要求进行身份验证,只有已认证的用户才能发起授权请求。默认情况下,通常会使用 Spring Security 的标准身份验证机制,如基于表单的登录或 HTTP Basic 认证。
    • 限制对该端点的请求方法,一般只允许 GET 和 POST 方法,以防止恶意的请求操作。
  • 令牌端点(/token)
    • 此端点用于发放访问令牌和刷新令牌,安全要求更高。默认配置会要求客户端进行身份验证,通常通过客户端 ID 和客户端密钥进行认证。
    • 同样限制请求方法,一般只允许 POST 方法,以确保只有合法的请求才能获取令牌。
2. 防止跨站请求伪造(CSRF)
  • 启用 CSRF 保护机制,CSRF 是一种常见的网络攻击手段,攻击者通过在用户已登录的情况下,利用用户的浏览器自动发送恶意请求。默认配置会在授权服务器的相关请求中添加 CSRF 防护措施,例如在表单提交时要求包含 CSRF 令牌。
  • 对于一些与 OAuth2 流程紧密相关的请求,可能会根据具体情况对 CSRF 保护进行特殊配置,例如在某些情况下允许特定的请求绕过 CSRF 检查,但这需要谨慎处理以确保安全性。
3. 安全头信息设置
  • 添加各种安全相关的 HTTP 头信息,以增强安全性。例如:
    • Content-Security-Policy:用于限制网页可以加载的资源来源,防止跨站脚本攻击(XSS)。
    • X-Frame-Options:防止页面被嵌入到其他页面的框架中,避免点击劫持攻击。
    • X-XSS-Protection:启用浏览器的 XSS 过滤机制,帮助检测和阻止 XSS 攻击。

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();  
}

配置相关授权信息,并进行相关接口的暴露

ResourceServer

核心在 OAuthFilter 中,实现了 ‘Filter, RestExtension’ ,并通过 @Activate 来进行激活,在 Dubbo 里进行拦截,下面是对接口的实现

@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;  
}

doFilter 中对令牌进行权限校验,如果令牌正确,则放行,反之就拒绝访问。

这里涉及到 有关 jwt 令牌的相关内容

具体来说就是,授权服务器根据配置暴露在公网 json:

// 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"
  ]
}

这里包含了授权服务器的相关信息,资源服务器访问后获取 jwks_uri ,来获取公钥

// 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"
    }
  ]
}

资源服务器通过公钥和客户端发来的 rest 风格的请求中的 header 中的 Authorization 中的私钥进行匹配,成功后则可以进行对资源的访问,失败则进行拦截。

测试流程

最初的参数配置

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");  
    }  
}