ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Security OAuth2实战:授权服务器与资源服务搭建详解

Spring Security OAuth2实战:授权服务器与资源服务搭建详解 1. 先聊清楚什么情况下你才需要面对这套东西Spring Security OAuth2 从来不是“学完就炫”的那种技术它通常是被业务倒逼出来的。比如你手里有四个内部系统都靠各自的 Session 登录老板说你们搞一个统一的登录入口账号打通权限也要统一控制又比如你开放了接口给第三方平台对接对方要的不是一个共享密钥而是规范化的“授权—拿令牌—调接口”流程。这个时候Session 那套逻辑明显够不着了OAuth2 就是最不坏的选择。网上聊 Spring Security OAuth2 的文章不少但大多数要么是照抄官方文档的配置要么只讲单点登录的快乐路径真到了你本地启动、联调、接口鉴权、上线运维的时候问题一个接一个。这篇我把自己的实操过程完整捋一遍不整那些花架子尽量说人话带你从“能用”走到“能上线”。适合三类人看刚接手老旧系统要拆认证模块的同事准备给团队搭统一认证服务的人以及面试前想搞明白授权码流程到底怎么走的人。先说结论OAuth2 这套东西核心思路就是把“你是谁”和“你能干嘛”分开管理。Spring Security 帮你把大框架搭好但真正决定项目体验的是你对授权模式的选择、令牌是怎么签发的、资源服务怎么校验权限。下面从设计开始一步步拆开讲。2. 整体设计先回答三个问题再动手配代码2.1 为什么这套框架值得折腾而不是自己写拦截器很多团队一开始都是“伪 OAuth2”自己签发 JWT拦截器校验签名接口上做个注解做权限控制。小程序阶段这没问题但系统一多就尴尬了——同一个用户在三套系统里算三次身份改密码以后已签发的令牌还不能作废跨系统跳转登录更是要手拼一堆加密参数。OAuth2 协议本质是把“授权”这一层单独抽出来。用户在哪登录、令牌怎么发、令牌怎么验、权限怎么查各司其职。Spring Security 对这套协议实现成熟社区踩坑记录多接手的人不用重新发明轮子。协议的好处是你换语言、换框架甚至把认证服务从 Java 换成别的技术栈消费方不需要跟着改。也有人问既然会话 Cookie 也能实现单点登录为啥要 OAuth2简单说Session 模式是“我在每个应用里记住你”OAuth2 是“我在认证中心给你发一张通行证”。通行证模式下后端服务间的调用、移动端、第三方网页集成都能统一走令牌不必担心浏览器 Cookie 跨域的问题。比如以后你加一个 Flutter 客户端能否复用现有登录体系关键就在于认证层是不是独立的令牌服务。2.2 授权模式怎么选别一上来就抄授权码OAuth2 定义了多种授权模式不同场景用不同的模式这是最容易踩的第一个坑。我见过不少项目明明是两个后端服务内网直连非要用授权码模式结果 redirect_uri 配来配去回调地址都写不对白白折腾两天。这里给出实用的选型建议前后端分离的 Web 应用有浏览器参与登录用授权码模式Authorization Code。如果前端是单页应用且没有后端帮它保管 client secret必须配 PKCE 扩展否则 client secret 暴露在浏览器里没有意义。服务端到服务端的内部调用比如运维平台调用订单中心双方都在内网用客户端模式Client Credentials最简单也够用。移动端 App 登录一般也是授权码加 PKCE或者直接走自定义手机号验证码登录后换取令牌。密码模式Resource Owner Password Credentials在新的 OAuth 2.1 草案中已经被移除Spring Authorization Server 默认不实现。老项目可能还在用但新项目建议别碰。实操下来一套系统里最常见的组合是面向用户的浏览器端走授权码 PKCE面向服务间调用的走客户端模式。两者在同一个授权服务里同时支持Spring Security 是天然支持的只需要注册多个 RegisteredClient。2.3 认证服务与资源服务拆开还是一家子Spring Security OAuth2 体系里两个名词绕不开授权服务器Authorization Server和资源服务器Resource Server。授权服务器管登录、管发令牌资源服务器管接口校验也就是你业务服务那一侧要做的事。新项目上如果你用 Spring Boot 3.x最好的组合是 Spring Authorization Server 提供授权服务业务服务用 spring-boot-starter-oauth2-resource-server 做资源服务。授权服务器可以抽成独立服务也可以先和某个业务服务放一起前期不物理拆分也没关系但代码边界要分开否则后面想拆的时候重构成本很大。我在实际项目里习惯把授权服务单独建一个 Spring Boot 工程只做认证相关的事包括登录页、令牌端点、客户端管理、用户查询。其他业务服务统一配置资源服务器模式只认授权服务签发的 JWT。这样加新业务系统时对方只需要引入了资源服务器依赖配好 issuer-uri 和 JWK 地址两三个配置项就接入完成了。3. 核心细节解析JWT 令牌与授权码流程的关键点3.1 令牌不是“随便发一下”就行签名必须非对称令牌的安全性是整套系统的地基。开发阶段怎么图方便都行HS256 对称签名一把密钥走天下但生产环境一旦有多套服务对称密钥的传递和轮换就是噩梦。建议直接上 RSA 非对称签名授权服务器持有私钥签发 JWT资源服务器只通过 JWK 端点拿公钥验签密钥根本不用分发到业务服务。Spring Authorization Server 支持配置 JWK 密钥一套密钥对还能挂多个密钥key id 轮换。实际项目中我会生成一个 RSA 密钥对配置进授权服务器JWK Set 端点会自动暴露资源服务器拿到 issuer 后也会主动去拉取公钥。这样即使密钥要更新只要在 JWK 里同时保留新旧两份旧令牌还能在过期前继续用平滑过渡。JWT 里还可以放自定义字段比如用户昵称、部门编号标准方式是实现 OAuth2TokenCustomizer 接口往 claims 里加。但注意不要把大对象塞进去令牌每次请求都会携带Payload 越小越好。权限大的列表不要全塞进 JWT令牌里只放用户主键和角色编码具体权限数据让业务服务去数据库查或者走缓存。3.2 授权码流程看着绕核心是 state 参数授权码模式的流程网上一搜一大把图片但很多人实操时容易忽略 state 参数。这个参数是前端生成的随机值发起授权请求时带上授权服务器回调时原样返回前端必须校验回调里的 state 和自己发的是一致的否则就可能被 CSRF 攻击。用 Spring Security 配置授权服务器时会默认校验 redirect_uri 是否已注册这一步千万不能省。很多小白为了开发方便写一个“接受任意回跳地址”的配置这是生产事故的温床。一旦 redirect_uri 可被篡改攻击者就能诱导用户授权给恶意回调令牌被截走。另外授权码本身是一次性的过期时间默认很短。Spring Authorization Server 的授权码默认有效期 5 分钟回调速度正常情况下完全够用。如果遇到授权码过期常见的原因是客户端回调地址太慢或者异步流程把授权码传丢了一段。排查的时候先看授权码是不是已经在授权页面停留超过时限。3.3 登录页、权限页面与“记住我”的取舍Spring Authorization Server 自带一个默认登录页样式简陋但功能完整。生产环境一般会自定义登录页配置方式是在授权服务器的 SecurityFilterChain 里指定 loginPage 路径Controller 返回你自己的模板或静态页面即可。说起“记住我”很多人期望勾选后一个月内免登录。在 OAuth2 授权码模式里这实际上发生在授权服务器这一层。Spring Security 支持 remember-me 机制登录时勾选后会在浏览器里种一个持久化 Cookie下次访问授权服务器的登录页时直接续上会话。这个功能比较适合对内系统对外互联网产品建议改成验证码验证码加短信验证码之类的强认证手段不要只用“记住我”背书。还要注意一个反直觉的细节授权页面会显示“是否允许应用访问”之类的确认。如果前后端都是自己的系统这个确认页体验并不好。Spring Authorization Server 支持跳过授权确认页配置 consentRequired(false) 即可。但如果你开放给第三方开发者必须保留确认页这是协议和信任模型的一部分。4. 实操过程从建工程到全部跑通4.1 依赖与版本选型Spring Boot 2.7 和 3.x 差异不小这里必须先打个预防针Spring Security OAuth2 的老模块 spring-security-oauth2-autoconfigure 已经进入维护模式官方不再推荐新项目使用。新项目直接用 Spring Authorization Server。不同版本配置差异巨大如果你是照着老教程写 EnableAuthorizationServer在新版本中大概率编译都不通过。我本地环境用的是 Spring Boot 3.2 Spring Authorization Server 1.2。依赖如下授信服务器和资源服务器分别放在两个 Module 或者两个独立工程里。以下是授权服务的关键依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency如果你用的是 Spring Boot 2.7那么对应版本是 Spring Authorization Server 1.0配置方式大体一致但个别默认值不同比如 token 端点的一些参数名。先确认好你的 Boot 大版本再选对应的 SAS 版本别在依赖上浪费一小时。4.2 授权服务器最小配置一个 SecurityFilterChain 搞定大半新版 Spring Authorization Server 最核心的配置就是组装一个 SecurityFilterChain定义授权服务器的所有端点行为。下面是一个经过裁剪可直接运行的基础配置基于 Spring Boot 3.2 SAS 1.2。我刻意用内存版 RegisteredClientRepository 示意方便你本地最快跑通。Configuration EnableWebSecurity public class AuthorizationServerConfig { Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); // 启用 OpenID Connect 1.0 return http.formLogin(Customizer.withDefaults()).build(); } Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient webClient RegisteredClient.withId(web-client) .clientId(web-client) .clientSecret({noop}web-secret) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://127.0.0.1:8080/login/oauth2/code/web-client) .scope(openid, profile, message.read) .build(); return new InMemoryRegisteredClientRepository(webClient); } Bean public JWKSourceSecurityContext jwkSource() { RSAKey rsaKey generateRsaKey(); JWKSet jwkSet new JWKSet(rsaKey); return (jwkSelector, securityContext) - jwkSelector.select(jwkSet); } }注意 client secret 我写的是 {noop}web-secret意思是明文不加密只用来本地测试。生产环境必须用 {bcrypt} 前缀加 BCrypt 加密后的串。配置里我同时加了 refresh_token 授权类型这样用户令牌过期后可以用刷新令牌续期不必重新扫码登录。但刷新令牌也是一把双刃剑它有效期长一旦泄露等于给攻击者长期通行证。所以刷新令牌一定要轮换也就是每次刷新后旧令牌作废Spring Authorization Server 默认行为已经是这样。4.3 资源服务器配置两三行让业务服务接入认证体系业务服务接入起来真的不难关键是在配置里指定 issuer-uri让 Spring Security 自己去授权服务器拉取公钥。以下是资源服务侧的关键配置spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9000如果授权服务和资源服务不在同一个网络环境下issuer-uri 需要是资源服务能访问到的地址。资源服务启动时会访问 {issuer}/.well-known/openid-configuration然后从中发现 JWK 地址。对应的 SecurityFilterChain 配置也很简洁Bean public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception { http .oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults())) .authorizeHttpRequests(auth - auth .requestMatchers(/public/**).permitAll() .anyRequest().authenticated() ); return http.build(); }本地测试时你会发现在授权服务器拿到的 JWT拿到资源服务来请求直接就被信任了整个过程不需要共享任何密钥。这就是非对称签名和 JWK 发现机制带来的好处也是 Spring Security 这套设计最舒服的地方。4.4 细粒度权限方法级安全加上角色判断仅靠 URL 的 authenticated() 控制并不够业务上常常需要做到“张三能看订单李四不能”。Spring Security 的办法是启用方法级安全在 Controller 或 Service 方法上直接加注解。启动类加 EnableMethodSecurity然后就可以在方法上写PreAuthorize(hasRole(ADMIN)) public void deleteOrder(Long orderId) { ... } PreAuthorize(hasAuthority(SCOPE_message.read)) public String readMessage() { ... }这里两个注解语义不同hasRole 判断的是 JWT 里的角色信息hasAuthority(SCOPE_xxx) 判断的是授权范围两者都是 OAuth2 体系中的常见校验点。一般建议权限模型分两层外层是 scope代表应用级别的能力内层是用户在某个业务领域的角色代表数据权限。配置上不要混在一起否则后续需求一变就头疼。5. 常见问题与排查技巧实录5.1 授权服务器与资源服务的典型报错速查很多问题不是代码写错而是对协议细节的理解不到位。这里列一个我踩过多次的排查表基本覆盖了上线初期大部分高频问题。现象常见原因排查与解决授权码回调报 invalid_requestredirect_uri 与注册时不一致检查数据库 RegisteredClient 里保存的回跳地址必须完全匹配包括端口和路径拿 token 时报 invalid_grant授权码已过期或已使用授权码一次性看看是否重复使用重新发起一次授权流程资源服务返回 401令牌缺失或校验失败检查 Authorization 头格式是否为 Bearer xxx检查公钥是否能正确拉取到资源服务返回 403令牌有效但权限不足看 scope 是否包含接口要求的权限方法级安全注解是否和请求路径绑定刷新令牌时报 invalid_grant刷新令牌被轮换后旧令牌再被使用刷新成功后旧 token 作废客户端应该用新令牌重新发起请求启动授权服务器端口被占配置端口冲突授权服务器默认 9000检查是否有其他服务占用改 server.port排查时最有效的办法是先把 JWT 复制出来去 jwt.io 之类的工具上解码看看 claims。能看清令牌里到底有没有对应的 scope 和角色比猜要快得多。生产环境不要随便贴 JWT 到第三方网站自己用代码写个小的解包工具或者直接用命令行解密签名。5.2 数据表设计内存注册客户端只是假象InMemoryRegisteredClientRepository 只适合本地验证流程重启后数据全丢生产环境必须换成 JDBC。Spring Authorization Server 自带一套建表 SQL 脚本在官方仓库里有 oauth2-registered-client-schema.sql、oauth2-authorization-schema.sql、oauth2-authorization-consent-schema.sql。建议在数据库初始化脚本中直接引入。我正式项目的表设计大致有三块客户端信息表存 client_id、client_secret、scope、授权类型、回调地址等授权记录表存授权码、访问令牌、刷新令牌、对应用户信息同意记录表存用户的授权确认记录。用户表是业务库自己的通过 principalName 关联。有一点要留意Spring Authorization Server 的授权记录表里令牌是加密存储的不是明文所以数据库泄露不会直接导致令牌泄露。这看似多此一举实际上很重要你在写自定义 SQL 或者备份迁移的时候不要试图去改令牌字段。5.3 生产环境的安全细节密钥轮换、日志脱敏与统一网关上线前有几件事必须做。第一把开发用的 {noop} 明文密码全部换掉客户端密钥用 BCrypt 加密后存数据库。第二准备一套密钥轮换流程。JWK 里支持多组密钥新老并存老密钥至少要存活一个完整令牌生命周期以上的时间才能保证所有已签发令牌自然过期。日志脱敏是另一个容易被忽略的问题但最容易泄露。很多框架在 Debug 级别会打印 Authorization 头一旦你把 OAuth2 客户端的 client_secret 或用户令牌打印到日志再配合集中日志平台权限不够严格事故就离你不远了。我在项目里专门写了一个日志过滤器把 Authorization 头里的令牌统一替换成后四位脱敏串避免误打。建议在业务服务前面加一层统一的网关负责初始的请求转发和日志记录资源服务器是在网关后面的。Spring Security 的过滤器顺序很关键认证过滤器要在业务过滤器之前执行否则你写一个自定义 Filter 拿到 request 里的 token发现还没被 Security 处理成 Authentication会被坑好久。6. 一点总结之外的实在经验我最后再分享一个最实际的建议在本地把 OAuth2 的完整调用链用 curl 手动跑一遍比任何单元测试都有用。先访问授权页面模一次浏览器登录流程拿到授权码然后用授权码去换令牌最后用令牌访问受保护的接口。手动跑通以后你对协议的每一步都心里有底后面排查问题基本都靠这个感觉。后续你可以在这个基础上扩展很多内容比如把授权服务器对接自己的用户体系、短信验证码、企业微信扫码给资源服务加上动态权限缓存把密钥的管理接入配置中心实现轮换自动化。这些东西都是在基座搭好之后才有意义。我个人的经验是Spring Security OAuth2 最大的门槛不在代码而在心态。它是协议框架不是 CRUD 脚手架你要先接受它的抽象模型再接受它的配置风格。一旦链条跑通再回头看以前的 Session 登录、手动拦截器你会发现这套东西解决的是整个组织级的信任问题值得你花时间把它琢磨透。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进