51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

Spring Security OAuth 2 教程 - 10:使用“客户端凭证模式”进行服务间的通信

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

使用上述配置创建客户端后,你将进入新创建的客户端 "Settings" 页面。

  • 转到 "Service account roles" 选项卡,并分配 ROLE_ADMIN 角色。
  • 点击 "Credentials" 选项卡,复制 Client secret 值。

在本例中,Client secretbL1a2V2kouKh4sBMX0UrSmc0d3qubD1a

创建 archival-service {#创建-archival-service}

点击此 链接 可使用 Spring Initializr 生成 archival-service 项目。我们选择了 WebValidationSecurityOAuth2 ClientOAuth2 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-servicespring.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);

} }

使用注入自动配置的 ClientRegistrationRepositoryOAuth2AuthorizedClientService 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-servicemessages-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-servicePOST /api/messages/archive API 端点,该端点只允许具有 ROLE_ADMIN 角色的用户访问。

虽然从技术上讲这是可行的,但将 ROLE_ADMIN 用于 "客户端凭证模式" 并不是一个好主意。相反,我们应该创建一个新角色(如 ROLE_ADMIN_JOB),将其分配给 archival-service 客户端,并将 messages-service POST /api/messages/archive API 端点配置为具有 ROLE_ADMINROLE_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 角色的用户才能访问。我们还使用 KeycloakJwtAuthenticationConverterrealm_access.roles 转换为 GrantedAuthority。你可以将相同的类从 messages-service 复制到 archival-service

messages-webapp 调用归档消息的 API 端点 {#从-messages-webapp-调用归档消息的-api-端点}

既然我们已经在 archival-service 中实现了 POST /api/messages/archive API 端点,我们就可以从 messages-webapp 中调用该 API 端点了。

messages-webappMessageServiceClient 中添加 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-webappHomeController 中添加 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-servicePOST /api/messages/archive API 端点,而 archival-service 内部则会调用 messages-servicePOST /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/

赞(7)
未经允许不得转载:工具盒子 » Spring Security OAuth 2 教程 - 10:使用“客户端凭证模式”进行服务间的通信