你有没有想过,为什么一个网站可以读取你在另一个平台的通讯录、订单或者头像,却始终不知道你的账号密码是什么?OAuth2 就是这套授权协议的通行标准。网上讲 OAuth2 的文章很多,但要么只画流程图不写代码,要么一上来就甩一堆 oauth2 请求参数,看得人云里雾里。这篇文章我不想抄 RFC,也不想做名词堆砌,而是从一个真实项目的角度,把 OAuth2 的核心原理、完整流程、Spring Authorization Server 的落地代码,以及我实际踩过的坑一次说清楚。如果你正准备做第三方登录、开放 API 授权、或者把公司内部系统的认证授权体系盘清楚,这篇内容可以直接当你的入门手册加排错清单。
1. 从“为什么需要 OAuth2”说起:它解决的从来不是认证问题
1.1 最原始的“授权”方式:把密码交给别人
要理解 OAuth2 的价值,得先回到它出现之前的世界。假设你做了个应用,叫“周报助手”,用户需要授权之后,它才能自动读取用户在协作平台上的项目列表,再帮你生成周报。早期实现这种需求时,最直接的办法就是:让用户把协作平台的账号密码给你。
这就像你把家里的钥匙直接配了一把给陌生人。问题非常明显:
- 密码被第三方应用持有,任何一个环节泄露,用户主账号就完蛋。
- 用户无法控制这个“钥匙”的权限范围,第三方拿到的是全部权利。
- 想收回授权只能改密码,一改密码,所有用过这个密码的第三方全部失效。
OAuth2 的核心思路,就是把“我允许你做什么”和“你是谁”彻底分开。它不解决登录问题,不关心你怎么验证用户身份,它只做一件事:让一个系统在用户明确同意的前提下,有控制地访问另一个系统中属于用户的资源。
1.2 认证与授权:一句大白话就能区分
很多新人把认证和授权混在一起,实际上这是两套东西:
- 认证(Authentication)解决“你是谁”:你出示用户名密码、短信验证码、扫码,系统确认你的确是这个用户。
- 授权(Authorization)解决“你能做什么”:系统允许你这个身份访问哪些资源、调用哪些接口。
OAuth2 属于后者。它本身不要求授权服务器必须用什么方式去确认用户身份,也不规定授权之后你的 token 长什么样。它只是定义了一套“怎么申请授权、怎么发放令牌、怎么使用令牌”的协议流程。
真正的身份认证,一般由 OIDC(OpenID Connect)在 OAuth2 基础上补上 id_token 来完成。你可以把 OIDC 理解为“OAuth2 协议上加了身份层”。所以做微信登录、GitHub 登录时,你看到的那套流程本质上是授权服务器先在内部完成认证,再通过 OAuth2 协议把授权结果告诉你。
1.3 四种授权模式,别在高并发场景下用错模式
OAuth2 定义了四套授权流程,适用场景完全不同:
| 模式 | 典型使用场景 | 客户端类型 | 安全性 |
|---|---|---|---|
| 授权码模式(Authorization Code) | Web 应用、移动端 App,代表用户访问资源 | 有后端的保密客户端 | 最推荐,token 不暴露给浏览器 |
| 隐式模式(Implicit) | 纯前端 SPA(旧方案,现在已被 PKCE 替代) | 无后端公钥客户端 | 不推荐,token 直接出现在 URL 或前端 |
| 密码模式(Password Credentials) | 自家官方 App 直接使用密码换 token | 受信任客户端 | 已废弃方向,OAuth2.1 中已移除 |
| 客户端凭证模式(Client Credentials) | 服务器到服务器、定时任务、内部服务调用 | 机器身份 | 适用于非用户场景 |
其中授权码模式是所有 Web 集成里最稳的选择。它最大的特点是:授权服务器发给客户端的不是一个可以直接用的 token,而是一个临时的授权码,客户端必须带着这个码回到后端,再由后端去换取真正的 token。
为什么要多绕这一步?因为如果直接在前端把 token 拿给客户端,token 就会暴露在浏览器环境里,浏览器里的脚本、恶意插件、中间人流量都可能截获它。而授权码模式让 token 的交换发生在客户端后端和授权服务器之间,这个通道可以用 client_id + client_secret 做可靠的身份验证,安全性要高得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 授权码模式完整走一遍:从重定向到换 token,每一步都在防什么
2.1 第一步:构造授权链接,scope 和 state 千万别省
授权码模式的第一跳是用户浏览器访问你的应用,你的后端把用户重定向到授权服务器的 /oauth2/authorize 端点。一个典型的链接长这样:
text复制https://auth.example.com/oauth2/authorize
?response_type=code
&client_id=web-client
&redirect_uri=https://app.example.com/callback
&scope=read:contacts write:reports
&state=xyz123
看懂这几个参数,你就看懂了授权请求的核心:
response_type=code:告诉授权服务器我要授权码模式。client_id:你是谁,这个值在授权服务器注册客户端时得到。redirect_uri:授权成功之后回调到哪个地址。授权服务器必须严格校验这个地址,必须精确匹配注册时填写的回调地址。scope:要申请的资源范围,用空格分隔。这里需要遵循最小权限原则,只申请真正需要的范围。state:一个随机字符串,用来防 CSRF。用户到达回调地址时,后端必须校验这个值和自己之前发出去的值一致。
state 经常被忽略,但它非常重要。如果没有它,攻击者可以伪造一个授权回调,诱导用户完成登录后把自己的账号授权给攻击者控制的应用,形成“登录 CSRF”漏洞。我在实际项目中就把 state 存到了 Session 里,回调时用 equals 比较,而不是把用户重定向后带回来的 state 直接拿来用。
2.2 第二步:用户在授权服务器上登录并确认授权
用户到达授权服务器后,如果还没登录,会看到登录页。登录完成之后,授权页面会明确列出:
- 哪个应用在请求授权(你看到的是
client_id对应的应用名)。 - 申请了哪些权限(对应 scope)。
- 用户可以选择“同意”或“拒绝”。
用户点击同意后,授权服务器生成一个授权码,通过 302 跳转把浏览器带回到 redirect_uri,链接变成:
text复制https://app.example.com/callback?code=abcdef&state=xyz123
这里有两个安全细节必须注意:
- 授权码有效期极短,通常是 5 分钟以内。
- 授权码只能使用一次,一旦换完 token 就作废,再次使用会被拒绝。
授权码相当于一张“一次性饭票”。你可以同时拿很多张饭票,但每张只能打一份饭。这个设计能防止授权码被截获后重放攻击。
2.3 第三步:后端用 code 换 token,必须走服务端通道
拿到授权码之后,前端浏览器不需要再做任何事。你的后端拿着 code,向后端令牌端点 /oauth2/token 发起请求:
bash复制curl -X POST "https://auth.example.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "web-client:client-secret" \
-d "grant_type=authorization_code" \
-d "code=abcdef" \
-d "redirect_uri=https://app.example.com/callback"
这里有几个容易被忽略的细节:
grant_type=authorization_code表明这是授权码交换。client_id:client_secret通过 HTTP Basic 认证携带,这要求客户端必须能安全保存client_secret,所以只有后端应用适合用这种模式。- 回调地址
redirect_uri必须和第一步里的完全一致,多一个斜杠都不能通过校验。
响应会返回一组 JSON:
json复制{
"access_token": "xxxxxx",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "yyyyyy",
"scope": "read:contacts write:reports"
}
到这里,你的后端才第一次接触到 access_token。之后的 API 请求里,它只需要放在请求头里:
text复制Authorization: Bearer xxxxxx
2.4 第四步:access_token 的过期与 refresh_token 的使用
access_token 是一种短期凭证,一般几十分钟到几小时就会过期。这是刻意的:就算 token 泄露,攻击者能利用的时间窗口也很有限。
当 access_token 过期后,客户端不能用它再访问资源服务器。如果当初申请授权码时包含了 refresh_token 的授权范围,客户端可以用 refresh_token 去换一个新的 access_token:
bash复制curl -X POST "https://auth.example.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "web-client:client-secret" \
-d "grant_type=refresh_token" \
-d "refresh_token=yyyyyy"
使用 refresh_token 时有几个实践原则:
- 刷新后旧 refresh_token 是否失效,取决于授权服务器配置。有的策略是“刷新一次就换新”,有的是“复用同一个”,生产环境建议配置成刷新后轮换,降低长期 token 泄露风险。
refresh_token绝对不能出现在前端,它比access_token更贵重,因为它能持续换新的访问令牌。- 如果你的资源接口会长时间被动调用,比如后台任务处理,别把
refresh_token存在内存里一放就是一天,要放到可靠的持久化存储里。
3. 用 Spring Authorization Server 搭一个能发 token 的最小授权服务器
3.1 为什么选它:老牌 Security OAuth2 已经停止维护
过去很多项目用的是 spring-security-oauth2 这个老框架,它最早是 Spring 社区很流行的 OAuth2 实现。但说实话,这个项目已经长期停止维护,很多新特性跟不上,Spring Boot 3 之后也不能直接在官方路径上使用它。如果你现在还要新起一个授权服务器,官方推荐方案就是 spring-boot-starter-oauth2-authorization-server,也就是 Spring Authorization Server。
这套东西由 Spring 官方团队维护,和 Spring Security 深度集成,能直接用现有的用户体系、过滤器链、异常机制,非常适合做公司内部的统一授权中心。
3.2 最小依赖与基础配置
假设你用的是 Spring Boot 3.2 以上版本,在 pom.xml 里引入:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
接着在 application.yml 里配置 issuer:
yaml复制spring:
application:
name: auth-server
security:
oauth2:
authorizationserver:
issuer: http://localhost:9000
server:
port: 9000
这里 issuer 表示授权服务器的对外地址,所有 OAuth2 相关端点都会基于这个地址生成。项目里经常有人忘了配这个值,结果授权链接全部指向默认的本地地址,部署到测试环境就找不到端点。
如果你本地是通过 HTTP 访问,记得还要允许明文授权。Spring Authorization Server 默认要求 HTTPS,在本地开发时需要显式开启:
yml复制spring:
security:
oauth2:
authorizationserver:
client:
demo-client:
require-proof-key: false
require-authorization-consent: true
3.3 注册客户端与用户信息
接下来写配置类,注册一个测试客户端:
java复制@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerFilterChain(HttpSecurity http)
throws Exception {
OAuth2AuthorizationServerConfigurer configurer =
OAuth2AuthorizationServerConfigurer.authorizationServer();
http
.securityMatcher(configurer.getEndpointsMatcher())
.with(configurer, (authorizationServer) ->
authorizationServer
.oidc(Customizer.withDefaults())
)
.authorizeHttpRequests((authorize) ->
authorize.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
http
.authorizeHttpRequests((authorize) ->
authorize.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("web-client")
.clientSecret("{noop}client-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")
.scope("read:profile")
.scope("openid")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(30))
.refreshTokenTimeToLive(Duration.ofHours(10))
.reuseRefreshTokens(false)
.build())
.build();
return new InMemoryRegisteredClientRepository(client);
}
@Bean
public UserDetailsService users() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public JWKSource<SecurityContext> jwkSource() throws Exception {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
private static KeyPair generateRsaKey() throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
}
}
这段代码里需要解释几个关键点:
@Order(1)的过滤器链专门匹配 OAuth2 端点,@Order(2)的过滤器链处理其他请求。顺序反了会导致授权服务器端点被普通安全配置拦截。clientSecret("{noop}client-secret")里的{noop}表示明文密码。仅限开发环境,生产环境必须用{bcrypt}或{pbkdf2}编码。JWKSource提供了 JWT 签名用的 RSA 密钥。生产环境应该把私钥放到外部密钥管理系统持久化,否则每次重启生成的 token 都不同,依赖 JWT 做资源服务器验签的客户端会直接验证失败。reuseRefreshTokens(false)表示每次刷新后旧的 refresh_token 作废,这是更安全的策略。
3.4 把流程跑通:请求、登录、拿 code、换 token
启动应用后,在浏览器访问:
text复制http://localhost:9000/oauth2/authorize
?response_type=code
&client_id=web-client
&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/web
&scope=read:profile
&state=test-state
你会先被带到登录页,输入上面配置的管理员账号密码,然后看到授权确认页。同意之后,浏览器跳转到:
text复制http://127.0.0.1:8080/login/oauth2/code/web?code=xxx&state=test-state
然后用 curl 在后面拿 code 换 token:
bash复制curl -X POST "http://localhost:9000/oauth2/token" \
-u "web-client:client-secret" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=xxx" \
-d "redirect_uri=http://127.0.0.1:8080/login/oauth2/code/web"
能拿到 access_token 和 refresh_token,说明这套最小授权服务就已经通了。这里最常犯的错是回调地址写了 localhost,而注册时用的是 127.0.0.1,授权服务器对回调地址是精确匹配,一个域名差异就会报 redirect_uri_mismatch。
4. 集成 OAuth2 时踩过的坑:从 redirect_uri 到 JWK 密钥配置
4.1 redirect_uri_mismatch:回调地址的严格匹配比想象中严
这个报错我在接入多个平台时都遇到过。它的本质是:授权服务器注册的 redirect_uri 和请求参数里的 redirect_uri 不是一模一样。
容易掉的坑包括:
- 注册写的是
http://localhost:8080/callback,请求时带了http://localhost:8080/callback/,多一个斜杠就失败。 - 注册写的是
https://app.example.com/callback,本地测试时改成了http://localhost:8080/callback,没同步改注册信息。 - 回调地址做成了动态拼接,前面被人为带了查询参数,比如
https://app.example.com/callback?from=xxx,授权服务器不会做智能匹配。
解决思路只有一个:把回调地址当成一个精确的白名单,每个环境单独注册一份,别想着兼容各种写法。
4.2 JWT 格式的 access_token 太长,网关日志全被刷屏
Spring Authorization Server 默认生成的 token 是 JWT。JWT 的好处是资源服务器不用每次回调授权服务器验证,解出来签名校验一下就行,适合分布式和高并发场景。但它也有个显而易见的问题:体积大。
一个典型的 JWT 通常在 800 到 2000 字符之间。如果网关把请求头和响应体完整打日志,一个高频接口一天能刷出几个 GB 的 token 日志。后来我们是在日志配置里把 Authorization 请求头和 access_token 响应字段设置了脱敏,只保留前几位和后几位,问题才解决。
如果你对接的是老系统,或者希望 token 能被服务端随时撤回,可以考虑用不透明 token(opaque token)。Spring Authorization Server 也支持配置为 opaque,但资源服务器就需要通过 introspection 端点向授权服务器校验 token,多了一次网络调用。
4.3 本地 HTTP 环境被拒绝:明文授权默认关闭
开发环境没有 HTTPS,访问授权端点时很容易遇到类似 “HTTP is not supported” 或者跳转直接被拦的情况。
Spring Authorization Server 默认不允许通过不安全的通道发送敏感信息。本地调试时,如果不想上证书,最简单的做法是在客户端配置里把 require-authorization-consent 打开并允许 HTTP:
yml复制spring:
security:
oauth2:
authorizationserver:
client:
web-client:
require-authorization-consent: true
然后指定 client 的配置时注意不要使用强制 HTTPS。这里补一句,生产环境一定要留 HTTPS 校验,这是协议安全性的底线。
4.4 资源服务器验签失败:授权服务器换了密钥,客户端全部 401
这个问题最隐蔽,也最容易出现在“用 JWT + 本地 RSA 密钥”的默认配置下。假设你开发时顺手在授权服务器里生成了一对 RSA 密钥,没做持久化,某天重启之后密钥换了。资源服务器如果缓存了旧的公钥,所有 token 验证都会失败。
解决办法通常有两个:
- 把授权服务器的 JWK 私钥持久化到本地密钥文件或密钥管理服务,保证重启后不换。
- 资源服务器不要手动配置一个写死的公钥,而是配置授权服务器的 JWK Set 地址,例如
http://localhost:9000/oauth2/jwks,并设置合理的缓存时间。这样即使授权服务器定期轮换密钥,资源服务器也能通过 JWKS 端点自动拿到新公钥。
这个坑非常典型,很多团队在联调阶段一切正常,一到环境迁移就全部接口 401,排查半天最后发现是两套环境不是同一把钥匙。
4.5 scope 规划混乱,下游权限越来越难管
scope 是 OAuth2 里最容易“先随便写写,后来收不了场”的部分。常见做法是有人把 scope 写成了 user_all,或者 read、write 这种过于笼统的命名。
我的建议是:scope 命名要能对应到具体业务资源,并且要区分读和写。
例如:
read:profile只读个人资料。write:reports写周报。admin:users管理用户。
这样做的好处是,下游服务可以基于 scope 做方法级权限控制,而不需要在业务代码里再建一套权限表。还有一点,授权确认页上展示给用户看的也是这些 scope 字符串,命名越是业务化,用户越容易理解自己同意了啥。纯技术的命名在合规审查时会很难说清楚。
5. 从 Demo 走向生产:scope 设计、token 选型与关键扩展点
5.1 token 撤销问题:JWT 天生无状态,也天生难撤回
JWT 很香,但它没有状态,授权服务器签发之后就不再保存这个 token 的“存活记录”。一旦发现有 token 泄露,想立刻让它失效很难。
应对策略主要有几种:
- 把
access_token过期时间设置得短一些,比如 15 到 30 分钟,泄露影响面可控。 - 对高安全场景,不接受 JWT,改用 opaque token,授权服务器侧维护会话状态,随时可以撤回。
- 如果必须要用 JWT 又需要撤销,可以在业务层维护一个 token 黑名单,资源服务器每次校验时查询,但这样就在某种程度上牺牲了无状态优势。
实际项目里最常见的折中方案是:核心接口走短时 JWT,操作类接口再叠加业务权限校验,把撤销敏感 token 的压力转移到 refresh_token 上。refresh_token 一撤,access_token 再短也撑不了多久。
5.2 客户端凭证模式:服务间调用的最佳选择
如果你要在两个后端服务之间做接口授权,比如定时任务服务调用数据服务,用管理员账号走授权码模式就很别扭。正确的做法是使用 client_credentials 模式。
这个模式和用户无关,客户端直接用 client_id 和 client_secret 换取一个代表它自己的 token。Spring Authorization Server 里注册客户端时加上这个 grant type 即可:
java复制.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("data:read")
服务间调用时,资源服务器只认 scope,不认具体用户身份。这种模式特别适合微服务里请求链路比较短、不需要用户上下文的情况。
5.3 前后端分离场景:授权码 + PKCE 才是正解
早期 SPA 被迫用隐式模式,因为纯前端没法安全保存 client_secret。但现在已经有更安全的方案:授权码 + PKCE。
PKCE 的核心是前端在发起授权请求前,自己生成一个随机 code_verifier,再计算出一个 code_challenge 放在授权链接里。授权服务器换 token 时,要求客户端提供原始的 code_verifier 并验证它和之前的 code_challenge 是否匹配。
这样一来,即使没有 client_secret,窃取到授权码的攻击者如果没有原始的 code_verifier,也无法完成换 token。这是目前 OAuth 官方对原生 App 和 SPA 推荐的标准方案。Spring Authorization Server 对 PKCE 的支持是内置的,注册客户端时不需要额外步骤,发起授权请求时带上 code_challenge 和 code_challenge_method=S256 就行了。
最后说一点个人体会。OAuth2 这套协议刚接触时最容易掉进去的误区就是“背流程、记端点”,但每次线上出问题,几乎都出在“某一步的安全约束没想明白”上。不管是授权码的一次性、redirect_uri 的精确匹配、还是 scope 的最小化,这些约束全部指向同一个目标:降低 token 泄露和越权访问的风险。
你如果在自己项目里做集成,建议把授权服务器的日志级别调成 DEBUG,把 curl -v 加在 token 请求上,仔细看每一条重定向和响应状态。把一次正常的授权流程从头到尾盯着看完,很多抽象概念会瞬间落地。之后再做资源服务器、网关透传和权限模型,就有底了。
