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 客户端

创建一个名为 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

点击此 链接 可使用 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

创建 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 类,如下:

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 中的 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 端点

既然我们已经在 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/