Spring Security OAuth2 Resource Server:JWT 鉴权与权限映射实战
一、先明确边界:Resource Server 不负责登录
一个常见的 OAuth2 系统包含三个角色:
- Authorization Server:登录用户、签发 Access Token、维护密钥;
- Client:获取令牌并调用后端 API;
- Resource Server:验证令牌,只向满足权限要求的请求开放资源。
业务服务属于 Resource Server。它不应该拿到用户密码,也不应该自己用一个共享字符串随意签发 JWT。它的职责是回答两个问题:
- 这个令牌是否可信、是否仍然有效?
- 令牌代表的主体是否有权执行当前操作?
JWT 只是令牌格式,不等于完整安全方案。Base64 解码任何人都能做,安全性来自签名验证和 Claim 校验。
二、请求进入 Spring Security 后发生了什么
请求携带令牌:
GET /api/orders/1001 HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
核心链路如下:
BearerTokenAuthenticationFilter
↓
JwtAuthenticationProvider
↓
JwtDecoder:验签并校验 iss、exp、nbf、aud
↓
JwtAuthenticationConverter:把 Claim 转成 GrantedAuthority
↓
SecurityContextHolder
↓
URL / 方法级授权
Spring Security 默认把 scope 或 scp 中的值映射成带 SCOPE_ 前缀的权限。例如:
{
"sub": "user-10086",
"scope": "orders.read orders.write"
}
会得到:
SCOPE_orders.read
SCOPE_orders.write
因此 hasAuthority("SCOPE_orders.read") 与 hasRole("orders.read") 不是一回事。hasRole("ADMIN") 会自动检查 ROLE_ADMIN。
三、引入 Resource Server 依赖
Maven 项目只需要让 Spring Boot 管理版本:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Resource Server 的 Bearer Token 支持位于 spring-security-oauth2-resource-server,JWT 解码和 JOSE 支持位于 spring-security-oauth2-jose;使用 Boot Starter 时会统一引入。
四、用 issuer-uri 完成最小配置
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://id.example.com/realms/demo
audiences: order-service
issuer-uri 必须与令牌中的 iss 完全一致。身份服务还应暴露 OIDC Discovery 或 OAuth2 Authorization Server Metadata,Spring Security 会由此发现 JWK Set 地址,并使用公开密钥验证签名。
audiences 用于校验 aud。只检查签名和签发者仍不充分:一个签给库存服务的令牌,不应被订单服务接受。
如果身份服务不提供 Discovery,可以显式配置:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://id.example.com/realms/demo
jwk-set-uri: https://id.example.com/realms/demo/protocol/openid-connect/certs
audiences: order-service
同时配置 issuer-uri 与 jwk-set-uri 的好处是:服务不必依赖 Discovery 获取密钥地址,但仍会校验 iss。
不要关闭签名校验,也不要根据 JWT Header 中未经信任的 alg 任意选择算法。算法白名单和密钥轮换应由服务端配置控制。
五、配置接口授权规则
下面的配置使用无状态会话,并把健康检查、读订单、写订单和管理接口分开授权:
package com.example.order.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http,
JwtAuthenticationConverter jwtAuthenticationConverter) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/**")
.hasAnyAuthority("SCOPE_orders.read", "ROLE_ADMIN")
.requestMatchers(HttpMethod.POST, "/api/orders/**")
.hasAuthority("SCOPE_orders.write")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter))
.authenticationEntryPoint(new JsonAuthenticationEntryPoint())
.accessDeniedHandler(new JsonAccessDeniedHandler()))
.build();
}
}
对只接受 Bearer Token 的 API,STATELESS 可以避免服务端创建登录会话。是否关闭 CSRF 要看认证方式:若 API 只从 Authorization Header 接受令牌且不依赖浏览器 Cookie,通常可以关闭;如果认证信息来自 Cookie,则不能照抄此配置。
规则应从具体到宽泛排列。最终保留 anyRequest().authenticated(),避免新接口因为漏配而意外公开。
六、同时映射 scope 与角色
不同身份平台的 Claim 结构不完全一致。假设令牌如下:
{
"sub": "42",
"preferred_username": "alice",
"scope": "orders.read",
"roles": ["ADMIN", "OPS"]
}
默认转换器只处理 scope。下面的配置保留默认 scope 映射,同时把 roles 转换为 ROLE_ 权限:
package com.example.order.security;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
@Configuration
public class JwtAuthorityConfig {
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopeConverter =
new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter authenticationConverter =
new JwtAuthenticationConverter();
authenticationConverter.setPrincipalClaimName("preferred_username");
authenticationConverter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>();
Collection<GrantedAuthority> scopeAuthorities =
scopeConverter.convert(jwt);
if (scopeAuthorities != null) {
authorities.addAll(scopeAuthorities);
}
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.forEach(authorities::add);
}
return authorities;
});
return authenticationConverter;
}
}
如果平台把角色放在 realm_access.roles 之类的嵌套结构中,应编写单独的 Claim 读取逻辑。不要把所有 JWT Claim 都直接转成权限,更不能信任客户端可以自行修改的业务 Header。
角色命名还要统一:令牌中存 ADMIN,服务内映射为 ROLE_ADMIN;不要让一部分接口检查 ADMIN,另一部分检查 ROLE_ADMIN。
七、在业务方法上做细粒度授权
URL 规则适合保护入口,方法授权更接近业务语义:
package com.example.order.application;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class OrderApplicationService {
@PreAuthorize("hasAuthority('SCOPE_orders.read') or hasRole('ADMIN')")
public OrderView findById(long orderId) {
return loadOrder(orderId);
}
@PreAuthorize("hasAuthority('SCOPE_orders.cancel') and #operatorId == authentication.name")
public void cancel(long orderId, String operatorId) {
// 校验订单状态并执行取消
}
}
@EnableMethodSecurity 会启用 @PreAuthorize。不过,不建议把复杂的数据权限全部写成很长的 SpEL。更易维护的方式是委托给授权组件:
@PreAuthorize("@orderAuthorization.canRead(#orderId, authentication)")
public OrderView findById(long orderId) {
return loadOrder(orderId);
}
这样授权逻辑可以独立测试,也能清楚处理租户、资源归属和管理员例外。
八、正确区分 401 与 403
401 Unauthorized:没有令牌,或令牌无法通过认证;403 Forbidden:令牌有效,身份已经确认,但权限不足。
统一 JSON 响应方便前端和网关识别:
package com.example.order.security;
import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(
"{\"code\":\"UNAUTHORIZED\",\"message\":\"访问令牌无效或已过期\"}");
}
}
package com.example.order.security;
import java.io.IOException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
public class JsonAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException exception) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(
"{\"code\":\"FORBIDDEN\",\"message\":\"当前身份没有操作权限\"}");
}
}
生产环境不要把签名失败、密钥详情或内部异常堆栈返回给客户端。详细原因写入受控日志,响应只保留稳定错误码。
九、用 MockMvc 测试权限矩阵
安全配置需要自动化测试,不能只在浏览器里手工贴 Token:
package com.example.order.api;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {
@Autowired
MockMvc mvc;
@Test
void noTokenShouldReturn401() throws Exception {
mvc.perform(get("/api/orders/1001"))
.andExpect(status().isUnauthorized());
}
@Test
void readScopeShouldBeAllowed() throws Exception {
mvc.perform(get("/api/orders/1001")
.with(jwt()
.jwt(jwt -> jwt.subject("alice"))
.authorities(new SimpleGrantedAuthority(
"SCOPE_orders.read"))))
.andExpect(status().isOk());
}
@Test
void unrelatedScopeShouldReturn403() throws Exception {
mvc.perform(get("/api/orders/1001")
.with(jwt().authorities(
new SimpleGrantedAuthority("SCOPE_profile.read"))))
.andExpect(status().isForbidden());
}
}
这类测试不需要启动真实身份服务。还应增加角色映射、租户隔离、资源归属、过期令牌和错误 aud 的测试;其中验签与 Claim Validator 可以针对 JwtDecoder 单独做集成测试。
十、常见故障定位
| 现象 | 优先检查 |
|---|---|
| 所有请求都返回 401 | issuer-uri 是否与 iss 完全一致,服务能否访问 Discovery/JWK Set |
| 令牌可解析但验签失败 | kid 是否存在于当前 JWK Set,算法是否在白名单,密钥是否刚轮换 |
| 明明有角色却返回 403 | Claim 路径是否正确,ROLE_/SCOPE_ 前缀是否匹配 |
| 同一个令牌在别的服务可用 | 检查当前服务的 aud,不要只校验签名 |
| 偶发提示未生效或已过期 | 检查机器时间同步;仅为合理时钟偏差设置容忍值 |
| 方法注解不生效 | 是否启用 @EnableMethodSecurity,调用是否经过 Spring 代理 |
排查时可以安全记录 iss、sub、aud、kid 和鉴权结果,但不要记录完整 Bearer Token。完整令牌进入日志相当于把临时凭证复制到更多系统。
十一、生产落地清单
- 使用非对称签名和 JWK Set,支持密钥轮换;
- 同时校验签名、
iss、aud、exp、nbf; - Access Token 短时有效,撤销需求强时评估不透明令牌或撤销机制;
- scope 表达客户端被授予的能力,角色表达组织内身份,两者不要混为一谈;
- URL 层默认认证,业务层补充资源归属和租户隔离;
- 401 与 403 分开处理,错误响应不泄露内部细节;
- 不记录完整令牌,不把敏感个人信息放入 JWT;
- 为每一条权限规则建立允许与拒绝两类测试;
- 网关鉴权不能替代服务自身鉴权,内部网络也不是安全边界。
总结
Spring Security 已经解决了 JWT 验签、标准 Claim 校验、JWK 密钥轮换和认证上下文建立等基础问题。业务系统真正需要设计的是权限模型:外部 Claim 如何转换为稳定的内部权限,哪些规则放在 URL 层,哪些规则必须结合资源归属在方法层判断。
一套可靠的 Resource Server 方案可以归纳为:可信来源、严格校验、明确映射、默认拒绝、测试覆盖。做到这五点,JWT 才是安全边界的一部分,而不只是一个能被解析的字符串。
参考资料
更多推荐




所有评论(0)