JWT、OAuth 2.0 与 SSO 详解

JWT、OAuth 2.0 与 SSO 详解

目录

  • [一、先厘清概念:JWT 和 OAuth 2.0 不是一回事](#一、先厘清概念:JWT 和 OAuth 2.0 不是一回事 "#%E4%B8%80%E5%85%88%E5%8E%98%E6%B8%85%E6%A6%82%E5%BF%B5jwt-%E5%92%8C-oauth-20-%E4%B8%8D%E6%98%AF%E4%B8%80%E5%9B%9E%E4%BA%8B")
  • [二、JWT(JSON Web Token)](#二、JWT(JSON Web Token) "#%E4%BA%8Cjwtjson-web-token")
    • [2.1 核心思想](#2.1 核心思想 "#21-%E6%A0%B8%E5%BF%83%E6%80%9D%E6%83%B3")
    • [2.2 结构](#2.2 结构 "#22-%E7%BB%93%E6%9E%84")
    • [2.3 工作流程(完整拆解)](#2.3 工作流程(完整拆解) "#23-%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%A8%8B%E5%AE%8C%E6%95%B4%E6%8B%86%E8%A7%A3")
    • [2.4 优缺点](#2.4 优缺点 "#24-%E4%BC%98%E7%BC%BA%E7%82%B9")
    • [2.5 签名算法:secret 到底是公钥还是私钥?](#2.5 签名算法:secret 到底是公钥还是私钥? "#25-%E7%AD%BE%E5%90%8D%E7%AE%97%E6%B3%95secret-%E5%88%B0%E5%BA%95%E6%98%AF%E5%85%AC%E9%92%A5%E8%BF%98%E6%98%AF%E7%A7%81%E9%92%A5")
    • [2.6 防篡改 vs 防窃取](#2.6 防篡改 vs 防窃取 "#26-%E9%98%B2%E7%AF%A1%E6%94%B9-vs-%E9%98%B2%E7%AA%83%E5%8F%96")
      • [一次完整的 HTTPS 请求](#一次完整的 HTTPS 请求 "#%E4%B8%80%E6%AC%A1%E5%AE%8C%E6%95%B4%E7%9A%84-https-%E8%AF%B7%E6%B1%82token-%E6%98%AF%E6%80%8E%E4%B9%88%E8%A2%AB%E5%AE%89%E5%85%A8%E9%80%81%E8%BE%BE%E7%9A%84")
    • [2.7 JWT 能保护什么,不能保护什么](#2.7 JWT 能保护什么,不能保护什么 "#27-jwt-%E8%83%BD%E4%BF%9D%E6%8A%A4%E4%BB%80%E4%B9%88%E4%B8%8D%E8%83%BD%E4%BF%9D%E6%8A%A4%E4%BB%80%E4%B9%88")
    • [2.8 Demo 示例](#2.8 Demo 示例 "#28-demo-%E7%A4%BA%E4%BE%8B")
    • [2.9 为什么需要 access_token + refresh_token 两个令牌](#2.9 为什么需要 access_token + refresh_token 两个令牌 "#29-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-access_token--refresh_token-%E4%B8%A4%E4%B8%AA%E4%BB%A4%E7%89%8C")
  • [三、OAuth 2.0](#三、OAuth 2.0 "#%E4%B8%89oauth-20")
    • [3.1 核心场景](#3.1 核心场景 "#31-%E6%A0%B8%E5%BF%83%E5%9C%BA%E6%99%AF")
    • [3.2 四个角色](#3.2 四个角色 "#32-%E5%9B%9B%E4%B8%AA%E8%A7%92%E8%89%B2")
    • [3.3 四种授权模式](#3.3 四种授权模式 "#33-%E5%9B%9B%E7%A7%8D%E6%8E%88%E6%9D%83%E6%A8%A1%E5%BC%8F")
    • [3.4 为什么要有"授权码"这一步](#3.4 为什么要有"授权码"这一步 "#34-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A6%81%E6%9C%89%E6%8E%88%E6%9D%83%E7%A0%81%E8%BF%99%E4%B8%80%E6%AD%A5")
      • [如果 code 在没使用前就被窃取了?](#如果 code 在没使用前就被窃取了? "#%E5%A6%82%E6%9E%9C-code-%E5%9C%A8%E6%B2%A1%E4%BD%BF%E7%94%A8%E5%89%8D%E5%B0%B1%E8%A2%AB%E7%AA%83%E5%8F%96%E4%BA%86")
  • 四、SSO(单点登录)
    • [4.1 核心场景](#4.1 核心场景 "#41-%E6%A0%B8%E5%BF%83%E5%9C%BA%E6%99%AF")
    • [4.2 工作流程](#4.2 工作流程 "#42-%E5%B7%A5%E4%BD%9C%E6%B5%81%E7%A8%8B")
    • [4.3 三种实现方式](#4.3 三种实现方式 "#43-%E4%B8%89%E7%A7%8D%E5%AE%9E%E7%8E%B0%E6%96%B9%E5%BC%8F")
    • [4.4 SSO 的核心问题:注销](#4.4 SSO 的核心问题:注销 "#44-sso-%E7%9A%84%E6%A0%B8%E5%BF%83%E9%97%AE%E9%A2%98%E6%B3%A8%E9%94%80")
  • 五、三者的定位与关系
    • [5.1 一句话区分](#5.1 一句话区分 "#51-%E4%B8%80%E5%8F%A5%E8%AF%9D%E5%8C%BA%E5%88%86")
    • [5.2 关系举例](#5.2 关系举例 "#52-%E5%85%B3%E7%B3%BB%E4%B8%BE%E4%BE%8B")
  • [六、JWT + OAuth 2.0 结合使用](#六、JWT + OAuth 2.0 结合使用 "#%E5%85%ADjwt--oauth-20-%E7%BB%93%E5%90%88%E4%BD%BF%E7%94%A8")
  • 七、一张图总结
  • 八、常见面试问题速答

一、先厘清概念:JWT 和 OAuth 2.0 不是一回事

很多新人会把它们混为一谈,其实它们是不同层面的东西:

JWT OAuth 2.0
是什么 一种令牌格式(数据结构) 一种授权协议(流程规范)
解决什么问题 "令牌里装什么数据、怎么验证" "第三方怎样安全地拿到授权"
类比 身份证(卡片本身的格式) 银行开户流程(你怎么证明你是你)
能互相替代吗 不能,它不是协议 不能,它不规定令牌格式

一句话总结:OAuth 2.0 定义"怎么发证",JWT 定义"证长什么样"。OAuth 2.0 可以用 JWT 作为令牌格式,也可以不用。


二、JWT(JSON Web Token)

2.1 核心思想

传统 Session 模式:服务器需要存储用户的登录状态(内存/Redis),每次请求都要查一下。

JWT 模式:服务器不存任何状态,把用户信息直接编码进令牌发给客户端,客户端每次请求带上来,服务器验签即可。

2.2 结构

JWT 由三部分组成,用 . 分隔:

css 复制代码
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEwMDF9.abcd1234
     ↓                      ↓                ↓
   Header                Payload          Signature
graph LR subgraph JWT H[&#34;Header<br/>算法类型&#34;] P[&#34;Payload<br/>用户数据&#34;] S[&#34;Signature<br/>签名校验&#34;] end H ---|&#34;Base64URL&#34;| P ---|&#34;Base64URL&#34;| S
Header --- 描述签名算法
json 复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}
Payload --- 存放声明(Claims)
json 复制代码
{
  "sub": "1001",           // 主题:用户ID
  "name": "Darren",
  "role": "admin",
  "iat": 1722154800,       // 签发时间
  "exp": 1722241200         // 过期时间
}
Signature --- 防篡改
scss 复制代码
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

签名的作用是防篡改 ,不是加密。Payload 只是 Base64 编码,任何人拿到令牌都能解码看到内容。所以不要在 JWT 里放敏感信息

2.3 工作流程(完整拆解)

JWT 的生命周期分为两个阶段:签发(登录时)验证(每次请求时)

阶段一:登录 → 签发 JWT

用户第一次登录时,服务器验证账号密码正确后,当场签发一个 JWT 返回给客户端。

sequenceDiagram participant User as 浏览器 participant Server as 服务器 Note over User,Server: ═══ 阶段一:登录时签发 JWT ═══ User->>Server: POST /login<br/>{username: &#34;darren&#34;, password: &#34;123456&#34;} Server->>Server: 1. 查数据库验证账号密码 Server->>Server: 2. 密码正确,准备签发 JWT<br/>Header: {alg: &#34;HS256&#34;}<br/>Payload: {sub: 1001, role: &#34;admin&#34;, iat: ..., exp: ...}<br/>Signature: HMACSHA256(header.payload, secret) Server-->>User: 3. 返回 JWT 字符串 Note over User: 浏览器存到 Cookie 或 localStorage

关键:签发动作发生在登录的时候。 签发完成后,服务器完全不存储这个 token,直接忘掉它。

阶段二:后续请求 → 每次验签

客户端后续每次请求都带着这个 JWT,服务器当场验签,签名对了就放行。

sequenceDiagram participant User as 浏览器 participant Server as 服务器 Note over User,Server: ═══ 阶段二:后续请求每次都验签 ═══ User->>Server: GET /api/orders (带 JWT) Note right of Server: 取出 JWT,拆成 header.payload.signature Server->>Server: 重新计算 HMACSHA256(header.payload, secret) Server->>Server: 计算结果 == 令牌自带签名? alt 两边一致 Server-->>User: ✅ 200 返回订单数据 else 签名不匹配(被篡改或伪造) Server-->>User: ❌ 401 拒绝 end
每次请求都要验签,性能好吗?

好,而且比传统的 Session 模式更快。

Session 模式的"认证"过程:

markdown 复制代码
用户请求 → 从 Cookie 取出 sessionId → 查 Redis/DB 找到对应 session → 返回
                                          ↑
                                    一次网络 IO(0.5~1ms)

JWT 的验签过程:

css 复制代码
用户请求 → 从 Header 取出 JWT → HMACSHA256(header.payload, secret) → 比较签名
                                       ↑
                                  纯 CPU 运算(≈0.001ms)
graph LR subgraph &#34;Session 模式&#34; S1[&#34;请求&#34;] --> S2[&#34;取出 sessionId&#34;] S2 --> S3[&#34;Redis 查询&#34;] S3 -.->|&#34;网络 IO 0.5~1ms&#34;| S4[&#34;返回结果&#34;] end subgraph &#34;JWT 模式&#34; J1[&#34;请求&#34;] --> J2[&#34;取出 JWT&#34;] J2 --> J3[&#34;HMACSHA256 验签&#34;] J3 -.->|&#34;纯 CPU 0.001ms&#34;| J4[&#34;返回结果&#34;] end

1 微秒 vs 1 毫秒,差距是三个数量级。而且------

  • 天然无锁:Session 查 Redis 有并发争用,JWT 验签无共享状态,多线程并行零开销
  • 可缓存:同一个 token 有效期内,验签结果可本地缓存几秒,后续请求直接跳过
  • 实际瓶颈不在验签:一次请求的开销在数据库查询、序列化、网络传输,HMACSHA256 那几微秒完全可以忽略

结论:不要被"每次都要算"吓到。验签是密码学中最便宜的操作之一,Redis 的网络往返比它贵 500 倍。

签名为什么能防篡改------用数字说话

假设服务器签发了一个 JWT:

css 复制代码
Header:   {"alg":"HS256"}                        → Base64 → eyJhbGciOiJIUzI1NiJ9
Payload:  {"sub":"1001","role":"user"}           → Base64 → eyJzdWIiOiIxMDAxIiwicm9sZSI6InVzZXIifQ
签名:     HMACSHA256("eyJ...eyJ...", "secret123") = abc123

最终令牌:eyJhbGci...NiJ9.eyJzdWIi...ifQ.abc123

现在攻击者想提权,把 role: "user" 改成 role: "admin"

graph TD subgraph &#34;原始 JWT&#34; H[&#34;Header<br/>eyJhbGci...&#34;] P1[&#34;Payload<br/>{role:user}&#34;] S1[&#34;Signature<br/>abc123&#34;] end H --- P1 --- S1 subgraph &#34;攻击者篡改&#34; H2[&#34;Header<br/>eyJhbGci... (不变)&#34;] P2[&#34;Payload<br/>{role:admin} ← 改了!&#34;] S2[&#34;Signature<br/>abc123 (没 secret,改不了)&#34;] end H2 --- P2 --- S2 subgraph &#34;服务器验签&#34; CALC[&#34;重新计算<br/>HMACSHA256(header.新payload, secret)<br/>= xyz789&#34;] COMPARE[&#34;xyz789 ≠ abc123&#34;] REJECT[&#34;❌ 拒绝!&#34;] end S2 --> CALC CALC --> COMPARE --> REJECT
为什么窃取者改不了 token------逐层拆解

最容易被误解的地方来了:"token 是在登录时下发的,所以改不了",这个理解是错的。

真正的原因和"登录时下发"没关系,而是签名算法的数学性质决定的。

一步步推:

第一层:JWT 是可以被篡改的

token 就是三段 Base64 字符串,任何人拿到都能解码、修改、再编码回去。技术上没有任何阻挡。

java 复制代码
// 攻击者拿到 token,轻松拆开
String[] parts = token.split("\\.");       // 拆成 3 段
String header  = parts[0];                  // 算法信息
String payload = parts[1];                  // Base64 {"sub":"1001","role":"user"}
String sig     = parts[2];                  // 签名

// 解码 payload,把 "user" 改成 "admin"
String decoded = new String(Base64.getUrlDecoder().decode(payload));
String tampered = decoded.replace("\"user\"", "\"admin\"");  
String newPayload = Base64.getUrlEncoder().encodeToString(tampered.getBytes());

// 拼回去------攻击者做的 token
String evilToken = header + "." + newPayload + "." + sig;  // ← 签名还是旧的!

第二层:但签名会暴露篡改

问题出在最后那步------签名(sig)没变。签名的计算过程:

scss 复制代码
签名 = HMACSHA256( header + "." + payload , secret )
                     ↑               ↑
                 公开部分        只有服务器知道

攻击者改了 payload,但 secret 他不知道,所以:

  • 服务器重新计算:HMACSHA256(header.新payload, 自己的secret) → 算出一个新签名
  • 令牌里带的签名还是旧的(针对旧 payload 算的)
  • 新旧签名不匹配 → 拒绝
graph TD subgraph &#34;攻击者视角&#34; KNOW[&#34;知道的:header + payload<br/>(Base64 解码就能看)&#34;] UNKNOW[&#34;不知道的:secret<br/>(只存在服务器上)&#34;] WANT[&#34;想要的:算出新签名&#34;] end KNOW --> WANT UNKNOW -.->|&#34;缺少这一块&#34;| WANT WANT --> FAIL[&#34;❌ 算不出来&#34;] subgraph &#34;类比&#34; ANA[&#34;你有一把锁的结构图(header+payload)<br/>但你没有钥匙(secret)<br/>你改不动锁,也仿不了锁&#34;] end

第三层:为什么不能逆向推出 secret

攻击者手里有:公开的 headerpayload、以及基于它们算出的 签名

能不能倒推出 secret不能。 这就是哈希函数的核心特性:

scss 复制代码
正向:HMACSHA256(数据, secret)  →  签名        ← 简单,微秒级
逆向:签名 →  secret                              ← 不可能,穷举需要宇宙年龄

算一下:HS256 的 secret 建议 256 bit(32 字节),暴力穷举需要尝试 2²⁵⁶ 次------这个数字大概比宇宙中的原子数还多。

总结,为什么窃取者改不了 token:

不是
不是因为"登录时下发的" 是因为签名的数学性质------哈希单向性
不是因为 token 有写保护 是因为攻击者缺少 secret,无法生成合法签名
不是因为 Base64 能防止修改 Base64 只是编码,任何人都能解码修改

一句话:你能看见锁长什么样,可以尝试撬锁,但你没有钥匙,所以你打造不出一模一样的锁。改了以后,锁就对不上了。


2.4 优缺点

优点:

  • 无状态,服务器不需要存 Session,易于水平扩展
  • 适合微服务/分布式架构,各服务共享同一把秘钥即可验签
  • 跨域友好

缺点:

  • 一旦签发,在过期前无法主动失效(不能"踢人下线")
  • Payload 不能太大,否则每次请求带宽开销大
  • 秘钥泄露 = 任何人都能伪造令牌

2.5 签名算法:secret 到底是公钥还是私钥?

我在前面一直说的 secret,严格来说是 HS256 的对称密钥------既不是公钥也不是私钥,就是一把共享钥匙。

JWT 支持两大类签名算法,取决于你的架构:

HS256(对称加密)------ 本文示例用的就是这个

只有一把钥匙 ,签发和验签都用它。这把钥匙就叫 secret

scss 复制代码
签发:HMACSHA256(header.payload, secret)  →  签名
验签:HMACSHA256(header.payload, secret)  →  和附带的签名对比
            ↑                          ↑
       用的是同一把 secret           同一把
graph LR subgraph &#34;HS256: 一把钥匙开一把锁&#34; SECRET[&#34;secret<br/>(共享密钥)&#34;] SIGN[&#34;签发 JWT&#34;] VERIFY[&#34;验证 JWT&#34;] end SECRET -->|&#34;同一把&#34;| SIGN SECRET -->|&#34;同一把&#34;| VERIFY

秘书的保险柜类比: 秘书用一把钥匙把文件锁进柜子,这把钥匙同时也能打开柜子。谁有这把钥匙,谁就能签发和验证。所以 secret 必须严格保密,签发和验证双方都要安全持有

关键:secret 只存在服务端 ,永远不会发给客户端。客户端拿到的 JWT 是 header.payload.签名 三段------前两段是公开的,第三段是服务端用 secret 算出来的结果,不是 secret 本身。就像你收到一把锁和钥匙孔,但你摸不到钥匙。

RS256(非对称加密)------ 微服务标配

两把钥匙:私钥签发,公钥验签。

scss 复制代码
签发:RSASHA256(header.payload, 私钥)   →  签名
验签:RSASHA256(header.payload, 公钥)   →  和附带的签名对比
            ↑                   ↑
        私钥(保密)         公钥(可以公开)
graph LR subgraph &#34;RS256: 一把锁,两把钥匙&#34; PRIVATE[&#34;私钥 Private Key<br/>(保密,只有签发方持有)&#34;] PUBLIC[&#34;公钥 Public Key<br/>(公开,谁都能拿到)&#34;] SIGN2[&#34;签发 JWT&#34;] VERIFY2[&#34;验证 JWT&#34;] end PRIVATE -->|&#34;签发用&#34;| SIGN2 PUBLIC -->|&#34;验签用&#34;| VERIFY2

公章和验章机的类比: 老板手里有公章(私钥),只有他能盖。办事大厅放了一台验章机(公钥),任何人都可以拿文件来验证"章是不是真的"。验章机丢了也没事,反正它不能盖章。

二者对比
HS256 RS256
钥匙数量 1 把(共享密钥) 2 把(私钥 + 公钥)
签发方 知道 secret 就行 必须有私钥
验签方 需要同一把 secret 只需要公钥(可公开)
secret 泄漏后果 能签发也能验签,彻底暴露 公钥泄漏没关系;私钥泄漏=别人能伪造
适用场景 单体应用 微服务:认证中心有私钥,其他服务拿公钥验签
密钥分发 难------每个服务都要安全持有 易------公钥直接放配置文件

所以回答你的问题:

  • HS256 的 secret既不是公钥也不是私钥,就是一把共享的对称密钥,签发验签都用它
  • RS256:私钥签发,公钥验签,这是真正的非对称加密
  • 本文前面的示例代码用的是 HS256,所以都用 secret 这个词

实际选哪种?简单项目 HS256 够用;只要超过一个服务,推荐 RS256------认证中心持私钥签发,业务服务拿公钥验签,公钥泄漏也无所谓。

2.6 防篡改 vs 防窃取

这两个词长得很像,但完全是两回事。

防篡改(签名保证): 攻击者改了 payload → 签名对不上 → 服务器拒绝。

防窃取(签名不管用): 攻击者原封不动偷走 token → 签名完全正确 → 服务器通过。

sequenceDiagram participant Issuer as 签发方 (服务器) participant Stealer as 攻击者 participant Verifier as 验证方 (服务器) Note over Issuer: 签发时 Issuer->>Issuer: header + payload + secret<br/>= 签名 S Note over Stealer: 篡改 payload Stealer->>Stealer: 把 role: &#34;user&#34; 改成 role: &#34;admin&#34; Note over Verifier: 验证时 Verifier->>Verifier: header + 篡改后的 payload<br/>+ secret = 签名 S' Verifier->>Verifier: S' ≠ S (签名不匹配) Verifier-->>Stealer: ❌ 401,拒绝请求
场景 签名是否匹配 结果
正常请求(未篡改) 匹配 通过
攻击者改了 payload(如 user→admin) 不匹配 拒绝
攻击者原封不动偷来用 匹配 通过------这就是为什么必须 HTTPS

防护手段:

  • HTTPS 是底线------防中间人截获
  • Token 有效期要短(15~30 分钟)------泄露窗口小
  • 敏感操作二次验证(改密码、转账)
  • 不要存 localStorage ------XSS 攻击能读到,优先用 httpOnly + Secure 的 Cookie
一次完整的 HTTPS 请求------Token 是怎么被安全送达的

先说没有 HTTPS 时会发生什么。

HTTP 是明文传输。在公共 Wi-Fi 下,你和服务器之间的对话就像两个人在广场上大声喊话,周围所有人都能听见:

graph LR User[&#34;你&#34;] -->|&#34;HTTP 明文<br/>token 直接可见&#34;| WiFi[&#34;公共 Wi-Fi&#34;] WiFi --> Server[&#34;服务器&#34;] Attacker[&#34;攻击者<br/>(同 Wi-Fi 下)&#34;] -.->|&#34;嗅探抓包&#34;| WiFi Attacker --> Result[&#34;看到 token<br/>以你身份操作&#34;]

你发出的请求里带着 JWT token,攻击者用 Wireshark 一抓,token 明文直接出现在他屏幕上。这就是为什么"只用 JWT 签名"不够------签名防篡改,但传输过程中被偷走,签名救不了你

那加密传输不就行了?问题是你怎么把"密码"安全地告诉对方。

假如你和朋友在嘈杂的酒吧里,你想跟他约定一个暗号,以后用暗号对话。但周围全是耳朵------你大声说出暗号,所有人都听到了;你悄悄走过去说,但你们根本不认识(浏览器和服务器没有私下见面的机会)。

复制代码
核心矛盾:
  要加密通信 → 需要一个密钥(暗号)
  要安全传递密钥 → 又需要加密通信
  鸡生蛋,蛋生鸡 ♻️

解法:非对称加密。 两把钥匙------公钥(可以大声喊出来)和私钥(死死揣兜里):

复制代码
公钥加密的数据 → 只有私钥能解开
公钥本身 → 谁都可以知道,知道了也没用

就像你有一个公开的带锁信箱:任何人都能把信塞进去并锁上(公钥加密),但只有你有钥匙能打开(私钥解密)。窃听者能看到信箱、看到有人塞信、甚至自己也做一个一模一样的信箱,但他打不开你的信箱,看不到里面的信。

现在问题变成了:浏览器和服务器怎么用这个"带锁信箱"来安全约定一个临时密钥?这个过程叫 TLS 握手

sequenceDiagram participant Browser as 浏览器 participant Attacker as 中间人 participant Server as 服务器 Note over Browser,Server: TLS 握手------在中间人眼皮底下安全约定密钥 Browser->>Server: ① ClientHello:你好,我能用这些加密算法,开始握手吧 Server-->>Browser: ② 我的证书(含公钥)------这是服务器唯一一次暴露&#34;身份信息&#34; Note over Attacker: 中间人也能收到证书和公钥<br/>但公钥本来就是公开的,看到也无所谓 Browser->>Browser: ③ 验证证书 OK(怎么验证的见下一节) Browser->>Browser: ④ 随机生成一个临时密钥 Browser->>Server: ⑤ 用服务器公钥加密这个临时密钥,发给服务器 Note over Attacker: 中间人也截获了这段密文<br/>但他没有私钥,解不开! Server->>Server: ⑥ 用私钥解密,拿到临时密钥 Note over Browser,Server: 握手完成!双方都有了临时密钥,中间人不知道 Browser->>Server: ⑦ 加密( token + 请求数据 , 临时密钥 ) Server-->>Browser: ⑧ 加密( 响应数据 , 临时密钥 ) Note over Attacker: 之后所有数据都是密文<br/>中间人彻底出局

从这个图可以看到,中间人每一步都参与了,但每一步都失败了:

  • 第②步:拿到了公钥,但公钥本来就可以公开,没用
  • 第⑤步:截获了密文(加密后的临时密钥),但没有私钥解不开
  • 第⑦⑧步:之后的数据全是密文,彻底看不见

那以后每次请求都用这个临时密钥加密,这就是对称加密------速度快几百倍。

把 TLS 握手串起来,就是三件事:

arduino 复制代码
① 服务器发证书 → 浏览器用内置的 CA 公钥验证证书 → 确认"你真的是你"
② 验证通过 → 浏览器随机生成临时密钥,用证书里的服务器公钥加密 → 发给服务器
③ 服务器用私钥解开 → 双方都有了临时密钥 → 后续所有报文都用它加密

关键记忆点:CA 的公钥在浏览器里(出厂就有),服务器的公钥在证书里(每次握手传过来),临时密钥是浏览器现场生成的(用完就丢)。三者各管各的,不要搞混。
一句话:TLS 握手用"笨重但安全"的非对称加密(公钥/私钥),安全地交换了一个"轻快但需要保密"的对称密钥。之后所有数据用对称密钥加密传输。为什么这么麻烦?因为非对称加密虽然安全,但太慢了,不能用来加密每一次请求。


等等,第③步说"验证证书 OK",怎么验证的?谁保证这个公钥真的是百度的,不是中间人伪造的?

这是 HTTPS 最关键的一环。回到 TLS 握手第②步------服务器把自己的证书(内含公钥)发给了浏览器。证书就是服务器的身份证。

想一想你去酒店开房:前台为什么信你的身份证?不是因为你把身份证做得多漂亮,而是因为这是公安局发的。前台信任公安局,公安局说你是谁,前台就信。

CA(Certificate Authority,证书颁发机构)就是这个"公安局":

graph TD subgraph &#34;浏览器内置(出厂就信任)&#34; ROOT[&#34;根 CA<br/>相当于'公安局'<br/>🔒 操作系统自带&#34;] end subgraph &#34;中间 CA&#34; MID[&#34;中间 CA<br/>相当于'市公安局'<br/>由根 CA 授权&#34;] end subgraph &#34;服务器证书&#34; SITE[&#34;example.com 证书<br/>相当于'身份证'<br/>由中间 CA 颁发&#34;] end ROOT -->|&#34;授权(签名)&#34;| MID MID -->|&#34;颁发(签名)&#34;| SITE style ROOT fill:#e8f5e9,stroke:#2e7d32

浏览器拿到证书后,验三件事:

校验项 验什么 酒店前台类比
签名链 证书是不是 CA 签发的(用 CA 的公钥验证签名) 身份证是不是公安局做的(真有这个公安局吗)
域名 证书上的域名和我访问的是不是同一个 身份证上的人是你吗
有效期 证书过期了没有 身份证过期了吗

为什么攻击者伪造不了:

  • 自签一张身份证? → 浏览器不认识"自签 CA",直接弹出红色警告
  • 拿别人的身份证? → 域名不匹配,浏览器照样警告
  • 把公安局黑了? → 历史上确实发生过(2011 年 DigiNotar 被黑),但浏览器有吊销列表(CRL/OCSP),被黑的证书立刻作废

一句话:浏览器只信任出厂内置的几十个根 CA,跟 JWT 的信任模型是一个道理------信任链层层传递,"我信他,他信他,所以我信他"。


把整个流程串起来------一次 HTTPS 请求,token 经历了什么:

graph TD A[&#34;你输入 https://example.com&#34;] --> B[&#34;TLS 握手:<br/>服务器发证书 + 公钥&#34;] B --> C{&#34;浏览器验证证书&#34;} C -->|&#34;❌ 不通过&#34;| D[&#34;红色警告,阻断&#34;] C -->|&#34;✅ 通过&#34;| E[&#34;生成临时密钥<br/>用公钥加密后发给服务器&#34;] E --> F[&#34;双方都有临时密钥<br/>中间人不知道&#34;] F --> G[&#34;之后所有请求<br/>用临时密钥加密传输&#34;] G --> H[&#34;token 安全到达服务器<br/>中间人全程看到密文,解不开&#34;]

对照最开始那 HTTP 明文图:

HTTP 的漏洞 HTTPS 的防护
明文传输,任何人都能看到 对称加密,只有双方知道临时密钥
不知道通信对象是谁 CA 证书验证身份,伪造不了
数据可能被篡改 加密信道自带完整性校验,改了能发现

HTTPS 保护了什么,没保护什么:

能保护 不能保护
传输过程中 token 不被嗅探 客户端 XSS 攻击(token 在浏览器内存里,JS 照样能读)
请求/响应不被篡改 用户电脑中了木马(键盘记录直接拿到密码)
中间人无法伪造服务器 服务器本身被黑了(黑客直接从数据库拿数据)

HTTPS 保护的是数据在"路上"的安全。数据在浏览器里的安全靠 httpOnly Cookie + CSP 防 XSS。不能把 HTTPS 当成万能药。

2.7 JWT 能保护什么,不能保护什么

这是一个很多人理解错的地方:JWT 签名只管"token 本身有没有被改",其他什么都不管。

JWT 的 Payload 不放业务数据
graph LR subgraph &#34;JWT Payload --- 只有身份标识&#34; P1[&#34;userId: 1001&#34;] P2[&#34;role: user&#34;] P3[&#34;exp: 过期时间&#34;] end subgraph &#34;Request Body --- 业务数据&#34; B1[&#34;orderId: 8848&#34;] B2[&#34;amount: 500&#34;] B3[&#34;address: 北京市...&#34;] end

Payload 里放的始终是身份相关的基础字段(用户ID、角色、过期时间),不是业务数据。订单金额、收货地址这类东西在 request body 里。

Token 被窃取后,攻击者在 body 里改参数------签名管不了

在 2.3 里讲了,验签过程是这样:

scss 复制代码
服务器验证:计算 HMACSHA256(header.payload, secret) ≟ 附带的签名

注意------验签只用到了 header 和 payload,body 完全没参与计算。所以你只要不改 token 本身,在 body 里随便改什么,签名都管不着。

sequenceDiagram participant Stealer as 攻击者(窃取了 token) participant Server as 服务器 Note over Stealer: 攻击者偷到了 userA 的 token<br/>(payload 里 sub=1001, role=user) rect rgb(255, 240, 240) Note over Stealer,Server: ❌ 攻击1:改 token 里的身份 → 签名拦截 Stealer->>Server: token 里 sub 改成 1002(管理员) Server->>Server: 验签失败!拒绝 end rect rgb(255, 255, 240) Note over Stealer,Server: ⚠️ 攻击2:token 不动,改 body → 签名无能为力! Stealer->>Server: token 不变(sub=1001)<br/>body 里改成 orderId=别人的订单 Server->>Server: 验签通过!token 没问题 Note over Server: 但接下来呢? end

那怎么防 body 被篡改?

答案不在 JWT,而在业务层的权限校验

java 复制代码
@GetMapping("/api/orders/{orderId}")
public Order getOrder(@PathVariable Long orderId,
                      @RequestAttribute Long currentUserId) { // ← 从 JWT 解析出来的
    Order order = orderService.findById(orderId);

    // 关键:校验这个订单是不是当前用户的
    if (!order.getUserId().equals(currentUserId)) {
        throw new ForbiddenException("无权访问他人订单");
    }

    return order;
}

总结一张表:

攻击方式 JWT 签名能防吗 真正的防线
改 token 里的 userId/role 能------签名不匹配 JWT 签名本身
偷 token 原封不动用 不能 HTTPS + 短有效期
token 不变,改 body 参数 不能 业务层权限校验(订单归属、金额范围等)
重复提交同一请求 不能 幂等键 / CSRF Token

一句话:JWT 只证明"你是谁",不证明"你想干嘛"。你想干嘛(body 里的操作)对不对,是业务代码的责任。

2.8 Demo 示例

以下用 Java 展示 JWT 签发、验证、以及篡改检测的全过程(依赖 io.jsonwebtoken:jjwt-api:0.12.5)。

2.8.1 签发 JWT
java 复制代码
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;

public class JwtDemo {

    private static final SecretKey SECRET = Keys.hmacShaKeyFor(
        "my-256-bit-secret-key-my-256-bit-secret-key!!".getBytes()
    );

    /** 签发一个 30 分钟有效期的 JWT */
    public static String issueToken(Long userId, String role) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + 30 * 60 * 1000); // 30分钟

        return Jwts.builder()
                .subject(String.valueOf(userId))    // sub: 用户ID
                .claim("role", role)                // 自定义字段: 角色
                .issuedAt(now)                      // iat: 签发时间
                .expiration(expiration)             // exp: 过期时间
                .signWith(SECRET)                   // 用 HS256 签名
                .compact();
    }

    public static void main(String[] args) {
        String token = issueToken(1001L, "user");
        System.out.println("签发的 JWT:\n" + token + "\n");
    }
}

输出示例:

复制代码
签发的 JWT:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIiwicm9sZSI6InVzZXIiLCJpYXQiOjE3NTM3MTUyMDAsImV4cCI6MTc1MzcxNzAwMH0.xxx-signature-xxx
2.8.2 验证 JWT(正常请求通过)
java 复制代码
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;

/** 验证 JWT------签名对得上才通过 */
public static void verifyToken(String token) {
    try {
        Jws<Claims> jws = Jwts.parser()
                .verifyWith(SECRET)
                .build()
                .parseSignedClaims(token);

        Claims claims = jws.getPayload();
        System.out.println("✅ 验证通过!");
        System.out.println("  用户ID: " + claims.getSubject());
        System.out.println("  角色:   " + claims.get("role"));
        System.out.println("  签发:   " + claims.getIssuedAt());
        System.out.println("  过期:   " + claims.getExpiration());
    } catch (Exception e) {
        System.out.println("❌ 验证失败: " + e.getMessage());
    }
}
2.8.3 篡改检测(攻击者改 payload 被拦截)
java 复制代码
/** 模拟攻击:篡改 payload 中的 role */
public static void tamperDemo() {
    // 1. 正常签发
    String token = issueToken(1001L, "user");
    System.out.println("原始令牌: " + token);

    // 2. 模拟攻击者偷到 token,解码并篡改
    //    (因为 JWT 只是 Base64,任何人解码就能读到内容)
    String[] parts = token.split("\\.");
    String header  = parts[0];                     // 不动
    String payload = parts[1];                     // 攻击者改这个
    String sig     = parts[2];                     // 攻击者没有 secret,改不了

    // 攻击者把 role 从 "user" 改成 "admin"
    // payload 原始内容 (Base64 解码后): {"sub":"1001","role":"user","iat":...,"exp":...}
    // 攻击者改成了:                   {"sub":"1001","role":"admin","iat":...,"exp":...}
    String tamperedPayload = base64UrlEncode(
        "{\"sub\":\"1001\",\"role\":\"admin\",\"iat\":1753715200,\"exp\":1753717000}"
    );
    String tamperedToken = header + "." + tamperedPayload + "." + sig;

    System.out.println("\n🚨 攻击者篡改后的令牌: " + tamperedToken);

    // 3. 服务器验证------签名不匹配,拒绝!
    verifyToken(tamperedToken);
}

// 输出:
// 原始令牌: eyJ...user...xxx
// 🚨 攻击者篡改后的令牌: eyJ...admin...xxx
// ❌ 验证失败: JWT signature does not match locally computed signature.
2.8.4 窃取演示(原封不动偷来,签名通过!)
java 复制代码
/** 模拟攻击:原封不动偷走 token,就能以你的身份调用 */
public static void stealDemo() {
    String token = issueToken(1001L, "user");
    System.out.println("合法用户签发的令牌: " + token);

    // --- 攻击者什么都没改,直接拿着用 ---

    System.out.println("\n🔓 攻击者窃取后直接使用(未篡改)...");
    verifyToken(token);
}

// 输出:
// 合法用户签发的令牌: eyJ...user...xxx
// 🔓 攻击者窃取后直接使用(未篡改)...
// ✅ 验证通过!
//   用户ID: 1001
//   角色:   user
2.8.5 完整的工具方法
java 复制代码
import java.util.Base64;

/** 把 payload 编码成 Base64URL(无填充) */
private static String base64UrlEncode(String json) {
    return Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes());
}

核心结论: stealDemo 中攻击者什么都没改,直接偷来用 → 验证通过。这说明签名只管"数据是不是被改过",不管"用的人是不是本人"。防窃取靠的是 HTTPS + 短有效期 + 不存 localStorage,不是签名。

2.9 为什么需要 access_token + refresh_token 两个令牌

先看"只用一个 token"会怎样

在 2.6 中说了,防窃取的一个关键手段是 Token 有效期要短。但如果只有一个 token,你面临一个两难选择:

方案 问题
Token 有效期设(15分钟) 用户每 15 分钟就要重新登录,体验极差
Token 有效期设(30天) 一旦泄露,攻击者有 30 天时间随意操作,没法收回

用一个 token = 安全性和用户体验你只能选一个。 两个 token 就是用来同时解决这两个问题的。

双 Token 的核心思想:各司其职
graph LR subgraph &#34;access_token --- 频繁使用的短命鬼&#34; A1[&#34;有效期 15~30 分钟&#34;] A2[&#34;每次请求都带&#34;] A3[&#34;泄露影响小<br/>(很快就过期)&#34;] end subgraph &#34;refresh_token --- 偶尔用的长命鬼&#34; R1[&#34;有效期 7~30 天&#34;] R2[&#34;只在换新 token 时用&#34;] R3[&#34;存在更安全的地方<br/>(httpOnly Cookie / 安全存储)&#34;] end

类比:公司门禁卡 + 酒店房卡

  • access_token = 房卡,今天有效,丢了也只影响一天,明天自动失效
  • refresh_token = 你的身份证在前台押着,房卡过期了拿身份证去前台换新的
  • 如果用一套:要么房卡和身份证是同一张(丢了全完蛋),要么每天都去前台验证一次身份(折腾死)
完整流转过程
sequenceDiagram participant User as 用户 participant Client as 前端(浏览器) participant Auth as 认证服务器 participant API as 业务 API Note over User,API: ═══ 登录 ═══ User->>Client: 输入账号密码 Client->>Auth: 登录请求 Auth-->>Client: access_token (15分钟) + refresh_token (7天) Note over User,API: ═══ 正常使用 ═══ loop 每次 API 请求 Client->>API: access_token API-->>Client: 数据 end Note over User,API: ═══ access_token 过期 ═══ Client->>API: access_token(已过期) API-->>Client: 401 rect rgb(240, 248, 255) Note over Client: 前端拦截器自动处理,用户无感 Client->>Auth: refresh_token (换取新 token) Auth-->>Client: 新的 access_token + 新的 refresh_token Client->>Client: 用新 token 重试刚才失败的请求 Client->>API: 新的 access_token API-->>Client: 数据 end

整个过程用户只输入了一次密码,此后再也没有见过登录页。

前端如何做到"无感刷新"------Axios 拦截器

用户无感的秘密在前端的 HTTP 拦截器里。以 Axios 为例:

javascript 复制代码
// 标记是否正在刷新 token,避免并发请求重复刷新
let isRefreshing = false;
let pendingRequests = [];  // 等待刷新的请求队列

axios.interceptors.response.use(
  (response) => response,  // 正常响应,直接放行
  async (error) => {
    const originalRequest = error.config;  // 失败的原始请求

    // 如果是 401 且不是刷新 token 接口本身
    if (error.response.status === 401
        && !originalRequest.url.includes('/refresh')) {

      // 如果已经在刷新了,把请求排队,等刷新完一起重试
      if (isRefreshing) {
        return new Promise((resolve) => {
          pendingRequests.push(() => resolve(axios(originalRequest)));
        });
      }

      isRefreshing = true;
      try {
        // 用 refresh_token 换新的 access_token
        const { accessToken, refreshToken } = await refreshAccessToken();

        // 更新本地存储
        setAccessToken(accessToken);
        setRefreshToken(refreshToken);

        // 把新 token 设到原请求头上,重试
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return axios(originalRequest);
      } catch (refreshError) {
        // refresh_token 也过期 → 跳转登录页
        redirectToLogin();
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

流程图:

graph TD A[&#34;发起 API 请求&#34;] --> B[&#34;后端返回响应&#34;] B -->|200| C[&#34;正常返回数据&#34;] B -->|401| D[&#34;拦截器捕获 401&#34;] D --> E{&#34;正在刷新中?&#34;} E -->|否| F[&#34;用 refresh_token 换新 token&#34;] E -->|是| G[&#34;加入等待队列&#34;] F --> H{&#34;刷新成功?&#34;} H -->|是| I[&#34;更新本地 token&#34;] H -->|否| J[&#34;跳转登录页&#34;] I --> K[&#34;用新 token 重试原请求&#34;] K --> L[&#34;释放等待队列<br/>(排队请求也重试)&#34;] L --> C G --> M[&#34;等刷新完成后回调&#34;] M --> K
refresh_token 就不会被窃取吗?

实话实说:会。 refresh_token 也是存在客户端的,理论上也能被偷。但它和 access_token 的风险不在一个量级,原因有三:

第一层:暴露面极小

复制代码
access_token:  每个 API 请求都带 → 一天传几百次 → 暴露在网络上的机会多
refresh_token: 只在过期那一刻用 → 一天用几次 → 绝大多数时间不在网络上出现

就像一个天天揣在兜里的零钱(access_token) vs 锁在保险柜里的大额存折(refresh_token)------零钱天天掏出来用容易丢,存折不动自然安全得多。

第二层:存储位置不同

bash 复制代码
access_token  → 存在 JavaScript 内存里(localStorage / 变量)
                  ↑ XSS 攻击一行代码就能读到

refresh_token → 存在 httpOnly Cookie 里
                  ↑ JavaScript 根本读不到这个 Cookie
                    只在请求 /refresh 接口时自动附带

第三层:Refresh Token Rotation(轮流换)------最关键的一道防线

每次用 refresh_token 换新的 access_token 时,服务器同时把旧的 refresh_token 作废,发一个新的 refresh_token

sequenceDiagram participant Legit as 正常用户 participant Attacker as 攻击者(偷到了旧 refresh_token) participant Server as 服务器 Note over Legit,Server: 正常用户的 access_token 过期了 Legit->>Server: 旧 refresh_token (v1) 换新 token Server->>Server: 作废 v1,生成 v2 Server-->>Legit: 新 access_token + 新 refresh_token (v2) Note over Attacker,Server: 攻击者拿着偷来的旧 refresh_token Attacker->>Server: 旧 refresh_token (v1) 换 token Server-->>Attacker: ❌ v1 已被使用过,拒绝! Note over Server: 检测到 v1 重复使用<br/>说明可能泄露了<br/>把这个用户的所有 refresh_token 全部作废

这就是 Refresh Token Rotation 的精髓 :攻击者偷到了一个还能用的 refresh_token,但只要正常用户先用它刷新了一次,攻击者手里的就变成了废纸。反过来,如果是攻击者先用了(正常用户后刷新失败),服务器检测到同一个 token 被用了两次,判定为泄露,全部作废,所有设备强制下线

三层防护总结
防护层 机制 类比
低调 只在对 /refresh 请求时发送,不像 access_token 每个请求都带 大额存折不随身带,放保险柜
藏好 存 httpOnly Cookie,JavaScript 读不到 存折上写满了各种随机字符,XSS 眼瞎
Rotation 用一次换一次,旧 token 立即作废,检测到重放立即全部作废 存折用完就去银行换新存折,旧存折烧毁

一句话:不是"不会被偷",而是"偷了也没什么大用"。 偷 access_token 能用 15 分钟,偷 refresh_token 要么已经被用过(废了),要么用一次就触发 Rotation 导致全部作废。

撤销到底怎么实现

前面说了很多次"可以撤销",那具体怎么实现?

关键认知:access_token 是 JWT(无状态),refresh_token 不能是无状态的。

前面 2.1 讲了 JWT 的核心思想------服务器不存任何状态。这意味着 JWT 一旦签发,服务器"忘了"它,自然也就没法主动让它失效。所以 access_token 不能撤销(除非引入黑名单,但那又回到了 Session 模式)。

但 refresh_token 不同------它必须存在服务端,否则撤销和 Rotation 都无从谈起:

graph TD subgraph &#34;access_token(JWT,不存服务端)&#34; AT1[&#34;签完就忘&#34;] AT2[&#34;靠签名验证&#34;] AT3[&#34;不能主动撤销&#34;] end subgraph &#34;refresh_token(存在数据库/Redis)&#34; RT1[&#34;签完记一笔&#34;] RT2[&#34;靠查表验证&#34;] RT3[&#34;删掉记录 = 撤销&#34;] end

数据库里存什么:

sql 复制代码
-- refresh_token 表结构
CREATE TABLE refresh_tokens (
    id          BIGINT PRIMARY KEY,
    user_id     BIGINT NOT NULL,           -- 属于谁
    token_hash  VARCHAR(128) NOT NULL,     -- token 的哈希(不存明文)
    expires_at  TIMESTAMP NOT NULL,        -- 过期时间
    is_revoked  BOOLEAN DEFAULT FALSE,     -- 是否已撤销
    created_at  TIMESTAMP DEFAULT NOW()
);

Revoke 就是一行 SQL:

java 复制代码
// 用户修改密码 → 作废所有登录态
jdbc.update(
    "UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = ?",
    userId
);

或 Redis 版本:

java 复制代码
// 把 token 加入黑名单,有效期设到 token 原本的过期时间
// 在这之后,任何拿这个 token 来刷新的请求都会被拒
redis.set("refresh_token_blacklist:" + tokenHash, "1", expireAt - now);

验证流程:

java 复制代码
public Token refresh(String refreshToken) {
    String hash = sha256(refreshToken);

    // 1. 查数据库
    RefreshToken record = db.findByHash(hash);
    if (record == null || record.isRevoked() || record.isExpired()) {
        throw new UnauthorizedException("refresh_token 无效或已撤销");
    }

    // 2. 作废旧 token(Rotation)
    db.revoke(record.getId());

    // 3. 签发新的 access_token (JWT,不存) 和 refresh_token (存库)
    String newAccessToken  = jwtService.issue(userId, role);
    String newRefreshToken = uuid();
    db.save(new RefreshToken(userId, sha256(newRefreshToken), expiresAt));

    return new Token(newAccessToken, newRefreshToken);
}

什么场景会触发撤销:

触发场景 撤销范围 效果
用户改密码 该用户全部 refresh_token 所有设备强制下线,需重新登录
用户点"退出登录" 当前这一个 refresh_token 本设备下线,其他设备不受影响
Rotation 检测到重放 该用户全部 refresh_token 发现泄露,全部作废
管理员踢人 指定用户的全部 refresh_token 后台管理能力

一个矛盾:JWT 不是号称"无状态"吗?这不又回到有状态了?

对,这就是工程的权衡:

复制代码
access_token(JWT)  → 无状态,不查库,快,但不能撤销
refresh_token        → 有状态,需要查库,慢一点,但能撤销

为什么可以接受 refresh_token 查库?
  因为 refresh_token 一天只用几次(只在刷新时),
  不像 access_token 每次请求都验,所以这点开销完全可以接受。

一句话:把"不能撤销"的包袱从 access_token 身上卸下来,让 refresh_token 背着。access_token 负责速度,refresh_token 负责控制。

用不用 refresh_token 取决于场景
场景 access_token refresh_token
内部微服务之间调用 服务间通信 不需要 refresh_token
BFF(Backend For Frontend) 对外暴露 需要,长会话必配
移动 App 存在内存 存在 Keychain(iOS)/ KeyStore(Android)
Web SPA 存在内存 存 httpOnly Cookie(防 XSS)

三、OAuth 2.0

3.1 核心场景

想一想这个场景:你在用某个第三方 App,它说"用微信登录"。你点了之后:

  1. 跳转到微信授权页
  2. 你确认授权
  3. 回到 App,App 就能拿到你的微信昵称和头像了

这个过程中,你的微信密码从来没给过第三方 App。这就是 OAuth 2.0 要解决的问题。

3.2 四个角色

graph TD RO[&#34;🔒 资源拥有者<br/>Resource Owner<br/>(就是你)&#34;] -->|&#34;授权&#34;| CL CL[&#34;📱 客户端<br/>Client<br/>(第三方 App)&#34;] -->|&#34;请求令牌&#34;| AS AS[&#34;🔑 授权服务器<br/>Authorization Server<br/>(微信登录服务)&#34;] -->|&#34;颁发令牌&#34;| CL CL -->|&#34;拿着令牌访问&#34;| RS RS[&#34;📦 资源服务器<br/>Resource Server<br/>(微信用户信息服务)&#34;] -->|&#34;返回资源&#34;| CL AS -. &#34;通常是同一套系统&#34; .-> RS

3.3 四种授权模式

OAuth 2.0 定义了四种授权模式,应对不同场景。选哪种取决于一个核心问题:"谁在向谁要数据?"

graph TD subgraph &#34;授权码模式 Authorization Code&#34; AC1[&#34;最安全、最常用 ⭐&#34;] AC2[&#34;前后端分离的 Web 应用&#34;] AC3[&#34;客户端不接触用户密码&#34;] end subgraph &#34;隐式模式 Implicit&#34; IM1[&#34;已废弃 ❌&#34;] IM2[&#34;纯前端 SPA(历史遗留)&#34;] end subgraph &#34;密码模式 Password&#34; PW1[&#34;不推荐 ⚠️&#34;] PW2[&#34;客户端是自家 App&#34;] end subgraph &#34;客户端凭证 Client Credentials&#34; CC1[&#34;服务间调用&#34;] CC2[&#34;没有用户参与&#34;] end

1. 授权码模式(Authorization Code)--- 最安全,最常用 ⭐

这是 OAuth 2.0 的"正牌"流程,也是唯一推荐在 Web 应用中使用的模式。

  • 场景:前后端分离的 Web 应用,有自己的后端服务器
  • 核心思路:用户密码只给授权服务器(微信),第三方 App 永远不接触用户密码。用户在微信页面登录 → 微信给 App 一个一次性授权码 → App 后端用授权码 + 自己的密钥去微信换 token
  • 为什么安全:token 只在服务器之间传输,不经过浏览器,不入 URL,不入日志
  • 详细流程见下方时序图

2. 隐式模式(Implicit)--- 已废弃,别再用了 ❌

隐式模式是授权码模式的"简化版",省掉了"用 code 换 token"这一步,token 直接拼在 URL 里返回。

  • 场景:当年给纯前端 SPA(没有后端)设计的
  • 为什么要废弃:token 直接暴露在浏览器地址栏 → 浏览器历史记录、Referer 头、服务器日志、第三方统计脚本全能拿到。安全风险太大
  • 替代方案:现在的 SPA 也通过 BFF(Backend For Frontend)层走授权码模式 + PKCE,不再需要隐式模式
  • OAuth 2.1 已将其移除

3. 密码模式(Password/Resource Owner Password Credentials)--- 不推荐 ⚠️

用户直接把用户名密码交给第三方 App,第三方 App 拿着去授权服务器换 token。

  • 场景:自家公司的 App(第一方应用),比如微信自己的 iOS App 拿用户密码去微信后端换 token
  • 为什么不推荐:第三方 App 接触到了用户密码。万一 App 是恶意的(或者被黑),用户密码就泄露了。密码模式把"用户信任第三方"变成了"用户把密码给第三方"------这违反了 OAuth 的初衷
  • OAuth 2.1 同样要移除它

4. 客户端凭证模式(Client Credentials)--- 没有用户参与

这里根本没有"用户"这个概念,是服务器自己向授权服务器证明身份后拿 token。

  • 场景:微服务之间调用、定时任务访问 API、后台数据同步
  • 流程 :服务 A 用自己的 client_id + client_secret 直接换 token → 拿 token 调用服务 B
  • 本质:这不是"用户授权",是"服务认证"。token 代表的是"这个服务有权限",不是"用户张三同意"
  • 典型例子:订单服务凌晨跑批,去用户服务拉全量用户数据------没有用户在场,授权的是订单服务自己

一句话选型指南:

  • 有用户 + Web 应用 → 授权码模式
  • 有用户 + 移动端/SPA → 授权码模式 + PKCE
  • 无用户 + 服务间调用 → 客户端凭证模式
  • 隐式模式和密码模式 → 别用

授权码模式完整流程(详解):
sequenceDiagram participant User as 用户 (浏览器) participant Frontend as App 前端 participant Backend as App 后端 participant Auth as 授权服务器 (微信) User->>Frontend: 点击&#34;微信登录&#34; Frontend-->>User: 重定向到微信授权页<br/>?response_type=code&client_id=xxx&redirect_uri=yyy User->>Auth: 看到授权页,点&#34;同意&#34; Auth-->>User: 重定向回 App 前端<br/>?code=abc123 Note over User: code 先到浏览器 User->>Backend: 前端把 code 传给后端<br/>POST /api/oauth/callback?code=abc123 Note over Backend: 后端拿到 code<br/>用 client_secret 去换 token<br/>(不走浏览器,安全) Backend->>Auth: POST /token<br/>code=abc123 + client_secret Auth-->>Backend: { access_token, refresh_token } Note over Backend: token 只存在后端<br/>浏览器从未见过 Backend->>Auth: GET /userinfo<br/>Authorization: Bearer access_token Auth-->>Backend: { nickname, avatar } Backend-->>Frontend: 返回登录成功 + 用户信息

3.4 为什么要有"授权码"这一步

先看一个关键事实:OAuth 2.0 的授权流程中,用户是在浏览器 里完成"确认授权"的,确认后微信要把令牌传回第三方 App。这个"传回"靠的是 URL 重定向------也就是在浏览器地址栏的 URL 里拼参数。

如果直接在 URL 里返回 access_token 会怎样?

这就是已经被废弃的 隐式模式(Implicit Grant) 的做法:

bash 复制代码
https://third-party-app.com/callback#access_token=eyJhbGci...
                                                     ↑
                                          token 直接暴露在 URL 里!

这个 URL 会被存在很多地方,每一处都是泄露点:

graph TD subgraph &#34;直接返回 token 的泄露面&#34; URL[&#34;https://app.com/callback#access_token=xxx&#34;] URL --> A[&#34;浏览器历史记录<br/>(网吧/公用电脑直接暴露)&#34;] URL --> B[&#34;Referer 请求头<br/>(页面里引用的外部图片/JS 都能看到)&#34;] URL --> C[&#34;Web 服务器日志<br/>(URL 会明文记录在 access.log)&#34;] URL --> D[&#34;第三方统计脚本<br/>(Google Analytics / 百度统计)&#34;] URL --> E[&#34;JavaScript 可读取<br/>(XSS 攻击直接拿到)&#34;] end

五个泄露渠道:

泄露点 具体场景
浏览器历史记录 用户浏览器后退/前进,URL 完整保留。公用电脑上后来的人翻历史记录就拿到了
Referer 头 回调页面如果加载了外部图片 <img src="https://evil.com/px">evil.com 的日志里就有完整 URL
服务器日志 Nginx/Tomcat access log 默认记录完整请求 URL,token 明文落盘
第三方统计 百度统计、Google Analytics 默认收集 pageview URL
JavaScript window.location.hash / window.location.href 直接可读,XSS 一行代码偷走
授权码模式怎么解决?

把"传令牌"拆成两步,中间用一个一次性授权码来隔离浏览器和令牌:

sequenceDiagram participant Browser as 浏览器(不安全环境) participant AppServer as App 后端(安全环境) participant AuthServer as 微信授权服务器 Note over Browser,AuthServer: step 1 --- 浏览器只经手授权码(不是 token) Browser->>AuthServer: 用户确认授权 AuthServer-->>Browser: 重定向:/callback?code=abc123 Note over Browser: code 是一次性的<br/>用完就废,泄露也不怕 Note over Browser,AuthServer: step 2 --- 后端服务器之间换 token(不走浏览器) Browser->>AppServer: 把 code 传给自己的后端 AppServer->>AuthServer: POST /token<br/>code=abc123 + client_secret Note over AppServer,AuthServer: 服务器到服务器<br/>不经过浏览器,不入日志 AuthServer-->>AppServer: { access_token, refresh_token } Note over AppServer: token 只存在后端内存<br/>浏览器从未见过它

为什么这样就安全了?

隐式模式(直接返回 token) 授权码模式(code 换 token)
URL 里暴露的是什么 access_token 本身 一次性 code(用完即废)
浏览器历史记录 能拿到有效 token 只能拿到已失效的 code
服务器日志 记录 token 只记录 code
XSS 能偷到什么 直接偷到 token 偷到 code,但 code 已用过或过期
token 交换通道 通过浏览器(不安全) 服务器到服务器(TLS + client_secret 认证)
client_secret 参与 不参与(没法保密) 参与,只有后端知道
一句话总结

浏览器是不可信的中转站。 授权码模式的精髓就是:浏览器只经手一个"一次性兑换券"(code),真正的"现金"(token)走后台服务器之间的安全通道。券丢了不可惜,反正只能用一次。

如果 code 在没使用前就被窃取了?

有风险,但 OAuth 2.0 设计了三层防护:

第一层:一次性使用

code 被微信标记为"未使用",App 后端拿 code 换 token → 微信把 code 标记为"已使用"。攻击者再拿同一个 code 去换 → 微信拒绝。

css 复制代码
攻击者窃取 code → 去微信换 token → ❌ code 已被使用 / 已过期

但问题是:如果攻击者在 App 后端之前先用了呢?

第二层:client_secret 认证

换 token 的请求长这样:

ini 复制代码
POST /token
code=abc123
client_id=my_app
client_secret=app_secret_xxx   ← 攻击者不知道这个

client_secret 哪来的?开发者在微信开放平台注册应用时,微信会生成一对凭据:client_id(公开标识)和 client_secret(密钥)。client_secret 相当于"App 的密码",只在注册时展示一次,开发者把它存到自己的后端服务器上,永远不暴露给前端。

所以 code 本身只是一半的钥匙,换 token 还需要 client_secret------这个是存在 App 后端的,攻击者拿不到。因此:

  • 攻击者只偷到 code → 没有 client_secret → 换不了 token
  • 即使攻击者伪造请求,微信会校验 client_id + client_secret 是否匹配

第三层:PKCE(Proof Key for Code Exchange)

这是更严格的防护,专治"code 在浏览器端被截获":

sequenceDiagram participant App as App 前端 participant Attacker as 攻击者 participant AuthServer as 授权服务器 Note over App: 发起授权前,随机生成 code_verifier App->>App: code_verifier = 随机字符串(如 &#34;xK3...9aL&#34;) App->>App: code_challenge = SHA256(code_verifier) App->>AuthServer: 授权请求 + code_challenge AuthServer->>AuthServer: 记住 code_challenge,签发 code Note over Attacker: 攻击者截获 code Attacker->>AuthServer: POST /token<br/>code=abc123 AuthServer->>Attacker: ❌ 请提供 code_verifier Note over Attacker: 不知道 code_verifier,换不了 Note over App: App 后端正常换 token App->>AuthServer: POST /token<br/>code=abc123 + code_verifier=xK3...9aL AuthServer->>AuthServer: SHA256(code_verifier) == code_challenge?✅ AuthServer->>App: access_token

总结三层防护:

防护层 机制 拦住的攻击
一次性 code 用完即废 重放攻击(同一个 code 用两次)
client_secret 服务端密钥,攻击者不知道 攻击者只有 code,换不了 token
PKCE code 绑定 code_verifier,只有原始客户端知道 即使 client_secret 泄露(如移动端),code 也无法被冒用

PKCE 最初是为移动端/SPA 设计的(因为这些场景 client_secret 保不了密),但现在 OAuth 2.1 草案已强制要求所有授权码模式都使用 PKCE


四、SSO(单点登录)

4.1 核心场景

公司内部有 10 个系统:OA、邮箱、HR、代码仓库、文档系统......如果每个系统都要单独登录一次,员工每天要输无数次密码。

SSO 要解决的问题:登录一次,访问所有系统。

graph LR subgraph &#34;没有 SSO&#34; U1[&#34;用户&#34;] -->|&#34;输密码&#34;| A[&#34;系统A&#34;] U1 -->|&#34;输密码&#34;| B[&#34;系统B&#34;] U1 -->|&#34;输密码&#34;| C[&#34;系统C&#34;] end subgraph &#34;有 SSO&#34; U2[&#34;用户&#34;] -->|&#34;登录一次&#34;| SSO[&#34;SSO 认证中心&#34;] SSO -.->|&#34;自动登录&#34;| D[&#34;系统A&#34;] SSO -.->|&#34;自动登录&#34;| E[&#34;系统B&#34;] SSO -.->|&#34;自动登录&#34;| F[&#34;系统C&#34;] end

4.2 工作流程

sequenceDiagram participant User as 用户 participant AppA as 系统A participant SSO as SSO 认证中心 participant AppB as 系统B Note over User,AppB: ------ 首次访问系统A ------ User->>AppA: 访问系统A AppA-->>User: 重定向到 SSO(没登录) User->>SSO: 输入账号密码 SSO->>SSO: 验证身份 SSO-->>User: 重定向回系统A + 令牌 User->>AppA: 带着令牌 AppA->>SSO: 验证令牌有效性 SSO-->>AppA: 令牌有效 AppA-->>User: 正常访问 Note over User,AppB: ------ 稍后访问系统B ------ User->>AppB: 访问系统B AppB-->>User: 重定向到 SSO(没登录) User->>SSO: (已有 SSO 的登录会话,无需再输密码) SSO-->>User: 直接重定向回系统B + 新令牌 User->>AppB: 带着令牌 AppB->>SSO: 验证令牌有效性 SSO-->>AppB: 令牌有效 AppB-->>User: 正常访问

4.3 三种实现方式

graph TD subgraph &#34;1. 共享 Cookie(同域名)&#34; C1[&#34;所有系统挂在同一父域名下<br/>login.company.com<br/>oa.company.com<br/>mail.company.com&#34;] C2[&#34;SSO 在父域名下设 Cookie<br/>所有子系统都能读到&#34;] C3[&#34;⚠️ 仅限同域名,不跨域&#34;] end subgraph &#34;2. CAS 协议(中央认证)&#34; D1[&#34;独立的 SSO 服务器&#34;] D2[&#34;所有系统都跳转到 CAS 登录&#34;] D3[&#34;CAS 签发 ST (Service Ticket)&#34;] D4[&#34;各系统拿 ST 去 CAS 验证&#34;] end subgraph &#34;3. OAuth 2.0 和 OIDC - 现代方案&#34; E1[&#34;本质是用 OAuth 2.0 做 SSO&#34;] E2[&#34;认证中心 = OAuth 授权服务器&#34;] E3[&#34;各系统 = OAuth 客户端&#34;] E4[&#34;最灵活,支持跨域、移动端&#34;] end

1. 共享 Cookie(同域名)--- 最简单,但限制大

所有子系统挂在同一个父域名下(如 *.company.com),SSO 登录后在父域名设一个 Cookie,所有子系统自动带上。

  • 流程 :用户访问 oa.company.com → 没登录,跳到 login.company.com → 登录成功,Set-Cookie: Domain=.company.com → 再访问 mail.company.com,浏览器自动带上这个 Cookie → 直接登录
  • 优点:实现极其简单,浏览器原生支持,无需额外协议
  • 致命问题 :只能同域名。company.compartner.com 完全没法共享 Cookie。而且 Cookie 的 Domain 不能跨顶级域名------你不能在 company.com 下设一个 Cookie 让 evil.com 也能读到(浏览器不允许)
  • 适用 :公司内部系统(OA、邮箱、HR 都在 *.company.com 下)

2. CAS 协议(Central Authentication Service)--- 经典的独立 SSO 方案

CAS 是一套专门的 SSO 协议,核心思想:认证中心独立部署,所有系统都跳转到它那里登录。

  • 流程:用户访问系统 A → 没登录,302 重定向到 CAS 登录页 → CAS 验证身份后生成 TGT(Ticket Granting Ticket,存在 CAS 自己的 Cookie 里)→ CAS 生成一个一次性 ST(Service Ticket),拼在 URL 上 302 回系统 A → 系统 A 拿 ST 后端调用 CAS 验证 → CAS 返回用户信息,登录成功
  • 用户再去系统 B → 同样跳转 CAS → 但这次 CAS 的 Cookie 里已经有 TGT 了,不用再输密码,直接签 ST 返回
  • TGT vs ST:TGT 是 CAS 和浏览器之间的"我已登录"凭证(Cookie),ST 是"允许访问某个特定系统"的一次性票据。类似 OAuth 的 refresh_token 和 access_token 关系,但更简单
  • 优点:独立认证中心,支持跨子域名,协议成熟,有大量开源实现(Apereo CAS)
  • 缺点:每次访问新系统都要跳转 CAS(有重定向开销),对移动端和 SPA 不太友好

3. OAuth 2.0 / OIDC(OpenID Connect)--- 现代标准 ⭐

这就是前面讲过的 OAuth 2.0 授权码流程,只是换了个用途------不做"第三方授权",而是做"统一登录"。

  • 流程 :和授权码模式完全一样。用户访问系统 A → 跳到认证中心 → 登录 → 拿 code → 后端换 token → 用 token 调 /userinfo 拿到用户信息 → 登录成功
  • OIDC 在 OAuth 2.0 之上多加了什么 :OAuth 2.0 只管"发 token",没规定 token 里是什么、怎么解析用户信息。OIDC 补上了这些------定义了 id_token(JWT 格式,包含用户身份信息)、标准化的 /userinfo 端点、标准 claims(sub、name、email 等)。一句话:OAuth 2.0 是授权框架,OIDC 是在它之上加了一层认证标准
  • 优点:跨域、支持移动端、支持第三方登录(微信/Google/GitHub)、生态极成熟
  • 缺点:比 Cookie 方案重,理解门槛高(但前面三章讲完你应该已经懂了)

一句话选型指南:

  • 公司内部系统,全在同一域名下 → 共享 Cookie,最简单
  • 公司内部系统,跨子域名 → CAS,比 OAuth 轻
  • 对外、跨域、移动端、第三方登录 → OAuth 2.0 / OIDC

4.4 SSO 的核心问题:注销

SSO 最大的一个坑是单点注销

graph TD subgraph &#34;单点登录容易&#34; LOGIN[&#34;登录一次 → 全部系统可访问 ✓&#34;] end subgraph &#34;单点注销很难&#34; LOGOUT[&#34;在一个系统点了退出&#34;] Q[&#34;其他系统怎么知道你退出了?&#34;] end subgraph &#34;解决方案&#34; S1[&#34;方案1: 各系统令牌设短,定期去 SSO 校验&#34;] S2[&#34;方案2: SSO 主动通知所有系统(广播注销)&#34;] S3[&#34;方案3: 各系统不维护本地会话,每次请求都问 SSO&#34;] end

三种方案的原理和权衡:

方案1:令牌设短,定期校验(最常用)

每个子系统把本地 session 的有效期设短(比如 5 分钟),每隔一段时间拿本地 session 去 SSO 问一次"这个人还登录着吗"。SSO 说"已注销"→ 子系统清掉本地 session。

  • 用户体验:用户点退出后,最多等 5 分钟(令牌过期),其他系统自动感知。不是即时的,但实现简单
  • 实现 :各系统本地 session 带一个 last_check_time,超过阈值就用 SSO 的 /introspect 端点校验
  • 权衡:实时性和性能之间的折中。设太短→频繁调用 SSO;设太长→退出后有延迟

方案2:SSO 主动通知,广播注销(最实时)

用户在一个系统点退出 → SSO 主动通知所有已登录的子系统:"把这个人踢了"。

  • 用户体验:即时生效,点退出后所有系统立刻登出
  • 实现:SSO 维护一个"用户在哪些系统登录了"的列表。收到注销请求后,逐个调子系统的注销回调接口(或通过消息队列广播)
  • :如果某个子系统挂了没收到通知怎么办?需要重试机制 + 方案1兜底。而且 SSO 需要知道每个用户登录了哪些系统------维护成本高
  • 适用:安全要求高的场景(银行、支付系统)

方案3:不维护本地会话,每次请求都问 SSO(最彻底)

子系统完全不维护自己的 session,每次收到用户请求都去 SSO 校验。

  • 用户体验:也是即时生效,但每次请求多一次网络往返
  • 实现:每个请求都带 token → 子系统调 SSO 验证 → SSO 返回"有效/无效"
  • 致命问题:SSO 变成单点瓶颈。一个用户刷一个页面可能触发 10+ 次 SSO 调用。SSO 挂了,所有系统都不可用
  • 适用:几乎不推荐单独用,通常和方案1结合------拿短期本地缓存减少 SSO 调用

实际生产环境通常是方案1 + 方案2混用:方案2做即时注销(主路径),方案1做兜底(广播失败或系统重启后自愈)。方案3太激进,不推荐。


五、三者的定位与关系

这是本文最重要的一张图------JWT、OAuth 2.0、SSO 三者的层次关系:

graph TB subgraph &#34;业务层 --- 解决什么问题&#34; BIZ1[&#34;SSO 单点登录<br/>多个系统,一次登录&#34;] BIZ2[&#34;OAuth 2.0 第三方授权<br/>你的数据,安全地分享给第三方&#34;] end subgraph &#34;协议层 --- 怎么做到&#34; PRO1[&#34;CAS / SAML / OAuth 2.0 / OIDC&#34;] end subgraph &#34;令牌格式层 --- 用什么承载&#34; TOK1[&#34;JWT<br/>也可以是 SAML Assertion<br/>或简单的随机字符串&#34;] end BIZ1 -.->|&#34;SSO 可以用 OAuth 2.0 实现&#34;| BIZ2 BIZ1 --> PRO1 BIZ2 --> PRO1 PRO1 -.->|&#34;令牌载体&#34;| TOK1

5.1 一句话区分

JWT OAuth 2.0 SSO
层级 令牌格式(数据层) 授权协议(协议层) 业务方案(业务层)
核心问题 令牌怎么设计、怎么验签 第三方怎么拿到授权 多系统怎么共享登录态
类比 工牌的材料和防伪技术 访客登记流程 公司一卡通的刷卡进门
依赖关系 被 OAuth/SSO 使用 可以作为 SSO 的底层实现 可以用 OAuth + JWT 实现

5.2 关系举例

一个实际场景帮你理清关系:

你用"微信扫码"登录某个网站------

  • SSO 是这件事的业务目标:你不希望为这个网站单独注册账号
  • OAuth 2.0 是背后的授权流程:微信确认你的身份 → 发给网站一个授权码 → 网站拿授权码换你的基本信息
  • JWT 是这个网站收到你的信息后,自己签发的登录凭证:以后你刷这个网站的页面,不用每次都去问微信
sequenceDiagram participant User as 你 participant Site as 第三方网站 participant WeChat as 微信开放平台 Note over User,WeChat: step 1 --- OAuth 2.0 授权流程 User->>Site: 点&#34;微信登录&#34; Site->>WeChat: 重定向到微信授权 WeChat->>User: 扫码确认 WeChat->>Site: 授权码 (OAuth 2.0) Site->>WeChat: 拿授权码换用户信息 WeChat->>Site: {openid, nickname, avatar} Note over Site: step 2 --- 网站签发自己的 JWT Site->>Site: 签发 JWT(包含用户ID、角色) Site->>User: 返回 JWT Note over User,Site: step 3 --- 之后访问网站都用 JWT loop 后续请求 User->>Site: 带 JWT 访问任意页面 Site->>Site: 验签 JWT end

六、JWT + OAuth 2.0 结合使用

实际项目中,最常见的组合是:

  • OAuth 2.0 定义授权流程
  • access_token 和 refresh_token 都使用 JWT 格式(但不是必须)
  • id_token(OpenID Connect)一定是 JWT 格式
graph TD subgraph &#34;OAuth 2.0 流程&#34; A[&#34;用户授权&#34;] --> B[&#34;授权服务器&#34;] B -->|&#34;颁发&#34;| C[&#34;access_token (JWT)&#34;] B -->|&#34;颁发&#34;| D[&#34;refresh_token (JWT)&#34;] B -->|&#34;颁发&#34;| E[&#34;id_token (JWT)&#34;] end subgraph &#34;JWT 内容&#34; F[&#34;access_token<br/>sub/scope/exp&#34;] G[&#34;refresh_token<br/>sub/exp&#34;] H[&#34;id_token<br/>sub/name/email/picture&#34;] end C -. &#34;承载&#34; .-> F D -. &#34;承载&#34; .-> G E -. &#34;承载&#34; .-> H

七、一张图总结

graph TB subgraph &#34;三大概念层次&#34; subgraph &#34;业务层&#34; SSO_L[&#34;SSO<br/>多系统一次登录&#34;] OAUTH_L[&#34;OAuth 2.0<br/>第三方授权&#34;] end subgraph &#34;格式层&#34; JWT_L[&#34;JWT<br/>令牌格式&#34;] end end subgraph &#34;解决什么问题&#34; Q1[&#34;多个系统怎么共享登录态?&#34;] --> SSO_L Q2[&#34;第三方怎么安全获取用户数据?&#34;] --> OAUTH_L Q3[&#34;令牌怎么设计、怎么验证?&#34;] --> JWT_L end OAUTH_L -.->|&#34;可作为 SSO 的底层实现&#34;| SSO_L SSO_L -.->|&#34;令牌载体&#34;| JWT_L OAUTH_L -.->|&#34;令牌载体&#34;| JWT_L

八、常见面试问题速答

Q: JWT、OAuth 2.0、SSO 到底什么关系? A: 三个不同层次的东西。SSO 是业务方案(多系统共享登录),OAuth 2.0 是授权协议(安全授权第三方),JWT 是令牌格式(数据结构)。SSO 可以用 OAuth 2.0 实现,OAuth 2.0 的令牌可以用 JWT 格式。它们是"组合使用"的关系,不是"选择哪个"的关系。

Q: SSO 和 OAuth 2.0 用微信登录是一回事吗? A: 不完全一样。用微信登录一个外部网站 = OAuth 2.0 的典型场景(第三方授权)。公司内部 10 个系统打通登录 = SSO 的典型场景(内部系统互信)。但如果你用微信扫码登录了 A 网站,再打开也用微信登录的 B 网站就不用扫了------这就同时用到了 OAuth 2.0 做 SSO。

Q: JWT 怎么实现踢人下线? A: 做不到。变通方案:(1) 维护一个黑名单(Redis),查令牌时先看是否在黑名单里;(2) 令牌有效期设短 + refresh_token 轮换;(3) 用户改密码后更新 iat 时间戳版本号。

Q: OAuth 2.0 的 CSRF 攻击怎么防? A: 在授权请求时加一个随机 state 参数,回调时校验这个 state 是否和发起时一致。

Q: access_token 过期了怎么办? A: 用 refresh_token 去授权服务器换新的 access_token。如果 refresh_token 也过期了,用户需要重新登录。

Q: 为什么说 JWT 的 Header 和 Payload 只是 Base64 不是加密? A: Base64 是可逆编码,任何人拿到令牌就能解码看到内容。HTTPS 是唯一保护传输过程的手段。不要在 JWT 里存密码、身份证号等敏感数据。

Q: SSO 单点注销为什么难做? A: 因为"登录态"在每个子系统里是分布式的。在 A 系统点退出,B 系统根本不知道。解决思路:(1) 用短令牌 + 每次访问都向 SSO 验证(性能开销大);(2) SSO 主动向所有系统发注销通知(实现复杂);(3) 各系统不存本地 Session,完全依赖 SSO 令牌(最简单)。

Q: CAS、SAML、OIDC 有什么区别? A: CAS 是老牌 SSO 协议(Java 圈子常用),SAML 是重量级企业标准(基于 XML,金融/政务常见),OIDC(OpenID Connect)是 OAuth 2.0 之上的一层认证协议(现代首选)。简单来说:新项目直接用 OIDC(基于 OAuth 2.0 + JWT),最通用。

相关推荐
SelectDB1 小时前
Apache Doris / SelectDB 实时分析三大范式:技术能力、选型对比与企业实践
后端
SelectDB1 小时前
Apache Doris 向量化执行与 CPU 性能优化:技术能力、选型对比与实践
后端
CoderLiu2 小时前
Agent 工程的下一层:为什么「把流程写成图」还不够,还需要 Graph Engineering
前端·人工智能·后端
Conan在掘金2 小时前
ArkTS 进阶之道(9):@Provide/@Consume 跨层传值——为啥不叫全局变量
后端
AskHarries2 小时前
错误监控怎么做
后端
MacroZheng2 小时前
同事:“Claude Code都能自动写代码了,还要什么Spec Coding?” 我反问:“屎山代码你来维护?”
java·人工智能·后端
爱勇宝2 小时前
《道德经》第 8 章:真正高级的能力,像水一样成事
前端·后端·程序员
Zane19942 小时前
可变对象 vs 不可变对象:为什么"可变默认参数"是 Python 最经典的坑?
后端·python
未秃头的程序猿2 小时前
JDK 26的Value Class,我做了个性能测试,结果出乎意料
java·后端·面试