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")
- 前端如何做到"无感刷新"
- [refresh_token 就不会被窃取吗?](#refresh_token 就不会被窃取吗? "#refresh_token-%E5%B0%B1%E4%B8%8D%E4%BC%9A%E8%A2%AB%E7%AA%83%E5%8F%96%E5%90%97")
- 撤销到底怎么实现
- [三、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
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 返回给客户端。
关键:签发动作发生在登录的时候。 签发完成后,服务器完全不存储这个 token,直接忘掉它。
阶段二:后续请求 → 每次验签
客户端后续每次请求都带着这个 JWT,服务器当场验签,签名对了就放行。
每次请求都要验签,性能好吗?
好,而且比传统的 Session 模式更快。
Session 模式的"认证"过程:
markdown
用户请求 → 从 Cookie 取出 sessionId → 查 Redis/DB 找到对应 session → 返回
↑
一次网络 IO(0.5~1ms)
JWT 的验签过程:
css
用户请求 → 从 Header 取出 JWT → HMACSHA256(header.payload, secret) → 比较签名
↑
纯 CPU 运算(≈0.001ms)
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":
为什么窃取者改不了 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 算的)
- 新旧签名不匹配 → 拒绝
第三层:为什么不能逆向推出 secret
攻击者手里有:公开的 header、payload、以及基于它们算出的 签名。
能不能倒推出 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 同一把
秘书的保险柜类比: 秘书用一把钥匙把文件锁进柜子,这把钥匙同时也能打开柜子。谁有这把钥匙,谁就能签发和验证。所以 secret 必须严格保密,签发和验证双方都要安全持有。
关键:secret 只存在服务端 ,永远不会发给客户端。客户端拿到的 JWT 是
header.payload.签名三段------前两段是公开的,第三段是服务端用 secret 算出来的结果,不是 secret 本身。就像你收到一把锁和钥匙孔,但你摸不到钥匙。
RS256(非对称加密)------ 微服务标配
两把钥匙:私钥签发,公钥验签。
scss
签发:RSASHA256(header.payload, 私钥) → 签名
验签:RSASHA256(header.payload, 公钥) → 和附带的签名对比
↑ ↑
私钥(保密) 公钥(可以公开)
公章和验章机的类比: 老板手里有公章(私钥),只有他能盖。办事大厅放了一台验章机(公钥),任何人都可以拿文件来验证"章是不是真的"。验章机丢了也没事,反正它不能盖章。
二者对比
| HS256 | RS256 | |
|---|---|---|
| 钥匙数量 | 1 把(共享密钥) | 2 把(私钥 + 公钥) |
| 签发方 | 知道 secret 就行 | 必须有私钥 |
| 验签方 | 需要同一把 secret | 只需要公钥(可公开) |
| secret 泄漏后果 | 能签发也能验签,彻底暴露 | 公钥泄漏没关系;私钥泄漏=别人能伪造 |
| 适用场景 | 单体应用 | 微服务:认证中心有私钥,其他服务拿公钥验签 |
| 密钥分发 | 难------每个服务都要安全持有 | 易------公钥直接放配置文件 |
所以回答你的问题:
- HS256 的
secret:既不是公钥也不是私钥,就是一把共享的对称密钥,签发验签都用它 - RS256:私钥签发,公钥验签,这是真正的非对称加密
- 本文前面的示例代码用的是 HS256,所以都用
secret这个词
实际选哪种?简单项目 HS256 够用;只要超过一个服务,推荐 RS256------认证中心持私钥签发,业务服务拿公钥验签,公钥泄漏也无所谓。
2.6 防篡改 vs 防窃取
这两个词长得很像,但完全是两回事。
防篡改(签名保证): 攻击者改了 payload → 签名对不上 → 服务器拒绝。
防窃取(签名不管用): 攻击者原封不动偷走 token → 签名完全正确 → 服务器通过。
| 场景 | 签名是否匹配 | 结果 |
|---|---|---|
| 正常请求(未篡改) | 匹配 | 通过 |
| 攻击者改了 payload(如 user→admin) | 不匹配 | 拒绝 |
| 攻击者原封不动偷来用 | 匹配 | 通过------这就是为什么必须 HTTPS |
防护手段:
- HTTPS 是底线------防中间人截获
- Token 有效期要短(15~30 分钟)------泄露窗口小
- 敏感操作二次验证(改密码、转账)
- 不要存 localStorage ------XSS 攻击能读到,优先用
httpOnly + Secure的 Cookie
一次完整的 HTTPS 请求------Token 是怎么被安全送达的
先说没有 HTTPS 时会发生什么。
HTTP 是明文传输。在公共 Wi-Fi 下,你和服务器之间的对话就像两个人在广场上大声喊话,周围所有人都能听见:
你发出的请求里带着 JWT token,攻击者用 Wireshark 一抓,token 明文直接出现在他屏幕上。这就是为什么"只用 JWT 签名"不够------签名防篡改,但传输过程中被偷走,签名救不了你。
那加密传输不就行了?问题是你怎么把"密码"安全地告诉对方。
假如你和朋友在嘈杂的酒吧里,你想跟他约定一个暗号,以后用暗号对话。但周围全是耳朵------你大声说出暗号,所有人都听到了;你悄悄走过去说,但你们根本不认识(浏览器和服务器没有私下见面的机会)。
核心矛盾:
要加密通信 → 需要一个密钥(暗号)
要安全传递密钥 → 又需要加密通信
鸡生蛋,蛋生鸡 ♻️
解法:非对称加密。 两把钥匙------公钥(可以大声喊出来)和私钥(死死揣兜里):
公钥加密的数据 → 只有私钥能解开
公钥本身 → 谁都可以知道,知道了也没用
就像你有一个公开的带锁信箱:任何人都能把信塞进去并锁上(公钥加密),但只有你有钥匙能打开(私钥解密)。窃听者能看到信箱、看到有人塞信、甚至自己也做一个一模一样的信箱,但他打不开你的信箱,看不到里面的信。
现在问题变成了:浏览器和服务器怎么用这个"带锁信箱"来安全约定一个临时密钥?这个过程叫 TLS 握手:
从这个图可以看到,中间人每一步都参与了,但每一步都失败了:
- 第②步:拿到了公钥,但公钥本来就可以公开,没用
- 第⑤步:截获了密文(加密后的临时密钥),但没有私钥解不开
- 第⑦⑧步:之后的数据全是密文,彻底看不见
那以后每次请求都用这个临时密钥加密,这就是对称加密------速度快几百倍。
把 TLS 握手串起来,就是三件事:
arduino
① 服务器发证书 → 浏览器用内置的 CA 公钥验证证书 → 确认"你真的是你"
② 验证通过 → 浏览器随机生成临时密钥,用证书里的服务器公钥加密 → 发给服务器
③ 服务器用私钥解开 → 双方都有了临时密钥 → 后续所有报文都用它加密
关键记忆点:CA 的公钥在浏览器里(出厂就有),服务器的公钥在证书里(每次握手传过来),临时密钥是浏览器现场生成的(用完就丢)。三者各管各的,不要搞混。
一句话:TLS 握手用"笨重但安全"的非对称加密(公钥/私钥),安全地交换了一个"轻快但需要保密"的对称密钥。之后所有数据用对称密钥加密传输。为什么这么麻烦?因为非对称加密虽然安全,但太慢了,不能用来加密每一次请求。
等等,第③步说"验证证书 OK",怎么验证的?谁保证这个公钥真的是百度的,不是中间人伪造的?
这是 HTTPS 最关键的一环。回到 TLS 握手第②步------服务器把自己的证书(内含公钥)发给了浏览器。证书就是服务器的身份证。
想一想你去酒店开房:前台为什么信你的身份证?不是因为你把身份证做得多漂亮,而是因为这是公安局发的。前台信任公安局,公安局说你是谁,前台就信。
CA(Certificate Authority,证书颁发机构)就是这个"公安局":
浏览器拿到证书后,验三件事:
| 校验项 | 验什么 | 酒店前台类比 |
|---|---|---|
| 签名链 | 证书是不是 CA 签发的(用 CA 的公钥验证签名) | 身份证是不是公安局做的(真有这个公安局吗) |
| 域名 | 证书上的域名和我访问的是不是同一个 | 身份证上的人是你吗 |
| 有效期 | 证书过期了没有 | 身份证过期了吗 |
为什么攻击者伪造不了:
- 自签一张身份证? → 浏览器不认识"自签 CA",直接弹出红色警告
- 拿别人的身份证? → 域名不匹配,浏览器照样警告
- 把公安局黑了? → 历史上确实发生过(2011 年 DigiNotar 被黑),但浏览器有吊销列表(CRL/OCSP),被黑的证书立刻作废
一句话:浏览器只信任出厂内置的几十个根 CA,跟 JWT 的信任模型是一个道理------信任链层层传递,"我信他,他信他,所以我信他"。
把整个流程串起来------一次 HTTPS 请求,token 经历了什么:
对照最开始那 HTTP 明文图:
| HTTP 的漏洞 | HTTPS 的防护 |
|---|---|
| 明文传输,任何人都能看到 | 对称加密,只有双方知道临时密钥 |
| 不知道通信对象是谁 | CA 证书验证身份,伪造不了 |
| 数据可能被篡改 | 加密信道自带完整性校验,改了能发现 |
HTTPS 保护了什么,没保护什么:
| 能保护 | 不能保护 |
|---|---|
| 传输过程中 token 不被嗅探 | 客户端 XSS 攻击(token 在浏览器内存里,JS 照样能读) |
| 请求/响应不被篡改 | 用户电脑中了木马(键盘记录直接拿到密码) |
| 中间人无法伪造服务器 | 服务器本身被黑了(黑客直接从数据库拿数据) |
HTTPS 保护的是数据在"路上"的安全。数据在浏览器里的安全靠 httpOnly Cookie + CSP 防 XSS。不能把 HTTPS 当成万能药。
2.7 JWT 能保护什么,不能保护什么
这是一个很多人理解错的地方:JWT 签名只管"token 本身有没有被改",其他什么都不管。
JWT 的 Payload 不放业务数据
Payload 里放的始终是身份相关的基础字段(用户ID、角色、过期时间),不是业务数据。订单金额、收货地址这类东西在 request body 里。
Token 被窃取后,攻击者在 body 里改参数------签名管不了
在 2.3 里讲了,验签过程是这样:
scss
服务器验证:计算 HMACSHA256(header.payload, secret) ≟ 附带的签名
注意------验签只用到了 header 和 payload,body 完全没参与计算。所以你只要不改 token 本身,在 body 里随便改什么,签名都管不着。
那怎么防 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 的核心思想:各司其职
类比:公司门禁卡 + 酒店房卡
- access_token = 房卡,今天有效,丢了也只影响一天,明天自动失效
- refresh_token = 你的身份证在前台押着,房卡过期了拿身份证去前台换新的
- 如果用一套:要么房卡和身份证是同一张(丢了全完蛋),要么每天都去前台验证一次身份(折腾死)
完整流转过程
整个过程用户只输入了一次密码,此后再也没有见过登录页。
前端如何做到"无感刷新"------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);
}
);
流程图:
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:
这就是 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 都无从谈起:
数据库里存什么:
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,它说"用微信登录"。你点了之后:
- 跳转到微信授权页
- 你确认授权
- 回到 App,App 就能拿到你的微信昵称和头像了
这个过程中,你的微信密码从来没给过第三方 App。这就是 OAuth 2.0 要解决的问题。
3.2 四个角色
3.3 四种授权模式
OAuth 2.0 定义了四种授权模式,应对不同场景。选哪种取决于一个核心问题:"谁在向谁要数据?"
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
- 无用户 + 服务间调用 → 客户端凭证模式
- 隐式模式和密码模式 → 别用
授权码模式完整流程(详解):
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 会被存在很多地方,每一处都是泄露点:
五个泄露渠道:
| 泄露点 | 具体场景 |
|---|---|
| 浏览器历史记录 | 用户浏览器后退/前进,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 一行代码偷走 |
授权码模式怎么解决?
把"传令牌"拆成两步,中间用一个一次性授权码来隔离浏览器和令牌:
为什么这样就安全了?
| 隐式模式(直接返回 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 在浏览器端被截获":
总结三层防护:
| 防护层 | 机制 | 拦住的攻击 |
|---|---|---|
| 一次性 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 要解决的问题:登录一次,访问所有系统。
4.2 工作流程
4.3 三种实现方式
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 最大的一个坑是单点注销:
三种方案的原理和权衡:
方案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 三者的层次关系:
5.1 一句话区分
| JWT | OAuth 2.0 | SSO | |
|---|---|---|---|
| 层级 | 令牌格式(数据层) | 授权协议(协议层) | 业务方案(业务层) |
| 核心问题 | 令牌怎么设计、怎么验签 | 第三方怎么拿到授权 | 多系统怎么共享登录态 |
| 类比 | 工牌的材料和防伪技术 | 访客登记流程 | 公司一卡通的刷卡进门 |
| 依赖关系 | 被 OAuth/SSO 使用 | 可以作为 SSO 的底层实现 | 可以用 OAuth + JWT 实现 |
5.2 关系举例
一个实际场景帮你理清关系:
你用"微信扫码"登录某个网站------
- SSO 是这件事的业务目标:你不希望为这个网站单独注册账号
- OAuth 2.0 是背后的授权流程:微信确认你的身份 → 发给网站一个授权码 → 网站拿授权码换你的基本信息
- JWT 是这个网站收到你的信息后,自己签发的登录凭证:以后你刷这个网站的页面,不用每次都去问微信
六、JWT + OAuth 2.0 结合使用
实际项目中,最常见的组合是:
- OAuth 2.0 定义授权流程
- access_token 和 refresh_token 都使用 JWT 格式(但不是必须)
- id_token(OpenID Connect)一定是 JWT 格式
七、一张图总结
八、常见面试问题速答
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),最通用。