1. Dependency

  • 用Keycloak创建有OAuth2支持的Cloud Gateway;

  • 网关充当OAuth2 Client和OAuth2 Resource Server,
    需依赖spring-boot-starter-oauth2-client和
    spring-security-oauth2-resource-server

  • 还需依赖spring-security-oauth2-jose来自动解码jwt token,
    当然我们需依赖spring-cloud-starter-gateway;

  • 最后需依赖JUnit自动化测试,和Testcontainers运行Keycloak容器;
    添加spring-boot-starter-test,testcontainers-keycloak
    和org.testcontainers.junit-jupiter依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>com.github.dasniko</groupId>
    <artifactId>testcontainers-keycloak</artifactId>
    <version>3.2.0</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.19.6</version>
    <scope>test</scope>
</dependency>

2. Explanation

  • oauth2Login()负责将未经身份认证的请求重定向到Keycloak登录页,
    oauth2ResourceServer()在转发到下游服务前验证访问令牌;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
    	//http.headers(header -> header.frameOptions(frame -> frame.mode(Mode.SAMEORIGIN)));
    	//http.headers(header -> header.frameOptions(frame -> frame.disable()));

        http.authorizeExchange(auth -> auth.anyExchange().authenticated())
            .oauth2Login(Customizer.withDefaults())
            .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        http.csrf(ServerHttpSecurity.CsrfSpec::disable);
        return http.build();
    }

}
  • 还需提供带spring.security.oauth2前缀的配置,OAuth2 Resource Server
    模块将使用Keycloak的JWKS端点来verify传入的Jwt令牌;

  • 而在OAuth2 Client部分中,需提供Keycloak issuer realm address,
    另还需提供Keycloak client credential,
    choose authorization grant type and scope;

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://192.168.0.123:18080/realms/elf/protocol/openid-connect/certs
      client:
        provider:
          keycloak:
            issuer-uri: http://192.168.0.123:18080/realms/elf
        registration:
          spring-with-test-scope:
            provider: keycloak
            client-id: spring-with-test-scope
            client-secret: riJEmHGVr0kvBSgTFRzZfJY5dhpBwc1A
            authorization-grant-type: authorization_code
            scope: openid
  • Gateway本身公开expose一个http端点,
    使用@RegisteredOAuth2AuthorizedClient返回当前Jwt Acccess Token;

@GetMapping(value = "/token")
public Mono<String> getHome(
	@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient client) {
    return Mono.just(client.getAccessToken().getTokenValue());
}
  • 另需在application.yaml配置Gateway Routing,Gateway可使用
    TokenRelay GatewayFilter将OAuth2访问令牌转发到其代理的服务的下游;

  • 可将其设置为所有传入请求的默认过滤器filter,网关将转发到caller和callee,
    此处没使用服务发现,默认callee端口8040,caller端口8020,网关端口8060;

server:
  port: 8060
spring:
  application:
    name: gateway
  cloud:
    gateway:
      default-filters:
        - TokenRelay=
      routes:
        - id: callee-service
          uri: http://localhost:8040
          predicates:
            - Path=/callee/**
        - id: caller-service
          uri: http://localhost:8020
          predicates:
            - Path=/caller/**

3. Verify Token

  • Microservice OAuth2 Resource Server Verify Token

  • callee和caller的依赖相似,因caller使用WebClient,
    故需依赖spring-webflux和spring-boot-starter-web;

  • 另需spring-security-oauth2-resource-server,
    spring-security-oauth2-jose(jwt令牌解码);

  • oauth2ResourceServer()用Keycloak JWKS端点验证访问令牌;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
            .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}
  • OAuth2 Resource Server application.yaml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://192.168.0.123:18080/realms/elf/protocol/openid-connect/certs
  • Rest Controller,ping()只能由具有cs-elf scope的客户端访问,
    返回从Authentication获取的assigned scope列表;

@RestController
@RequestMapping("/callee")
public class CalleeController {

	@GetMapping("/ping")
    @PreAuthorize("hasAuthority('SCOPE_cs-elf')")
    public String ping() {
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();
        return "Scope List:" + authentication.getAuthorities();
    }

}
  • 此方法由外部客户端通过Api网关直接调用,但caller也会在自己的ping端点实现中调用该端点;

@RestController
@RequestMapping("/caller")
public class CallerController {

    private WebClient webClient;

    public CallerController(WebClient webClient) {
        this.webClient = webClient;
    }

    @GetMapping("/ping")
    @PreAuthorize("hasAuthority('SCOPE_cs-elf')")
    public String ping() {
        SecurityContext context = SecurityContextHolder.getContext();
        var authentication = context.getAuthentication();
        System.out.println("authenticationName:" + authentication.getName());

        String scopes = webClient
            .get()
            .uri("http://localhost:8040/callee/ping")
            .retrieve()
            .bodyToMono(String.class)
            .block();
        return "Callee Scope List: " + scopes;
    }
}
  • 若WebClient调用第二个微服务公开的端点,它还必须传播(propagate)bearer token,
    可通过ServletBearerExchangeFilterFunction轻松实现,如下所示,

  • 另Security将查找当前的Authentication并提取AbstractOAuth2Token
    credential,但它将自动在Authorization header中propagate该令牌;

@SpringBootApplication
public class CallerEntry {

	public static void main(String[] sa) {
		Class<?> cls = MethodHandles.lookup().lookupClass();
		SpringApplication.run(cls, sa);
	}

    @Bean
    WebClient webClient() {
        return WebClient.builder()
    		.filter(new ServletBearerExchangeFilterFunction())
            .build();
    }

}

4. Testing

  • 依次启动gateway:8060,caller:8020,callee:8040,应用启动顺序无关

  • 现通过网关调用应用端点:http://localhost:8060/caller/ping,
    网关会将我们重定向到Keycloak登录页,使用elf-user - elf-cipher登录

  • 返回:Callee Scope List: Scope List:
    [SCOPE_openid, SCOPE_email, SCOPE_profile, SCOPE_cs-elf]

  • callee:Secured GET /callee/ping

  • caller:Secured GET /caller/ping
    authenticationName:70a8b381-b1d3-44ab-be9f-be33d796800d
    HTTP GET http://localhost:8040/callee/ping, headers={masked}
    Response 200 OK, headers={masked}

  • 可使用网关暴露的expose端点(GET /token)获取访问令牌;
    http://localhost:8060/token
    eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJIX2taUW1MeFl0M3dxNU1rdG9NQUc3M1p3UTBSMVp3SHhPbFRTNEhCZmRjIn0.eyJleHAiOjE3MDkzNDA1NDEsImlhdCI6MTcwOTM0MDI0MSwiYXV0aF90aW1lIjoxNzA5MzM3MjI0LCJqdGkiOiIyNzdiNWM1MS00ZTEyLTRlYWItYTJhMi03YjkyNDY4OTYwZTEiLCJpc3MiOiJodHRwOi8vMTkyLjE2OC4wLjEyMzoxODA4MC9yZWFsbXMvZWxmIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjcwYThiMzgxLWIxZDMtNDRhYi1iZTlmLWJlMzNkNzk2ODAwZCIsInR5cCI6IkJlYXJlciIsImF6cCI6InNwcmluZy13aXRoLXRlc3Qtc2NvcGUiLCJub25jZSI6InBJRllzSWRoejFSZndxMzl3ZTNiVmU2c3dXbHZoNFFtR1lRZWduWEZXaGMiLCJzZXNzaW9uX3N0YXRlIjoiMzdlNDgwMTYtYjgzZS00MWVjLWJiZTQtYWE0NmYyZjFiNjIxIiwiYWNyIjoiMCIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsImRlZmF1bHQtcm9sZXMtZWxmIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBlbWFpbCBwcm9maWxlIGNzLWVsZiIsInNpZCI6IjM3ZTQ4MDE2LWI4M2UtNDFlYy1iYmU0LWFhNDZmMmYxYjYyMSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IuWMheWtkCDlnJ8iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJlbGYtdXNlciIsImdpdmVuX25hbWUiOiLljIXlrZAiLCJmYW1pbHlfbmFtZSI6IuWcnyIsImVtYWlsIjoiMjAzMjA0NzUxMUBxcS5jb20ifQ.gsFcuTb0WH6FGFPYc5FqO4yxEnRzDb-gtn8d1R2VizJcNFmZbLbKyUtS_5TMAJl_8M3uClHrUv0-Vn6C7EJ3ED9iFCbhBTEIOxrQMAWUf4gsqZVm3K-gxeaZZNkmA8QIlqujFd4JLd8bgqXVHjUd1PE83RN5lEm5-n7d99DiUPXmp7HX1vsoAmtnbtpI4G1M3AAJWC0db_2zuG_wgX0B78rNAhg2YJWT-2xjy3_h4bQ-xIOZv3x0TA1ubmmMl9A9zy1Tuul3AbyYvSNM318F5lrUuqHzUDNIG-acqkF8n8eOVWk2CigonUo3TmTrv2Ff8iRt2NJVbJDHAyHxP6bARA

  • 现在可使用curl,postman或程序等工具来执行之前类似的调用,
    将bearer token添加到Authorization header;

  • curl http://localhost:8060/callee/ping \
    -H "Authorization: Bearer eyJh……P6bARA" -v

*   Trying [::1]:8060...
* Connected to localhost (::1) port 8060
> GET /callee/ping HTTP/1.1
> Host: localhost:8060
> User-Agent: curl/8.4.0
> Accept: */*
> Authorization: Bearer eyJh……P6bARA
>
< HTTP/1.1 200 OK
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 69
< Date: Sat, 02 Mar 2024 00:44:51 GMT
< Referrer-Policy: no-referrer
<
Scope List:[SCOPE_openid, SCOPE_email, SCOPE_profile, SCOPE_cs-elf]
  • 现在我们可使用JUnit and Testcontainers自动化测试

5. Keycloak Testcontainer

  • 现在路由到Gateway模块的src/test/java

package io.os.security.keycloak.gateway;

@RestController
@RequestMapping("/callee")
public class CalleeController {

    @PreAuthorize("hasAuthority('SCOPE_cs-elf')")
    @GetMapping("/ping")
    public String ping() {
        return "Hello!";
    }
}
  • 运行测试所需的配置,在8060端口启动gateway,并使用WebTestClient实例调用,
    为启动自动配置Keycloak,将导入realm-export.json中的elf realm配置;

  • 因Testcontainers使用随机端口,需覆盖一些Spring OAuth2配置,
    还覆盖Gateway route,将流量转发到callee控制器的测试实现,而非真正的服务;

  • Testcontainers在Win平台支持度不够,故不做测试

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class GatewayEntryTest {

    static String accessToken;

    @Autowired
    WebTestClient webTestClient;

    @Container
    static KeycloakContainer keycloak = new KeycloakContainer()
        .withRealmImportFile("realm-export.json")
        .withExposedPorts(18080);

    @DynamicPropertySource
    static void registerResourceServerIssuerProperty(DynamicPropertyRegistry registry) {
        registry.add("spring.security.oauth2.client.provider.keycloak.issuer-uri",
                () -> keycloak.getAuthServerUrl() + "/realms/elf");
        registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
                () -> keycloak.getAuthServerUrl() + "/realms/elf/protocol/openid-connect/certs");
        registry.add("spring.cloud.gateway.routes[0].uri",
                () -> "http://localhost:8060");
        registry.add("spring.cloud.gateway.routes[0].id", () -> "callee-service");
        registry.add("spring.cloud.gateway.routes[0].predicates[0]", () -> "Path=/callee/**");
    }
}
  • 首个测试:不含任何令牌,故应将其重定向到Keycloak授权机制。

@Test
@Order(1)
void shouldBeRedirectedToLoginPage() {
    webTestClient.get().uri("/callee/ping")
            .exchange()
            .expectStatus().is3xxRedirection();
}
  • 第二个测试:用WebClient实例与Keycloak容器交互,Kecloak对
    elf-user用户和spring-with-test-scope客户端进行身份认证,
    Kecloak将生成并返回访问令牌

@Test
@Order(2)
void shouldObtainAccessToken() throws URISyntaxException {
    URI authorizationURI = new URIBuilder(keycloak.getAuthServerUrl()
    	+ "/realms/elf/protocol/openid-connect/token").build();
    WebClient webclient = WebClient.builder().build();
    MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
    formData.put("grant_type", Collections.singletonList("password"));
    formData.put("client_id", Collections.singletonList("spring-with-test-scope"));
    formData.put("username", Collections.singletonList("elf-user"));
    formData.put("password", Collections.singletonList("elf-cipher"));

    String result = webclient.post()
            .uri(authorizationURI)
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(BodyInserters.fromFormData(formData))
            .retrieve()
            .bodyToMono(String.class)
            .block();
    JacksonJsonParser jsonParser = new JacksonJsonParser();
    accessToken = jsonParser.parseMap(result)
            .get("access_token")
            .toString();
    Assertions.assertNotNull(accessToken);
}
  • 最后运行类似第一步的测试,但在Authorization Header
    中提供访问令牌,预期响应是200 OK和"Hello!"的有效负载payload;

@Test
@Order(3)
void shouldReturnToken() {
    webTestClient.get().uri("/callee/ping")
        .header("Authorization", "Bearer " + accessToken)
        .exchange()
        .expectStatus().is2xxSuccessful()
        .expectBody(String.class).isEqualTo("Hello!");
}