Spring Security OAuth2 Resource Server:JWT 鉴权与权限映射实战 一、先明确边界Resource Server 不负责登录一个常见的 OAuth2 系统包含三个角色Authorization Server登录用户、签发 Access Token、维护密钥Client获取令牌并调用后端 APIResource 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 管理版本dependenciesdependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-security/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-oauth2-resource-server/artifactId/dependencydependencygroupIdorg.springframework.security/groupIdartifactIdspring-security-test/artifactIdscopetest/scope/dependency/dependenciesResource Server 的 Bearer Token 支持位于spring-security-oauth2-resource-serverJWT 解码和 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 MetadataSpring 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;ConfigurationEnableMethodSecuritypublicclassSecurityConfig{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 的 APISTATELESS可以避免服务端创建登录会话。是否关闭 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(){JwtGrantedAuthoritiesConverterscopeConverternewJwtGrantedAuthoritiesConverter();JwtAuthenticationConverterauthenticationConverternewJwtAuthenticationConverter();authenticationConverter.setPrincipalClaimName(preferred_username);authenticationConverter.setJwtGrantedAuthoritiesConverter(jwt-{CollectionGrantedAuthorityauthoritiesnewArrayList();CollectionGrantedAuthorityscopeAuthoritiesscopeConverter.convert(jwt);if(scopeAuthorities!null){authorities.addAll(scopeAuthorities);}ListStringrolesjwt.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 与 403401 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 测试权限矩阵安全配置需要自动化测试不能只在浏览器里手工贴 Tokenpackagecom.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;SpringBootTestAutoConfigureMockMvcclassOrderSecurityTest{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单独做集成测试。十、常见故障定位现象优先检查所有请求都返回 401issuer-uri是否与iss完全一致服务能否访问 Discovery/JWK Set令牌可解析但验签失败kid是否存在于当前 JWK Set算法是否在白名单密钥是否刚轮换明明有角色却返回 403Claim 路径是否正确ROLE_/SCOPE_前缀是否匹配同一个令牌在别的服务可用检查当前服务的aud不要只校验签名偶发提示未生效或已过期检查机器时间同步仅为合理时钟偏差设置容忍值方法注解不生效是否启用EnableMethodSecurity调用是否经过 Spring 代理排查时可以安全记录iss、sub、aud、kid和鉴权结果但不要记录完整 Bearer Token。完整令牌进入日志相当于把临时凭证复制到更多系统。十一、生产落地清单使用非对称签名和 JWK Set支持密钥轮换同时校验签名、iss、aud、exp、nbfAccess Token 短时有效撤销需求强时评估不透明令牌或撤销机制scope 表达客户端被授予的能力角色表达组织内身份两者不要混为一谈URL 层默认认证业务层补充资源归属和租户隔离401 与 403 分开处理错误响应不泄露内部细节不记录完整令牌不把敏感个人信息放入 JWT为每一条权限规则建立允许与拒绝两类测试网关鉴权不能替代服务自身鉴权内部网络也不是安全边界。总结Spring Security 已经解决了 JWT 验签、标准 Claim 校验、JWK 密钥轮换和认证上下文建立等基础问题。业务系统真正需要设计的是权限模型外部 Claim 如何转换为稳定的内部权限哪些规则放在 URL 层哪些规则必须结合资源归属在方法层判断。一套可靠的 Resource Server 方案可以归纳为可信来源、严格校验、明确映射、默认拒绝、测试覆盖。做到这五点JWT 才是安全边界的一部分而不只是一个能被解析的字符串。参考资料Spring SecurityOAuth2 Resource Server JWTSpring SecurityMethod SecuritySpring SecurityTesting OAuth 2.0