一、先明确边界: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/demoaudiences:order-serviceissuer-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/demojwk-set-uri:https://id.example.com/realms/demo/protocol/openid-connect/certsaudiences:order-service同时配置issuer-uri与jwk-set-uri的好处是:服务不必依赖 Discovery 获取密钥地址,但仍会校验iss。
不要关闭签名校验,也不要根据 JWT Header 中未经信任的alg任意选择算法。算法白名单和密钥轮换应由服务端配置控制。
五、配置接口授权规则
下面的配置使用无状态会话,并把健康检查、读订单、写订单和管理接口分开授权:
packagecom.example.order.security;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.http.HttpMethod;importorg.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;importorg.springframework.security.config.annotation.web.builders.HttpSecurity;importorg.springframework.security.config.http.SessionCreationPolicy;importorg.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;importorg.springframework.security.web.SecurityFilterChain;@Configuration@EnableMethodSecuritypublicclassSecurityConfig{@BeanSecurityFilterChainsecurityFilterChain(HttpSecurityhttp,JwtAuthenticationConverterjwtAuthenticationConverter)throwsException{returnhttp.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(newJsonAuthenticationEntryPoint()).accessDeniedHandler(newJsonAccessDeniedHandler())).build();}}对只接受 Bearer Token 的 API,STATELESS可以避免服务端创建登录会话。是否关闭 CSRF 要看认证方式:若 API 只从AuthorizationHeader 接受令牌且不依赖浏览器 Cookie,通常可以关闭;如果认证信息来自 Cookie,则不能照抄此配置。
规则应从具体到宽泛排列。最终保留anyRequest().authenticated(),避免新接口因为漏配而意外公开。
六、同时映射 scope 与角色
不同身份平台的 Claim 结构不完全一致。假设令牌如下:
{"sub":"42","preferred_username":"alice","scope":"orders.read","roles":["ADMIN","OPS"]}默认转换器只处理 scope。下面的配置保留默认 scope 映射,同时把roles转换为ROLE_权限:
packagecom.example.order.security;importjava.util.ArrayList;importjava.util.Collection;importjava.util.List;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.security.core.GrantedAuthority;importorg.springframework.security.core.authority.SimpleGrantedAuthority;importorg.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;importorg.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;@ConfigurationpublicclassJwtAuthorityConfig{@BeanJwtAuthenticationConverterjwtAuthenticationConverter(){JwtGrantedAuthoritiesConverterscopeConverter=newJwtGrantedAuthoritiesConverter();JwtAuthenticationConverterauthenticationConverter=newJwtAuthenticationConverter();authenticationConverter.setPrincipalClaimName("preferred_username");authenticationConverter.setJwtGrantedAuthoritiesConverter(jwt->{Collection<GrantedAuthority>authorities=newArrayList<>();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->newSimpleGrantedAuthority("ROLE_"+role)).forEach(authorities::add);}returnauthorities;});returnauthenticationConverter;}}如果平台把角色放在realm_access.roles之类的嵌套结构中,应编写单独的 Claim 读取逻辑。不要把所有 JWT Claim 都直接转成权限,更不能信任客户端可以自行修改的业务 Header。
角色命名还要统一:令牌中存ADMIN,服务内映射为ROLE_ADMIN;不要让一部分接口检查ADMIN,另一部分检查ROLE_ADMIN。
七、在业务方法上做细粒度授权
URL 规则适合保护入口,方法授权更接近业务语义:
packagecom.example.order.application;importorg.springframework.security.access.prepost.PreAuthorize;importorg.springframework.stereotype.Service;@ServicepublicclassOrderApplicationService{@PreAuthorize("hasAuthority('SCOPE_orders.read') or hasRole('ADMIN')")publicOrderViewfindById(longorderId){returnloadOrder(orderId);}@PreAuthorize("hasAuthority('SCOPE_orders.cancel') and #operatorId == authentication.name")publicvoidcancel(longorderId,StringoperatorId){// 校验订单状态并执行取消}}@EnableMethodSecurity会启用@PreAuthorize。不过,不建议把复杂的数据权限全部写成很长的 SpEL。更易维护的方式是委托给授权组件:
@PreAuthorize("@orderAuthorization.canRead(#orderId, authentication)")publicOrderViewfindById(longorderId){returnloadOrder(orderId);}这样授权逻辑可以独立测试,也能清楚处理租户、资源归属和管理员例外。
八、正确区分 401 与 403
401 Unauthorized:没有令牌,或令牌无法通过认证;403 Forbidden:令牌有效,身份已经确认,但权限不足。
统一 JSON 响应方便前端和网关识别:
packagecom.example.order.security;importjava.io.IOException;importjakarta.servlet.ServletException;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.http.MediaType;importorg.springframework.security.core.AuthenticationException;importorg.springframework.security.web.AuthenticationEntryPoint;publicclassJsonAuthenticationEntryPointimplementsAuthenticationEntryPoint{@Overridepublicvoidcommence(HttpServletRequestrequest,HttpServletResponseresponse,AuthenticationExceptionexception)throwsIOException{response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.setCharacterEncoding("UTF-8");response.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"访问令牌无效或已过期\"}");}}packagecom.example.order.security;importjava.io.IOException;importjakarta.servlet.http.HttpServletRequest;importjakarta.servlet.http.HttpServletResponse;importorg.springframework.http.MediaType;importorg.springframework.security.access.AccessDeniedException;importorg.springframework.security.web.access.AccessDeniedHandler;publicclassJsonAccessDeniedHandlerimplementsAccessDeniedHandler{@Overridepublicvoidhandle(HttpServletRequestrequest,HttpServletResponseresponse,AccessDeniedExceptionexception)throwsIOException{response.setStatus(HttpServletResponse.SC_FORBIDDEN);response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.setCharacterEncoding("UTF-8");response.getWriter().write("{\"code\":\"FORBIDDEN\",\"message\":\"当前身份没有操作权限\"}");}}生产环境不要把签名失败、密钥详情或内部异常堆栈返回给客户端。详细原因写入受控日志,响应只保留稳定错误码。
九、用 MockMvc 测试权限矩阵
安全配置需要自动化测试,不能只在浏览器里手工贴 Token:
packagecom.example.order.api;importstaticorg.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;importstaticorg.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;importstaticorg.springframework.test.web.servlet.result.MockMvcResultMatchers.status;importorg.junit.jupiter.api.Test;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;importorg.springframework.boot.test.context.SpringBootTest;importorg.springframework.security.core.authority.SimpleGrantedAuthority;importorg.springframework.test.web.servlet.MockMvc;@SpringBootTest@AutoConfigureMockMvcclassOrderSecurityTest{@AutowiredMockMvcmvc;@TestvoidnoTokenShouldReturn401()throwsException{mvc.perform(get("/api/orders/1001")).andExpect(status().isUnauthorized());}@TestvoidreadScopeShouldBeAllowed()throwsException{mvc.perform(get("/api/orders/1001").with(jwt().jwt(jwt->jwt.subject("alice")).authorities(newSimpleGrantedAuthority("SCOPE_orders.read")))).andExpect(status().isOk());}@TestvoidunrelatedScopeShouldReturn403()throwsException{mvc.perform(get("/api/orders/1001").with(jwt().authorities(newSimpleGrantedAuthority("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 才是安全边界的一部分,而不只是一个能被解析的字符串。
参考资料
- Spring Security:OAuth2 Resource Server JWT
- Spring Security:Method Security
- Spring Security:Testing OAuth 2.0