本文将带你了解如何使用 Keycloak 为 Spring Cloud Gateway 和 Spring Boot 微服务启用 OAuth2。本文是 上一篇文章 的扩展,并分析了 Spring Security 项目中提供的一些最新功能。
我们的架构由两个 Spring Boot 微服务组成、一个基于 Spring Cloud Gateway 的 API 网关和一个 Keycloak 授权服务器。Spring Cloud Gateway 在此充当 OAuth2 客户端和 OAuth2 资源服务器。对于任何传入请求,它都会在将流量转发到下游服务之前验证 Access Token。对于任何未经验证的请求,它都会使用 Keycloak 初始化一个授权码授权的流程。我们的方案需要包括内部微服务之间的通信。它们都隐藏在 API 网关后面。caller
应用调用 callme
应用暴露的端点。通信中使用的 HTTP 客户端必须使用网关发送的 Access Token。
源码 {#源码}
本文中的代码托管在 Github,你可以克隆这个 Repository,进入到 oauth
目录,其中包含了两个 Spring Boot 微服务:callme
和 caller
。当然,还有构建在 Spring Cloud Gateway 之上的 gateway
应用。之后,只需按照说明操作即可。
运行并配置 Keycloak {#运行并配置-keycloak}
我们以 Docker 容器的形式运行 Keycloak。默认情况下,Keycloak 在 8080
端口上公开 API 和 Web 控制台。还需要通过环境变量设置管理员用户名和密码。下面是运行 Keycloak 容器的命令:
$ docker run -d --name keycloak -p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:23.0.7 start-dev
容器启动后,访问 http://localhost:8080/admin
地址下的管理控制台。创建一个新 realm。该 realm 的名称是 demo
。与其手动创建所需内容,不如导入包含整个 realm 配置的 JSON 资源文件。你可以在 GitHub
仓库中找到这样的资源文件:oauth/gateway/src/test/resources/realm-export.json
。不过,接下来,我将使用 Keycloak 面板逐步创建对象。如果你从 JSON 资源文件导入了配置,可以直接跳到下一章节。
然后,在 demo
realm 中添加一个 OpenID Connect 客户端。客户端的名称是 spring-with-test-scope
。启用客户端身份认证,并在 "Valid redirect URIs" 字段中输入正确的地址(为测试目的,可以使用通配符)。
我们需要保存客户端的名称及其 secret。这两项设置必须在应用端设置。
然后,创建一个名称为 TEST
的新 Client Scope。
接着,将 TEST
添加到 spring-with-test-scope
Client Scope 中。
还需要创建一个用户来验证 Keycloak。用户名为 spring
。为了设置密码,需要切换到 "Credentials" 选项卡,设置密码为 Spring_123
。
完成配置后,可以将其导出为 JSON 文件(与创建新 realm 时使用的文件相同)。这样的文件对以后使用 Testcontainers
构建自动化测试很有用。
但是,Keycloak 不会将真实用户导出到文件中。因此,需要在导出文件的 users
部分添加以下 JSON。
{
"username": "spring",
"email": "piotr.minkowski@gmail.com",
"firstName": "Piotr",
"lastName": "Minkowski",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "Spring_123"
}
],
"realmRoles": [
"default-roles-demo",
"USER"
]
}
创建 Spring Cloud Gateway 网关应用 {#创建-spring-cloud-gateway-网关应用}
如前所述,gateway 应用充当 OAuth2 客户端和 OAuth2 资源服务器,包含的依赖如下:
<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>
<!-- 自动解码 JWT Token -->
<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-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 使用 Testcontainers 在 JUnit 测试期间运行 Keycloak 容器 -->
<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>
从 Spring Security 配置开始。首先,需要用 @EnableWebFluxSecurity
对 Configuration
Bean 进行注解。这是因为 Spring Cloud Gateway 使用的是 Spring Web 模块的响应式版本。oauth2Login()
方法负责将未经验证的请求重定向到 Keycloak 登录页面。oauth2ResourceServer()
方法会在将流量转发到下游服务之前验 Access Token。
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.authorizeExchange(auth -> auth.anyExchange().authenticated())
.oauth2Login(withDefaults())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
http.csrf(ServerHttpSecurity.CsrfSpec::disable);
return http.build();
}
}
除此以外,还需要设置几个 spring.security.oauth2
前缀开头的配置。Spring OAuth2 资源服务器模块使用 Keycloak JWK 端点来验证传入的 JWT Token。在 Spring OAuth2 客户端部分,我们需要提供 Keycloak issuer realm 的地址。
当然,还需要提供 Keycloak 客户端凭证,选择授权类型和 scope。
spring.security.oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs
client:
provider:
keycloak:
issuer-uri: http://localhost:8080/realms/demo
registration:
spring-with-test-scope:
provider: keycloak
client-id: spring-with-test-scope
client-secret: IWLSnakHG8aNTWNaWuSj0a11UY4lzxd9
authorization-grant-type: authorization_code
scope: openid
网关本身只公开一个 HTTP 端点。它使用 OAuth2AuthorizedClient
Bean 返回当前的 JWT Access Token。
@SpringBootApplication
@RestController
public class GatewayApplication {
private static final Logger LOGGER = LoggerFactory
.getLogger(GatewayApplication.class);
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@GetMapping(value = "/token")
public Mono<String> getHome(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) {
return Mono.just(authorizedClient.getAccessToken().getTokenValue());
}
}
如上就是该部分关于 OAuth2 配置的全部内容。还需要在 Spring application.yml
文件中配置网关的路由。Spring Cloud Gateway 可使用 TokenRelay
GatewayFilter
将 OAuth2 Access Token 转发到其代理的下游服务。可以将其设置为所有传入请求的默认 Filter。网关会将流量转发给 callme
和 caller
服务。本例中,没有使用服务发现功能。默认情况下,callme
应用监听 8040
端口,而 caller
应用监听 8020
端口。
spring:
application:
name: gateway
cloud:
gateway:
default-filters:
- TokenRelay=
routes:
- id: callme-service
uri: http://localhost:8040
predicates:
- Path=/callme/**
- id: caller-service
uri: http://localhost:8020
predicates:
- Path=/caller/**
使用 OAuth2 资源服务器验证微服务中的 Token {#使用-oauth2-资源服务器验证微服务中的-token}
callme
和 caller
的依赖非常相似。它们都使用 Spring Web 模块公开 HTTP 端点。由于 caller
应用使用 WebClient
Bean,因此还需要加入 Spring WebFlux 依赖。同样,还需要加入 Spring OAuth2 Resource Server 模块和 spring-security-oauth2-jose
依赖项,以解码 JWT Token。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</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</groupId>
<artifactId>spring-webflux</artifactId>
</dependency>
下面是应用 Security 的配置。这次需要使用 @EnableWebSecurity
注解,因为我们有一个 Spring Web 模块。oauth2ResourceServer()
方法通过 Keyclock JWK 端点验证 Access Token。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
下面是 Spring application.yml
文件中 Keycloak 的 OAuth2 资源服务器配置:
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs
来看看 REST Controller 类的实现。它只有一个 ping
方法。该方法只能由具有 TEST
scope 的客户端访问。它会返回一个从 Authentication
Bean 获取的已分配 scope 列表。
@RestController
@RequestMapping("/callme")
public class CallmeController {
@PreAuthorize("hasAuthority('SCOPE_TEST')")
@GetMapping("/ping")
public String ping() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
return "Scopes: " + authentication.getAuthorities();
}
}
外部客户端可以通过 API 网关直接调用该方法。不过,caller
应用也可以在自己的 ping
端点实现中调用该端点。
@RestController
@RequestMapping("/caller")
public class CallerController {
private WebClient webClient;
public CallerController(WebClient webClient) {
this.webClient = webClient;
}
@PreAuthorize("hasAuthority('SCOPE_TEST')")
@GetMapping("/ping")
public String ping() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String scopes = webClient
.get()
.uri("http://localhost:8040/callme/ping")
.retrieve()
.bodyToMono(String.class)
.block();
return "Callme scopes: " + scopes;
}
}
如果 WebClient
调用第二个微服务暴露的端点,它还必须传播 Bearer Token。我们可以使用 ServletBearerExchangeFilterFunction
轻松实现这一功能,如下所示。有了这个 Function
,Spring Security 将查找当前的 Authentication
并提取 AbstractOAuth2Token
凭证。然后,它会自动在 Authorization
Header 中传播该 Token。
@SpringBootApplication
public class CallerApplication {
public static void main(String[] args) {
SpringApplication.run(CallerApplication.class, args);
}
@Bean
public WebClient webClient() {
return WebClient.builder()
.filter(new ServletBearerExchangeFilterFunction())
.build();
}
}
运行应并进行测试 {#运行应并进行测试}
使用相同的 Maven 命令运行所有三个 Spring Boot 应用。从 gateway
应用开始:
$ cd oauth/gateway
$ mvn spring-boot:run
运行第一个应用后,可以通过日志检查一切是否正常。下面是 gateway
应用生成的日志。如你所见,它监听 8060
端口。
之后,运行 caller
应用。
$ cd oauth/caller
$ mvn spring-boot:run
它监听 8020
端口。
当然,应用的启动顺序并不重要。最后,来运行 callme
应用。
$ cd oauth/callme
$ mvn spring-boot:run
现在,通过网关调用 caller
应用端点。在这种情况下,我们需要访问 http://localhost:8060/caller/ping
URL。网关应用会将我们重定向到 Keycloak 登录页面。我们需要使用 spring
和 Spring_123
登录。
登录后,一切都会自动发生。Spring Cloud Gateway 从 Keycloak 获取 Access Token,然后将其发送到下游服务。caller
应用收到请求后,会使用 WebClient
实例调用 callme
应用。结果如下
我们可以使用 gateway
应用提供的端点 GET /token
轻松获取 Access Token。
现在,可以使用 curl
命令执行与之前类似的调用。我们需要复制 Token 字符串,并将其作为 Bearer Token 放入 Authorization
Header 中。
$ curl http://localhost:8060/callme/ping \
-H "Authorization: Bearer <TOKEN>" -v
结果如下:
接下来,我们用 JUnit 和 Testcontainers 以完全自动化的方式来进行类似的操作。
Spring OAuth2 和 Keycloak Testcontainers {#spring-oauth2-和-keycloak-testcontainers}
再次切换到 gateway
模块。执行运行 API gateway 应用的测试,将其连接到 Keycloak 实例,并将授权流量路由到目标端点。下面是 src/test/java
目录中模拟 callme
应用端点的 @RestController
:
pl.piomin.samples.security.gateway.CallmeController
@RestController
@RequestMapping("/callme")
public class CallmeController {
@PreAuthorize("hasAuthority('SCOPE_TEST')")
@GetMapping("/ping")
public String ping() {
return "Hello!";
}
}
下面是运行测试所需的配置。我们在 8060
端口上启动 gateway 应用,并使用 WebTestClient
实例来调用它。为了自动配置 Keycloak,导入存储在 realm-export.json
中的 demo
realm 配置。由于 Testcontainers 使用随机端口号,我们需要覆盖一些 Spring OAuth2 配置设置。我们还将覆盖 Spring Cloud Gateway 路由,将流量转发到 callme
应用 Controller 的测试实现,而不是真实服务。
现在,就可以开始测试了。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class GatewayApplicationTests {
static String accessToken;
@Autowired
WebTestClient webTestClient;
@Container
static KeycloakContainer keycloak = new KeycloakContainer()
.withRealmImportFile("realm-export.json")
.withExposedPorts(8080);
@DynamicPropertySource
static void registerResourceServerIssuerProperty(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.client.provider.keycloak.issuer-uri",
() -> keycloak.getAuthServerUrl() + "/realms/demo");
registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
() -> keycloak.getAuthServerUrl() + "/realms/demo/protocol/openid-connect/certs");
registry.add("spring.cloud.gateway.routes[0].uri",
() -> "http://localhost:8060");
registry.add("spring.cloud.gateway.routes[0].id", () -> "callme-service");
registry.add("spring.cloud.gateway.routes[0].predicates[0]", () -> "Path=/callme/**");
}
// 测试 。。。
}
这是第一个测试。由于它不包含任何 Token,因此会被应重定向到 Keycloak 的授权机制中。
@Test
@Order(1)
void shouldBeRedirectedToLoginPage() {
webTestClient.get().uri("/callme/ping")
.exchange()
.expectStatus().is3xxRedirection();
}
在第二个测试中,使用 WebClient
实例与 Keycloak 容器交互。需要使用 spring
用户和 spring-with-test-scope
客户端对 Kecloak 进行身份认证。Keycloak 将生成并返回 Access Token。为下一次测试保存其值。
@Test
@Order(2)
void shouldObtainAccessToken() throws URISyntaxException {
URI authorizationURI = new URIBuilder(keycloak.getAuthServerUrl() + "/realms/demo/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("spring"));
formData.put("password", Collections.singletonList("Spring_123"));
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();
assertNotNull(accessToken);
}
最后,运行与第一步类似的测试。不过,这次我们在 Authorization
Header 中提供了一个 Access Token。预期响应是 200 OK
状态码和 "Hello!"
响应体,由 CallmeController
Bean 的测试实例返回。
@Test
@Order(3)
void shouldReturnToken() {
webTestClient.get().uri("/callme/ping")
.header("Authorization", "Bearer " + accessToken)
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(String.class).isEqualTo("Hello!");
}
在本地运行所有测试,结果如下。
如你所见,全部通过。
最后 {#最后}
相比于 上一篇文章,本文更关注自动化和服务间通信,而不仅仅是 Spring Cloud Gateway 中的 OAuth2 支持。我们考虑了网关同时充当 OAuth2 客户端和资源服务器的情况。最后,使用 Testcontainers 验证了 Spring Cloud Gateway 和 Keycloak 的应用场景。
Ref:https://piotrminkowski.com/2024/03/01/microservices-with-spring-cloud-gateway-oauth2-and-keycloak/