在本文中,我们将学习如何使用 "客户端凭证模式"(Client Credentials Flow)实现服务间的通信。我们将创建 archival-service
,在其中通过定时任务使用 "客户端凭证模式" 来调用 messages-service
API 以归档消息。
我们还会在 archival-service
中实现 POST /api/messages/archive
API 端点,只有拥有 ROLE_ADMIN
角色的用户才能调用。
有鉴于此,archival-service
既是资源服务器(Resource Server),也是客户端。
- 资源服务器 - 暴露
POST /api/messages/archive
API 端点,该端点将由messages-webapp
调用。 - 客户端 - 调用
messages-service
API 来归档消息。
你可以从 Github 仓库 获取到当前项目的完整源码。
在 Keycloak 中启用客户端凭证模式,创建 archival-service
客户端 {#在-keycloak-中启用客户端凭证模式创建-archival-service-客户端}
创建一个名为 archival-service
的新客户端:
- General Settings :
- Client type:OpenID Connect
- Client ID:archival-service
- Capability config :
- Client authentication:On
- Authorization:Off
- Authentication flow :选中 Service accounts roles,取消选中其余复选框
- Login settings :
- Root URL :
http://localhost:8282
- Home URL :
http://localhost:8282
- Root URL :
使用上述配置创建客户端后,你将进入新创建的客户端 "Settings" 页面。
- 转到 "Service account roles" 选项卡,并分配
ROLE_ADMIN
角色。 - 点击 "Credentials" 选项卡,复制 Client secret 值。
在本例中,Client secret 是 bL1a2V2kouKh4sBMX0UrSmc0d3qubD1a。
创建 archival-service {#创建-archival-service}
点击此 链接 可使用 Spring Initializr 生成 archival-service
项目。我们选择了 Web
、Validation
、Security
、OAuth2 Client
和 OAuth2 Resource Server
Starter。应用生成后,在 IDE 中打开它。
编辑 application.properties
,配置以下属性:
spring.application.name=archival-service
server.port=8282
OAUTH_SERVER=http://localhost:9191/realms/sivalabs
# Resource Server configuration
spring.security.oauth2.resourceserver.jwt.issuer-uri=${OAUTH_SERVER}
# Client configuration
spring.security.oauth2.client.registration.archival-service.provider=archival-service
spring.security.oauth2.client.registration.archival-service.client-id=archival-service
spring.security.oauth2.client.registration.archival-service.client-secret=bL1a2V2kouKh4sBMX0UrSmc0d3qubD1a
spring.security.oauth2.client.registration.archival-service.authorization-grant-type=client_credentials
spring.security.oauth2.client.registration.archival-service.scope=openid, profile
spring.security.oauth2.client.registration.archival-service.redirect-uri={baseUrl}/login/oauth2/code/archival-service
spring.security.oauth2.client.provider.archival-service.issuer-uri=${OAUTH_SERVER}
如果你阅读过本系列的前几篇文章,应该对这种配置不会陌生。
- 将资源服务器属性
spring.security.oauth2.resourceserver.jwt.issuer-uri
配置为指向 Keycloak 服务器。 - 接着,配置了客户端属性(
spring.security.oauth2.client.registration.archival-service
、spring.security.oauth2.client.provider.archival-service.issuer-uri
),使其指向 Keycloak 服务器。
客户端凭证模式获取 Access Token {#客户端凭证模式获取-access-token}
配置就绪后,让我们看看如何通过 "客户端凭证模式" 来获取 access_token
。
创建 SecurityConfig
类,内容如下:
package com.sivalabs.archival.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
@Configuration
public class SecurityConfig {
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
return new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientService);
}
}
使用注入自动配置的 ClientRegistrationRepository
和 OAuth2AuthorizedClientService
Bean,创建、注册一个 OAuth2AuthorizedClientManager
类型的 Bean。
我们将使用 OAuth2AuthorizedClientManager
获取 access_token
。
创建 SecurityHelper
类,内容如下:
package com.sivalabs.archival.domain;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.stereotype.Service;
@Service
public class SecurityHelper {
private final OAuth2AuthorizedClientManager authorizedClientManager;
public SecurityHelper(OAuth2AuthorizedClientManager authorizedClientManager) {
this.authorizedClientManager = authorizedClientManager;
}
public OAuth2AccessToken getOAuth2AccessToken() {
String clientRegistrationId = "archival-service";
OAuth2AuthorizeRequest authorizeRequest =
OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
// principal 值非必须,但是不设置的话会抛出异常
.principal("dummy")
.build();
OAuth2AuthorizedClient authorizedClient =
this.authorizedClientManager.authorize(authorizeRequest);
return authorizedClient.getAccessToken();
}
}
我们使 client registration id archival-service
来创建 OAuth2AuthorizeRequest
。然后,我们调用 OAuth2AuthorizedClientManager
上的 authorize()
方法获取 OAuth2AuthorizedClient
,由其在内部执行身份认证。最后,我们从 OAuth2AuthorizedClient
返回 OAuth2AccessToken
。
现在,我们可以使用此 token 调用 messages-service
API。
创建 MessageServiceClient {#创建-messageserviceclient}
创建 MessageServiceClient
类,如下:
package com.sivalabs.archival.domain;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class MessageServiceClient {
private static final Logger log = LoggerFactory.getLogger(MessageServiceClient.class);
private static final String MESSAGES_SVC_URL = "http://localhost:8181";
private final SecurityHelper securityHelper;
private final RestTemplate restTemplate;
public MessageServiceClient(SecurityHelper securityHelper, RestTemplate restTemplate) {
this.securityHelper = securityHelper;
this.restTemplate = restTemplate;
}
public void archiveMessages() {
try {
String url = MESSAGES_SVC_URL + "/api/messages/archive";
OAuth2AccessToken oAuth2AccessToken = securityHelper.getOAuth2AccessToken();
String accessToken = oAuth2AccessToken.getTokenValue();
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
HttpEntity<?> httpEntity = new HttpEntity<>(headers);
ResponseEntity<Void> response = restTemplate.exchange(
url, HttpMethod.POST, httpEntity,
new ParameterizedTypeReference<>() {});
log.info("Archive messages response code: {}", response.getStatusCode());
} catch (Exception e) {
log.error("Error while invoking Archive messages API", e);
}
}
}
我们从 SecurityHelper
获取 accessToken
,并将其添加到 Authorization
头中。然后,使用 RestTemplate
调用 POST /api/messages/archive
API 端点。
通过定时任务来归档消息 {#通过定时任务来归档消息}
Spring Boot 提供了 @Scheduled
注解来实现定时任务。首先,需要在 ArchivalServiceApplication
类上添加 @EnableScheduling
注解来启用任务调度。
然后,在方法上添加 @Scheduled
注解,通过定时任务来归档消息,如下所示:
package com.sivalabs.archival.jobs;
import com.sivalabs.archival.domain.MessageServiceClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Instant;
@Service
public class MessageArchivalJob {
private static final Logger log = LoggerFactory.getLogger(MessageArchivalJob.class);
private final MessageServiceClient messageServiceClient;
public MessageArchivalJob(MessageServiceClient messageServiceClient) {
this.messageServiceClient = messageServiceClient;
}
@Scheduled(fixedDelay = 30000)
public void run() {
log.info("Running MessageArchivalJob at {}", Instant.now());
messageServiceClient.archiveMessages();
}
}
我们将定时任务配置为每 30 秒运行一次。
现在,如果启动 archival-service
和 messages-service
,就会看到以下日志:
Running MessageArchivalJob at 2023-09-29T14:48:11.606017Z
Archive messages response code: 200 OK
在 messages-service
日志中,你应该可以看到以下日志:
Archiving all messages
本文的重点是通过 "客户端凭证模式" 获取 AccessToken
并调用 messages-service
API。所以,并没有真正实现归档消息的逻辑。
在客户端凭证模式中使用
ROLE_ADMIN
是否合适?在本文中,我们为
archival-service
客户端分配了ROLE_ADMIN
角色,这样它就能调用messages-service
的POST /api/messages/archive
API 端点,该端点只允许具有ROLE_ADMIN
角色的用户访问。虽然从技术上讲这是可行的,但将
ROLE_ADMIN
用于 "客户端凭证模式" 并不是一个好主意。相反,我们应该创建一个新角色(如ROLE_ADMIN_JOB
),将其分配给archival-service
客户端,并将 messages-servicePOST /api/messages/archive
API 端点配置为具有ROLE_ADMIN
或ROLE_ADMIN_JOB
的用户可以访问。
在 archival-service 中实现归档消息的 API 端点 {#在-archival-service-中实现归档消息的-api-端点}
最后要实现的是 archival-service
中的 POST /api/messages/archive
API 端点。
创建 MessageArchivalController
类,如下:
package com.sivalabs.archival.api;
import com.sivalabs.archival.domain.MessageServiceClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
class MessageArchivalController {
private final MessageServiceClient messageServiceClient;
MessageArchivalController(MessageServiceClient messageServiceClient) {
this.messageServiceClient = messageServiceClient;
}
@PostMapping("/api/messages/archive")
Map<String, String> archiveMessages() {
messageServiceClient.archiveMessages();
return Map.of("status", "success");
}
}
这个 Controller 没有什么特别之处,我们只是调用 messageServiceClient.archiveMessages()
方法。但是,我们需要控制对 API 端点的访问,只有 ROLE_ADMIN
角色的用户才能访问。
更新 SecurityConfig
类,如下:
package com.sivalabs.archival.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(c ->
c
.requestMatchers(HttpMethod.POST, "/api/messages/archive").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(CorsConfigurer::disable)
.csrf(CsrfConfigurer::disable)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter()))
);
return http.build();
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
return new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientService);
}
}
如果你阅读过本系列的前几篇文章,应该对这种配置不会感到陌生。它与 messages-service
配置类似,只是我们将 /api/messages/archive
API 端点配置为只有 ROLE_ADMIN
角色的用户才能访问。我们还使用 KeycloakJwtAuthenticationConverter
将 realm_access.roles
转换为 GrantedAuthority
。你可以将相同的类从 messages-service
复制到 archival-service
。
从 messages-webapp
调用归档消息的 API 端点 {#从-messages-webapp-调用归档消息的-api-端点}
既然我们已经在 archival-service
中实现了 POST /api/messages/archive
API 端点,我们就可以从 messages-webapp
中调用该 API 端点了。
在 messages-webapp
的 MessageServiceClient
中添加 archiveMessages()
方法,如下所示:
@Service
public class MessageServiceClient {
//...
public void archiveMessages() {
try {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + securityHelper.getAccessToken());
HttpEntity<?> httpEntity = new HttpEntity<>(headers);
ResponseEntity<Message> response = restTemplate.exchange(
"http://localhost:8282/api/messages/archive", HttpMethod.POST, httpEntity,
new ParameterizedTypeReference<>() {
});
log.info("Archive messages response code: {}", response.getStatusCode());
} catch (Exception e) {
log.error("Error while invoking Archive messages", e);
}
}
}
在 messages-webapp
的 HomeController
中添加 archiveMessages()
Handler 方法,如下:
@Controller
public class HomeController {
//...
@PostMapping("/messages/archive")
String archiveMessages() {
messageServiceClient.archiveMessages();
return "redirect:/";
}
}
最后,在 home.html
中添加 Archive Messages 按钮,如下:
<div sec:authorize="hasRole('ADMIN')">
<form method="post" action="/messages/archive">
<input type="submit" value="Archive Messages">
</form>
</div>
现在,如果你运行所有服务,并以分配了 ROLE_ADMIN
的用户登录 messages-webapp
,你应该会看到 Archive Messages 按钮。点击该按钮后,它会调用 archival-service
的 POST /api/messages/archive
API 端点,而 archival-service
内部则会调用 messages-service
的 POST /api/messages/archive
API 端点。
总结 {#总结}
在本系列 Spring Security OAuth2 教程中,我们已经学到了以下内容:
- 各种 OAuth2 / OpenID Connect Flow。
- 如何使用 Keycloak 和 Spring Boot 实现 OAuth2 / OpenID Connect Flow。
希望本系列教程能帮助你了解 OAuth 2.0 的工作原理以及如何使用 Spring Boot 和 Keycloak 实现来它。
参考:https://www.sivalabs.in/spring-security-oauth2-tutorial-service-to-service-communication-using-client-credentials-flow/