在前面的文章中,我们创建了 messages-webapp
和 messages-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-service
和 messages-webapp
,并访问 http://localhost:8080
,就能看到消息列表。
在实现 "创建新消息" 功能之前,我们先来看看如何获取 access_token
以调用 messages-service
的 POST /api/messages
API 端点。
获取 Access Token {#获取-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_ADMIN
和 ROLE_USER
在 roles
中没有列出。
为了把分配的角色作为 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-webapp
和 messages-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/