ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot OAuth2授权登录实战:授权码模式与Spring Authorization Server踩坑指南

SpringBoot OAuth2授权登录实战:授权码模式与Spring Authorization Server踩坑指南 如果你和我一样接手过一个要给多个子系统做统一登录的平台就一定理解为什么网上搜SpringBoot OAuth2授权登录会翻到那么多互相矛盾的帖子。表面看只是从一个页面跳到另一个页面登录实际落地时授权服务器、资源服务器、客户端三端的配置一环扣一环Spring Boot版本稍微高一点以前能用的依赖直接给你抛自动配置失效的错误排查起来非常磨人。这篇文章把我在SpringBoot下实现OAuth2授权登录的完整过程和踩坑记录整理出来从授权码模式的流程本质到Spring Authorization Server的选型与配置再到资源服务的token校验细节和前端对接每一步都给出可以直接参考的代码和配置。准备自建登录平台、或者被各种登录集成搞得头疼的Java后端同学可以对照着走一遍。1. 先想清楚OAuth2授权登录到底解决了什么问题1.1 四个角色一次借书流程OAuth2最容易被搞蒙的是它的四个角色资源所有者、客户端、授权服务器、资源服务器。不少新手看到这些名词直接放弃其实用一个生活例子就能说明白。假设你是一家图书馆的管理员图书馆有你的联系方式、借阅记录资源服务器你本人是资源所有者。现在一家论文机构客户端想核对你的身份信息它不能直接找图书馆前台要你的密码而是把你引导到图书馆官网的授权页面你输完账号密码并同意之后图书馆告诉论文机构这位读者允许你读取他的联系方式但借阅记录不能看。论文机构拿到这个有限的许可凭证再去调用图书馆的资料接口。对应到系统里就是用户是资源所有者让他输入密码的地方是授权服务器想获取用户信息的业务系统是客户端存放用户资料和业务数据的接口是资源服务器。OAuth2的核心就是资源所有者把一部分访问权限授权给客户端而不是把密码交出去。1.2 授权码模式为什么是主流OAuth2协议定义了多种授权模式实际开发中90%的场景用的都是授权码模式。这个模式把流程分成两个阶段第一阶段客户端把用户引导到授权服务器的登录和授权页面拿到一个一次性授权码code第二阶段客户端拿着这个code再出示自己的client_id和client_secret在服务端后台向授权服务器的token端点换取access_token。为什么要隔一个code核心目的是保护客户端密钥。如果客户端是浏览器里的前端应用把client_secret直接暴露在页面上等于把钥匙放在家门口脚垫下面。授权码模式要求换token的请求必须由服务端发出client_secret只在服务端到服务端的通道里出现泄露面小得多。我在项目里实际操作下来完整流程是下面这样前端引导用户访问授权服务器的authorize端点带client_id、redirect_uri、scope、state。授权服务器发现用户未登录跳转到统一登录页。用户输入账号密码登录成功系统判断是否要展示授权确认页。授权服务器重定向回客户端的回调地址URL上带着code和state。客户端后端用code client_id client_secret到token端点换access_token。客户端拿着access_token去资源服务器请求用户信息。客户端拿到用户信息后建立自己的会话体系后续业务请求不再走OAuth2。有人会问那简化模式implicit和密码模式password呢简化模式因为code换token过程容易被截获在新版OAuth 2.1安全建议里已经基本被弃用密码模式要求用户把账号密码直接交给客户端和OAuth2的初衷完全违背除非客户端和授权服务器是同一家公司且传输链路完全可信否则不要用。我在做技术方案时只考虑授权码模式简单、安全、生态支持也最好。2. 技术选型与版本选择的教训2.1 为什么放弃spring-security-oauth2网上大量老教程里SpringBoot集成OAuth2都会出现两个注解EnableAuthorizationServer和EnableResourceServer。这套东西来自Spring Security OAuth项目但官方从Spring Security 5.0开始就把它标记为维护模式之后不再加新功能。更关键的是Spring Boot 2.5之后官方把它的自动配置也移除了你就算在pom里加上依赖注解也不会生效。我自己第一次做的时候就是踩了这个坑照着老文章写了一个下午启动后所有配置纹丝不动控制台连个报错都不给全靠看源码才发现是版本问题。如果推高版本比如把项目升级到Spring Boot 3.x老的spring-security-oauth2依赖和Spring Security 6.x的包结构冲突非常严重启动时经常会遇到找不到类、循环依赖这类问题。所以现在我看到任何还在用EnableAuthorizationServer的教程都会直接跳过那套方案只适合历史项目维护不适合新项目选型。2.2 新方案Spring Authorization Server的关键依赖官方的继任者是Spring Authorization Server从1.0版本开始就能用于生产环境。这个项目并不是简单把旧库换个包名而是基于Spring Security 5.7重新实现的授权服务器天然支持OIDC、JWK、客户端动态注册等能力也更贴合OAuth 2.1的安全规范。依赖坐标非常简单dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency版本对应关系大概是这样的Spring Boot版本Spring Authorization Server版本2.7.x1.0.x3.0.x / 3.1.x1.1.x / 1.2.x3.2.x / 3.3.x1.3.x / 1.4.x最稳妥的做法是锁定Spring Boot版本后去Maven仓库里选对应SAS最新的稳定版本。别只看网上博客写的版本号不同Boot的自动配置兼容性差很多尤其是Spring Boot 3.x之后包名、自动配置机制、Servlet API版本都有变化乱配版本基本是给自己挖坑。2.3 手写token方案适不适合你如果只是公司内部三五个系统想统一登录上全套授权服务器反而增加维护成本。这时候很多团队选择自己写一套轻量的token方案数据库里存client_id和client_secret登录成功后自己用UUID生成tokenRedis里存token对应用户信息再写一个拦截器校验Authorization头。这个方案的好处是逻辑完全可控大概半天就能跑通缺点是它并不算标准OAuth2以后如果有外部系统需要按标准协议接入比如做开放平台、对接企业微信授权还得再做一层适配。我当时判断的标准是三年内有对外系统接入就走Spring Authorization Server只是内部系统想共享登录态手写token方案完全够用。两种方案没有绝对好坏关键看业务边界。3. 从零搭建OAuth2授权服务3.1 数据库表设计客户端、授权记录、用户绑定用Spring Authorization Server时官方提供了标准的数据库脚本包括oauth2_registered_client注册客户端表、oauth2_authorization授权记录表、oauth2_authorization_consent授权确认表。但如果想快速理解机制可以先用一张简化的客户端表CREATE TABLE oauth2_client ( id VARCHAR(64) PRIMARY KEY, client_id VARCHAR(64) NOT NULL UNIQUE, client_secret VARCHAR(255) NOT NULL, redirect_uri VARCHAR(512), scope VARCHAR(255), grant_type VARCHAR(64), enabled TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );设计时注意几点。client_secret存的是BCrypt加密后的密文不是明文这一点和数据表里的密码字段同理不能让DBA直接看到密钥。grant_type和scope都是用逗号分隔的字符串因为一个客户端往往支持多种授权类型和多个scope为了省表结构我才用逗号分隔生产环境如果追求规范还是建议拆关联表。登录中心还需要一张用户绑定表把授权服务器的用户ID和各个子系统的用户ID关联起来CREATE TABLE oauth2_user_binding ( id BIGINT PRIMARY KEY, client_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, binding_user_id VARCHAR(64) NOT NULL, create_time DATETIME );这里是最容易忽略的一层。用户在授权服务器的账号体系和在子系统的账号体系往往不是一一对应的有人在A系统用手机号注册在B系统用邮箱注册如果拿到user_id就直接去查资源服务器的用户表大概率查到空数据。我用这张绑定表做了一次映射之后各个子系统的账号打通才变得顺畅。3.2 核心配置注册授权服务器授权服务器本身也是一个Spring Security应用。核心配置类大概长这样Configuration EnableWebSecurity public class AuthorizationServerConfig { private static final String JWK_SET_URI http://localhost:8080/oauth2/jwks; Bean Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize - authorize.anyRequest().authenticated()) .exceptionHandling(ex - ex.defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint(/login), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) )) .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); return http.build(); } Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { return new JdbcRegisteredClientRepository(jdbcTemplate); } Bean public JWKSourceSecurityContext jwkSource() { RSAKey key generateRsaKey(); JWKSet jwkSet new JWKSet(key); return (jwkSelector, context) - jwkSet.select(jwkSelector); } }这里最不好理解的是JWKSource。JWT签名需要一个RSA密钥对授权服务器用自己的私钥签发JWT资源服务器拿到公钥就能验签。私钥一旦丢失攻击者可以伪造任意token所以生产上一定要把密钥对生成后保存到jks文件不要每次启动都随机生成。我把密钥文件路径配置在application.yml里用KeyStoreKeyFactory加载比运行时动态生成踏实得多。3.3 用户认证与授权确认页的处理用户访问/oauth2/authorize时如果未登录Spring Security会重定向到登录页。登录页的认证逻辑建议用表单登录UsernamePasswordAuthenticationFilter把用户信息加载成功之后Spring Authorization Server会继续处理授权确认流程。授权确认页是很多人没接触过的环节。默认情况下Spring Authorization Server会为每个客户端生成一个consent页面用户点Allow之后才发放授权码。如果是企业内部登录平台通常希望用户别看到这个页面。操作方式是在给客户端配置clientSettings时把requireAuthorizationConsent设为false。在数据库中这个配置对应client_settings字段里的一段JSON类似{ settings.client.require-proof-key: false, settings.client.require-authorization-consent: false }如果是用RegisteredClient对象注册Java侧设置方式是RegisteredClient client RegisteredClient.withId(xxx) .clientId(client-a) .clientSecret({bcrypt}密文) .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()) .build();改完配置后如果发现不生效很可能是缓存在作怪重启授权服务器或者清掉对应缓存即可。我在这个环节上折腾过一下午最后发现就是老客户端在JVM缓存里还保留着旧的consent配置重启就好了。4. 资源服务的接入与用户信息下发4.1 token校验链路jwk-set-uri解耦校验逻辑资源服务器往往和授权服务器是两套独立服务。授权服务器签发JWT后资源服务器验证JWT不需要再发请求去问授权服务器原理是RSA非对称签名授权服务器用私钥签名资源服务器通过jwk-set-uri拿到公钥后在本地验签。这个设计让每次请求的token校验成本降到一次本地的非对称解密和签名比对远好过每个请求都远程调用授权服务去查token状态。在资源服务器里配置如下spring: security: oauth2: resourceserver: jwt: jwk-set-uri: http://auth-server:8080/oauth2/jwks issuer-uri: http://auth-server:8080issuer-uri会在校验时额外核对JWT的iss字段更严格。注意授权服务器配置的issuer要和这里保持一致spring.application.name或者显式配置的issuer如果改乱了资源服务器会一直报invalid_token。4.2 自定义JWT Claims把用户信息塞进token默认签发的JWT只包含标准字段比如sub、scope、iss、exp。实际业务中我们往往需要知道用户ID、部门ID、角色列表这时候可以用OAuth2TokenCustomizer往claims里加内容Bean public OAuth2TokenCustomizerJwtEncodingContext jwtTokenCustomizer() { return context - { if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) { Authentication authentication context.getPrincipal(); if (authentication.getPrincipal() instanceof CustomUserDetails user) { context.getClaims().claim(user_id, user.getUserId()); context.getClaims().claim(dept_id, user.getDeptId()); context.getClaims().claim(roles, user.getRoleList()); } } }; }但这里有一条红线不要往claims里塞手机号、身份证号、家庭住址这类敏感字段。JWT虽然签名能防篡改但payload部分是base64编码任何人拿到token都能解码出来看信息等同明文暴露。能查库的就不要放token里token里只放一个标识其余信息让资源服务器通过接口取。4.3 与Vue前端对接的几个关键点和Vue等前后端分离项目对接时最常见的诉求是access_token保存在哪里。我的处理方式是前端拿code换token的动作放在后端也就是auth模块提供一个callback接口前端跳转回来时带上code后端完成code换token、查询用户信息、创建session并设置cookie然后跳回前端首页。这样前端始终接触不到access_token就算页面被XSS攻击token也不容易被偷走。具体跳转逻辑可以这样写// 伪代码 const authorizeUrl ${authServer}/oauth2/authorize?response_typecodeclient_id${clientId}redirect_uri${redirectUri}scopereadstate${randomState}; window.location.href authorizeUrl;回到后端后校验state与发起时一致然后用code加上client_id、client_secret换tokencurl -X POST http://auth-server:8080/oauth2/token \ -H Content-Type: application/x-www-form-urlencoded \ -d grant_typeauthorization_code \ -d client_idclient-a \ -d client_secretyour-secret \ -d code从回调地址拿到的code \ -d redirect_urihttp://你的回调地址如果选择在前端保存token注意别把client_secret放进前端的环境变量我之前见过有人把client_secret写在.env文件里提交到前端仓库这等于直接把系统暴露出去。前端需要的只有client_id、redirect_uri和scope敏感参数必须留在服务端。5. 常见问题与排查技巧实录5.1 登录失败未授权类错误的排查思路开发过程中控制台或前端经常会提示登录失败甚至出现类似未授权用户在此计算机上的请求登录类型的说法一开始很容易以为是用户账号出问题了。实际上在OAuth2里遇到未授权相关字眼往往不是用户被禁用而是客户端配置、授权类型或scope出了问题。常见的OAuth2错误码可以整理成一张速查表错误码含义排查方向invalid_request请求参数缺失或格式错误检查authorize/token请求的各个参数invalid_clientclient_id或client_secret错误核对数据库里的client配置和应用里使用的配置invalid_grant授权码、refresh_token无效或已过期检查code是否被重复使用token是否过期unauthorized_client客户端无权使用该授权类型看grant_type在客户端表里是否登记unsupported_grant_type授权类型不支持确认grant_type拼写和客户端配置invalid_scopescope不在客户端注册列表内默认scope和客户端表里的scope是否一致access_denied用户拒绝授权排查授权确认页和consent配置我在日志里看到invalid_grant的次数最多大部分原因是回调地址每次跳转都重新生成了code但客户端的回调处理接口有重试机制导致同一个code被换了好几次token。所以code是严格一次性使用的重试逻辑里需要判断返回结果别盲重试。5.2 版本过高导致的自动配置失效我的旧项目最早用Spring Boot 2.3配合spring-security-oauth2的2.5版本能跑但升级Spring Boot到2.7之后自动配置直接失效登录跳转时报404查了半天才发现是依赖已废弃。后来切到Spring Authorization Server 1.0.x问题才解决。还有一次接手同事的代码Spring Boot 3.2.4搭配SAS 1.0.2启动时报NoClassDefFoundError查Maven仓库的依赖关系SAS 1.0.2压根没有适配Boot 3.x的自动配置类。版本这把尺子一定要提前量好。我的习惯是先把Boot版本钉死然后去Spring官网查对应版本的SAS版本号再在pom里显式声明SAS版本不要依赖starter传递的版本否则升级Boot版本时很容易踩到冲突。5.3 一堆容易忽略的小坑清单回调地址必须完全一致包括协议、域名、端口、路径和query参数任何一个不一致都会导致授权失败。用http://localhost:8080/callback注册的客户端用http://127.0.0.1:8080/callback跳回来都会报redirect_uri_mismatch。state参数必须校验它是用来防止CSRF的跳出去带上一个随机值回调的时候再比对不一致直接拒绝。code有效期默认只有几分钟拿到之后要尽快换token不要存在前端缓存里。access_token的有效期不建议设太长2到4小时是常规做法还需要配合refresh_token续期而不是让用户频繁重新登录。refresh_token本身要放在服务端保存不能下发到浏览器。登出操作要同时清理授权服务器和资源服务器两端的会话只退出其中一个系统会出现这边退出那边还能拿到用户信息的尴尬情况。6. 最后说几点个人经验做OAuth2接入我前面提到的最大教训就是不要在网上随便抄老配置版本和方案选型决定了大半个项目的成败。实际动手的时候建议先用curl把整套流程手工跑通拿到code再换token再请求用户信息这个最小闭环能帮你快速分清问题到底在授权服务器的配置上还是资源服务器的验签上排查效率会高很多。关于密钥管理所有私钥、client_secret统一放到配置中心线上环境不要散落在各个子系统的配置文件里。如果注册的客户端多了建议走一个审批流程防止有人为了调个接口就乱申请权限。这个项目给我的最大感触是OAuth2协议本身流程不算复杂真正的复杂度集中在谁有权限做什么事的授权管理上。数据模型先设计清楚权限边界定义好编码基本半天就能完成反过来如果一上来就写代码后面全是接口联调和权限对账的苦活。
返回列表