在 上一篇文章 中,我们创建了 messages-webapp
,并使用 "授权码模式" 通过 Spring Security OAuth 2.0 对其进行了访问控制。在本文中,我们将创建 messages-service
(Spring Boot 资源服务器),并使用 Spring Security OAuth 2.0 进行访问控制。
你可以在 Github 仓库 找到该项目完整的源码。
创建 messages-service {#创建-messages-service}
点击此 链接 可使用 Spring Initializr 生成 messages-service
。我们选择了 Web
、Validation
、Security
和 OAuth2 Resource Server
Starter。应用生成后,在 IDE 打开它。
配置 OAuth 2.0 资源服务器属性 {#配置-oauth-20-资源服务器属性}
messages-service
是 bearer-only
类型的资源服务器。这意味着如果有人使用有效的 access_token
作为 Authorization
头发送请求到受保护的 API 端点,该服务将返回响应。否则,它将只会返回 401 或 403 的 HTTP 状态码,而不会启动 OAuth 2.0 的授权流程。
bearer-only
类型的资源服务器无需向授权服务器(Keycloak)注册。我们只需在 application.properties
文件中配置 issuer-uri
如下:
spring.application.name=messages-service
server.port=8181
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9191/realms/sivalabs
实现 API 端点 {#实现-api-端点}
实现第一个 API 端点 /api/messages
,它返回一个消息列表。
创建 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 方法
}
创建 MessageRepository
类,如下:
package com.sivalabs.messages.repository;
import com.sivalabs.messages.model.Message;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;
@Repository
public class MessageRepository {
private static final AtomicLong ID = new AtomicLong(0L);
private static final List<Message> MESSAGES = new ArrayList<>();
@PostConstruct
void init() {
getDefaultMessages().forEach( p -> {
p.setId(ID.incrementAndGet());
MESSAGES.add(p);
});
}
public List<Message> getMessages() {
return MESSAGES;
}
public Message createMessage(Message message) {
message.setId(ID.incrementAndGet());
message.setCreatedAt(Instant.now());
MESSAGES.add(message);
return message;
}
private List<Message> getDefaultMessages() {
List<Message> messages = new ArrayList<>();
messages.add(new Message(null, "Test Message 1", "admin", Instant.now()));
messages.add(new Message(null, "Test Message 2", "admin", Instant.now()));
return messages;
}
}
我们的重点是使用 OAuth 2 确保 API 端点的安全,因此我们不使用任何数据库来存储消息。我们仅使用一个简单的内存 List 来存储消息。
创建 MessageController
类,如下:
package com.sivalabs.messages.api;
import com.sivalabs.messages.domain.Message;
import com.sivalabs.messages.domain.MessageRepository;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/messages")
class MessageController {
private final MessageRepository messageRepository;
MessageController(MessageRepository messageRepository) {
this.messageRepository = messageRepository;
}
@GetMapping
List<Message> getMessages() {
return messageRepository.getMessages();
}
@PostMapping
Message createMessage(@RequestBody @Valid Message message) {
return messageRepository.createMessage(message);
}
}
我们实现了两个 API 端点,GET /api/messages
用于获取所有消息,POST /api/messages
用于创建新消息。
现在,如果我们尝试访问 http://localhost:8181/api/messages
,会得到 401 HTTP 状态码,因为我们没有发送任何 access_token
。默认情况下,Spring Security 会确保所有端点的安全。
使用 Postman 访问受保护的 API 端点 {#使用-postman-访问受保护的-api-端点}
我们在 前面的文章 中已经介绍了如何使用 Postman 获取 access_token
。
让我们使用 Postman 获取 access_token
并调用 GET /api/messages
API 端点。
- 在 Postman 中打开新请求选项卡
- 选择 HTTP Method 为 GET ,然后输入 URL:
http://localhost:8181/api/messages
- 转到 Authorization 选项, 选择 Type 为 OAuth 2.0
- 在 Configure New Token 部分:
- Grant Type: Authorization Code
- Callback URL :
http://localhost:8080/login/oauth2/code/messages-webapp
- Auth URL :
http://localhost:9191/realms/sivalabs/protocol/openid-connect/auth
- Access Token URL :
http://localhost:9191/realms/sivalabs/protocol/openid-connect/token
- Client ID: messages-webapp
- Client Secret: qVcg0foCUNyYbgF0Sg52zeIhLYyOwXpQ
- Scope: openid profile
- State: randomstring
- Client Authentication: Send as Basic Auth header
- 点击 Get New Access Token 按钮
- Postman 会弹出 Keycloak 登录页面
- 使用用户凭证
siva/siva1234
登录 - 现在你应该可以看到带有 Token 详细信息的响应了
- 点击 Use Token 按钮
- 点击 Send 按钮调用 API 端点
你应该可以看到包含消息列表的响应。
如上,我们使用 Postman 首先获取了 access_token
,然后通过将 access_token
作为 Authorization
头调用了 API 端点。
自定义 Security 配置 {#自定义-security-配置}
默认情况下,Spring Security OAuth 2.0 资源服务器实现将确保所有端点的安全。但是,我们希望允许未经身份认证的匿名用户访问 GET /api/messages
API 端点。
自定义 Security 配置类 SecurityConfig,如下:
package com.sivalabs.messages.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(c ->
c.requestMatchers(HttpMethod.GET, "/api/messages").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(CorsConfigurer::disable)
.csrf(CsrfConfigurer::disable)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults())
);
return http.build();
}
}
如你所见,我们已经允许匿名用户访问 GET /api/messages
API 端点,并对其余所有端点进行了访问控制。此外,我们配置了 OAuth2 资源服务器以使用基于 JWT 令牌的访问控制,并使用默认配置。
现在,如果你重启应用并访问 http://localhost:8181/api/messages
,就可以在没有任何身份认证的情况下看到响应。但是,在调用 POST /api/messages
API 端点时,你需要按照上一节所述配置身份认证(Authentication
)。
它是如何运行的?
我们在
application.properties
文件中配置了issuer-uri
,http://localhost:9191/realms/sivalabs
。启动应用时,Spring Security OAuth 2.0 会使用发现端点 (http://localhost:9191/realms/sivalabs/.well-known/openid-configuration
)获取jwks_uri
,并用它下载用于验证 JWT token 的公钥。
因此,我们可以配置某些 API 端点为公开访问,无需进行身份认证,并对其余端点进行安全保护。但是,基于角色的访问呢?
记住,我们只允许 ROLE_ADMIN 用户调用 POST /api/messages/archive
API 端点。
在了解这一点之前,我们先来看看如何获取当前用户的详细信息。
获取当前用户的详细信息 {#获取当前用户的详细信息}
我们可以从 SecurityContextHolder
获取当前用户的详细信息。
实现一个 API 端点 GET /api/me
,以获取当前用户的详细信息,如下:
package com.sivalabs.messages.api;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
class UserInfoController {
@GetMapping("/api/me")
Map<String, Object> currentUserDetails() {
return getLoginUserDetails();
}
Map<String, Object> getLoginUserDetails() {
Map<String, Object> map = new HashMap<>();
JwtAuthenticationToken authentication =
(JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
Jwt jwt = (Jwt) authentication.getPrincipal();
map.put("username", jwt.getClaimAsString("preferred_username"));
map.put("email", jwt.getClaimAsString("email"));
map.put("name", jwt.getClaimAsString("name"));
map.put("token", jwt.getTokenValue());
map.put("authorities", authentication.getAuthorities());
map.put("roles", getRoles(jwt));
return map;
}
List<String> getRoles(Jwt jwt) {
Map<String,Object> realm_access = (Map<String, Object>) jwt.getClaims().get("realm_access");
if(realm_access != null && !realm_access.isEmpty()) {
return (List<String>) realm_access.get("roles");
}
return List.of();
}
}
现在,如果我们在 Postman 中调用 GET /api/me
API 端点,并像之前一样配置授权(Authorization
),我们将获得类似以下的当前用户详细信息:
{
"name": "Siva Katamreddy",
"email": "siva@gmail.com",
"username": "siva",
"token": "eyJhbGciOiJSUzI1NiIsInR5c.....qgGIu8iF86azw",
"roles": [
"default-roles-sivalabs",
"offline_access",
"uma_authorization"
],
"authorities": [
{
"authority": "SCOPE_openid"
},
{
"authority": "SCOPE_email"
},
{
"authority": "SCOPE_profile"
}
]
}
如果我们访问 jwt.io 并粘贴 token 值,就可以看到解码后的响应如下:
{
"exp": 1695919675,
"iat": 1695919375,
"auth_time": 1695914182,
"jti": "64128bc7-8f4d-48ff-978f-93b0764f39cd",
"iss": "http://localhost:9191/realms/sivalabs",
"aud": "account",
"sub": "ca1a2f34-1614-45dd-86c1-5eafff085d8a",
"typ": "Bearer",
"azp": "messages-webapp",
"session_state": "3e5865f1-0f0e-4ada-b2a1-97e7b118af4d",
"acr": "0",
"allowed-origins": [
"http://localhost:8080"
],
"realm_access": {
"roles": [
"default-roles-sivalabs",
"offline_access",
"uma_authorization"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid email profile",
"sid": "3e5865f1-0f0e-4ada-b2a1-97e7b118af4d",
"email_verified": true,
"name": "Siva Katamreddy",
"preferred_username": "siva",
"given_name": "Siva",
"family_name": "Katamreddy",
"email": "siva@gmail.com"
}
如果我们将 /api/me
API 响应与解码后的 token 数据进行比较,就会发现:
- roles 源自 JWT 令牌中的 realm_access claim。
- authorities 来自 JWT token 中的 scope claim。
默认的 JwtAuthenticationConverter
实现会将 scope claim 转换为添加 SCOPE_
前缀的 authorities。
Keycloak 会在 JWT token 中发送 realm_access claim 中的 roles 。我们从 realm_access claim 明中提取了 roles,并将其添加到了响应中。
现在,让我们在 Keycloak 中创建 ROLE_USER
和 ROLE_ADMIN
角色,并将它们分配给用户 siva
。
- 进入 Keycloak 管理控制台,选择
sivalabs
Realm - 单击 Realm roles ,创建
ROLE_USER
和ROLE_ADMIN
角色 - 点击 Users ,选择用户
siva
- 点击 Role Mappings ,为用户
siva
分配ROLE_USER
和ROLE_ADMIN
角色
现在,如果我们从 Postman 中调用 GET /api/me
API 端点,就会得到如下更新后的响应:
{
"name": "Siva Katamreddy",
"email": "siva@gmail.com",
"username": "siva",
"token": "eyJhbGciOiJSUzI1NiIsInR5c.....qgGIu8iF86azw",
"roles": [
"default-roles-sivalabs",
"offline_access",
"uma_authorization",
"ROLE_USER",
"ROLE_ADMIN"
],
"authorities": [
{
"authority": "SCOPE_openid"
},
{
"authority": "SCOPE_email"
},
{
"authority": "SCOPE_profile"
}
]
}
我们刚才所做的是从 JWT token 中找出当前用户的详细信息并提取角色。但是,要让 Spring Security 将角色(roles)视为授权(authorities),我们需要以某种方式将角色转换为授权。
为此,我们可以通过实现 Converter<Jwt, AbstractAuthenticationToken>
创建一个自定义的 JwtAuthenticationConverter
,如下:
package com.sivalabs.messages.config;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
class KeycloakJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private final Converter<Jwt, Collection<GrantedAuthority>> delegate = new JwtGrantedAuthoritiesConverter();
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
List<GrantedAuthority> authorityList = extractRoles(jwt);
Collection<GrantedAuthority> authorities = delegate.convert(jwt);
if (authorities != null) {
authorityList.addAll(authorities);
}
return new JwtAuthenticationToken(jwt, authorityList);
}
private List<GrantedAuthority> extractRoles(Jwt jwt) {
Map<String,Object> realm_access = (Map<String, Object>) jwt.getClaims().get("realm_access");
if(realm_access == null || realm_access.isEmpty()) {
return List.of();
}
List<String> roles = (List<String>) realm_access.get("roles");
if (roles == null || roles.isEmpty()) {
roles = List.of("ROLE_USER");
}
return roles.stream()
.filter(role -> role.startsWith("ROLE_"))
.map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
}
如上:
- 我们正在从
realm_access
claim 中提取 roles ,并将其转换为 authorities 。注意,我们只考虑具有ROLE_
前缀的角色。 - 我们使用 Spring Security 的
JwtGrantedAuthoritiesConverter
将scope
claim 转换为 authorities。 - 我们将 scope claim 中的 authorities 和 realm_access claim 中的 roles 结合起来,创建一个新的
JwtAuthenticationToken
。
现在,让我们在 SecurityConfig
中注册这个自定义的 JwtAuthenticationConverter
,如下:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
...
...
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter()))
);
return http.build();
}
}
现在,如果我们从 Postman 中调用 GET /api/me
API 端点,就会得到如下更新后的响应:
{
"name": "Siva Katamreddy",
"email": "siva@gmail.com",
"username": "siva",
"token": "eyJhbGciOiJSUzI1NiIsInR5c.....qgGIu8iF86azw",
"roles": [
"default-roles-sivalabs",
"offline_access",
"uma_authorization",
"ROLE_USER",
"ROLE_ADMIN"
],
"authorities": [
{
"authority": "ROLE_USER"
},
{
"authority": "ROLE_ADMIN"
},
{
"authority": "SCOPE_openid"
},
{
"authority": "SCOPE_email"
},
{
"authority": "SCOPE_profile"
}
]
}
实际上,我们并不需要 GET /api/me
API 端点,但我们实现它只是为了了解如何获取当前用户的详细信息,以及如何使用自定义 JwtAuthenticationConverter
将 Keycloak 角色(roles)映射为授权(authorities)。
验证基于角色的访问控制 {#验证基于角色的访问控制}
通过实现 POST /api/messages/archive
API 端点来验证基于角色的访问控制。
在 MessageController
类中添加如下端点:
@RestController
@RequestMapping("/api/messages")
class MessageController {
private static final Logger log = LoggerFactory.getLogger(MessageController.class);
...
...
@PostMapping("/archive")
Map<String,String> archiveMessages() {
log.info("Archiving all messages");
return Map.of("status", "success");
}
}
更新 SecurityConfig
类如下:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(c ->
c.requestMatchers(HttpMethod.GET, "/api/messages").permitAll()
.requestMatchers(HttpMethod.POST, "/api/messages/archive").hasAnyRole("ADMIN")
.anyRequest().authenticated()
)
...
...
return http.build();
}
}
现在,如果我们在 Postman 中调用 POST /api/messages/archive
API 端点,并像之前那样配置授权(Authorization
),就会得到以下 HTTP 状态码为 200 的响应。
{
"status": "success"
}
现在,进入 Keycloak 管理控制台,删除用户 siva
的 ROLE_ADMIN
角色。现在尝试获取新的 access_token
,并从 Postman 调用 POST /api/messages/archive
API 端点。由于用户 siva
没有 ROLE_ADMIN
角色,因此会收到 Forbidden 403 HTTP 状态码。
总结 {#总结}
在本文中,我们创建了 messages-service
资源服务器,并使用 Spring Security OAuth 2.0 对其进行了访问控制,我们还学习了如何实现基于角色的访问控制。
在下一篇文章中,我们将整合 messages-webapp
与 messages-service
资源服务器。
参考:https://www.sivalabs.in/spring-security-oauth2-tutorial-securing-resource-server/