Spring Security OAuth 2 教程 - 9:客户端调用资源服务器 API

在前面的文章中,我们创建了 messages-webappmessages-service,并使用 Postman 调用了 API 端点。在本文中,我们将学习如何从客户端应用 messages-webapp 调用受保护的 messages-service API 端点。

你可以从 Github 仓库 获取到完整的源码。

展示消息列表

由于 messages-service 中的 GET /api/messages API 端点是可公开访问的,因此我们可以从 messages-webapp 调用它,而无需任何身份认证。

RestTemplate 和 RestClient

我们使用传统的 RestTemplate 来调用 messages-service 中的 API 端点。但在 Spring Boot 3.2.0 后,建议改用 RestClient

messages-webapp 中,创建 AppConfig 类,如下:

package com.sivalabs.messages.config;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class AppConfig {

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder.build();
    }
}

我们注册了一个 RestTemplate Bean,以便将其注入到其他组件中。

创建 SecurityHelper 类,如下:

package com.sivalabs.messages.domain;

import org.springframework.stereotype.Service;

@Service
public class SecurityHelper {

    public String getAccessToken() {
        String accessToken = null;
        // 获取 Access token 的逻辑
        return accessToken;
    }
}

该类一个方法 getAccessToken(),返回 accessToken,稍后再来做具体的实现。

创建 Message 类,内容如下:

package com.sivalabs.messages.domain;

import jakarta.validation.constraints.NotEmpty;

import java.time.Instant;

public class Message {
    private Long id;
    @NotEmpty
    private String content;
    @NotEmpty
    private String createdBy;
    private Instant createdAt;

    // 忽略构造函数和 get/set 方法
}

创建 MessageServiceClient 类,如下:

package com.sivalabs.messages.domain;

import com.sivalabs.messages.domain.Message;
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.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.List;

@Service
public class MessageServiceClient {
    private static final Logger log = LoggerFactory.getLogger(MessageServiceClient.class);
    private static final String MESSAGE_SVC_BASE_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 List<Message> getMessages() {
        try {
            String url = MESSAGE_SVC_BASE_URL + "/api/messages";
            ResponseEntity<List<Message>> response = restTemplate.exchange(
                    url, HttpMethod.GET, null,
                    new ParameterizedTypeReference<>() {});
            return response.getBody();
        } catch (Exception e) {
            log.error("Error while fetching messages", e);
            return List.of();
        }
    }

    public void createMessage(Message message) {
        try {
            String url = MESSAGE_SVC_BASE_URL + "/api/messages";
            HttpHeaders headers = new HttpHeaders();
            headers.add("Authorization", "Bearer " + securityHelper.getAccessToken());
            HttpEntity<?> httpEntity = new HttpEntity<>(message, headers);
            ResponseEntity<Message> response = restTemplate.exchange(
                    url, HttpMethod.POST, httpEntity,
                    new ParameterizedTypeReference<>() {});
            log.info("Create message response code: {}", response.getStatusCode());
        } catch (Exception e) {
            log.error("Error while creating message", e);
        }
    }
}

现在,更新 HomeController 以从 messages-service 获取消息列表,并将其显示在主页上。

package com.sivalabs.messages.web;

import com.sivalabs.messages.domain.MessageServiceClient;
import com.sivalabs.messages.domain.Message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import java.util.List;

@Controller
public class HomeController {
    private static final Logger log = LoggerFactory.getLogger(HomeController.class);

    private final MessageServiceClient messageServiceClient;

    public HomeController(MessageServiceClient messageServiceClient) {
        this.messageServiceClient = messageServiceClient;
    }

    @GetMapping("/")
    public String home(Model model, @AuthenticationPrincipal OAuth2User principal) {
        if(principal != null) {
          model.addAttribute("username", principal.getAttribute("name"));
        } else {
          model.addAttribute("username", "Guest");
        }
        List<Message> messages = messageServiceClient.getMessages();
        log.info("Message count: {}", messages.size());
        model.addAttribute("messages", messages);
        return "home";
    }
}

更新 home.html,渲染消息列表。如下:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity">
<head>
    <title>Home</title>
</head>
<body>
<div>
    <p sec:authorize="!isAuthenticated()">
      <a href="/oauth2/authorization/messages-webapp">Login</a>
    </p>
    <h1>Welcome <span th:text="${username}">username</span></h1>

    <div id="messages" class="pt-2">
        <div class="message" th:each="message: ${messages}">
            <div class="alert alert-light" role="alert">
                <p th:text="${message.content}">content</p>
                <p>Posted By: <span th:text="${message.createdBy}">CreatedBy</span></p>
            </div>
        </div>
    </div>
</div>
</body>
</html>

现在,只要启动 messages-servicemessages-webapp,并访问 http://localhost:8080,就能看到消息列表。

在实现 “创建新消息” 功能之前,我们先来看看如何获取 access_token 以调用 messages-servicePOST /api/messages API 端点。

获取 Access Token

我们可以从 SecurityContextHolder 中获取当前用户的详细信息。

SecurityHelper 类中实现 getAccessToken() 方法,如下:

package com.sivalabs.messages.domain;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.stereotype.Service;

@Service
public class SecurityHelper {
    private final OAuth2AuthorizedClientService authorizedClientService;
  
    public SecurityHelper(OAuth2AuthorizedClientService authorizedClientService) {
      this.authorizedClientService = authorizedClientService;
    }
    
    public String getAccessToken() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if(!(authentication instanceof OAuth2AuthenticationToken oauthToken)) {
          return null;
        }
        OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(
                oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName());
  
        return client.getAccessToken().getTokenValue();
    }
}

我们注入了自动配置的 OAuth2AuthorizedClientService Bean,加载当前的 AuthorizedClient,然后获取 accessToken。

创建新的消息

现在我们已经实现了 getAccessToken() 方法。MessageServiceClient 使用 access_token 调用 POST /api/messages API端点,让我们在 home.html 中添加一个 “创建新消息” 的表单,并在 HomeController 中实现相应的 Handler 方法。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity">
<head>
    <title>Home</title>
</head>
<body>
<div>
    <p sec:authorize="!isAuthenticated()">
      <a href="/oauth2/authorization/messages-webapp">Login</a>
    </p>
    <h1>Welcome <span th:text="${username}">username</span></h1>

    <div sec:authorize="isAuthenticated()">
      <div class="card">
        <div class="card-body">
          <form method="post" action="/messages">
            <div class="mb-3">
              <label for="content" class="form-label">Message</label>
              <textarea class="form-control" id="content" name="content"></textarea>
            </div>
            <button type="submit" class="btn btn-primary">Submit</button>
          </form>
        </div>
      </div>
    </div>
  
    <div id="messages" class="pt-2">
        <!-- 现实消息 -->
    </div>
    
</div>
</body>
</html>

只有用户通过身份认证后,才会显示 “创建新信息” 表单。

创建新消息,我们需要当前用户的详细信息,如 username

SecurityHelper 中添加一个方法来获取当前用户的详细信息。

package com.sivalabs.messages.domain;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class SecurityHelper {

    private final OAuth2AuthorizedClientService authorizedClientService;

    public SecurityHelper(OAuth2AuthorizedClientService authorizedClientService) {
        this.authorizedClientService = authorizedClientService;
    }

    public static Map<String, Object> getLoginUserDetails() {
        Map<String, Object> map = new HashMap<>();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if(!(authentication instanceof OAuth2AuthenticationToken)) {
            return null;
        }
        DefaultOidcUser principal = (DefaultOidcUser) authentication.getPrincipal();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        List<String> roles = authorities.stream().map(GrantedAuthority::getAuthority).toList();
        OidcUserInfo userInfo = principal.getUserInfo();
        
        map.put("id", userInfo.getSubject());
        map.put("fullName", userInfo.getFullName());
        map.put("email", userInfo.getEmail());
        map.put("username", userInfo.getPreferredUsername());
        map.put("roles", roles);
        
        return map;
    }

    // 其他代码省略
}

由于我们使用的是 OpenID Connect,通过身份认证的 User Principal 属于 DefaultOidcUser 类型。我们从 DefaultOidcUser 中提取用户详细信息,并以 Map 的形式返回。为了方便,我们没有使用任何 DTO 类来表示用户详细信息,而是使用了 Map

HomeController 中添加以下 Handler 方法,以创建新消息:

@Controller
public class HomeController {
    private final MessageServiceClient messageServiceClient;
    private final SecurityHelper securityHelper;
    
    // 其他代码省略
  
    @PostMapping("/messages")
    String createMessage(Message message) {
        Map<String, Object> loginUserDetails = SecurityHelper.getLoginUserDetails();
        message.setCreatedBy(loginUserDetails.get("username").toString());
        messageServiceClient.createMessage(message);
        return "redirect:/";
    }
}

现在,重新启动 messages-webapp 并登录应用,就可以创建新信息了。

基于角色的访问控制

在上一节中,如果我们打印了 loginUserDetails map,那么输出如下:

{ 
  id = "ca1a2f34-1614-45dd-86c1-5eafff085d8a", 
  fullName = "Siva Katamreddy", 
  email = "siva@gmail.com", 
  username = "siva",
  roles = [
    OIDC_USER, 
    SCOPE_email, 
    SCOPE_openid, 
    SCOPE_profile
  ]
}

和我们在之前的文章中注意到的 messages-service 一样,ROLE_ADMINROLE_USERroles 中没有列出。

为了把分配的角色作为 Claim 的一部分,我们需要更新 Keycloak 中的一个设置。

  • 转到 Keycloak 管理控制台 -> 选择 sivalabs Realm
  • Client scopes -> roles -> Mappers -> realm_roles -> Add to ID token -> ON

现在分配的角色将放置在 key 为 real_access 的 Claim 中。

与我们在 messages-service 中实现自定义 JwtTokenConverter 的方法类似,我们也可以在 messages-webapp 中实现自定义 GrantedAuthoritiesMapper,以便从 real_access Claim 中提取角色。

创建 KeycloakAuthoritiesMapper 类,如下:

package com.sivalabs.messages.config;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;

import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

class KeycloakAuthoritiesMapper implements GrantedAuthoritiesMapper {

    @Override
    public Collection<? extends GrantedAuthority> mapAuthorities(
            Collection<? extends GrantedAuthority> authorities) {
        Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

        authorities.forEach(authority -> {
            if (authority instanceof SimpleGrantedAuthority) {
                mappedAuthorities.add(authority);
            }
            else if (authority instanceof OidcUserAuthority oidcUserAuthority) {
                OidcIdToken idToken = oidcUserAuthority.getIdToken();
                Map<String, Object> claims = idToken.getClaims();
                Map<String,Object> realm_access = (Map<String, Object>) claims.get("realm_access");
                if(realm_access != null && !realm_access.isEmpty()) {
                    List<String> roles = (List<String>) realm_access.get("roles");
                    var list = roles.stream()
                            .filter(role -> role.startsWith("ROLE_"))
                            .map(SimpleGrantedAuthority::new).toList();
                    mappedAuthorities.addAll(list);
                }
            } else if (authority instanceof OAuth2UserAuthority oauth2UserAuthority) {
                Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();
                // 将 userAttributes 中的属性映射到一个或多个 GrantedAuthority 中,并将其添加到 mappedAuthorities 中
            }
        });
        return mappedAuthorities;
    }
}

检查授权类型,并从 realm_access Claim 中提取角色。由于我们使用的是 OpenID Connect Flow,授权类型为 OidcUserAuthority。如果我们使用的是 OAuth 2.0 Flow,授权将是 OAuth2UserAuthority 类型。

我们从 realm_access Claim 中提取了 roles,并将其映射到 SimpleGrantedAuthority

现在,更新 messages-webapp 中的 SecurityConfig 以使用此自定义的 GrantedAuthoritiesMapper,如下所示:

package com.sivalabs.messages.config;


@Configuration
public class SecurityConfig {
    private final ClientRegistrationRepository clientRegistrationRepository;

    public SecurityConfig(ClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(c ->
                c.requestMatchers("/").permitAll()
                 .anyRequest().authenticated()
            )
            .cors(CorsConfigurer::disable)
            .csrf(CsrfConfigurer::disable)
                //.oauth2Login(Customizer.withDefaults())
                .oauth2Login(oauth2 ->
                    oauth2.userInfoEndpoint(userInfo -> userInfo
                            .userAuthoritiesMapper(new KeycloakAuthoritiesMapper())))
            .logout(logout -> logout
                .logoutSuccessHandler(oidcLogoutSuccessHandler())
            );
        return http.build();
    }

    // 其他代码省略
}

重新启动 messages-webappmessages-service,并打印 loginUserDetails(登录用户详情) map,就会看到以下输出:

{ 
  id = "ca1a2f34-1614-45dd-86c1-5eafff085d8a", 
  fullName = "Siva Katamreddy", 
  email = "siva@gmail.com", 
  username = "siva",
  roles = [
    SCOPE_email, 
    SCOPE_openid, 
    SCOPE_profile,
    ROLE_USER,
    ROLE_ADMIN
  ]
}

现在,我们可以使用 roles 来实实现于角色的访问控制。

更新 home.html,只有当用户具有 ROLE_ADMIN 角色时,才能显示 You are an ADMIN 的信息。

<h1>Welcome <span th:text="${username}">username</span></h1>
<div sec:authorize="isAuthenticated()">
    <div sec:authorize="hasRole('ADMIN')">
        <p>You are an ADMIN</p>
    </div>
    <div sec:authorize="!hasRole('ADMIN')">
        <p>You are NOT an ADMIN</p>
    </div>
</div>
<!-- 其他代码省略 -->

现在,如果你使用分配了 ROLE_ADMIN 的用户登录,就会看到 You are an ADMIN 的信息。否则,你将看到 You are NOT an ADMIN 的提示。

总结

在本文中,我们学习了如何从 messages-webapp 客户端调用 messages-service 资源服务器 API。我们还学习了如何自定义 GrantedAuthoritiesMapper 以将角色(roles)转换为授权(Authorities)并实现基于角色的访问控制。

在下一篇文章中,我们将创建 archival-service,并学习如何使用 “客户端凭证模式” 调用 messages-service API。


参考:https://www.sivalabs.in/spring-security-oauth2-tutorial-integrating-client-and-resource-server/