Spring Boot 3 安全与 OAuth2:把服务改造成资源服务器
第 12 章(ch11sec)我们用一个内存用户跑通了登录与放行。真实项目里,认证往往由统一的 SSO 负责,你的服务只负责"拿着令牌来判断能不能进"。这种角色在 OAuth2 里叫 Resource Server(资源服务器),而 Spring Boot 3 把它简化到了"加一个依赖 + 写一个 Bean"。
起步:加一个依赖,服务就变成了资源服务器
先别急着写配置。搞清楚这个 starter 到底给你装了什么东西,后面出问题才知道往哪查。
| 自动配置类 | 它做了什么 |
|---|---|
OAuth2ResourceServerAutoConfiguration | 检测到 spring.security.oauth2.resourceserver.* 配置或存在 JwtDecoder Bean 时,启用 JWT 认证。 |
OAuth2ResourceServerJwtConfiguration | 根据 issuer-uri 创建 NimbusJwtDecoder。启动时会去拉 /.well-known/openid-configuration,拉不到就直接启动失败。 |
OAuth2ResourceServerWebSecurityConfiguration | 把 BearerTokenAuthenticationFilter 注册进过滤器链。注意:它只负责"认证",鉴权规则还得你自己写。 |
SecurityAutoConfiguration | 没有自定义 SecurityFilterChain 时提供一条默认链,默认所有请求都要认证,并给你生成一个随机密码的 user。 |
① 授权服务器还没起来:本地开发时你的 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 标记 @Deprecated | 6.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 错误
JwtDecoder:默认校验了什么,还缺什么
不同来源的令牌,对应不同的解码器。这张表决定你该用哪套配置。
| 场景 | Decoder | 配置要点 |
|---|---|---|
| 授权服务器有 OIDC 发现文档 | JwtDecoders.fromIssuerLocation(issuer) | 一行搞定,自动发现 JWKS 与支持的算法。推荐。 |
| 只有 JWKS 地址 | NimbusJwtDecoder.withJwkSetUri(uri).build() | 不校验 iss,必须自己加 iss 校验,否则会接受同一公钥签发的其他系统令牌。 |
| 非对称,本地有公钥文件 | NimbusJwtDecoder.withPublicKey(pub).build() | 适合无法访问授权服务器的场景;公钥轮换时会失效,要有人工更新流程。 |
| 对称 HS256 | NimbusJwtDecoder.withSecretKey(secret).build() | 密钥至少 32 字节;只适合单体或内网可信场景。 |
| 非 JWT 的不透明令牌 | OpaqueTokenIntrospector | 必须每次调授权服务器的 introspection 端点,有性能开销,务必加缓存。 |
自定义 JwtDecoder:显式开启 aud 校验与算法白名单
① 只按 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 和 role 在设计上是两件事
① scope 是给"应用"的,role 是给"用户"的:scope 表达"这个客户端被允许做哪些操作",通常由用户在授权页上同意;role 表达"这个用户在组织里是什么身份"。混用会导致权限模型无法表达"管理员用只读客户端访问"这种组合。
② 建议同时使用,各司其职:网关层用 scope 做粗粒度限制("这个客户端只能读"),业务层用 role 做细粒度判断("这个用户是管理员")。两层都过才放行,这就是纵深防御。
③ 别把权限集合做得太大:有些 IdP 会把用户所有 AD 组都塞进令牌,一个用户几百个组,令牌体积暴涨、每次请求都要反序列化几百个权限,还容易触发请求头大小限制(Nginx 默认 large_client_header_buffers 有限)。只映射你真正会用到的权限。
④ 权限应该"变少"而不是"变多":用户角色调整后,令牌里的旧权限会一直有效到过期。所以权限判断不能只看令牌——敏感操作还要查一次数据库或缓存里的实时权限。
方法级鉴权与规则顺序:什么时候该写在方法上
URL 级规则(requestMatchers)写在链上,方法级规则(@PreAuthorize)写在 Service 上。判断标准是"这条规则能不能用路径表达"。能用路径表达就写在链上(集中、好审计),涉及数据归属就只能写在方法上。
① @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 只在网关一处配,或者网关统一配、后端关闭。 |
① 加 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 报错。
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(限制用户操作范围),构成纵深防御。加分:强调敏感操作不能只信令牌里的权限(令牌未过期时权限变更有滞后),要查一次实时的权限缓存。解析:面试官想听的是"你能把两种声明的语义区别讲清楚,并能给出分层落地方案"。