面试官:OAuth2.0 授权码模式为什么最安全?——从密码模式的血泪教训讲起
引言
前年接一个第三方平台对接,对方技术说"我们用 OAuth2.0 密码模式对接吧,简单直接"。我图省事同意了。上线三个月后出事了——合作方的服务器被黑了,攻击者从日志里捞到了一批用户密码(明文记在请求日志里),拿这些密码去试其他平台的撞库,引发了客诉。
事后复盘,问题的根源不是日志没脱敏,而是密码模式本身的设计缺陷:用户的密码经过了第三方客户端,客户端就是不可信的。一旦客户端被攻破或日志管理不严,密码必然泄漏。而用户在不同平台复用密码的比例高达 60%——一个平台泄密,多个平台遭殃。
后来我们把对接方式全部迁移到授权码模式:用户密码只在授权服务器上输入,客户端永远接触不到密码,只能拿到一个一次性的授权码(code),用 code 换 token。再后来 SPA 和移动端普及,授权码又暴露了"code 拦截"风险,于是有了 PKCE 增强。
这篇文章把 OAuth2.0 四种授权模式从"为什么不安全"到"怎么变安全"完整讲一遍,最后附 Spring Security OAuth2 的实现代码。面试被问"授权码模式为什么最安全",这篇的内容够你聊二十分钟。
一、OAuth2.0 到底解决什么问题
1.1 核心问题:授权委托
OAuth2.0 的本质是授权委托——用户(资源所有者)让第三方应用(客户端)访问自己在服务提供商(资源服务器)上的数据,但不把密码给第三方。
经典场景:你用"格志日记"这个 App,想导入微信里的相册。你不想把微信密码给格志,但又需要授权它读相册。OAuth2.0 就是解决这个矛盾的协议。
1.2 四个核心角色
| 角色 | 术语 | 例子 |
|---|---|---|
| 资源所有者 | Resource Owner | 你(用户) |
| 客户端 | Client | 格志日记(第三方应用) |
| 授权服务器 | Authorization Server | 微信的授权服务 |
| 资源服务器 | Resource Server | 微信的相册 API |
关键边界:客户端 ≠ 授权服务器。用户只在授权服务器上输入密码,客户端永远拿不到密码。理解这条边界,就理解了 OAuth2.0 所有模式的设计逻辑。
1.3 四种授权模式一览
RFC 6749 定义了四种授权模式(Grant Type),区别在于"客户端怎么拿到 access_token":
| 模式 | 谁输入密码 | 客户端能否接触密码 | 适用场景 | 安全性 |
|---|---|---|---|---|
| 授权码模式 (Authorization Code) | 授权服务器页面 | ❌ 不能 | Web 应用、有后端的应用 | ✅ 最高 |
| 简化模式 (Implicit) | 授权服务器页面 | ❌ 不能 | 纯前端 SPA(已过时,被 PKCE 替代) | ⚠️ 中 |
| 密码模式 (Resource Owner Password Credentials) | 客户端页面 | ✅ 能直接接触 | 官方第一方应用 | ❌ 低 |
| 客户端模式 (Client Credentials) | 无用户参与 | 不涉及用户密码 | 服务间 M2M 调用 | ✅ 高(场景不同) |
下面逐个拆解,重点讲密码模式为什么不安全、授权码模式怎么解决。
二、密码模式的血泪教训:客户端为什么不能碰密码
2.1 密码模式的流程
密码模式最简单粗暴:用户在客户端的页面上输入用户名和密码,客户端直接拿这组凭证去授权服务器换 token:
用户 客户端 授权服务器
│ │ │
│ 用户名/密码 │ │
├───────────►│ │
│ │ POST /token │
│ │ grant_type=password│
│ │ username=xxx │
│ │ password=yyy ← 明文密码!
│ ├───────────────────►│
│ │ │ 验证用户名密码
│ │ access_token │
│ │◄───────────────────┤
│ │ │
│ 授权完成 │ │
│◄───────────┤ │
看起来简单直接,但这个流程有一个致命问题:用户的密码经过了客户端。
2.2 三个致命风险
风险一:客户端日志泄漏密码
客户端收到用户的用户名密码后,要发起 HTTP 请求给授权服务器。这条请求经过的每一个环节都可能记日志:
// 客户端的请求日志(如果没脱敏)
POST /oauth/token HTTP/1.1
grant_type=password&username=zhangsan&password=P@ssw0rd123
^^^^^^^^^^^^^^^^^^
明文密码被记在日志里
我们出事的那个项目,就是合作方把 Nginx access_log 开了 request_body 记录,所有密码请求体的明文都躺在日志文件里。运维的离职、服务器的被黑、日志的上传排查——任何一个环节出问题,密码就泄了。
风险二:客户端可以"记住"密码
密码模式下客户端拿到了明文密码,技术上它完全可以存下来:
// 恶意的客户端代码(用户完全不知情)
public Token login(String username, String password) {
// 表面上:用密码换 token
Token token = authServer.exchangePassword(username, password);
// 暗地里:把密码存到自己的数据库
passwordDatabase.save(username, password); // 用户的密码被偷了
return token;
}
用户无从验证客户端有没有这么干。密码模式下,用户必须 100% 信任客户端——但第三方客户端恰恰是最不可信的。
风险三:撞库攻击放大器
用户在不同平台复用密码的比例极高。密码模式下一旦某个客户端泄密,攻击者拿这批密码去试其他平台,造成连锁泄密。我们的那次事故,虽然只有合作方一台服务器被黑,但受影响的用户里有 23% 在其他平台也用了相同密码。
2.3 密码模式还能用吗
OAuth2.1 草案已经废弃了密码模式。现实中只有一种场景还在用:官方第一方应用(比如微信官方 App 调微信 API),因为客户端和服务端是同一家公司,用户对客户端有天然信任。即便如此,新版 OAuth2.1 也建议用授权码 + PKCE 替代。
结论:第三方对接一律不用密码模式,这是红线。
三、授权码模式:用一道"中间码"隔绝密码
3.1 核心思路
授权码模式的精髓是:不让客户端碰密码,也不直接给客户端 token,而是先给一个一次性、短时效的授权码(code),客户端用 code 换 token。
用户 客户端 授权服务器 资源服务器
│ │ │ │
│ 1.点"用微信登录" │ │ │
├──────────────►│ │ │
│ │ 2.重定向到授权页面 │ │
│ │ +client_id │ │
│ │ +redirect_uri │ │
│ │ +state │ │
│ ├───────────────────►│ │
│ │ │ │
│ 3.显示登录页面 │ │ │
│◄───────────────────────────────────┤ │
│ │ │ │
│ 4.输入密码 ← 密码只在授权服务器,客户端看不到! │
├───────────────────────────────────►│ │
│ │ │ 验证密码 │
│ │ │ 生成授权码 code │
│ │ 5.重定向回客户端 │ │
│ │ redirect_uri? │ │
│ │ code=AUTH_CODE │ │
│ │ +state │ │
│ │◄───────────────────┤ │
│ │ │ │
│ │ 6.POST /token │ │
│ │ grant_type= │ │
│ │ authorization_ │ │
│ │ code │ │
│ │ code=AUTH_CODE │ │
│ │ +client_secret │ ← 后端到后端调用 │
│ ├───────────────────►│ │
│ │ │ 验证 code │
│ │ │ 验证 client_secret│
│ │ access_token │ │
│ │ +refresh_token │ │
│ │◄───────────────────┤ │
│ │ │ │
│ │ 7.调API + Bearer │ │
│ ├──────────────────────────────────────►│
│ │ 返回资源 │ │
│ │◄──────────────────────────────────────┤
3.2 两步走:为什么拆成 code 和 token
这是授权码模式最精妙的设计。对比密码模式的一步到位,授权码模式拆成了两步:
| 步骤 | 前端(浏览器) | 后端(服务器) |
|---|---|---|
| 第一步:拿 code | 浏览器重定向,code 通过 URL 传回 | 不参与 |
| 第二步:换 token | 不参与 | 后端用 code + client_secret 换 token |
为什么不让授权服务器直接在第一步就返回 token? 因为第一步的返回是浏览器重定向(URL 传参),URL 会出现在浏览器历史记录、Referer 头、日志里。token 直接通过 URL 传 = 到处泄漏。
code 的巧妙之处:
- code 是一次性的,用过即失效
- code 有时效限制(通常 10 分钟)
- code 绑定了 client_id 和 redirect_uri,A 客户端的 code 不能给 B 用
- code 换 token 时需要 client_secret(只有客户端后端知道),前端拿不到
所以即使 code 被拦截了,攻击者没有 client_secret 也换不到 token。这就是授权码模式比密码模式安全的核心原因。
3.3 各字段的安全设计
| 字段 | 作用 | 安全意义 |
|---|---|---|
client_id | 标识哪个客户端 | 公开信息,不保密 |
client_secret | 客户端密钥 | 只有后端知道,换 token 时验证,防 code 被盗后冒充客户端 |
redirect_uri | code 回调地址 | 必须与注册时一致,防 code 被重定向到攻击者地址 |
state | 随机数 | 防 CSRF 攻击(攻击者诱导用户点恶意链接完成授权) |
code | 授权码 | 一次性 + 短时效 + 绑定客户端 |
access_token | 访问令牌 | 只在后端到后端通道传输,不经过浏览器 |
3.4 state 防 CSRF:一个容易被忽略的细节
state 参数不是可选项。没有它,授权码模式会面临 CSRF 攻击:
攻击场景(无 state):
1. 攻击者用自己的账号在客户端发起授权,拿到 code=ATTACKER_CODE
2. 攻击者把恶意链接 http://client.com/callback?code=ATTACKER_CODE 发给受害者
3. 受害者点击,客户端用 ATTACKER_CODE 换到攻击者的 token
4. 受害者以为在操作自己的账号,实际操作的是攻击者的账号
→ 受害者往"自己"的相册传照片,实际传到了攻击者的相册
有了 state,客户端在发起授权时生成随机 state 存 Session,回调时比对——不一致就拒绝:
// 发起授权
String state = UUID.randomUUID().toString();
session.setAttribute("oauth_state", state);
String authUrl = "https://auth-server/oauth/authorize"
+ "?client_id=" + clientId
+ "&redirect_uri=" + redirectUri
+ "&response_type=code"
+ "&state=" + state; // ← 随机 state
// 回调验证
String returnedState = request.getParameter("state");
String expectedState = (String) session.getAttribute("oauth_state");
if (!expectedState.equals(returnedState)) {
throw new SecurityException("state 不匹配,疑似 CSRF 攻击");
}
四、PKCE:授权码模式的最后一公里
4.1 授权码模式的残留漏洞
授权码模式用 client_secret 保护了 code 换 token 的过程——但 SPA 和移动端没有安全的后端,client_secret 无处安放:
| 客户端类型 | 能安全存储 client_secret? | 授权码模式可用? |
|---|---|---|
| 有后端的 Web 应用 | ✅ 存后端 | ✅ 标准授权码模式 |
| 纯前端 SPA | ❌ 代码可反编译 | ⚠️ 需 PKCE |
| 移动端 App | ❌ APK 可反编译 | ⚠️ 需 PKCE |
| 桌面应用 | ❌ 本地文件可读 | ⚠️ 需 PKCE |
没有 client_secret,code 换 token 只需 code 本身——如果 code 被拦截,攻击者就能直接换 token。这就是 PKCE(Proof Key for Code Exchange)要解决的问题。
4.2 code 拦截攻击场景
攻击场景(无 PKCE 的 SPA):
1. SPA 发起授权:redirect_uri = https://app.com/callback
2. 用户在授权服务器登录,授权服务器重定向:https://app.com/callback?code=AUTH_CODE
3. 攻击者通过以下方式拦截 code:
- 恶意 App 注册了相同的自定义 URL Scheme(myapp://callback?code=xxx)
- 浏览器扩展监听 URL 变化
- 日志/Referer 泄漏
4. 攻击者用拦截到的 code 换 token(SPA 没有 client_secret 保护)
4.3 PKCE 的原理:用动态密钥替代 client_secret
PKCE 的思路:既然 client_secret 是静态的、存不住的,那就让客户端每次动态生成一个临时密钥。
PKCE 流程:
① 客户端生成随机 code_verifier(43~128 字符的随机串)
② 计算 code_challenge = BASE64URL(SHA256(code_verifier))
③ 发起授权时带上 code_challenge:
GET /authorize?client_id=xxx&code_challenge=YYY&code_challenge_method=S256
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
告诉授权服务器"我有 PKCE 证明"
④ 授权服务器记住 code_challenge,返回 code
⑤ 客户端用 code 换 token 时,带上 code_verifier(原始随机串):
POST /token?code=xxx&code_verifier=ZZZ
^^^^^^^^^
原始明文密钥
⑥ 授权服务器:BASE64URL(SHA256(code_verifier)) == 之前存的 code_challenge?
相等 → 发 token;不等 → 拒绝
为什么这样能防 code 拦截? 攻击者拦截到 code,但拿不到 code_verifier(code_verifier 只存在客户端内存里,不在 URL 中传输)。攻击者用 code 换 token 时无法提供正确的 code_verifier,授权服务器拒绝。
4.4 PKCE vs client_secret
| | client_secret | PKCE |
|--|--------------|------|
| 密钥类型 | 静态(注册时分配) | 动态(每次请求生成) |
| 存储位置 | 后端配置文件 | 客户端内存(不持久化) |
| 泄漏风险 | 后端被黑则泄漏 | 每次不同,泄漏无意义 |
| 适用 | 有后端的 Web 应用 | SPA、移动端、桌面应用 |
OAuth2.1 的建议:所有客户端都用 PKCE,即使有 client_secret 也可以叠加 PKCE 做双重保护。Spring Security 从 6.x 开始默认启用 PKCE。
4.5 PKCE 代码示例(客户端侧)
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class PkceUtil {
/** 生成 code_verifier:43~128 字符的随机串 */
public static String generateCodeVerifier() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
// 输出 43 字符的 URL-safe Base64
}
/** 从 code_verifier 计算 code_challenge(S256 方法) */
public static String generateCodeChallenge(String codeVerifier) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
}
}
使用:
// 发起授权前
String codeVerifier = PkceUtil.generateCodeVerifier();
String codeChallenge = PkceUtil.generateCodeChallenge(codeVerifier);
session.setAttribute("pkce_code_verifier", codeVerifier); // 存起来,换 token 时用
// 授权 URL 带上 code_challenge
String authUrl = "https://auth-server/oauth/authorize"
+ "?client_id=" + clientId
+ "&redirect_uri=" + redirectUri
+ "&response_type=code"
+ "&code_challenge=" + codeChallenge
+ "&code_challenge_method=S256"
+ "&state=" + state;
// 换 token 时带上 code_verifier
String codeVerifier = (String) session.getAttribute("pkce_code_verifier");
TokenRequest tokenRequest = TokenRequest.builder()
.grantType("authorization_code")
.code(code)
.codeVerifier(codeVerifier) // ← PKCE 证明
.redirectUri(redirectUri)
.build();
五、Spring Security OAuth2 实战
5.1 角色说明
一个完整的 OAuth2 对接需要两个角色:
| 角色 | Spring Security 组件 | 作用 |
|---|---|---|
| 授权服务器 | Spring Authorization Server | 管理用户登录、发 code、发 token |
| 客户端 | Spring Security OAuth2 Client | 发起授权、接 code 回调、换 token、用 token 调 API |
下面分别给出两个角色的核心配置。
5.2 授权服务器配置
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import java.time.Duration;
import java.util.UUID;
/**
* 授权服务器配置
*
* Spring Authorization Server 1.3.x
* 提供授权码模式 + PKCE 支持
*/
@Configuration
public class AuthorizationServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
// 开启 OIDC UserInfo 端点(可选)
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
return http.formLogin(Customizer.withDefaults()).build();
}
/**
* 注册客户端:配置 client_id、client_secret、授权模式、回调地址
*/
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("my-app") // client_id
.clientSecret("{bcrypt}$2a$10$...") // client_secret(BCrypt 加密)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
// 授权码模式
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
// 刷新令牌
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// 回调地址(必须精确匹配)
.redirectUri("https://my-app.com/login/oauth2/code/my-app")
// PKCE 支持:不强制要求,但允许客户端使用
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true) // 显示授权同意页
.requireProofKey(false) // 不强制 PKCE(Web 应用有 client_secret)
.build())
// Token 有效期
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(30)) // access_token 30 分钟
.refreshTokenTimeToLive(Duration.ofDays(7)) // refresh_token 7 天
.authorizationCodeTimeToLive(Duration.ofMinutes(5)) // code 5 分钟
.build())
.scope(OidcScopes.OPENID)
.scope("read")
.scope("write")
.build();
return new InMemoryRegisteredClientRepository(client);
}
/**
* JWK 源:JWT 签名密钥对(生产环境从密钥库加载,不硬编码)
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = generateRsaKey();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, context) -> jwkSelector.select(jwkSet);
}
private static RSAKey generateRsaKey() {
KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
return new RSAKey.Builder("key-id-1")
.keyID("key-id-1")
.publicKey((RSAPublicKey) keyPair.getPublic())
.privateKey((RSAPrivateKey) keyPair.getPrivate())
.build();
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("https://auth.example.com") // 授权服务器地址
.build();
}
}
生产环境注意:JWKSource 里的 RSA 密钥对不能每次启动重新生成——重启后旧 token 全部失效。必须从密钥库(JKS/PKCS12)或 Vault/KMS 加载固定密钥。这和我们上一篇《JWT 密钥泄露》讲的是同一件事。
5.3 客户端配置(有后端的 Web 应用)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
/**
* OAuth2 客户端配置(有后端的 Web 应用,标准授权码模式)
*
* 用户访问受保护资源 → 自动跳授权服务器登录 → 回调换 token → 存 session
*/
@Configuration
public class OAuth2ClientConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository repo) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults()) // 启用 OAuth2 登录
.oidcLogout(logout -> logout // OIDC 单点登出
.backChannel(Customizer.withDefaults())
)
.logout(logout -> logout
.logoutSuccessHandler(
new OidcClientInitiatedLogoutSuccessHandler(repo))
);
return http.build();
}
/**
* 客户端注册信息:对应授权服务器里的 RegisteredClient
*/
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
ClientRegistration registration = ClientRegistration.withRegistrationId("my-app")
.clientId("my-app")
.clientSecret("my-app-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid", "profile", "read", "write")
.authorizationUri("https://auth.example.com/oauth2/authorize")
.tokenUri("https://auth.example.com/oauth2/token")
.userInfoUri("https://auth.example.com/userinfo")
.jwkSetUri("https://auth.example.com/oauth2/jwks")
.issuerUri("https://auth.example.com")
.clientName("My App")
.build();
return new InMemoryClientRegistrationRepository(registration);
}
}
Spring Security 的 oauth2Login 帮你完成了授权码模式的全流程:
- 用户访问受保护页面 → 自动重定向到
authorizationUri(带 client_id、redirect_uri、state) - 用户在授权服务器登录 → 授权服务器回调
redirect_uri(带 code、state) - Spring Security 自动用 code + client_secret 换 token(后端到后端,前端无感)
- token 存入 SecurityContext,后续请求自动携带
5.4 客户端配置(SPA + PKCE)
/**
* SPA 客户端配置:无 client_secret,用 PKCE 保护
*
* SPA 场景下 Spring Security 充当"令牌中转":
* 浏览器 → Spring Security(发授权、接 code、换 token)→ 返回 token 给 SPA
*/
@Configuration
public class SpaOAuth2ClientConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults())
// SPA 场景:不创建 session,用 Bearer token 认证
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 资源服务器模式:校验 SPA 传来的 access_token
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
);
return http.build();
}
@Bean
public ClientRegistrationRepository spaClientRegistration() {
return new InMemoryClientRegistrationRepository(
ClientRegistration.withRegistrationId("spa-app")
.clientId("spa-app")
// ⚠️ 没有 client_secret
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://spa.example.com/callback")
.scope("openid", "read", "write")
.authorizationUri("https://auth.example.com/oauth2/authorize")
.tokenUri("https://auth.example.com/oauth2/token")
.jwkSetUri("https://auth.example.com/oauth2/jwks")
// PKCE 自动启用(Spring Security 6.x + ClientAuthenticationMethod.NONE)
.clientName("SPA App")
.build()
);
}
}
Spring Security 6.x 在 ClientAuthenticationMethod.NONE(无 client_secret)时会自动启用 PKCE——生成 code_verifier、计算 code_challenge、在授权和换 token 时自动携带。开发者不需要手写 PKCE 逻辑。
5.5 资源服务器配置
/**
* 资源服务器配置:校验 access_token,保护 API
*
* 客户端调 API 时带上 Authorization: Bearer <access_token>
* 资源服务器通过 JWKS 端点验签 JWT 格式的 access_token
*/
@Configuration
public class ResourceServerConfig {
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/user/**").hasAuthority("SCOPE_read")
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_write")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwkSetUri("https://auth.example.com/oauth2/jwks")
// 自定义权限映射:从 JWT claim 转 Spring Security authority
.jwtAuthenticationConverter(jwtAuthorityConverter())
)
);
return http.build();
}
/**
* JWT → Authentication 转换器
* 从 token 的 scope claim 提取权限
*/
private Converter<Jwt, AbstractAuthenticationToken> jwtAuthorityConverter() {
JwtGrantedAuthoritiesConverter scopesConverter = new JwtGrantedAuthoritiesConverter();
// 默认从 "scope" claim 提取,加 "SCOPE_" 前缀
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(scopesConverter);
return converter;
}
}
六、四种模式的安全对比总结
6.1 安全性对比矩阵
| 安全特性 | 密码模式 | 简化模式 | 授权码模式 | 授权码+PKCE |
|---|---|---|---|---|
| 密码不经过客户端 | ❌ | ✅ | ✅ | ✅ |
| token 不经过浏览器 | ❌ | ❌ token 在 URL | ✅ 后端换 token | ✅ |
| code 拦截防护 | N/A | N/A | ✅ client_secret | ✅ code_verifier |
| CSRF 防护 | ❌ | ⚠️ 需 state | ✅ state | ✅ state |
| 适用无后端客户端 | ✅ | ✅ | ❌ 需后端存 secret | ✅ |
| OAuth2.1 推荐 | ❌ 废弃 | ❌ 废弃 | ✅ | ✅ 首选 |
6.2 选型决策树
你的客户端是什么类型?
│
├─ 有后端的 Web 应用
│ └→ 授权码模式 + client_secret
│ (可叠加 PKCE 做双重保护)
│
├─ SPA / 移动端 / 桌面应用(无后端)
│ └→ 授权码模式 + PKCE
│ (不用 client_secret,用 PKCE 替代)
│
├─ 服务间 M2M 调用(无用户参与)
│ └→ 客户端模式(Client Credentials)
│ (不涉及用户密码,client_credentials 直接换 token)
│
└─ 官方第一方应用(用户高度信任)
└→ 授权码模式 + PKCE(OAuth2.1 不再推荐密码模式)
七、常见问题
7.1 授权码模式为什么要用 code 中转,不能直接返回 token?
直接返回 token 有两个问题:① 浏览器重定向通过 URL 传参,token 会出现在浏览器历史、Referer 头、日志里;② 前端拿到 token 后无法安全存储(localStorage 可被 XSS 窃取)。code 中转让 token 只在后端到后端的通道中传输,前端永远接触不到 token。code 是一次性的、短时效的,即使泄漏危害也有限。
7.2 PKCE 为什么用 SHA256 而不直接传 code_verifier?
如果直接在授权请求里传 code_verifier,它会出现在 URL 里——攻击者拦截 URL 就拿到了 code_verifier,PKCE 就失效了。SHA256 是单向的,攻击者拿到 code_challenge 也反推不出 code_verifier。换 token 时才传 code_verifier,这一步是后端到后端的 POST 请求,不在 URL 中暴露。
7.3 refresh_token 会不会被盗?
refresh_token 比 access_token 有效期长(通常 7~30 天),确实需要额外保护:① 只在后端存储和使用,不传给前端;② 绑定客户端(client_id 不匹配则拒绝刷新);③ 支持 token 撤销(用户登出时主动撤销);④ rotation(每次用 refresh_token 换新 access_token 时,同时发新 refresh_token,旧的失效)。Spring Authorization Server 默认启用 rotation。
7.4 简化模式(Implicit)为什么被废弃?
简化模式直接在重定向 URL 里返回 access_token(redirect_uri#access_token=xxx),有三个问题:① token 在 URL fragment 里,容易被 Referer/日志泄漏;② 没有 client_secret 验证,token 被盗后无法阻止滥用;③ 无法发 refresh_token(没有后端验证)。OAuth2.1 用授权码 + PKCE 完全替代了简化模式,安全性更高。
7.5 state 参数和 PKCE 的 code_verifier 有什么区别?
两者防的攻击不同:state 防 CSRF(攻击者诱导用户用攻击者的 code 完成授权),code_verifier 防 code 拦截(攻击者偷到 code 后冒充客户端换 token)。state 是"确认回调是同一个请求发出的",code_verifier 是"确认换 token 的人和拿 code 的人是同一个"。两者互补,不能替代。
7.6 微服务内部调用该用哪种模式?
服务间 M2M 调用没有用户参与,用客户端模式(Client Credentials):每个微服务注册一个 client_id + client_secret,直接用 client_credentials 换 token,不涉及用户密码。配合服务网格(Istio mTLS)做传输层认证,token 做应用层授权。
八、总结
四种模式速查卡
┌──────────┬───────────────┬──────────────────────────────┐
│ 模式 │ 密码经过客户端? │ OAuth2.1 状态 │
├──────────┼───────────────┼──────────────────────────────┤
│ 授权码 │ ❌ 不经过 │ ✅ 推荐(有后端) │
│ 授权码+PKCE│ ❌ 不经过 │ ✅ 推荐(无后端 / 所有场景首选)│
│ 客户端模式 │ 无用户密码 │ ✅ 推荐(M2M 服务间调用) │
│ 简化模式 │ ❌ 但 token 过URL │ ❌ 废弃(被 PKCE 替代) │
│ 密码模式 │ ✅ 经过! │ ❌ 废弃 │
└──────────┴───────────────┴──────────────────────────────┘
授权码模式安全设计四要素
① 密码不经过客户端 → 用户在授权服务器页面输入密码
② code 中转 → 一次性短时代码替代直接传 token
③ client_secret → 后端验证,防 code 被盗后冒充换 token
④ PKCE → 动态密钥替代 client_secret,保护无后端客户端
关键数据
- code 有效期:通常 5~10 分钟,一次性使用
- access_token 有效期:通常 30 分钟
- refresh_token 有效期:通常 7~30 天,支持 rotation
- PKCE code_verifier:43~128 字符随机串
- PKCE code_challenge:SHA256(code_verifier) 的 Base64URL 编码
- state:每次授权请求随机生成,回调时比对
一句话
密码模式让客户端碰密码是原罪,授权码模式用"code 中转 + client_secret 后端验证"把密码和 token 都隔绝在客户端之外,PKCE 用动态密钥补上了无后端客户端的最后一道口子——OAuth2.0 的安全不是某一个机制的功劳,而是"密码不经过客户端 + token 不经过浏览器 + code 一次性 + PKCE 动态证明"四层设计的叠加。
给团队的建议
| 场景 | 建议 |
|---|---|
| 第三方对接 | 一律授权码模式,拒绝密码模式,这是红线 |
| 有后端 Web 应用 | 授权码 + client_secret(可叠加 PKCE) |
| SPA / 移动端 | 授权码 + PKCE,不用 client_secret |
| 服务间调用 | 客户端模式,配合 mTLS |
| 仍在用密码模式 | 做迁移计划,OAuth2.1 已废弃 |
| 仍在用简化模式 | 迁移到授权码 + PKCE |
互动话题:你们项目用的哪种 OAuth2 模式?有没有被密码模式的日志泄漏坑过?SPA 场景是手写 PKCE 还是靠框架自动处理?评论区聊聊你们的实践和选型考量。
参考资料
- RFC 6749:OAuth 2.0 Framework
- RFC 7636:PKCE 扩展
- OAuth 2.1 草案(合并最佳实践)
- Spring Authorization Server 官方文档
- Spring Security OAuth2 Client 文档
- OAuth 2.0 Security Best Current Practice
- Okta:PKCE 原理详解
标题:面试官:OAuth2.0 授权码模式为什么最安全?——从密码模式的血泪教训讲起
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/02/1787992104598.html
公众号:服务端技术精选
- 引言
- 一、OAuth2.0 到底解决什么问题
- 1.1 核心问题:授权委托
- 1.2 四个核心角色
- 1.3 四种授权模式一览
- 二、密码模式的血泪教训:客户端为什么不能碰密码
- 2.1 密码模式的流程
- 2.2 三个致命风险
- 2.3 密码模式还能用吗
- 三、授权码模式:用一道"中间码"隔绝密码
- 3.1 核心思路
- 3.2 两步走:为什么拆成 code 和 token
- 3.3 各字段的安全设计
- 3.4 state 防 CSRF:一个容易被忽略的细节
- 四、PKCE:授权码模式的最后一公里
- 4.1 授权码模式的残留漏洞
- 4.2 code 拦截攻击场景
- 4.3 PKCE 的原理:用动态密钥替代 client_secret
- 4.4 PKCE vs client_secret
- 4.5 PKCE 代码示例(客户端侧)
- 五、Spring Security OAuth2 实战
- 5.1 角色说明
- 5.2 授权服务器配置
- 5.3 客户端配置(有后端的 Web 应用)
- 5.4 客户端配置(SPA + PKCE)
- 5.5 资源服务器配置
- 六、四种模式的安全对比总结
- 6.1 安全性对比矩阵
- 6.2 选型决策树
- 七、常见问题
- 7.1 授权码模式为什么要用 code 中转,不能直接返回 token?
- 7.2 PKCE 为什么用 SHA256 而不直接传 code_verifier?
- 7.3 refresh_token 会不会被盗?
- 7.4 简化模式(Implicit)为什么被废弃?
- 7.5 state 参数和 PKCE 的 code_verifier 有什么区别?
- 7.6 微服务内部调用该用哪种模式?
- 八、总结
- 四种模式速查卡
- 授权码模式安全设计四要素
- 关键数据
- 一句话
- 给团队的建议
- 参考资料
评论