多系统认证授权利器:OAuth2,究竟该如何使用?
朋友老周被一个需求卡住了:新系统要访问老系统的用户数据,两边的鉴权怎么搞?"总不能把老系统密码直接发给新系统吧?"我说你这场景有现成的实现方案——OAuth2,核心思路就三句话:用户不暴露密码,第三方拿一张有权限、有时效的令牌去访问资源,令牌过期了还能静默续上,不用重新登录。老周一拍大腿:"懂了,就是发张门禁卡!"
门禁卡的比喻很贴切,但除了架构师,恐怕大多数开发者对 OAuth2 的认知,也只是停在了这个比喻上。本文的目标很明确:让你不仅会用,还能把核心原理和最佳实践讲得清清楚楚。
一、从"门禁卡"说起:OAuth2 到底解决了什么问题
1.1 一个你每天都遇到的场景
假设你入住一家酒店,前台给了你一张门禁卡。这张卡有以下几个特点:
- 只对特定房门有效(你只能进你的房间,不能进别人的)
- 有时间限制(过了退房时间就打不开了)
- 能干什么是确定的(开门可以,但不能用它去餐厅签单)
- 随时可以挂失(前台可以把卡作废)
这张门禁卡,就是一种授权机制。
现在把这个场景搬到互联网世界:
你打开一个叫"咕咚笔记"的第三方应用,它说"可以用微信账号登录"。你点了授权,微信给你弹了个确认框:"咕咚笔记申请获取你的昵称和头像"。你点了同意,然后咕咚笔记就能拿到你的微信昵称和头像了。
这个过程是怎么实现的?答案是:OAuth2。
1.2 传统方式的致命缺陷
在没有 OAuth2 的年代,第三方应用想要访问你在另一个服务上的数据,只能这样做:
你 → 告诉咕咚笔记你的微信账号密码 → 咕咚笔记拿着你的密码去微信服务器登录 → 拿到你的数据问题在哪?你把微信的密码给了咕咚笔记。
- 咕咚笔记是好人还好说,但如果它把密码存了明文呢?
- 如果它不止读取了昵称头像,还偷偷翻了你的聊天记录呢?
- 如果你在 10 个应用里都用了这种"给密码"的方式,改一次密码 = 10 个应用全部失效
OAuth2 的解决思路非常朴素:让用户在不暴露自己密码的情况下,授权第三方应用访问自己在某个服务上的受保护资源。
你 → 在微信授权页点"同意" → 微信发给咕咚笔记一张"临时门禁卡" → 咕咚笔记拿卡访问你的昵称头像你的密码从头到尾只有你知道,微信服务器知道。咕咚笔记拿到的,只是一个有时效性、有权限范围的令牌。
1.3 OAuth2 到底是什么
OAuth2(Open Authorization 2.0)是一个授权框架,它允许第三方应用在资源拥有者的授权下,获取对受保护资源的有限访问权限。
注意这两个关键词——授权而非认证,有限而非无限。
| 概念 | 通俗解释 |
|---|---|
| 认证(Authentication) | "你是谁?"——出示身份证 |
| 授权(Authorization) | "你能干什么?"——刷门禁卡进房间 |
OAuth2 主要解决的是授权问题,虽然在实际使用中它经常被用来做认证(也就是"用微信登录"这类场景),但那其实是基于 OAuth2 之上的扩展(OpenID Connect,也就是我们常说的 OIDC)。
二、四个人一台戏:OAuth2 的核心角色
在深入流程之前,你必须先把这四个角色刻在脑子里。整个 OAuth2 就是这四个角色之间的交互。
┌──────────────┐ ┌──────────────────┐ │ │ │ │ │ 资源拥有者 │ │ 授权服务器 │ │ (Resource │ │ (Authorization │ │ Owner) │ │ Server) │ │ │ │ │ │ 就是你,用户 │ │ 比如微信的OAuth │ │ │ │ 授权服务 │ └──────┬───────┘ └────────┬─────────┘ │ ┌──────────────────┐ │ │ │ │ │ └───────┤ 客户端 ├───────┘ │ (Client) │ │ │ │ 咕咚笔记这个应用 │ └────────┬─────────┘ │ ┌────────┴─────────┐ │ │ │ 资源服务器 │ │ (Resource │ │ Server) │ │ │ │ 微信的个人信息API │ │ │ └──────────────────┘- 资源拥有者(Resource Owner):用户本人。拥有数据的所有权。
- 客户端(Client):第三方应用。想要访问用户数据的那一方。
- 授权服务器(Authorization Server):负责认证用户并颁发令牌的服务。
- 资源服务器(Resource Server):存放用户资源的服务,验证令牌后提供数据。
在实际的工程实现中,授权服务器和资源服务器经常是同一个系统的不同模块,但它们在 OAuth2 协议中是逻辑上独立的角色。
小贴士:面试的时候面试官经常问"OAuth2 有哪几个角色",这是一道送分题,四个角色一个都不能少。
三、令牌三兄弟:Access Token、Refresh Token 和 JWT
在正式进入授权流程之前,我们先认识 OAuth2 中最核心的三个"令牌"概念。
3.1 Access Token —— 门禁卡
Access Token是 OAuth2 最核心的令牌。客户端拿着它去资源服务器请求数据,资源服务器验证它有效就返回数据。
它的特点:
- 短期有效:通常是几十分钟到几小时
- 包含权限信息:能访问什么、不能访问什么
- 对客户端不透明:客户端不需要(也不应该试图)解析它的内容
3.2 Refresh Token —— 换卡凭证
Refresh Token用于在 Access Token 过期后换取新的 Access Token,而不需要让用户重新授权。
为什么要这样设计?因为 Access Token 是频繁在网络上传输的,泄露风险大,所以设置较短的有效期。Refresh Token 只在与授权服务器通信时才使用,暴露面小得多,所以可以设置较长的有效期(几天到几个月)。
3.3 JWT —— 自包含令牌
JWT(JSON Web Token)不是 OAuth2 协议要求的,但在实际落地中极其常见。它是一种自包含的令牌格式:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIn0.xxxxxxxxxxxxxxxxxx │ │ │ │ │ └── 签名 │ └── Payload(载荷,Base64解码后是JSON) └── Header(头,Base64解码后是JSON)为什么 JWT 和 OAuth2 常常一起出现?因为传统的 Access Token 可能只是一个随机字符串("不透明令牌"),资源服务器每次收到这种令牌,都需要去授权服务器校验。而 JWT 本身携带了用户信息和签名,资源服务器可以自己验证 JWT 的有效性,不需要每次都远程调用授权服务器——这叫"无状态验证",性能更好。
关键区分:OAuth2 是授权协议,定义的是"怎么发令牌、怎么用令牌";JWT 是令牌格式,定义的是"令牌里写什么、怎么签名"。两者不是同一个层面的东西。
四、四大授权模式:选对武器才能打赢仗
OAuth2 定义了四种授权模式(Grant Type)。这是整个 OAuth2 最核心的知识点,也是面试中必考的。
OAuth2 四种授权模式 │ ├── 授权码模式(Authorization Code)── ⭐ 最安全、最常用 │ 适用:有后端服务器的 Web 应用 │ 比如:咕咚笔记的后端服务器 │ ├── 简化模式(Implicit)── ⚠ OAuth 2.1 已不推荐 │ 适用:纯前端应用(SPA) │ 比如:没有后端、纯 JS 写的单页应用 │ ├── 密码模式(Resource Owner Password)── ⚠ 已废弃 │ 适用:用户完全信任客户端(比如官方App) │ 比如:微信自己的 App │ └── 客户端凭证模式(Client Credentials) 适用:服务端到服务端的调用 比如:后端微服务之间的调用4.1 授权码模式(Authorization Code)—— 王者
这是最经典、最安全、使用最广泛的模式。如果你只记住一种,就记这一种。
完整流程图
资源拥有者 客户端 授权服务器 资源服务器 (你) (咕咚笔记后端) (微信OAuth) (微信API) │ │ │ │ │1 点击"微信登录" │ │ │ ├───────────────>│ │ │ │ │ 2 重定向到授权页 │ │ │ ├───────────────────>│ │ │ │ │ │ │ 3 显示授权确认页 │ │ │ │<───────────────┼────────────────────┤ │ │ │ │ │ │ 4 用户点击"同意" │ │ │ ├────────────────┼───────────────────>│ │ │ │ │ │ │ │ 5 返回授权码(code) │ │ │ │<───────────────────┤ │ │ │ │ │ │ │ 6 用code换token │ │ │ ├───────────────────>│ │ │ │ │ │ │ │ 7 返回Access Token │ │ │ │ + Refresh Token │ │ │ │<───────────────────┤ │ │ │ │ │ │ │ 8 拿Access Token │ │ │ │ 请求用户数据 │ │ │ ├────────────────────┼─────────────────>│ │ │ │ │ │ │ 9 返回用户数据 │ │ │ │<───────────────────┼──────────────────┤ │ │ │ │ │10 登录成功,展示 │ │ │ │ 用户信息 │ │ │ │<───────────────┤ │ │关键问题:为什么叫"授权码模式"?为什么要有 code 这一步?
这是面试中的高频追问。答案是:安全——确保令牌只发给真正的后端服务器,而不是浏览器。
具体来说:
code是通过浏览器重定向传递的,会暴露在 URL 中- 如果用
code直接当令牌用,任何一个看到 URL 的人都能拿它访问数据 - 但
code是一次性的,而且换 token 时需要带上client_secret(客户端密钥) client_secret只存在后端服务器上,浏览器里没有- 所以即使别人截获了
code,没有client_secret也换不到真正的 Access Token
Authorization Code 流程的核心安全逻辑就是:code 通过不安全的渠道(浏览器)传输,但换 token 的操作只通过安全的渠道(后端到后端)完成。
浏览器(不安全通道) 后端服务器 │ │ │ code=xxxx ← 出现在URL里 │ │ │ └──── 后端收到 code ──────────────┘ │ code + client_secret │ → 授权服务器 │ Access Token │ ← 授权服务器4.2 简化模式(Implicit)—— 被时代抛弃的快枪手
简化模式省略了"用 code 换 token"这一步,授权服务器直接把 Access Token 返回给了浏览器。
为什么被抛弃?因为 Access Token 直接暴露在浏览器端:
- 浏览器的 URL 片段(
#access_token=xxx)虽然不会发给服务器,但可以被 JavaScript 读取 - 浏览器的历史记录、第三方脚本、浏览器插件都可能泄露这个 token
- 没有
client_secret的保护,任何一个拿到 token 的人都能用
OAuth 2.1 已经明确不推荐使用 Implicit 模式,改为推荐带 PKCE 的授权码模式。
4.3 密码模式(Resource Owner Password)—— 已死
用户直接把用户名和密码交给客户端,客户端拿它们去授权服务器换 token。
这又回到了我们最开始说的那个问题:你把密码给了第三方。只有一个场景勉强可以用:客户端和授权服务器是同一家公司的产品(比如微信 App 和微信服务端)。即便如此,OAuth 2.1 也已经将其标记为废弃。
4.4 客户端凭证模式(Client Credentials)—— 机器人的世界
当客户端本身就是资源拥有者,不涉及"用户"的时候使用。比如:
- 后端微服务 A 调用微服务 B 的 API
- 定时任务访问自己的数据
这种模式最简单:客户端拿自己的client_id+client_secret直接去授权服务器换一个 Access Token。
4.5 四种模式对比速查表
| 模式 | 适用场景 | 安全性 | OAuth 2.1 状态 | 是否涉及用户 |
|---|---|---|---|---|
| 授权码模式 | Web 应用(有后端) | ⭐⭐⭐⭐⭐ | ✅ 推荐 | 是 |
| 简化模式 | 纯前端 SPA | ⭐⭐ | ❌ 不推荐 | 是 |
| 密码模式 | 高度可信应用 | ⭐ | ❌ 废弃 | 是 |
| 客户端凭证模式 | 服务间调用 | ⭐⭐⭐⭐ | ✅ 推荐 | 否 |
五、PKCE:给授权码模式加一层装甲
如果你只了解了上面四种模式,面试官很可能追问:"那 PKCE 是什么?"
5.1 授权码模式的隐患
即使是授权码模式,也存在一个攻击场景:
- 攻击者在自己设备上发起授权流程,拿到 code
- 攻击者在受害者的应用回调 URL 中拦截到 code(比如通过恶意 App 注册了相同的回调 scheme)
- 攻击者用自己的 code 替换掉受害者的 code
- 受害者登录后,攻击者能通过自己的 code 关联到受害者的 session
这种攻击叫授权码拦截攻击(Authorization Code Interception Attack)。
5.2 PKCE 做了什么
PKCE(Proof Key for Code Exchange,发音 "pixy")在授权码流程中增加了两个步骤:
客户端生成 code_verifier(随机字符串) │ ├── 对其做 SHA-256 哈希 → code_challenge │ ├── ① 发起授权时,把 code_challenge 传给授权服务器 │ ├── ② 用户授权后,客户端拿到 code │ └── ③ 用 code 换 token 时,把 code_verifier 也传过去 授权服务器验证:SHA256(code_verifier) == code_challenge ?关键安全点在于:
code_challenge是code_verifier的哈希值,无法反推- 只有最初生成
code_verifier的客户端,才能在最后一步提供正确的code_verifier - 任何中间人截获了 code 也没用,因为它没有
code_verifier
一句话总结 PKCE:我先把锁给你(code_challenge),等我拿到授权码后来换 token 时,我再证明我有钥匙(code_verifier)。换不了钥匙的人,拿到授权码也没用。
5.3 OAuth 2.1 的规定
在 OAuth 2.1 中,所有使用授权码模式的客户端都必须使用 PKCE。这不是可选增强,而是硬性要求。
六、Spring Security 实战:10 分钟搭一个 OAuth2 授权服务器
理论讲得差不多了,手该上键盘了。我们来搭一个可以跑的 OAuth2 授权服务器。
6.1 项目依赖
Spring Boot 3.x + Spring Security 6.x:
<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>6.2 授权服务器配置
@Configuration public class AuthorizationServerConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain( HttpSecurity http) throws Exception { // Spring Security 6 的 OAuth2 授权服务器配置 OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); return http.formLogin(Customizer.withDefaults()).build(); } @Bean public RegisteredClientRepository registeredClientRepository() { // 注册一个客户端:咕咚笔记 RegisteredClient client = RegisteredClient .withId(UUID.randomUUID().toString()) .clientId("gudong-notes") // 客户端ID .clientSecret("{noop}secret-123456") // 客户端密钥(生产环境必须加密) .clientAuthenticationMethod( ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType( AuthorizationGrantType.AUTHORIZATION_CODE) // 授权码模式 .authorizationGrantType( AuthorizationGrantType.REFRESH_TOKEN) // 支持刷新令牌 .redirectUri("http://localhost:8081/login/oauth2/code/gudong") .scope("read") // 读权限 .scope("write") // 写权限 .clientSettings( ClientSettings.builder() .requireAuthorizationConsent(true) // 需要用户确认 .requireProofKey(true) // 强制PKCE .build()) .build(); return new InMemoryRegisteredClientRepository(client); } @Bean public JWKSource<SecurityContext> jwkSource() { // 生成 RSA 密钥对,用于签名 JWT KeyPair keyPair = generateRsaKey(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } private static KeyPair generateRsaKey() { try { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); return generator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } } }6.3 资源服务器配置
@Configuration public class ResourceServerConfig { @Bean @Order(2) public SecurityFilterChain resourceServerSecurityFilterChain( HttpSecurity http) throws Exception { http .securityMatcher("/api/**") // 只保护 /api 路径 .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults()) // 使用 JWT 验证 ); return http.build(); } }6.4 资源接口
@RestController @RequestMapping("/api") public class UserController { @GetMapping("/public/hello") public String publicHello() { return "这是公开接口,不需要 token"; } @GetMapping("/user/me") public Map<String, Object> currentUser( @AuthenticationPrincipal Jwt jwt) { // jwt 里直接可以拿到用户信息和权限 Map<String, Object> result = new HashMap<>(); result.put("subject", jwt.getSubject()); // 用户ID result.put("claims", jwt.getClaims()); // 所有声明 result.put("authorities", jwt.getClaimAsStringList("scope")); return result; } }6.5 配置 application.yml
server: port: 8080 spring: security: user: name: zhangsan password: 123456 # 演示用,生产环境绝不这样写 logging: level: org.springframework.security: DEBUG # 调试时可以打开6.6 验证流程
启动项目后,在浏览器里访问:
http://localhost:8080/oauth2/authorize ?response_type=code &client_id=gudong-notes &redirect_uri=http://localhost:8081/login/oauth2/code/gudong &scope=read &code_challenge=<PKCE_challenge> &code_challenge_method=S256你会依次看到:
- Spring Security 默认的登录页面(输入 zhangsan / 123456)
- 授权确认页面("咕咚笔记申请获取你的 read 权限")
- 点击同意后,浏览器重定向到
http://localhost:8081/...?code=xxxx
拿到了code,后端就可以用code+client_secret+code_verifier去换 Access Token 了:
curl -X POST http://localhost:8080/oauth2/token \ -H "Authorization: Basic $(echo -n 'gudong-notes:secret-123456' | base64)" \ -d "grant_type=authorization_code" \ -d "code=<刚才拿到的code>" \ -d "redirect_uri=http://localhost:8081/login/oauth2/code/gudong" \ -d "code_verifier=<PKCE_verifier>"响应:
{ "access_token": "eyJhbGciOiJSUzI1NiJ9...", "refresh_token": "abc123...", "token_type": "Bearer", "expires_in": 3600, "scope": "read" }七、细说 Token 安全:Access Token 过期了怎么办
7.1 Token 生命周期的设计哲学
Access Token: 有效期短(15分钟 ~ 2小时) ↓ 过期后 Refresh Token: 有效期长(7天 ~ 30天) ↓ 过期后 重新授权: 用户再次登录确认为什么要这样分层?最小化暴露面。
- Access Token 在每次 API 请求中都会发送,暴露在网络上,所以让它短命
- Refresh Token 只在 token 过期时使用一次,暴露次数极少
- 即使 Access Token 泄露,攻击者也只有十几分钟的窗口
7.2 Refresh Token 轮转(Rotation)
这是 OAuth 2.1 的最佳实践:每次用 Refresh Token 换新 Access Token 时,同时发放一个新的 Refresh Token,并把旧的 Refresh Token 作废。
请求:refresh_token_123 → 换取新的 access_token 响应:新的 access_token + 新的 refresh_token_456 服务端:refresh_token_123 标记为已使用好处是:如果攻击者偷到了一个 Refresh Token,但用户使用它会触发轮转,那么当用户也用了一下这个 Refresh Token(或攻击者用完之后用户再用),服务端就会发现"这个 token 已经被用过了"——说明可能被泄露了,可以立刻作废该用户的所有 token。
7.3 Token 存储:前端到底该放哪?
这也是面试和实际开发中都绕不过的问题。
| 存储方式 | 安全性 | XSS 风险 | CSRF 风险 | 推荐度 |
|---|---|---|---|---|
| localStorage | 低 | ❌ 可被 JS 读取 | 无 | 不推荐 |
| sessionStorage | 低 | ❌ 可被 JS 读取 | 无 | 不推荐 |
| Cookie(HttpOnly) | 高 | ✅ JS 无法读取 | 需防 CSRF | ⭐ 推荐 |
| 内存变量 | 高 | ✅ | ✅ | 刷新即丢失 |
| BFF(Backend For Frontend) | 最高 | ✅ | ✅ | ⭐⭐ 最推荐 |
最佳实践:Token 存在后端的 session 中,前端只持有一个 session cookie(HttpOnly + Secure + SameSite=Strict)。这就是 BFF 模式——前端根本不碰 token。
八、OAuth2 + JWT 深度融合:从原理到代码
8.1 JWT 的结构深入
一个 JWT 由三部分组成,用.分隔:
Header.Payload.SignatureHeader(算法信息):
{ "alg": "RS256", "kid": "key-id-001", "typ": "JWT" }Payload(声明信息):
{ "iss": "http://auth-server:8080", // 签发者 "sub": "zhangsan", // 主体(用户ID) "aud": "gudong-notes", // 受众(谁在使用这个token) "exp": 1718000000, // 过期时间 "iat": 1717996400, // 签发时间 "scope": "read write" // 权限范围 }Signature(签名):
RSASHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), 私钥 )8.2 资源服务器如何验证 JWT
资源服务器收到请求(Authorization: Bearer <jwt>) │ ├── 1. 解码 JWT Header,获取 kid(key id) │ ├── 2. 从授权服务器的 JWKS 端点获取公钥 │ GET http://auth-server:8080/oauth2/jwks │ ├── 3. 用公钥验证签名 │ 签名有效 → 内容未被篡改 │ ├── 4. 检查 Claims │ exp 是否过期 │ iss 是否是信任的签发者 │ aud 是否包含自己 │ └── 5. 全部通过 → 放行,把 JWT 信息注入 SecurityContextSpring Security 的oauth2ResourceServer().jwt()配置自动完成了这一切。
8.3 手动解析 JWT(不用 Spring Security)
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import java.security.PublicKey; public class JwtValidator { public static Claims validateAndParse(String token, PublicKey publicKey) { return Jwts.parser() .verifyWith(publicKey) // 用公钥验证签名 .build() .parseSignedClaims(token) // 解析 .getPayload(); // 获取载荷 } }8.4 自定义 JWT 中的 Claims
有时候你需要在 JWT 中加入业务相关的信息:
@Bean public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() { return context -> { if (context.getTokenType().getValue().equals( OAuth2TokenType.ACCESS_TOKEN.getValue())) { // 往 JWT 里加自定义字段 context.getClaims().claims(claims -> { claims.put("department", "engineering"); // 部门 claims.put("role", "admin"); // 角色 }); } }; }资源服务器侧读取:
@GetMapping("/admin/info") public Map<String, Object> adminInfo(@AuthenticationPrincipal Jwt jwt) { String department = jwt.getClaimAsString("department"); // "engineering" String role = jwt.getClaimAsString("role"); // "admin" // ... }九、生产环境的七大安全最佳实践
这一节不讲代码,讲原则。每一条都是用血泪教训换来的。
9.1 始终使用 HTTPS
全文最重要的一条。如果授权请求走的是 HTTP,中间人可以截获code;如果 token 端点走的是 HTTP,中间人可以截获client_secret。一旦泄漏,全盘皆输。
生产环境强制 HTTPS,没有任何商量的余地。
9.2 Token 不存前端
我们在 7.3 节已经讨论过了。localStorage 存 token 等价于在大马路上贴银行卡密码。用 HttpOnly Cookie + BFF 模式。
9.3 最小权限原则
申请 scope 时只申请你真正需要的。一个只展示用户昵称的应用,不要申请"读取好友列表""发送私信"的权限。
9.4 验证 redirect_uri
授权服务器必须严格校验redirect_uri,只允许预先注册的地址。不校验的话,攻击者可以诱导用户授权后把 code 发到自己的服务器。
9.5 使用 state 参数防 CSRF
在发起 OAuth2 授权请求时,生成一个随机字符串作为state参数,并在回调时验证。这可以防止攻击者诱导用户点击一个已预授权的链接。
// 发起授权时 String state = UUID.randomUUID().toString(); session.setAttribute("oauth2_state", state); // 回调时验证 String returnedState = request.getParameter("state"); if (!returnedState.equals(session.getAttribute("oauth2_state"))) { throw new SecurityException("CSRF attack detected!"); }9.6 启用 PKCE(即便是机密客户端)
虽然 PKCE 最初是为公开客户端(无法安全存储 client_secret 的应用)设计的,但 OAuth 2.1 要求所有客户端都使用。多一层防护总没错。
9.7 监控异常 Token 使用
- 同一个 Refresh Token 被使用了两次 → 可能是泄露
- 同一个 Access Token 在短时间内从不同 IP 使用 → 可能被盗用
- 大量 token 签发请求 → 可能是暴力攻击
十、面试八股速通:高频问题一网打尽
10.1 OAuth2 的四种角色是什么?
资源拥有者、客户端、授权服务器、资源服务器。四个角色,一个都别少。
10.2 授权码模式为什么要先发 code 再换 token?
因为code通过浏览器传输(不安全),但换token需要client_secret(只存在后端)。这样确保了即使code泄露,没有client_secret也拿不到真正的 Access Token。
10.3 PKCE 解决了什么问题?原理是什么?
解决了授权码拦截攻击。原理是客户端先生成code_verifier和它的哈希值code_challenge,授权时传 challenge,换 token 时传 verifier,授权服务器验证两者匹配。只有最初发起授权请求的客户端才知道code_verifier。
10.4 JWT 和 OAuth2 是什么关系?
OAuth2 是授权协议,定义了"怎么发、怎么用";JWT 是令牌格式,定义了"长什么样"。OAuth2 的 Access Token 可以用 JWT 格式,也可以用随机字符串(不透明令牌)。
10.5 Access Token 过期了怎么办?
用 Refresh Token 去换新的 Access Token,不需要用户重新登录。如果 Refresh Token 也过期了,才需要用户重新授权。
10.6 Refresh Token 轮转是什么?
每次使用 Refresh Token 时,授权服务器同时发放一个新的 Refresh Token,旧 token 作废。用于检测 Refresh Token 是否被泄露。
10.7 如何在微服务之间传递 token?
下游服务从请求头中获取 token,透传或由网关统一处理:
用户 → Gateway → 服务A → 服务B │ │ │ │ 验证token │ │ │ │ │ └── 透传 ───────────┘ Authorization Header或者服务 A 使用客户端凭证模式,以自己的身份去调用服务 B。
10.8 OAuth2 和 SSO(单点登录)是什么关系?
OAuth2 本身是授权协议,不能直接用于认证。OpenID Connect (OIDC)是基于 OAuth2 的身份认证层,可以实现 SSO。简单理解:
- OAuth2:给你一张门禁卡,你可以进房间
- OIDC:给你一张门禁卡,卡上还印了你的名字和照片,可以证明你是谁
10.9 Cookie 和 Token 两种认证方式的区别?
| 维度 | Cookie + Session | JWT Token |
|---|---|---|
| 状态 | 服务端有状态(存 session) | 服务端无状态(token 自包含) |
| 扩展性 | 需要共享 session(如 Redis) | 天然支持分布式 |
| 注销 | 简单(删 session) | 复杂(token 未过期仍有效,需黑名单) |
| 移动端 | 不友好 | 友好(HTTP Header) |
| 安全性 | 需防 CSRF | 需防 XSS(若存前端) |
写在最后
OAuth2 不是一个能"一眼看懂"的协议。它的规范 RFC 6749 是一份篇幅不小的文档,而 OAuth 2.1 还在持续演进中。但好消息是:对于绝大多数 Java 开发者来说,你不需要通读 RFC,你只需要搞懂本文覆盖的这些核心概念和原则。
回顾一下你的学习路径:
- 理解问题:OAuth2 是为了让第三方在不拿密码的情况下访问用户数据
- 记住角色:四个角色、四种模式
- 吃透流程:授权码模式为什么分两步、PKCE 做了什么
- 动手实践:Spring Security 搭一套授权/资源服务器
- 守住安全:七大实践,条条都是真金白银的经验