在本文中,我们将学习如何使用 "客户端凭证模式"(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/