楼层: 首页/ 软件技术/ Spring Boot 3.5/ Spring Boot 3 安全与 OAuth2:把服务改造成资源服务器
15

Spring Boot 3 安全与 OAuth2:把服务改造成资源服务器

Spring Boot 3 Security · OAuth2 Resource Server

第 12 章(ch11sec)我们用一个内存用户跑通了登录与放行。真实项目里,认证往往由统一的 SSO 负责,你的服务只负责"拿着令牌来判断能不能进"。这种角色在 OAuth2 里叫 Resource Server(资源服务器),而 Spring Boot 3 把它简化到了"加一个依赖 + 写一个 Bean"。

起步:加一个依赖,服务就变成了资源服务器

先别急着写配置。搞清楚这个 starter 到底给你装了什么东西,后面出问题才知道往哪查。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <!-- 如果你还要做"授权服务器"(自己签发令牌),那是另一个依赖 --> <!-- <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> --> <!-- 如果你要"作为客户端去登录第三方"(如企业微信扫码),用这个 --> <!-- <artifactId>spring-boot-starter-oauth2-client</artifactId> -->
自动配置类它做了什么
OAuth2ResourceServerAutoConfiguration检测到 spring.security.oauth2.resourceserver.* 配置或存在 JwtDecoder Bean 时,启用 JWT 认证。
OAuth2ResourceServerJwtConfiguration根据 issuer-uri 创建 NimbusJwtDecoder。启动时会去拉 /.well-known/openid-configuration,拉不到就直接启动失败。
OAuth2ResourceServerWebSecurityConfiguration把 BearerTokenAuthenticationFilter 注册进过滤器链。注意:它只负责"认证",鉴权规则还得你自己写。
SecurityAutoConfiguration没有自定义 SecurityFilterChain 时提供一条默认链,默认所有请求都要认证,并给你生成一个随机密码的 user。
启动失败:拉不到 issuer 的三种情况

① 授权服务器还没起来:本地开发时你的 SSO 容器没启动,应用就起不来。解法:测试环境用 spring.security.oauth2.resourceserver.jwt.jwk-set-uri 指向具体的 JWKS 地址,或者干脆在测试 profile 里提供一个静态公钥。

② 网络不通/需要代理:内网环境访问不了外网 SSO。解法:配 JwtDecoder 时传入自定义的 RestOperations(带代理设置),或者手动指定 jwk-set-uri。

③ issuer 地址多一个斜杠:配置写 https://sso.example.com/,而授权服务器声明的 iss 是 https://sso.example.com,字符串比对失败 → 所有令牌校验不过。这是最冤的一类 bug,排查时先把 issuer 字符串逐字符对齐。

为什么 WebSecurityConfigurerAdapter 被删掉了

这是从 Boot 2 升到 Boot 3 时,改动量最大、也最容易被搜到的老教程带偏的一处。先讲清"为什么删",你自然就知道"新的该怎么写"。

维度旧方式(适配器)新方式(Bean)
配置载体继承 WebSecurityConfigurerAdapter,重写 configure(HttpSecurity)声明一个 @Bean SecurityFilterChain
多套配置定义多个子类,各自加 @Order——类级别的顺序容易搞混多个 @Bean,每个 Bean 上加 @Order + securityMatcher,一目了然
可测试性要测就得 new 一个子类,还得处理父类里的模板方法就是一个方法返回一个对象,可以直接在测试里构造和断言
组合能力受继承关系限制,一个类只能有一套行为可以把 SecurityFilterChain 拆成多个 @Bean 方法甚至多个配置类,按需组合
状态5.7 标记 @Deprecated6.0 起彻底删除,不是废弃。Boot 3 项目里出现这个类名,只能说明代码没升级

论为什么"组合优于继承"在这里特别重要

① 继承把配置锁死在一个类里:适配器模式下,configure(HttpSecurity) 只有一个。你想给 /admin/** 一套规则、给 /api/** 另一套,就得靠 http.securityMatcher(...) 在一个方法里写 if,或者定义第二个子类——而两个子类的执行顺序由 @Order 在类上控制,新人很难看出谁先谁后。

② Bean 方式让"顺序"变成显式的:每个链是一个 Bean,@Order(1)、@Order(2) 就写在方法旁边,还带 securityMatcher 说明这段规则管哪些路径。配置即文档,不需要脑内维护一张映射表。

③ 启动期就能发现问题:适配器模式下,如果忘了 @EnableWebSecurity,可能只是行为不符合预期;而 Bean 模式下缺少 SecurityFilterChain,Boot 会给出明确的提示,并且容易用测试断言"容器里应该有 N 条链"。

④ 迁移心法:不用一行行翻译旧代码。先写下你想表达的规则(哪些路径放行、哪些要什么角色),然后直接用 Lambda DSL 写出来。机械翻译适配器代码,只会把旧问题一起搬过来。

Boot 3 的标准写法:资源服务器 + 无状态 + JSON 错误

@Configuration @EnableWebSecurity @EnableMethodSecurity // 需要 @PreAuthorize 时必须加 public class SecurityConfig { @Bean public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { http // 无状态:不创建 HttpSession,不写 JSESSIONID .sessionManagement(s -> s.sessionCreationPolicy( SessionCreationPolicy.STATELESS)) // 纯 Bearer 令牌的 API:CSRF 可关(前提是令牌不放在 Cookie 里) .csrf(AbstractHttpConfigurer::disable) // 关掉表单登录与 Basic,否则浏览器访问会跳登录页 .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .logout(AbstractHttpConfigurer::disable) // 规则顺序:从具体到宽泛 .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health", "/v3/api-docs/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/**").authenticated() .anyRequest().denyAll()) // 兜底拒绝,比 permitAll 安全 // JWT 认证:启用后就自动注册 BearerTokenAuthenticationFilter .oauth2ResourceServer(o -> o .jwt(jwt -> jwt .decoder(jwtDecoder) // 用自定义 decoder .jwtAuthenticationConverter(jwtAuthConverter) // 自定义权限映射 ) .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) .accessDeniedHandler(new BearerTokenAccessDeniedHandler())) // CORS 必须显式开启,并且要在 authorizeHttpRequests 之前生效 .cors(Customizer.withDefaults()); return http.build(); } }

JwtDecoder:默认校验了什么,还缺什么

不同来源的令牌,对应不同的解码器。这张表决定你该用哪套配置。

场景Decoder配置要点
授权服务器有 OIDC 发现文档JwtDecoders.fromIssuerLocation(issuer)一行搞定,自动发现 JWKS 与支持的算法。推荐。
只有 JWKS 地址NimbusJwtDecoder.withJwkSetUri(uri).build()不校验 iss,必须自己加 iss 校验,否则会接受同一公钥签发的其他系统令牌。
非对称,本地有公钥文件NimbusJwtDecoder.withPublicKey(pub).build()适合无法访问授权服务器的场景;公钥轮换时会失效,要有人工更新流程。
对称 HS256NimbusJwtDecoder.withSecretKey(secret).build()密钥至少 32 字节;只适合单体或内网可信场景。
非 JWT 的不透明令牌OpaqueTokenIntrospector必须每次调授权服务器的 introspection 端点,有性能开销,务必加缓存。

自定义 JwtDecoder:显式开启 aud 校验与算法白名单

@Bean public JwtDecoder jwtDecoder(@Value("${sso.issuer}") String issuer, @Value("${sso.audience}") String audience) { // 1) 从 issuer 拿到 JWKS,并限定算法白名单 NimbusJwtDecoder decoder = NimbusJwtDecoder .withIssuerLocation(issuer) .signatureAlgorithm(SignatureAlgorithm.RS256) .build(); // 2) 默认那套只校验 iss/exp/nbf,aud 要自己加 OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer); OAuth2TokenValidator<Jwt> withAudience = new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(audience)); decoder.setJwtValidator( new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience)); // 3) 排障提示:把 Spring Security 的日志级别调到 DEBUG,校验失败原因会打印出来 // logging.level.org.springframework.security=DEBUG return decoder; }
JwtDecoder 的四个坑

① 只按 jwk-set-uri 配置,以为 iss 会被校验:不会。JWKS 方案只验签名和 exp,不校验 iss 也不校验 aud。同一套 SSO 下发的、给其他系统的令牌能直接访问你的接口。

② JwtDecoders.fromIssuerLocation 启动时就发网络请求:每次启动都要调一次授权服务器,SSO 慢的话启动就慢,SSO 挂了根本起不来。生产建议把 JWKS 缓存策略和启动容错想清楚,或者用 jwk-set-uri 减少一次发现请求。

③ 时间偏移没配:集群时间不同步时会出现"刚签发的令牌就报 expired"。可以给 JwtTimestampValidator 设置 setClockSkew(默认 60 秒),但更该做的是上 NTP。

④ 同时存在两个 JwtDecoder Bean:自己定义的 + 自动配置的,注入时报"期望单个 Bean 但找到两个"。给自己定义的加 @Primary,或者用 @ConditionalOnMissingBean 的思路避免重复。

从令牌到权限:roles 与 scope 的两种映射风格

JWT 里的声明不等于 Spring Security 的权限。中间需要一个转换器把声明翻译成 GrantedAuthority,否则 hasRole/hasAuthority 永远不通过。这是资源服务器里第二条高频 403 的根因。

风格令牌里的声明配置与注意点
scope 风格"scope": "read:orders write:orders"(空格分隔的字符串)OAuth2 原生风格。转换后权限名不带前缀,要用 hasAuthority("read:orders") 或 @PreAuthorize("hasAuthority('read:orders')")。不能配前缀。
scp 风格"scp": ["read:orders"](数组)某些 IdP(如 Okta 的旧格式)用 scp。Spring 支持,但要注意它是不带前缀的 hasAuthority 语义。
roles 风格"roles": ["ADMIN","USER"]非标准声明,需要自己写 Converter 加 ROLE_ 前缀,才能用 hasRole("ADMIN")。
AD 组声明"groups": ["/dept/it"]企业 IdP 常见。通常需要按前缀过滤(只取 /dept/ 开头的),不然一个用户可能被映射出几百个权限,塞爆内存。

两种风格各配一次

// 风格一:scope 声明 —— 用默认配置即可(Spring 自带) // 令牌里 scope="read:orders write:orders" // 得到权限:read:orders、write:orders(无前缀) @Bean public JwtAuthenticationConverter scopeConverter() { // 只需把 authorities 前缀设为空,其他保持默认(默认就是空) var conv = new JwtAuthenticationConverter(); JwtGrantedAuthoritiesConverter gac = new JwtGrantedAuthoritiesConverter(); gac.setAuthorityPrefix(""); // 不加 SCOPE_ 前缀 gac.setAuthoritiesClaimName("scope"); conv.setJwtGrantedAuthoritiesConverter(gac); return conv; } // 风格二:roles 声明 —— 加 ROLE_ 前缀,让 hasRole 能用 @Bean public JwtAuthenticationConverter roleConverter() { JwtGrantedAuthoritiesConverter gac = new JwtGrantedAuthoritiesConverter(); gac.setAuthoritiesClaimName("roles"); gac.setAuthorityPrefix("ROLE_"); // roles:["ADMIN"] → ROLE_ADMIN JwtAuthenticationConverter conv = new JwtAuthenticationConverter(); conv.setJwtGrantedAuthoritiesConverter(gac); // 默认 principal 是 Jwt 对象;改成用某个声明当用户名,方便 @AuthenticationPrincipal conv.setPrincipalClaimName("preferred_username"); return conv; }

论scope 和 role 在设计上是两件事

① scope 是给"应用"的,role 是给"用户"的:scope 表达"这个客户端被允许做哪些操作",通常由用户在授权页上同意;role 表达"这个用户在组织里是什么身份"。混用会导致权限模型无法表达"管理员用只读客户端访问"这种组合。

② 建议同时使用,各司其职:网关层用 scope 做粗粒度限制("这个客户端只能读"),业务层用 role 做细粒度判断("这个用户是管理员")。两层都过才放行,这就是纵深防御。

③ 别把权限集合做得太大:有些 IdP 会把用户所有 AD 组都塞进令牌,一个用户几百个组,令牌体积暴涨、每次请求都要反序列化几百个权限,还容易触发请求头大小限制(Nginx 默认 large_client_header_buffers 有限)。只映射你真正会用到的权限。

④ 权限应该"变少"而不是"变多":用户角色调整后,令牌里的旧权限会一直有效到过期。所以权限判断不能只看令牌——敏感操作还要查一次数据库或缓存里的实时权限。

方法级鉴权与规则顺序:什么时候该写在方法上

URL 级规则(requestMatchers)写在链上,方法级规则(@PreAuthorize)写在 Service 上。判断标准是"这条规则能不能用路径表达"。能用路径表达就写在链上(集中、好审计),涉及数据归属就只能写在方法上。

@Service public class OrderService { // scope 风格:注意 hasAuthority 不补前缀,用令牌里原样的值 @PreAuthorize("hasAuthority('read:orders')") public Order getById(long id) { return null; } // role 风格:hasRole 会自动补 ROLE_,别自己再写 @PreAuthorize("hasRole('ADMIN')") public void cancelAny(long id) { } // 数据归属:用 @PreAuthorize + 自己的 Bean 判断(推荐,逻辑可测试) @PreAuthorize("@orderAuth.isOwner(#id, authentication)") public void confirm(long id) { } // principal 直接注入 Controller,比到处 getContext() 干净 @GetMapping("/me") public UserVO me(@AuthenticationPrincipal Jwt jwt) { return new UserVO(jwt.getSubject(), jwt.getClaimAsString("preferred_username")); } }
三个必须记住的顺序问题

① @PreAuthorize 抛的是 AccessDeniedException,会让接口返回 500:因为此时已经离开了 ExceptionTranslationFilter 的视野。必须在 @RestControllerAdvice 里加 @ExceptionHandler(AccessDeniedException.class) 返回 403,否则权限不足在监控里会显示成"服务错误",掩盖真实原因。

② @EnableMethodSecurity 与 @PreAuthorize 顺序无关,但漏了就完全静默失效。建议在配置类上加好之后写一个测试用例(期望得到 403 的调用必须真的返回 403),把它钉死在 CI 里。

③ 规则顺序错会导致 permitAll 覆盖鉴权:requestMatchers("/api/**").permitAll() 写在 requestMatchers("/api/admin/**").hasRole("ADMIN") 之前,管理接口就变成完全公开了。这是一类非常隐蔽的越权漏洞,代码审查时重点看顺序。

CORS 与 Security 的配合:为什么会莫名 403

前后端分离项目里,"浏览器说 CORS 报错,但 Postman 里请求正常"是极高频的问题。原因是请求根本没走到你的 Controller——被安全链或 CORS 处理器拦在了前面。

现象根因修法
预检(OPTIONS)403预检请求不携带 Authorization 头(浏览器规范如此)。如果你的安全链要求所有请求认证,预检就被拒,浏览器直接报 CORS 错误,真正的请求压根没发。① 开启 http.cors(Customizer.withDefaults()),让 CorsFilter 在 AuthorizationFilter 之前处理;② 或者显式 requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()。前者是正解。
配置了 CorsConfigurationSource 但仍报 CORS只声明了 Bean,没在 HttpSecurity 上开 .cors()。此时 Spring Security 不会启用 CORS 过滤。安全链上加 .cors(Customizer.withDefaults()),它会自动找到你声明的 CorsConfigurationSource Bean。
allowedOrigins("*") 配了但带 Cookie 失败规范禁止 Access-Control-Allow-Origin: * 与 allowCredentials(true) 同时使用。改用 allowedOriginPatterns(支持通配且能与凭证共存),并明确列出允许的域名。
401 却看不到 WWW-Authenticate 头浏览器默认不把该头暴露给 JS,预检没允许。在 CORS 配置的 exposedHeaders 里加上 WWW-Authenticate。
网关和后端都配了 CORS,响应头重复两层都写了 Access-Control-Allow-Origin,浏览器认为重复头非法。CORS 只在网关一处配,或者网关统一配、后端关闭。
@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); // 生产环境不要用 "*";用 allowedOriginPatterns 支持子域通配 cfg.setAllowedOriginPatterns(List.of("https://*.example.com")); cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); cfg.setAllowedHeaders(List.of("Authorization", "Content-Type")); cfg.setExposedHeaders(List.of("WWW-Authenticate")); cfg.setAllowCredentials(true); // 需要带 Cookie 时才开,否则保持 false cfg.setMaxAge(3600L); // 预检结果缓存 1 小时,减少 OPTIONS 请求 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", cfg); return source; }
记
Boot 3 安全与 OAuth2 一句话总结

① 加 spring-boot-starter-oauth2-resource-server + 一个 SecurityFilterChain Bean,服务就是资源服务器;鉴权规则仍要自己写。

② WebSecurityConfigurerAdapter 在 6.0 已被删除,原因是从"继承"换成了"Bean 组合",多条链用 @Order + securityMatcher 表达。

③ 默认只校验签名、iss、exp、nbf,aud 要自己加;算法白名单要显式限定,防算法混淆。

④ 令牌声明不会自动变成权限,要用 JwtAuthenticationConverter 映射;scope 无前缀用 hasAuthority,roles 加 ROLE_ 才用 hasRole。

⑤ @PreAuthorize 的 AccessDeniedException 要自己翻译成 403,否则监控里全是"500 服务错误"。

⑥ CORS 必须在安全链上显式开启,让 CorsFilter 排在鉴权之前,否则预检 OPTIONS 被 403,前端只看到一片 CORS 报错。

章末面试 · Boot 3 安全(4 题)

1.(概念题)Spring Security 6 为什么删掉 WebSecurityConfigurerAdapter?新的写法是什么?

查看答案

答案:核心是从继承转向组合。适配器模式把配置锁在一个类的模板方法里,多套规则要靠多子类 + 类级别 @Order,顺序不直观、也不方便测试。新写法是声明 @Bean SecurityFilterChain,用 Lambda DSL 描述规则,多条链各自加 @Order 与 securityMatcher,配置即文档,也更容易做单元测试。解析:注意它是 6.0 起被删除而不是废弃,Boot 3 项目里出现这个类名说明代码没升到位。

2.(排错题)资源服务器只配了 jwk-set-uri,令牌验签通过、exp 也没过期,但接口一直 403。请给出至少两个可能原因。

查看答案

可能原因:① 权限没映射上——JWT 里的 scope/roles 声明没有通过 JwtAuthenticationConverter 变成 GrantedAuthority,或者前缀不匹配(用 hasRole("ADMIN") 查 ROLE_ADMIN,但映射出来的只有 ADMIN)。② aud 校验失败——如果你自己加了 audience 校验而令牌的 aud 里没有当前服务标识。③ 规则顺序问题——前面的 anyRequest().authenticated() 或更宽泛的规则先匹配上了。排查建议:先打开 logging.level.org.springframework.security=DEBUG,日志里会明确打印"授权失败的原因是缺少某个 authority",比猜快得多。解析:403 永远只有两个方向——权限没映射上,或者规则没匹配对。

3.(选择题)前端带 Authorization 头请求接口,Postman 正常、浏览器报 CORS 错误,Network 面板里预检 OPTIONS 是 403。最直接的修法是什么?

查看答案

答案:在安全链上开启 CORS:http.cors(Customizer.withDefaults()),并提供一个 CorsConfigurationSource Bean。因为预检请求不携带 Authorization 头(这是浏览器规范),如果鉴权过滤器排在 CORS 处理之前,预检就会被判定为未认证而返回 403,浏览器随即报 CORS 错误——真正的业务请求根本没有发出。解析:开启 .cors() 会让 CorsFilter 排在鉴权之前,这是顺序问题而不是头配置问题。用 permitAll 放行 OPTIONS 是次优解。

4.(设计题)令牌里的 roles 声明和 scope 声明,在设计权限模型时该怎么分工?

查看答案

参考:scope 面向客户端应用——表达"这个应用被用户授权可以做什么"(如 read:orders),由用户在授权页确认,适合在网关做粗粒度限制。roles 面向用户身份——表达"这个人在组织里是谁"(如 ADMIN),适合在业务层做细粒度判断。建议两层都过才放行:网关验 scope(限制客户端能力边界),业务服务验 role(限制用户操作范围),构成纵深防御。加分:强调敏感操作不能只信令牌里的权限(令牌未过期时权限变更有滞后),要查一次实时的权限缓存。解析:面试官想听的是"你能把两种声明的语义区别讲清楚,并能给出分层落地方案"。