
先说一下我为什么想写这篇东西。接触过授权的朋友都知道系统里动不动就要接第三方登录或者自己搭一个认证中心给多个业务系统共用。我最早踩这些坑的时候被那些概念绕得晕头转向后来做得多了才慢慢摸清楚里面的门道。SpringBoot作为目前最主流的后端框架配合OAuth2协议做授权登录几乎成了企业级项目的标配姿势。这篇博文不打算粘贴官方文档我把这些年实际做过的授权登录方案、踩过的坑、调过的参数全部整理出来从原理到落地一步步讲清楚适合正在接手授权模块、或者准备给自己的系统接入单点登录的Java开发朋友参考。1. 授权登录的需求从哪来OAuth2解决的是什么问题1.1 没有OAuth2的时候我们怎么做的最早做登录大家习惯用Session。用户在浏览器输账号密码后端存一份Session再给浏览器种一个Cookie。这套流程在单体应用时代问题不大但系统一旦拆分问题就出来了。比如公司里有了OA系统、HR系统、报表系统每个系统都做一套登录用户得记好几个账号密码体验差不说安全也难保障。为了统一登录就得考虑做单点登录而OAuth2就是目前比较通用的一套授权协议。有人可能分不清认证和授权的区别这里我用大白话解释。认证是确认“你是谁”授权是确认“你能干什么”。OAuth2主要解决的是授权问题但在实际落地中它经常被用来实现单点登录这时候它顺带也承担了认证的职责。1.2 OAuth2的四个角色和酒店类比OAuth2里面有四个角色理解了这个后面看代码就顺了。资源所有者、客户端、授权服务器、资源服务器。这四个词太学术我打一个酒店代客泊车的比方。你把车钥匙交给酒店门口的代泊员让他帮你把车停到停车场。你相当于资源所有者你拥有的是“车”这个资源。酒店柜台的停泊服务系统相当于客户端它自己不能直接开车门需要征得你同意。停车管理系统和停车场相当于授权服务器和资源服务器授权服务器负责发停车凭证令牌停车场凭令牌放行车辆。OAuth2就是这套“凭证换访问权”的流程。客户端不能直接拿你的密码去访问你的资源而是先去授权服务器申请一个令牌再拿着令牌去资源服务器换数据。这样做最大的好处是你不需要把密码告诉第三方系统。1.3 授权码模式为什么是主角OAuth2协议定义了多种授权模式授权码模式、简化模式、密码模式、客户端凭证模式。实际做SpringBoot授权登录用得最多的就是授权码模式。授权码模式的流程说起来很绕但拆开就三步。第一步用户访问客户端系统客户端发现用户没登录就引导用户跳转到授权服务器的登录页。第二步用户在授权服务器上输入账号密码并确认授权授权服务器在浏览器地址栏回调URL上带上一个授权码。第三步客户端拿着这个授权码在后端向授权服务器换取访问令牌之后拿着令牌去资源服务器取数据。这个模式最大的特点是用户密码只经过授权服务器客户端系统拿不到。而且授权码换令牌是在后端完成的令牌不会暴露在浏览器地址栏安全性很高。适合Web应用、前后端分离项目、移动端App。2. SpringBoot接入OAuth2之前的选型别一上来就写代码2.1 先想清楚你是哪条路线客户端还是服务端很多人一搜SpringBoot OAuth2就冲进代码结果写了两天发现路子不对。先判断自己的定位。如果你的系统需要接入别人的登录体系比如用GitHub账号、企业微信、钉钉登录你的系统那你的角色是OAuth2客户端你需要做的是“对接外部授权服务器”。这类需求SpringBoot有现成的starter配置量不大。如果你的系统要给别人提供服务比如你们公司要做一个统一的认证中心让各个子系统的用户都到这边登录那你的角色是授权服务器。这类需求SpringBoot没有一个官方开箱即用的starter确切说是新版换了思路需要引入Spring Authorization Server或者自己封装底层逻辑。这两条路线后续的代码完全不一样选错方向会浪费大量时间。2.2 老方案和新方案的版本鸿沟SpringBoot整合OAuth2有个特别容易掉进去的坑版本兼容问题。老一代方案用的是spring-security-oauth2这个库配合SpringBoot 2.x使用。这个库从2.5版本开始就不再维护了官方明确说要转向新的Spring Authorization Server。很多人网上搜到老教程拿着Boot 2.7的代码往Boot 3.2项目里套结果编译直接崩。新一代方案SpringBoot 3.x Spring Security 6.x需要引入的是spring-boot-starter-oauth2-authorization-server这是Spring官方在2020年启动的新项目专门替代老旧的spring-security-oauth2。两者包名、类名、配置方式完全不同。建议新项目一律用新方案。如果维护老项目实在没法升级也要明确老库的类已经停止更新安全问题自负。2.3 依赖引入的两种典型方式我做授权服务器或者当客户端接入的时候常见的依赖配置有两种。如果是要做授权服务器引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency如果只是作为客户端接入外部登录引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-client/artifactId /dependency顺带提一个容易忽略的点spring-boot-starter-security尽量显式写出来因为有些场景需要自定义SecurityFilterChain没有这个依赖你想配置都没有地方配。3. 场景一自建授权服务器走一次完整的授权码模式3.1 授权服务器核心类配置自建授权服务器核心工作其实是四个注册客户端信息、配置授权服务器安全规则、管理令牌存储、提供用户认证逻辑。在Spring Authorization Server里注册客户端信息可以用内存方式也可以存数据库。开发阶段用内存很方便生产环境建议走数据库。先看内存方式怎么配Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(messaging-client) .clientSecret({noop}secret) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://127.0.0.1:8080/login/oauth2/code/messaging-client) .postLogoutRedirectUri(http://127.0.0.1:8080/logout) .scope(message.read) .scope(message.write) .build(); return new InMemoryRegisteredClientRepository(registeredClient); }这段配置看着简单但有几个细节值得注意。{noop}secret是Spring Security的密码编码前缀代表明文密码生产环境千万别这样用要换成{bcrypt}加密后的密文。redirectUri就是授权码回调地址稍后客户端回调的时候必须和这里完全一致少一个字母都会报错这是新手高频踩坑点。scope是权限范围客户端申请令牌和后续访问资源都围绕这个范围来做。再看安全规则配置Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); return http.formLogin(Customizer.withDefaults()).build(); }Order(1)这个注解很关键。授权服务器安全过滤器和普通的Spring Security过滤器要区分开两条链接优先级不同。很多人在项目里同时配了授权服务器和普通资源接口结果接口路径全被授权服务器拦截了加了这个优先级排序就好了。3.2 用户认证信息的加载逻辑授权码模式里用户要在授权服务器页面上登录所以授权服务器得知道用户的账号密码从哪来。最简单的方式是使用默认的Spring Security认证机制提供UserDetailsServiceBean public UserDetailsService userDetailsService() { UserDetails user User.withDefaultPasswordEncoder() .username(admin) .password(123456) .roles(USER) .build(); return new InMemoryUserDetailsManager(user); }生产环境改成查数据库就行关键是理解这个Service的作用——用户在授权服务器的登录页输入密码后由这个Service去校验身份。校验通过才会继续往下走授权逻辑。3.3 测试授权码模式的最佳姿势配置完这些启动SpringBoot应用浏览器手动访问授权端点。如果端口映射为localhost:9000客户端clientId是messaging-client授权URL是http://localhost:9000/oauth2/authorize?response_typecodeclient_idmessaging-clientredirect_urihttp://127.0.0.1:8080/login/oauth2/code/messaging-clientscopemessage.read这里会弹登录页输入账号密码后页面会问你要不要授权给messaging-client。同意之后浏览器地址栏就会带上codexxxxx。拿到这个授权码再用Postman之类的工具去换令牌POST /oauth2/token Content-Type: application/x-www-form-urlencoded grant_typeauthorization_code client_idmessaging-client client_secretsecret redirect_urihttp://127.0.0.1:8080/login/oauth2/code/messaging-client code上一步拿到的授权码返回的JSON里会自带access_token和refresh_token。到这里自建授权服务器最小可用链路就跑通了。4. 场景二作为客户端接入第三方登录以GitHub为例4.1 第三方接入的本质是“借用身份”很多企业系统要求接入微信扫码、支付宝、GitHub登录。本质上就是让自己系统变成第三方授权服务器的一个客户端。用户在第三方那边登录授权你的系统拿到对方的令牌然后用这个令牌去调第三方的用户信息接口拿到用户昵称、头像、邮箱再映射成你自己体系里的一个账号。这种方式比让用户单独注册一个账号省事而且不需要存用户的密码安全压力小很多。4.2 SpringBoot接入GitHub的完整配置在application.yml里配置OAuth2客户端信息spring: security: oauth2: client: registration: github: client-id: 你的client-id client-secret: 你的client-secret scope: - read:user - user:email provider: github: authorization-uri: https://github.com/login/oauth/authorize token-uri: https://github.com/login/oauth/access_token user-info-uri: https://api.github.com/user这段配置的核心是三个端点授权端点、令牌端点、用户信息端点。所有的OAuth2流程都是围绕这三个URL转的。然后写一个Controller接收回调RestController public class LoginController { GetMapping(/login/oauth2/code/github) public MapString, Object login(OAuth2AuthenticationToken authentication) { OAuth2User user authentication.getPrincipal(); MapString, Object attributes user.getAttributes(); String name (String) attributes.get(login); String email (String) attributes.get(email); // 这里根据第三方用户信息去匹配本地用户没有就自动注册 return attributes; } }很多教程到这里就结束了但实际生产环境这里有一个关键动作要做第三方登录成功后你的系统必须建立自己的会话。也就是说从GitHub换来的令牌是“临时身份凭证”用它换到了用户的GitHub信息后你的系统自己也要发一套Session或者JWT给前端后续的请求不再走OAuth2体系而是走你自己的认证体系。4.3 第三方授权成功后的本地会话设计我一般这么做在回调接口里根据第三方用户的唯一标识去查本地用户表如果用户不存在就用随机密码创建一个本地账号并绑定第三方标识。之后手动注入Spring Security的Authentication对象使后续请求都带着本地登录态。UsernamePasswordAuthenticationToken token new UsernamePasswordAuthenticationToken( localUser, null, localUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(token);这样处理之后前端拿到的是你自己的会话凭证跟第三方那边已经完全解耦。即使GitHub的令牌过期也不影响用户在当前系统的登录状态。5. 令牌的存储与刷新生产环境最常踩的雷区5.1 令牌到底存哪各方案对比授权服务器签发令牌之后令牌的存储方式直接决定系统的扩展性和安全性。我做过的项目里三种方案都遇到过。Redis存储是很多B端系统的选择。访问令牌和刷新令牌都存在Redis里设置好过期时间注销的时候直接删Key令牌立即失效很直观。JWT方案是目前最流行的无状态方案。令牌本身携带用户信息、过期时间授权服务器不需要存储资源服务器只需要验签即可。但JWT也有劣势就是令牌一旦签发到期之前没法主动撤销。如果用户被踢下线只能等令牌自然过期。数据库存储是最原始的方式适合小规模内部系统逻辑简单但每次验证都要查一次库性能不行。我个人的经验中小型企业内部系统直接Redis存令牌简单灵活对外开放的API服务用JWT因为资源服务器可能不止一个无状态验签最方便。5.2 Refresh Token的轮换机制不管用哪种存储Refresh Token绝对不能省也不建议把过期时间设置得过长。访问令牌一般有效期短比如30分钟到2小时。Refresh Token有效期长比如7天到30天。当访问令牌过期时客户端拿Refresh Token去换新的访问令牌。关键细节Refresh Token每次换新都应该作废旧值换新值也就是轮换机制。这么做是为了防止Refresh Token泄露后无限期使用。还有一个容易被忽略的点Refresh Token只能使用一次。客户端需要处理并发刷新场景不然两个请求同时刷新后一个就会因为Token已被使用而失败。我见过不少项目上线后出现偶发401排查半天发现就是这个原因。5.3 资源服务器的Bearer Token解析资源服务器和授权服务器通常分开资源服务器拿到请求头里的Authorization: Bearer xxx需要解析令牌内容。Bean public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception { http.oauth2ResourceServer(oauth2 - oauth2 .jwt(Customizer.withDefaults()) ); return http.build(); }如果在JWT模式下记得配置JWT密钥或JWT解析器共用的密钥可以通过环境变量注入避免硬编码在代码里。6. 常见问题与排查实录我踩过的那些坑6.1 回调地址对不上永远授权失败redirect_uri不匹配是OAuth2项目里出现频率最高的报错。授权页面上明明点了同意结果直接跳到400错误页。排查思路很简单检查注册客户端时填写的redirectUri和请求参数中的redirect_uri是否逐字符一致。特别留意末尾斜杠、大小写、HTTP还是HTTPS。Spring Authorization Server对redirect_uri的匹配是精确匹配不是前缀匹配。6.2 登录之后页面无限302这种情况多半是会话Session没建立成功。授权服务器签发了授权码但客户端拿授权码去换令牌时客户端自己的Security上下文还是空的于是又跳回登录页。解决办法是检查客户端回调接口是否正确触发了OAuth2AuthorizationCodeGrantFilter。如果是自写的回调Controller一定要把授权码交给框架处理而不是自己拿着代码简单请求一次令牌接口就完事。6.3 获取用户信息接口返回401第三方授权成功但调用户信息接口报401。最常见的原因是请求头里没带正确格式的令牌。资源服务器要求的是Authorization: Bearer xxx注意Bearer后面有一个空格。我排查过很多次前端把令牌直接放在没有Bearer前缀的请求头里后端解析不出principal自然401。还有一种情况是令牌带对了但JWT验签失败多半是密钥没对齐。6.4 授权码只能换一次令牌授权码是一次性的用完即废。如果在回调接口里换了令牌但又因为网络问题刷新了一次页面把同一个授权码又发了一遍第二次必然报错。我习惯在换令牌成功后就跳转到业务页面避免浏览器重复提交同一个授权码。6.5 Base64加密的Client信息导致前端报错有些开发图省事在前端代码里直接放clientId和clientSecret然后由前端发起换令牌请求。这在授权码模式下是危险的。因为clientSecret是客户端的机密凭证一旦在浏览器暴露就相当于你把自家的钥匙挂在了大门外。正确做法永远是把clientId和clientSecret放在后端换令牌的请求由后端发起前端拿到的是最终的业务数据或令牌结果。7. 写在最后的实操心得做OAuth2授权登录最容易犯的错误是拿着代码一路狂奔完全不考虑协议背后的安全边界。授权码模式的核心价值不在流程有多复杂而在于密码不经过第三方系统、令牌可以在后端安全换发。我在实际项目中见过太多花架子把OAuth2流程写得非常完整但clientSecret放前端、Refresh Token不轮换、state参数不校验等于把协议的安全红利全吐回去了。如果只让我从这一堆经验里挑一条最建议的那就是一开始就把令牌的过期时间、存储方式、撤销策略设计好。登录功能不是上线就完事的后续的令牌过期续期、用户主动注销、管理员强制下线这些场景最后都会找上门来。提前设计好后面能省下大把改代码的时间。