☰
SpringBoot OAuth2授权登录实战:授权码模式与Spring Authorization Server踩坑指南
2026/10/2 2:50:43 网站建设 项目流程

如果你和我一样,接手过一个要给多个子系统做统一登录的平台,就一定理解为什么网上搜"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只在服务端到服务端的通道里出现,泄露面小得多。我在项目里实际操作下来,完整流程是下面这样:

  1. 前端引导用户访问授权服务器的authorize端点,带client_id、redirect_uri、scope、state。
  2. 授权服务器发现用户未登录,跳转到统一登录页。
  3. 用户输入账号密码,登录成功,系统判断是否要展示授权确认页。
  4. 授权服务器重定向回客户端的回调地址,URL上带着code和state。
  5. 客户端后端用code + client_id + client_secret,到token端点换access_token。
  6. 客户端拿着access_token去资源服务器请求用户信息。
  7. 客户端拿到用户信息后建立自己的会话体系,后续业务请求不再走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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> </dependency>

版本对应关系大概是这样的:

Spring Boot版本Spring Authorization Server版本
2.7.x1.0.x
3.0.x / 3.1.x1.1.x / 1.2.x
3.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生成token,Redis里存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 JWKSource<SecurityContext> 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:8080

issuer-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 OAuth2TokenCustomizer<JwtEncodingContext> 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_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=read&state=${randomState}`; window.location.href = authorizeUrl;

回到后端后,校验state与发起时一致,然后用code加上client_id、client_secret换token:

curl -X POST "http://auth-server:8080/oauth2/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "client_id=client-a" \ -d "client_secret=your-secret" \ -d "code=从回调地址拿到的code" \ -d "redirect_uri=http://你的回调地址"

如果选择在前端保存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协议本身流程不算复杂,真正的复杂度集中在"谁有权限做什么事"的授权管理上。数据模型先设计清楚,权限边界定义好,编码基本半天就能完成;反过来,如果一上来就写代码,后面全是接口联调和权限对账的苦活。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询