JWT、OAuth 2.0 与 SSO 详解
目录
- 一、先厘清概念:JWT 和 OAuth 2.0 不是一回事
- 二、JWT(JSON Web Token)
- 三、OAuth 2.0
- 四、SSO(单点登录)
- 五、三者的定位与关系
- 六、JWT + OAuth 2.0 结合使用
- 七、一张图总结
- 八、常见面试问题速答
一、先厘清概念: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 由三部分组成,用 . 分隔:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEwMDF9.abcd1234
↓ ↓ ↓
Header Payload Signature
graph LR
subgraph JWT
H["Header<br/>算法类型"]
P["Payload<br/>用户数据"]
S["Signature<br/>签名校验"]
end
H ---|"Base64URL"| P ---|"Base64URL"| S
Header — 描述签名算法
{
"alg": "HS256",
"typ": "JWT"
}
Payload — 存放声明(Claims)
{
"sub": "1001", // 主题:用户ID
"name": "Darren",
"role": "admin",
"iat": 1722154800, // 签发时间
"exp": 1722241200 // 过期时间
}
Signature — 防篡改
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: "darren", password: "123456"}
Server->>Server: 1. 查数据库验证账号密码
Server->>Server: 2. 密码正确,准备签发 JWT<br/>Header: {alg: "HS256"}<br/>Payload: {sub: 1001, role: "admin", 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 模式的"认证"过程:
用户请求 → 从 Cookie 取出 sessionId → 查 Redis/DB 找到对应 session → 返回
↑
一次网络 IO(0.5~1ms)
JWT 的验签过程:
用户请求 → 从 Header 取出 JWT → HMACSHA256(header.payload, secret) → 比较签名
↑
纯 CPU 运算(≈0.001ms)
graph LR
subgraph "Session 模式"
S1["请求"] --> S2["取出 sessionId"]
S2 --> S3["Redis 查询"]
S3 -.->|"网络 IO 0.5~1ms"| S4["返回结果"]
end
subgraph "JWT 模式"
J1["请求"] --> J2["取出 JWT"]
J2 --> J3["HMACSHA256 验签"]
J3 -.->|"纯 CPU 0.001ms"| J4["返回结果"]
end
1 微秒 vs 1 毫秒,差距是三个数量级。而且——
- 天然无锁:Session 查 Redis 有并发争用,JWT 验签无共享状态,多线程并行零开销
- 可缓存:同一个 token 有效期内,验签结果可本地缓存几秒,后续请求直接跳过
- 实际瓶颈不在验签:一次请求的开销在数据库查询、序列化、网络传输,HMACSHA256 那几微秒完全可以忽略
结论:不要被"每次都要算"吓到。验签是密码学中最便宜的操作之一,Redis 的网络往返比它贵 500 倍。
签名为什么能防篡改——用数字说话
假设服务器签发了一个 JWT:
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 "原始 JWT"
H["Header<br/>eyJhbGci..."]
P1["Payload<br/>{role:user}"]
S1["Signature<br/>abc123"]
end
H --- P1 --- S1
subgraph "攻击者篡改"
H2["Header<br/>eyJhbGci... (不变)"]
P2["Payload<br/>{role:admin} ← 改了!"]
S2["Signature<br/>abc123 (没 secret,改不了)"]
end
H2 --- P2 --- S2
subgraph "服务器验签"
CALC["重新计算<br/>HMACSHA256(header.新payload, secret)<br/>= xyz789"]
COMPARE["xyz789 ≠ abc123"]
REJECT["❌ 拒绝!"]
end
S2 --> CALC
CALC --> COMPARE --> REJECT
为什么窃取者改不了 token——逐层拆解
最容易被误解的地方来了:"token 是在登录时下发的,所以改不了",这个理解是错的。
真正的原因和"登录时下发"没关系,而是签名算法的数学性质决定的。
一步步推:
第一层:JWT 是可以被篡改的
token 就是三段 Base64 字符串,任何人拿到都能解码、修改、再编码回去。技术上没有任何阻挡。
// 攻击者拿到 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)没变。签名的计算过程:
签名 = HMACSHA256( header + "." + payload , secret )
↑ ↑
公开部分 只有服务器知道
攻击者改了 payload,但 secret 他不知道,所以:
- 服务器重新计算:
HMACSHA256(header.新payload, 自己的secret)→ 算出一个新签名 - 令牌里带的签名还是旧的(针对旧 payload 算的)
- 新旧签名不匹配 → 拒绝
graph TD
subgraph "攻击者视角"
KNOW["知道的:header + payload<br/>(Base64 解码就能看)"]
UNKNOW["不知道的:secret<br/>(只存在服务器上)"]
WANT["想要的:算出新签名"]
end
KNOW --> WANT
UNKNOW -.->|"缺少这一块"| WANT
WANT --> FAIL["❌ 算不出来"]
subgraph "类比"
ANA["你有一把锁的结构图(header+payload)<br/>但你没有钥匙(secret)<br/>你改不动锁,也仿不了锁"]
end
第三层:为什么不能逆向推出 secret
攻击者手里有:公开的 header、payload、以及基于它们算出的 签名。
能不能倒推出 secret?不能。 这就是哈希函数的核心特性:
正向: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。
签发:HMACSHA256(header.payload, secret) → 签名
验签:HMACSHA256(header.payload, secret) → 和附带的签名对比
↑ ↑
用的是同一把 secret 同一把
graph LR
subgraph "HS256: 一把钥匙开一把锁"
SECRET["secret<br/>(共享密钥)"]
SIGN["签发 JWT"]
VERIFY["验证 JWT"]
end
SECRET -->|"同一把"| SIGN
SECRET -->|"同一把"| VERIFY
秘书的保险柜类比: 秘书用一把钥匙把文件锁进柜子,这把钥匙同时也能打开柜子。谁有这把钥匙,谁就能签发和验证。所以 secret 必须严格保密,签发和验证双方都要安全持有。
关键:secret 只存在服务端,永远不会发给客户端。客户端拿到的 JWT 是
header.payload.签名三段——前两段是公开的,第三段是服务端用 secret 算出来的结果,不是 secret 本身。就像你收到一把锁和钥匙孔,但你摸不到钥匙。
RS256(非对称加密)—— 微服务标配
两把钥匙:私钥签发,公钥验签。
签发:RSASHA256(header.payload, 私钥) → 签名
验签:RSASHA256(header.payload, 公钥) → 和附带的签名对比
↑ ↑
私钥(保密) 公钥(可以公开)
graph LR
subgraph "RS256: 一把锁,两把钥匙"
PRIVATE["私钥 Private Key<br/>(保密,只有签发方持有)"]
PUBLIC["公钥 Public Key<br/>(公开,谁都能拿到)"]
SIGN2["签发 JWT"]
VERIFY2["验证 JWT"]
end
PRIVATE -->|"签发用"| SIGN2
PUBLIC -->|"验签用"| 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: "user" 改成 role: "admin"
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["你"] -->|"HTTP 明文<br/>token 直接可见"| WiFi["公共 Wi-Fi"]
WiFi --> Server["服务器"]
Attacker["攻击者<br/>(同 Wi-Fi 下)"] -.->|"嗅探抓包"| WiFi
Attacker --> Result["看到 token<br/>以你身份操作"]
你发出的请求里带着 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: ② 我的证书(含公钥)——这是服务器唯一一次暴露"身份信息"
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 握手串起来,就是三件事:
① 服务器发证书 → 浏览器用内置的 CA 公钥验证证书 → 确认"你真的是你"
② 验证通过 → 浏览器随机生成临时密钥,用证书里的服务器公钥加密 → 发给服务器
③ 服务器用私钥解开 → 双方都有了临时密钥 → 后续所有报文都用它加密
关键记忆点:CA 的公钥在浏览器里(出厂就有),服务器的公钥在证书里(每次握手传过来),临时密钥是浏览器现场生成的(用完就丢)。三者各管各的,不要搞混。
一句话:TLS 握手用"笨重但安全"的非对称加密(公钥/私钥),安全地交换了一个"轻快但需要保密"的对称密钥。之后所有数据用对称密钥加密传输。为什么这么麻烦?因为非对称加密虽然安全,但太慢了,不能用来加密每一次请求。
等等,第③步说"验证证书 OK",怎么验证的?谁保证这个公钥真的是百度的,不是中间人伪造的?
这是 HTTPS 最关键的一环。回到 TLS 握手第②步——服务器把自己的证书(内含公钥)发给了浏览器。证书就是服务器的身份证。
想一想你去酒店开房:前台为什么信你的身份证?不是因为你把身份证做得多漂亮,而是因为这是公安局发的。前台信任公安局,公安局说你是谁,前台就信。
CA(Certificate Authority,证书颁发机构)就是这个"公安局":
graph TD
subgraph "浏览器内置(出厂就信任)"
ROOT["根 CA<br/>相当于'公安局'<br/>🔒 操作系统自带"]
end
subgraph "中间 CA"
MID["中间 CA<br/>相当于'市公安局'<br/>由根 CA 授权"]
end
subgraph "服务器证书"
SITE["example.com 证书<br/>相当于'身份证'<br/>由中间 CA 颁发"]
end
ROOT -->|"授权(签名)"| MID
MID -->|"颁发(签名)"| SITE
style ROOT fill:#e8f5e9,stroke:#2e7d32
浏览器拿到证书后,验三件事:
| 校验项 | 验什么 | 酒店前台类比 |
|---|---|---|
| 签名链 | 证书是不是 CA 签发的(用 CA 的公钥验证签名) | 身份证是不是公安局做的(真有这个公安局吗) |
| 域名 | 证书上的域名和我访问的是不是同一个 | 身份证上的人是你吗 |
| 有效期 | 证书过期了没有 | 身份证过期了吗 |
为什么攻击者伪造不了:
- 自签一张身份证? → 浏览器不认识"自签 CA",直接弹出红色警告
- 拿别人的身份证? → 域名不匹配,浏览器照样警告
- 把公安局黑了? → 历史上确实发生过(2011 年 DigiNotar 被黑),但浏览器有吊销列表(CRL/OCSP),被黑的证书立刻作废
一句话:浏览器只信任出厂内置的几十个根 CA,跟 JWT 的信任模型是一个道理——信任链层层传递,"我信他,他信他,所以我信他"。
把整个流程串起来——一次 HTTPS 请求,token 经历了什么:
graph TD
A["你输入 https://example.com"] --> B["TLS 握手:<br/>服务器发证书 + 公钥"]
B --> C{"浏览器验证证书"}
C -->|"❌ 不通过"| D["红色警告,阻断"]
C -->|"✅ 通过"| E["生成临时密钥<br/>用公钥加密后发给服务器"]
E --> F["双方都有临时密钥<br/>中间人不知道"]
F --> G["之后所有请求<br/>用临时密钥加密传输"]
G --> H["token 安全到达服务器<br/>中间人全程看到密文,解不开"]
对照最开始那 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 "JWT Payload — 只有身份标识"
P1["userId: 1001"]
P2["role: user"]
P3["exp: 过期时间"]
end
subgraph "Request Body — 业务数据"
B1["orderId: 8848"]
B2["amount: 500"]
B3["address: 北京市..."]
end
Payload 里放的始终是身份相关的基础字段(用户ID、角色、过期时间),不是业务数据。订单金额、收货地址这类东西在 request body 里。
Token 被窃取后,攻击者在 body 里改参数——签名管不了
在 2.3 里讲了,验签过程是这样:
服务器验证:计算 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,而在业务层的权限校验:
@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
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(正常请求通过)
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 被拦截)
/** 模拟攻击:篡改 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 窃取演示(原封不动偷来,签名通过!)
/** 模拟攻击:原封不动偷走 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 完整的工具方法
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 "access_token — 频繁使用的短命鬼"
A1["有效期 15~30 分钟"]
A2["每次请求都带"]
A3["泄露影响小<br/>(很快就过期)"]
end
subgraph "refresh_token — 偶尔用的长命鬼"
R1["有效期 7~30 天"]
R2["只在换新 token 时用"]
R3["存在更安全的地方<br/>(httpOnly Cookie / 安全存储)"]
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 为例:
// 标记是否正在刷新 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["发起 API 请求"] --> B["后端返回响应"]
B -->|200| C["正常返回数据"]
B -->|401| D["拦截器捕获 401"]
D --> E{"正在刷新中?"}
E -->|否| F["用 refresh_token 换新 token"]
E -->|是| G["加入等待队列"]
F --> H{"刷新成功?"}
H -->|是| I["更新本地 token"]
H -->|否| J["跳转登录页"]
I --> K["用新 token 重试原请求"]
K --> L["释放等待队列<br/>(排队请求也重试)"]
L --> C
G --> M["等刷新完成后回调"]
M --> K
refresh_token 就不会被窃取吗?
实话实说:会。 refresh_token 也是存在客户端的,理论上也能被偷。但它和 access_token 的风险不在一个量级,原因有三:
第一层:暴露面极小
access_token: 每个 API 请求都带 → 一天传几百次 → 暴露在网络上的机会多
refresh_token: 只在过期那一刻用 → 一天用几次 → 绝大多数时间不在网络上出现
就像一个天天揣在兜里的零钱(access_token) vs 锁在保险柜里的大额存折(refresh_token)——零钱天天掏出来用容易丢,存折不动自然安全得多。
第二层:存储位置不同
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 "access_token(JWT,不存服务端)"
AT1["签完就忘"]
AT2["靠签名验证"]
AT3["不能主动撤销"]
end
subgraph "refresh_token(存在数据库/Redis)"
RT1["签完记一笔"]
RT2["靠查表验证"]
RT3["删掉记录 = 撤销"]
end
数据库里存什么:
-- 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:
// 用户修改密码 → 作废所有登录态
jdbc.update(
"UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = ?",
userId
);
或 Redis 版本:
// 把 token 加入黑名单,有效期设到 token 原本的过期时间
// 在这之后,任何拿这个 token 来刷新的请求都会被拒
redis.set("refresh_token_blacklist:" + tokenHash, "1", expireAt - now);
验证流程:
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,它说"用微信登录"。你点了之后:
- 跳转到微信授权页
- 你确认授权
- 回到 App,App 就能拿到你的微信昵称和头像了
这个过程中,你的微信密码从来没给过第三方 App。这就是 OAuth 2.0 要解决的问题。
3.2 四个角色
graph TD
RO["🔒 资源拥有者<br/>Resource Owner<br/>(就是你)"] -->|"授权"| CL
CL["📱 客户端<br/>Client<br/>(第三方 App)"] -->|"请求令牌"| AS
AS["🔑 授权服务器<br/>Authorization Server<br/>(微信登录服务)"] -->|"颁发令牌"| CL
CL -->|"拿着令牌访问"| RS
RS["📦 资源服务器<br/>Resource Server<br/>(微信用户信息服务)"] -->|"返回资源"| CL
AS -. "通常是同一套系统" .-> RS
3.3 四种授权模式
OAuth 2.0 定义了四种授权模式,应对不同场景。选哪种取决于一个核心问题:"谁在向谁要数据?"
graph TD
subgraph "授权码模式 Authorization Code"
AC1["最安全、最常用 ⭐"]
AC2["前后端分离的 Web 应用"]
AC3["客户端不接触用户密码"]
end
subgraph "隐式模式 Implicit"
IM1["已废弃 ❌"]
IM2["纯前端 SPA(历史遗留)"]
end
subgraph "密码模式 Password"
PW1["不推荐 ⚠️"]
PW2["客户端是自家 App"]
end
subgraph "客户端凭证 Client Credentials"
CC1["服务间调用"]
CC2["没有用户参与"]
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: 点击"微信登录"
Frontend-->>User: 重定向到微信授权页<br/>?response_type=code&client_id=xxx&redirect_uri=yyy
User->>Auth: 看到授权页,点"同意"
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) 的做法:
https://third-party-app.com/callback#access_token=eyJhbGci...
↑
token 直接暴露在 URL 里!
这个 URL 会被存在很多地方,每一处都是泄露点:
graph TD
subgraph "直接返回 token 的泄露面"
URL["https://app.com/callback#access_token=xxx"]
URL --> A["浏览器历史记录<br/>(网吧/公用电脑直接暴露)"]
URL --> B["Referer 请求头<br/>(页面里引用的外部图片/JS 都能看到)"]
URL --> C["Web 服务器日志<br/>(URL 会明文记录在 access.log)"]
URL --> D["第三方统计脚本<br/>(Google Analytics / 百度统计)"]
URL --> E["JavaScript 可读取<br/>(XSS 攻击直接拿到)"]
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 去换 → 微信拒绝。
攻击者窃取 code → 去微信换 token → ❌ code 已被使用 / 已过期
但问题是:如果攻击者在 App 后端之前先用了呢?
第二层:client_secret 认证
换 token 的请求长这样:
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 = 随机字符串(如 "xK3...9aL")
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 "没有 SSO"
U1["用户"] -->|"输密码"| A["系统A"]
U1 -->|"输密码"| B["系统B"]
U1 -->|"输密码"| C["系统C"]
end
subgraph "有 SSO"
U2["用户"] -->|"登录一次"| SSO["SSO 认证中心"]
SSO -.->|"自动登录"| D["系统A"]
SSO -.->|"自动登录"| E["系统B"]
SSO -.->|"自动登录"| F["系统C"]
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 "1. 共享 Cookie(同域名)"
C1["所有系统挂在同一父域名下<br/>login.company.com<br/>oa.company.com<br/>mail.company.com"]
C2["SSO 在父域名下设 Cookie<br/>所有子系统都能读到"]
C3["⚠️ 仅限同域名,不跨域"]
end
subgraph "2. CAS 协议(中央认证)"
D1["独立的 SSO 服务器"]
D2["所有系统都跳转到 CAS 登录"]
D3["CAS 签发 ST (Service Ticket)"]
D4["各系统拿 ST 去 CAS 验证"]
end
subgraph "3. OAuth 2.0 和 OIDC - 现代方案"
E1["本质是用 OAuth 2.0 做 SSO"]
E2["认证中心 = OAuth 授权服务器"]
E3["各系统 = OAuth 客户端"]
E4["最灵活,支持跨域、移动端"]
end
1. 共享 Cookie(同域名)— 最简单,但限制大
所有子系统挂在同一个父域名下(如 *.company.com),SSO 登录后在父域名设一个 Cookie,所有子系统自动带上。
- 流程:用户访问
oa.company.com→ 没登录,跳到login.company.com→ 登录成功,Set-Cookie:Domain=.company.com→ 再访问mail.company.com,浏览器自动带上这个 Cookie → 直接登录 - 优点:实现极其简单,浏览器原生支持,无需额外协议
- 致命问题:只能同域名。
company.com和partner.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 "单点登录容易"
LOGIN["登录一次 → 全部系统可访问 ✓"]
end
subgraph "单点注销很难"
LOGOUT["在一个系统点了退出"]
Q["其他系统怎么知道你退出了?"]
end
subgraph "解决方案"
S1["方案1: 各系统令牌设短,定期去 SSO 校验"]
S2["方案2: SSO 主动通知所有系统(广播注销)"]
S3["方案3: 各系统不维护本地会话,每次请求都问 SSO"]
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 "业务层 — 解决什么问题"
BIZ1["SSO 单点登录<br/>多个系统,一次登录"]
BIZ2["OAuth 2.0 第三方授权<br/>你的数据,安全地分享给第三方"]
end
subgraph "协议层 — 怎么做到"
PRO1["CAS / SAML / OAuth 2.0 / OIDC"]
end
subgraph "令牌格式层 — 用什么承载"
TOK1["JWT<br/>也可以是 SAML Assertion<br/>或简单的随机字符串"]
end
BIZ1 -.->|"SSO 可以用 OAuth 2.0 实现"| BIZ2
BIZ1 --> PRO1
BIZ2 --> PRO1
PRO1 -.->|"令牌载体"| 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: 点"微信登录"
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 "OAuth 2.0 流程"
A["用户授权"] --> B["授权服务器"]
B -->|"颁发"| C["access_token (JWT)"]
B -->|"颁发"| D["refresh_token (JWT)"]
B -->|"颁发"| E["id_token (JWT)"]
end
subgraph "JWT 内容"
F["access_token<br/>sub/scope/exp"]
G["refresh_token<br/>sub/exp"]
H["id_token<br/>sub/name/email/picture"]
end
C -. "承载" .-> F
D -. "承载" .-> G
E -. "承载" .-> H
七、一张图总结
graph TB
subgraph "三大概念层次"
subgraph "业务层"
SSO_L["SSO<br/>多系统一次登录"]
OAUTH_L["OAuth 2.0<br/>第三方授权"]
end
subgraph "格式层"
JWT_L["JWT<br/>令牌格式"]
end
end
subgraph "解决什么问题"
Q1["多个系统怎么共享登录态?"] --> SSO_L
Q2["第三方怎么安全获取用户数据?"] --> OAUTH_L
Q3["令牌怎么设计、怎么验证?"] --> JWT_L
end
OAUTH_L -.->|"可作为 SSO 的底层实现"| SSO_L
SSO_L -.->|"令牌载体"| JWT_L
OAUTH_L -.->|"令牌载体"| 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),最通用。