面试官: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_uricode 回调地址必须与注册时一致,防 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 帮你完成了授权码模式的全流程:

  1. 用户访问受保护页面 → 自动重定向到 authorizationUri(带 client_id、redirect_uri、state)
  2. 用户在授权服务器登录 → 授权服务器回调 redirect_uri(带 code、state)
  3. Spring Security 自动用 code + client_secret 换 token(后端到后端,前端无感)
  4. 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/AN/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 还是靠框架自动处理?评论区聊聊你们的实践和选型考量。


参考资料


标题:面试官:OAuth2.0 授权码模式为什么最安全?——从密码模式的血泪教训讲起
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/02/1787992104598.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消