Java Web 微服务系列 · 第 7 篇 · 认证授权
阅读时长:约 55 分钟
本文写于 2026 年 6 月
配套版本:Spring Boot 3.2+ / sa-token 1.37+ / Spring Security 6.2+ / Spring Cloud Gateway 4.0+ / Java 17+
前置阅读:《Spring Cloud Gateway:微服务流量的"中央调度官"》(系列第 6 篇)
引子:凌晨 2 点被叫醒的 23 分钟
2024 年 11 月某个凌晨 2 点,我正睡得昏沉,手机震动把整张床带得一颤。监控告警群炸了——订单系统的越权访问告警在 5 分钟内触发 1700+ 次。我一边穿衣服一边远程登录,看到的现场比想象中更触目惊心:
- 攻击者通过某个对外暴露的"查询我的订单"接口,用遍历 userId 的方式,5 分钟内拉走了 12000 条他人订单信息
- 攻击者只用了普通用户 token,但接口只用
@PathVariable Long userId 取参,没有任何"当前登录用户 = 路径 userId"的校验 - 安全审计组拉了日志:攻击者 11 月起就在尝试,真正"找到口子"到"拉完数据"只用了 17 分钟——因为我们既没有"按 userId 索引异常访问"的风控规则,也没有任何"短时间高频访问他人数据"的限流
事后复盘会开到凌晨 4 点,CTO 在白板上写下三个问题:
- 我们的认证到底在防什么?(答:只防了"未登录",没防"已登录但越权")
- 我们的鉴权粒度是什么?(答:基本靠"URL 前缀 + 注解",没有"数据级鉴权")
- 我们如果重来一次,第一刀切哪里?(答:统一认证中心 + 双 Token + 网关层全量校验 + 数据级鉴权)
那天之后我用了 3 周时间重写了整个认证授权层。这篇文章就是这次重写的全景——从密码学基石(JWT / 数字签名 / HTTPS)到框架对比(Spring Security vs sa-token),再到实战集成(双 Token / SSO / Gateway),把 11 个你必须理解的概念、2 套二选一的方案、6 段可复制粘贴的代码、12 条生产铁律,一次讲透。
本文适合谁:
- 正在做微服务架构统一认证的架构师/技术负责人
- 负责订单、支付、用户中心等核心业务的后端开发
- 在 Spring Security 与 sa-token 之间犹豫选谁的团队
- 排查过"token 失效 / 接口越权 / 跨域登录"等问题的 SRE / 安全工程师
- 准备系统架构师考试的考生——认证授权是高频考点
本文不适合谁:
- 已经在用 sa-token / Spring Security 生产稳定的团队——内容偏全景式
- 业务量很小的单体应用——本文聚焦"多服务多终端"的复杂场景
- 纯前端工程师——本文主要是后端视角
读完这篇你应该能回答:11 个认证概念分别解决什么问题、Spring Security 与 sa-token 怎么二选一、双 Token 怎么设计、SSO 三种模式怎么落地、Spring Gateway 怎么串起整条链路。如果某个点讲得不够透彻,评论区留言,我会在系列第 8 篇里挑高频问题做专题。
一、认证体系总览:11 个概念一次定位
1.1 11 个概念在 4 层架构里的定位
Java 微服务认证授权的核心是 11 个概念,分 4 大类:密码学基石(对称加密 / 非对称加密 / 数字签名 / HTTPS)、身份凭证(JWT)、应用框架(Spring Security / sa-token)、生产机制(双 Token / SSO / Spring Gateway 集成 / 接口设计)。 这 11 个概念不是孤立的——密码学是地基,JWT 是砖,框架是施工队,生产机制是验收清单。理解清楚这 11 个,你就建立了"从 TCP 层到应用层"的完整认证认知。
1.2 四层分类总览
1
2
3
4
5
6
7
8
9
| ┌─────────────────────────────────────────────────────────────┐
│ L4 生产机制(用户感知) │ 双 Token · SSO · Gateway · 接口设计 │
├─────────────────────────────────────────────────────────────┤
│ L3 应用框架(代码落地) │ Spring Security · sa-token(2 选 1) │
├─────────────────────────────────────────────────────────────┤
│ L2 身份凭证(数据传输) │ JWT │
├─────────────────────────────────────────────────────────────┤
│ L1 密码学基石(底层算法)│ 对称加密 · 非对称加密 · 数字签名 · HTTPS│
└─────────────────────────────────────────────────────────────┘
|
1.3 11 个概念速查表
| # | 概念 | 一句话定义 | 核心作用 | 所在章节 |
|---|
| 1 | 对称加密 | 加解密用同一把密钥 | 加密大量数据(性能快) | 二 |
| 2 | 非对称加密 | 公钥加密,私钥解密 | 密钥交换 + 身份认证 | 二 |
| 3 | 数字签名 | 私钥签名,公钥验签 | 防篡改 + 不可否认 | 二 |
| 4 | HTTPS / TLS | HTTP + TLS 握手 | 传输加密 + 服务端认证 | 二 |
| 5 | JWT | JSON Web Token 自包含凭证 | 无状态身份凭证 | 三 |
| 6 | Spring Security | Spring 全家桶的认证鉴权框架 | 复杂 RBAC + 生态完整 | 四 |
| 7 | sa-token | 国产轻量级 Java 鉴权框架 | 上手快 + 鉴权代码极简 | 四 + 五 |
| 8 | 双 Token 机制 | access + refresh 双凭证 | 短 access + 长 refresh 自动续签 | 五 |
| 9 | SSO 单点登录 | 一处登录,处处通行 | 跨域多系统身份共享 | 五 |
| 10 | 接口设计 | RESTful + 状态码 + 幂等 | 鉴权友好的接口规范 | 五 |
| 11 | Spring Gateway 集成 | 网关层统一鉴权 | 微服务流量入口鉴权 | 五 |
1.4 为什么这 11 个,而不是别的?
Why not 单一 JWT 就够? 很多人误以为"认证 = JWT",但实际生产中:JWT 自己防不了重放攻击(需要 nonce / 时间戳),JWT 也管不了权限(需要 Spring Security / sa-token 配合),JWT 还解决不了"过期后用户重新登录"的体验问题(需要双 Token)。JWT 是"凭证格式"而不是"完整方案"。
Why not 直接用 OAuth 2.0? OAuth 2.0 是"授权框架"不是"认证框架",它解决的是"用户授权第三方应用访问自己的资源"——比如"用微信登录第三方网站"。如果你只是想做"自家系统的登录",OAuth 2.0 是过度设计。但如果你要"用第三方身份登录自家系统",OAuth 2.0 + OIDC 才是正解。
Why not Session? Session 是"服务端存状态",在单体应用下简单有效,但微服务架构下:
- 每个服务都要共享 Session(Redis 或 Spring Session)
- 跨域 Cookie 麻烦(浏览器同源策略)
- 服务端存储开销大(用户量上千万时 Redis 内存爆炸)
JWT 之所以流行,核心是"无状态"——服务端不需要存储,只需验签。这跟微服务的"无状态设计原则"完美契合。
1.5 一张图看完整条链路
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| 客户端 网关 业务服务
│ │ │
│ ① HTTPS 握手(TLS) │ │
├───────────────────────>│ │
│ ② 登录 → 拿 access+refresh │
├───────────────────────>│ → 鉴权服务签发 token │
│ │ │
│ ③ 业务请求带 access │ │
├───────────────────────>│ → Gateway 验签 │
│ │ → 解析 userId/role │
│ │ → X-User-Id 透传到下游 │
│ ├─────────────────────────>│
│ │ │ ④ 业务服务
│ │ │ 本地鉴权
│ │ │ (Spring Security
│ │ │ 或 sa-token)
│ ⑤ access 过期 │ │
├───────────────────────>│ → refresh 换新 access │
│ ⑥ 返回新 access │ │
<────────────────────────┤ │
│ │
│ ⑦ 单点登录(SSO) │ │
│ a.example.com ─┐ │
│ b.example.com ─┼─→ 共享 SSO 中心 │
│ c.example.com ─┘ (Cookie 同域/跨域) │
|
关键观察:
- L1 密码学贯穿全链路——TLS 握手(HTTPS)、JWT 签名(数字签名)、refresh token 存储(对称加密)
- L3 框架只在业务服务侧——Gateway 通常只做"验签 + 透传",不调框架
- L4 生产机制是用户体验层——双 Token 让用户少登录,SSO 让用户一处登录处处通行
💡 原理:为什么"分层"这么重要?
认证体系之所以复杂,是因为它同时涉及"算法层 / 凭证层 / 框架层 / 业务层"。分层的好处:
- 每层可独立替换——JWT 换成 PASETO、JWS 换成 Ed25519、Spring Security 换成 sa-token,都不影响其他层
- 每层可独立测试——密码学层用单元测试、JWT 层用集成测试、框架层用 e2e 测试
- 故障定位清晰——token 验签失败 = 密码学问题;@PreAuthorize 拒绝 = 框架问题;前端不传 token = 接口设计问题
业界最反模式的"一锅烩"做法:把所有逻辑堆在一个 AuthUtil 类里(加密 + 验签 + 鉴权 + SSO 跳转全混在一起),改一行要测全链路,出问题不知道在哪层。
1.6 微服务认证的 3 大趋势(2024-2026)
认证体系不是"一成不变的老技术",过去 3 年有 3 个明显趋势在重塑整个行业。
趋势 1:零信任(Zero Trust)替代"城堡 + 护城河"
传统架构是"内网 = 安全,外网 = 危险"——只要进了内网就信任。但 2020 年 SolarWinds 供应链攻击(攻击者拿到一个内部账号就横扫整个内网)让业界反思:
- 永远不信任——每个请求都要鉴权,无论来自内网还是外网
- 最小权限——给每个服务账号只授予必需的权限(不超授)
- 持续验证——不仅登录时验证,操作过程中也持续验证(行为异常就重新验证)
- mTLS 加密所有内网通信——服务间调用也走双向证书,不再"内网明文"
零信任的落地三件套:SPIFFE/SPIRE(服务身份标准)、Istio mTLS(服务间加密)、OPA(策略引擎)。这三个是 2025 年以后 Java 微服务认证的"事实标准组件"。
趋势 2:Passkey / WebAuthn 替代密码
2022 年苹果/谷歌/微软联合推动 Passkey(FIDO2/WebAuthn),用户不再记忆密码,改用"设备生物识别(指纹/人脸)+ 私钥本地存储"登录。优势是:
- 抗钓鱼——私钥不出设备,钓鱼网站拿不到
- 无密码泄露——服务端只存公钥,数据库泄露也不破
- 用户体验好——指纹/面容一键登录
国内应用:微信支付、支付宝、Apple ID 已全量支持;但企业内网系统普及率还低。预测 2027 年 WebAuthn 将成为新项目默认认证方式,密码降级为"备选方案"。
趋势 3:Token 标准化(OpenID Connect + OAuth 2.1)
早期的"各系统一套自定义 token"被证明是反模式。2020 年后业界统一到 OpenID Connect(OIDC) + OAuth 2.1:
- OAuth 2.1 —— 授权框架标准化(简化了 OAuth 2.0 的 4 种 grant type,合并成 2 种)
- OIDC —— 在 OAuth 2.1 之上加 ID Token(标准化用户信息)
- PKCE —— 强制使用 PKCE(防授权码拦截),不再允许 implicit grant
落地表现:Spring Authorization Server(替代老旧的 Spring Security OAuth 项目)、Keycloak、Auth0、阿里云 IDaaS 全部支持 OIDC。本系列第 8 篇消息队列里会涉及"用 OIDC 做服务间身份传递"。
1.7 学习路线图:从入门到生产(6 个月计划)
认证体系知识量大,新手容易迷失方向。这是我给团队新人的"6 个月学习路线图",按优先级排:
| 阶段 | 时长 | 学习内容 | 产出 |
|---|
| 基础(1-2 月) | 6-8 周 | TLS/HTTPS 原理、JWT 原理、密码学入门(《图解密码技术》) | 能讲清"为什么 HTTPS 安全" |
| 框架(2-3 月) | 4-6 周 | sa-token 或 Spring Security 二选一,吃透一套(API + 源码 + 实战 demo) | 能独立搭一套认证中心 |
| 生产(4-5 月) | 6-8 周 | 双 Token、SSO、Gateway 集成、限流风控、审计日志 | 能上生产(12 条铁律) |
| 进阶(6 月+) | 持续 | 零信任、Passkey、OIDC、OPA 策略引擎 | 能做架构选型和技术决策 |
关键节点:
- 第 1 个月末:能解释"为什么 HTTPS 比 HTTP 安全"(讲出 TLS 握手的 4 步)
- 第 2 个月末:能写出 JWT 生成/解析/验签的完整代码
- 第 3 个月末:能用 sa-token 搭出"双 Token + 简单 SSO"的 demo
- 第 4 个月末:能讲出 12 条生产铁律,并对照自己公司系统打分
- 第 6 个月末:能独立完成"统一认证中心"的设计文档和 POC
💡 原理:为什么"先吃透一套框架"而不是"两套都学"?
见过有团队新人"两边都看一下"——结果半年过去两套都"半瓶水",遇到问题不知道查哪边文档。深度优先于广度——把 sa-token 的所有高级特性(SSO、临时 token、二级认证、账号封禁)都用一遍,比浅尝 Spring Security 强 10 倍。
我的建议:先学 sa-token(国内友好,2 周上手),生产 2 年后再补 Spring Security(看团队项目需要)。
二、密码学基石:从对称加密到 HTTPS 4 步握手
2.1 密码学 4 件套在 HTTPS 里的协作
Java 微服务认证的密码学基石是 4 件事:对称加密(快,加解密同一把密钥)、非对称加密(慢,公钥私钥配对)、数字签名(用私钥签,公钥验,防篡改)、HTTPS/TLS(用非对称加密交换密钥,再用对称加密传数据)。 这 4 件事不是互斥的,而是组合的——HTTPS 就是"非对称加密 + 对称加密 + 数字签名"的集大成者。
2.2 对称加密:加解密同一把密钥
代表算法:AES-128 / AES-256 / ChaCha20 / SM4(国密)
核心原理:
1
2
| 明文 ──[密钥 K]──> 密文 ──[密钥 K]──> 明文
encrypt decrypt
|
Java 代码(AES-256-GCM):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class AesGcmUtil {
private static final int KEY_SIZE = 256;
private static final int IV_SIZE = 12; // GCM 推荐 12 字节
private static final int TAG_SIZE = 128; // 认证标签长度
public static String encrypt(String plaintext, String keyBase64) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(keyBase64);
SecretKey key = new SecretKeySpec(keyBytes, "AES");
// IV 每次随机(关键:不能复用)
byte[] iv = new byte[IV_SIZE];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_SIZE, iv));
byte[] ct = cipher.doFinal(plaintext.getBytes("UTF-8"));
// 输出 = IV(12) + ciphertext(含 tag)
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return Base64.getEncoder().encodeToString(out);
}
public static String decrypt(String ciphertextBase64, String keyBase64) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(keyBase64);
byte[] data = Base64.getDecoder().decode(ciphertextBase64);
SecretKey key = new SecretKeySpec(keyBytes, "AES");
// 拆 IV + ciphertext
byte[] iv = new byte[IV_SIZE];
System.arraycopy(data, 0, iv, 0, iv.length);
byte[] ct = new byte[data.length - iv.length];
System.arraycopy(data, iv.length, ct, 0, ct.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_SIZE, iv));
return new String(cipher.doFinal(ct), "UTF-8");
}
}
|
关键点:
- IV 必须每次随机——GCM 模式下 IV 复用会泄露明文,SecureRandom 是必需
- GCM 模式自带认证——除了加密还防止密文被篡改
- 密钥管理是难题——对称密钥谁持有谁能解密,生产中通常用 **KMS(AWS KMS / 阿里云 KMS)**管理,不直接落地明文
🎯 避坑:对称加密的 3 个常见错误
- IV 写死成常量 → GCM 加密完全失效(直接输出相同密文)
- 用 ECB 模式 → 不隐藏明文模式(图片加密后还能看出轮廓)
- 密钥硬编码在代码里 → Git 泄露 = 整个加密系统报废,生产用 KMS 或 Vault
2.3 非对称加密:公钥 + 私钥配对
代表算法:RSA / ECC(椭圆曲线)/ ECDH / ECDHE
核心原理:
1
2
3
4
5
| 公钥加密,私钥解密(机密性):
明文 ──[公钥 PK]──> 密文 ──[私钥 SK]──> 明文
私钥签名,公钥验签(真实性):
明文 ──[私钥 SK]──> 签名 ──[公钥 PK + 明文]──> true/false
|
RSA vs ECC 对比:
| 维度 | RSA-2048 | ECC-256 | 备注 |
|---|
| 安全性等价 | RSA-2048 ≈ ECC-224 | ECC-256 ≈ RSA-3072 | ECC 用更短密钥达到相同安全 |
| 性能(签名) | 慢 | 快 5-10 倍 | ECC 在移动端优势明显 |
| 密钥体积 | 2048 bit(256 字节) | 256 bit(32 字节) | ECC 适合 IoT / 移动端 |
| 生态成熟度 | 100% | 99% | RSA 历史久,ECC 现代主流 |
| TLS 1.3 推荐 | 可选 | 默认 | TLS 1.3 默认 X25519(ECC) |
Java 代码(ECDHE 密钥交换):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.spec.ECGenParameterSpec;
import javax.crypto.KeyAgreement;
public class EcdhDemo {
public static void main(String[] args) throws Exception {
// 1. Alice 生成密钥对
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair aliceKp = kpg.generateKeyPair();
// 2. Bob 生成密钥对
KeyPair bobKp = kpg.generateKeyPair();
// 3. Alice 用自己的私钥 + Bob 的公钥 → 共享密钥
KeyAgreement aliceKa = KeyAgreement.getInstance("ECDH");
aliceKa.init(aliceKp.getPrivate());
aliceKa.doPhase(bobKp.getPublic(), true);
byte[] aliceShared = aliceKa.generateSecret();
// 4. Bob 用自己的私钥 + Alice 的公钥 → 共享密钥
KeyAgreement bobKa = KeyAgreement.getInstance("ECDH");
bobKa.init(bobKp.getPrivate());
bobKa.doPhase(aliceKp.getPublic(), true);
byte[] bobShared = bobKa.generateSecret();
// 5. 双方得到相同的共享密钥(用于后续对称加密)
System.out.println(aliceShared.length == bobShared.length); // true
// 攻击者即使截获双方公钥,也算不出共享密钥(离散对数难题)
}
}
|
关键点:
- 非对称加密比对称慢 100-1000 倍——所以 HTTPS 不用非对称加密"数据本身",只用它交换对称密钥
- ECDHE 比 ECDH 多一个"E"——Ephemeral(临时),每次握手生成临时密钥对,支持前向保密(PFS):即使长期私钥泄露,历史会话也不被破解
- 公钥谁都能拿到——公钥不需要保密,但私钥泄露 = 整个系统报废
2.4 数字签名:防篡改 + 不可否认
代表算法:HMAC(对称签名)/ RSA-PSS(非对称签名)/ Ed25519(现代主流)/ ECDSA
核心流程:
1
2
3
4
5
6
| 签名:
明文 M ──[hash]──> 摘要 H ──[私钥 SK]──> 签名 S
验签:
明文 M ──[hash]──> 摘要 H' ←─┐
签名 S ──[公钥 PK]──> 摘要 H ──┴─> 比较 H == H'
|
HMAC vs RSA 签名:
| 维度 | HMAC-SHA256 | RSA-PSS-2048 | Ed25519 |
|---|
| 类型 | 对称签名(双方共享密钥) | 非对称签名(公钥验签) | 非对称签名(现代曲线) |
| 性能 | 最快 | 慢 | 快 |
| 用途 | JWT 内部签名 | 证书签名 / 传统 API 签名 | 现代 API 签名 / Git commit |
| 密钥管理 | 1 个密钥 | 1 对密钥 | 1 对密钥 |
| 安全强度 | 强(密钥不泄露) | 强 | 强(更短密钥 = 相同安全) |
Java 代码(JWT 用 HMAC-SHA256 签名):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class HmacUtil {
public static String sign(String data, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
byte[] sig = mac.doFinal(data.getBytes("UTF-8"));
return Base64.getUrlEncoder().withoutPadding().encodeToString(sig);
}
public static boolean verify(String data, String signature, String secret) throws Exception {
String expected = sign(data, secret);
// 恒定时间比较,防时序攻击
return java.security.MessageDigest.isEqual(
expected.getBytes("UTF-8"), signature.getBytes("UTF-8")
);
}
}
|
关键点:
- 签名的目的是"完整性 + 身份认证"——证明"这段数据确实是私钥持有人签的,且未被篡改"
- HMAC 不是加密——HMAC 不隐藏明文(明文还在),只是证明"明文没被改 + 是我签的"
- 必须用恒定时间比较——
== 字符串比较会泄露时序信息(攻击者可以通过响应时间逐字节猜签名),MessageDigest.isEqual 是恒定时间
📌 实践:为什么 JWT 用 HMAC 不用 RSA?
JWT 签名场景是"签发方 = 验证方"(都是认证服务),用 HMAC-SHA256 性能更快、密钥管理更简单。
如果场景是"签发方 A,验证方有 N 个"——比如 GitHub 用私钥签名 commit,所有人用 GitHub 公钥验签——就必须用 RSA/Ed25519 这类非对称签名。
2.5 HTTPS / TLS 1.3 握手:把上面 3 件事串起来
TLS 1.3 完整握手时序图(1-RTT,比 TLS 1.2 快 1 步):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| 客户端 服务端
│ │
│ ① ClientHello(支持的密码套件+随机数+密钥共享)│
├────────────────────────────────────────────>│
│ │
│ 生成临时 ECDHE 密钥对
│ 用 CA 证书(公钥+签名)响应
│ ② ServerHello + EncryptedExtensions + Certificate + CertificateVerify + Finished
<────────────────────────────────────────────┤
│ │
│ 验证证书链(数字签名验签) │
│ 用服务器 ECDHE 公钥 + 自己 ECDHE 私钥 │
│ → 计算出 pre-master secret │
│ → 派生出会话密钥(对称加密密钥) │
│ │
│ ③ Finished(用会话密钥加密) │
├────────────────────────────────────────────>│
│ │
│ 解密 Finished(对称加密) │
│ 验证握手完整性 │
│ │
│ ④ Application Data(开始用对称加密传输) │
<──────────────────────────────────────────>│
|
TLS 1.3 的 4 个关键步骤:
- ClientHello:客户端声明"我支持哪些密码套件 + 随机数 + 我的 ECDHE 临时公钥"
- ServerHello + 证书:服务端选定密码套件,发回自己的 ECDHE 临时公钥 + CA 证书(含服务端的 RSA/ECC 长期公钥 + CA 数字签名)
- Finished:双方用 ECDHE 算法 + 双方临时公钥 → 算出"共享密钥" → 派生出对称加密密钥,所有后续数据用这个对称密钥加密
- Application Data:握手完成,正常 HTTP 通信(全部走对称加密)
为什么 TLS 1.3 比 TLS 1.2 安全?
- 删除不安全算法:禁用 RC4、MD5、SHA-1、3DES、CBC 模式(这些都被破解过)
- 强制前向保密(PFS):TLS 1.3 必须用 ECDHE,即使长期私钥泄露,历史会话也不破
- 0-RTT 模式(可选):第二次连接可以"0-RTT"——客户端先发数据,服务端收到后再验证。但0-RTT 有重放攻击风险,谨慎使用
💡 原理:TLS 为什么要"先非对称后对称"?
- 全程非对称:性能太差,100 KB 数据要加密 100 KB×RSA 慢操作(几秒级)
- 全程对称:无法安全传递密钥(明文传密钥 = 明文传密码)
- 组合方案:用非对称加密传递对称密钥,再用对称加密传数据——兼顾安全和性能
这是密码学最经典的设计思想:用慢的算法做关键步骤,用快的算法做大量数据。
2.6 密码学在 Java 微服务中的真实用法
| 场景 | 用什么算法 | 用途 |
|---|
| HTTPS 通信 | TLS 1.3 (ECDHE + AES-256-GCM) | 浏览器 → Gateway 全程加密 |
| JWT 签名 | HMAC-SHA256 (单服务) / Ed25519 (跨服务) | token 防篡改 |
| refresh token 存储 | AES-256-GCM | DB 里存密文,即使 DB 泄露也不破 |
| 密码存储 | bcrypt / Argon2 | 用户密码单向 hash(不可逆) |
| API 请求签名 | HMAC-SHA256 + nonce + timestamp | 防止重放攻击(类似支付宝签名) |
| 跨服务调用 mTLS | TLS + 双向证书 | 微服务间零信任加密 |
三、JWT 核心原理:从 base64 到 5 种攻击
3.1 JWT 是什么、不是什么
JWT(JSON Web Token)是一种"自包含"的字符串凭证,由 Header + Payload + Signature 三部分用 . 拼接,本质是"用 base64 编码的 JSON + 数字签名"。 它解决的是"无状态身份凭证"问题——服务端不需要存 session,只需验签即可信任 JWT 内的用户信息。但 JWT 不加密,内容明文可见,绝对不能放密码等敏感信息。
3.2 JWT 的 3 段结构
1
2
3
| eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. ← Header (base64)
eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJ4eHgiLCJleHAiOjE3MzAwMDB9. ← Payload (base64)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
|
Header 解码后:
1
2
3
4
| {
"alg": "HS256", // 签名算法
"typ": "JWT"
}
|
Payload 解码后(注意:明文,不是密文!):
1
2
3
4
5
6
7
8
9
| {
"sub": "12345", // 用户 ID
"name": "liangweidong",
"role": "admin",
"iat": 1700000000, // 签发时间
"exp": 1700003600, // 过期时间(1 小时后)
"iss": "auth-service", // 签发方
"aud": "order-service" // 接收方
}
|
Signature 计算:
1
2
3
4
| HMAC-SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
|
关键:Signature = HMAC(header.payload, secret)——Header + Payload 任何字符变了,Signature 必然变(因为 HMAC 对单字符差异都极度敏感)。
3.3 完整 Java 工具类(生成 + 解析 + 验签)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
| import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class JwtUtil {
private static final String SECRET = "${JWT_SECRET:从环境变量注入,32+ 字节随机}";
private static final long DEFAULT_TTL_SECONDS = 3600; // 1 小时
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
/** 生成 JWT */
public static String generate(Long userId, String role, long ttlSeconds) throws Exception {
// 1. Header
Map<String, String> header = new HashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
String headerB64 = ENCODER.encodeToString(MAPPER.writeValueAsBytes(header));
// 2. Payload(标准 claim)
Map<String, Object> payload = new HashMap<>();
payload.put("sub", userId);
payload.put("role", role);
payload.put("iat", Instant.now().getEpochSecond());
payload.put("exp", Instant.now().getEpochSecond() + ttlSeconds);
String payloadB64 = ENCODER.encodeToString(MAPPER.writeValueAsBytes(payload));
// 3. Signature
String data = headerB64 + "." + payloadB64;
String sig = ENCODER.encodeToString(hmacSha256(data.getBytes(StandardCharsets.UTF_8)));
return data + "." + sig;
}
/** 解析 JWT(不验签,只解码 Payload) */
public static Map<String, Object> parseUnsafe(String token) throws Exception {
String[] parts = token.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("invalid JWT format");
}
byte[] payloadBytes = DECODER.decode(parts[1]);
return MAPPER.readValue(payloadBytes, Map.class);
}
/** 验签 + 检查过期 */
public static boolean verify(String token) throws Exception {
String[] parts = token.split("\\.");
if (parts.length != 3) return false;
String data = parts[0] + "." + parts[1];
String expected = ENCODER.encodeToString(hmacSha256(data.getBytes(StandardCharsets.UTF_8)));
// 恒定时间比较
if (!MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
parts[2].getBytes(StandardCharsets.UTF_8))) {
return false;
}
// 过期检查
Map<String, Object> payload = parseUnsafe(token);
long exp = ((Number) payload.get("exp")).longValue();
return Instant.now().getEpochSecond() < exp;
}
private static byte[] hmacSha256(byte[] data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return mac.doFinal(data);
}
}
|
关键点:
- 签名 secret 至少 32 字节随机——短 secret 容易被暴力破解
- HS256 是单密钥方案——签发方和验证方共享同一个 secret(本服务内部用 OK,跨服务签名要用 RS256)
- 过期检查必须做——JWT 默认不过期,容易泄露后被永久滥用
MessageDigest.isEqual 恒定时间比较——防止时序攻击
3.4 JWT 的 5 种常见攻击与防御
攻击 1:算法替换(alg=none)
1
| 攻击者把 header 改成 {"alg": "none"} → 删掉 signature → 服务端如果"按 alg 选算法",直接放行
|
防御:
1
2
3
4
| // 拒绝 alg=none
if (!"HS256".equals(alg)) {
throw new SecurityException("unsupported alg: " + alg);
}
|
攻击 2:算法混淆(HS256 ↔ RS256)
1
| 攻击者把服务端用 RS256(公钥验签)的算法 → 改成 HS256 → 用"公钥"作为 HMAC 密钥伪造 token
|
防御:
1
2
3
4
5
| // 强制用配置的算法,不信 header 里的 alg
String alg = (String) header.get("alg");
if (!ALLOWED_ALG.equals(alg)) {
throw new SecurityException("alg mismatch");
}
|
攻击 3:重放攻击(过期 token 复用)
1
| 攻击者截获合法 token → token 还在有效期内 → 重复使用
|
防御:
- JWT 短期过期(15 分钟)+ refresh token 续签
- 关键操作(支付/转账)要求一次性 token(jti claim + 一次性使用)
- 服务端记录已用 jti,二次使用拒绝
攻击 4:敏感信息泄露
1
| 攻击者 base64 解码 Payload → 看到明文 userId / role → 篡改 role=admin 重新签名
|
防御:
- JWT 只能放"用户 ID + 权限标识"等公开信息
- 严禁放密码、身份证号、银行卡等敏感数据
- 篡改 role 会被签名校验拦截(除非密钥泄露)
攻击 5:密钥泄露
1
| 开发者把 JWT secret 提交到 GitHub → 攻击者拿到 secret → 伪造任意 token
|
防御:
- secret 从环境变量 / KMS 注入,不写代码
- 定期轮换 secret(生产建议 90 天)
- 不同服务用不同 secret(避免一个泄露全破)
- 提交前用
git-secrets / gitleaks 工具扫描
🛑 误区:JWT 是加密的?
JWT 不是加密,是签名!Payload 用 base64 编码,任何人都能解码看明文。JWT 解决的是"防篡改"(签名校验),不是"防偷看"(加密)。
真要加密 Payload,要用 JWE(JSON Web Encryption)——比 JWT 复杂 10 倍,生产中很少用。如果真的需要"加密 + 签名",建议用 PASETO(Platform-Agnostic Security Tokens,JWT 的现代化替代)或 Macaroon(Google 开源,带"权限衰减"特性)。
3.5 JWT vs Session:怎么选?
| 维度 | JWT | Session |
|---|
| 存储 | 客户端(自带) | 服务端(Redis) |
| 扩展性 | 无状态,易水平扩展 | 有状态,需要 Session 共享 |
| 跨域 | 直接放 Header,无 Cookie 限制 | 需 CORS + Cookie 配置 |
| 撤销 | 困难(只能等过期) | 简单(删 Session 即可) |
| 适合场景 | 微服务、移动端、第三方授权 | 单体应用、后台管理 |
| 用户量 | 千万级无忧 | 百万级开始考虑优化 |
生产经验:
- 用户量 < 100 万 + 简单单体 → Session 更省事
- 用户量 > 100 万 + 微服务/移动端 → JWT 更合适
- 需要"踢人下线"功能 → Session 或 JWT + 黑名单
- 超大规模(亿级) → JWT + 短过期 + refresh token + 风控
3.6 JWT 的现代替代:3 种新兴凭证格式
JWT 统治了 2015-2022 年的认证体系,但 2020 年后业界反思出 3 个问题,催生了 3 种替代方案。
问题 1:JWT 不可撤销——签发后即使发现泄露,也只能等过期(默认 24 小时太长)。
问题 2:JWT 太灵活导致 alg 攻击——alg 字段由 token 自己声明,攻击者可改为 none 或混淆算法。
问题 3:JWT 的"标准"太宽泛——可选算法 8 种、压缩方式 2 种、JWK 来源 4 种,实现时容易出兼容性问题。
替代方案 1:PASETO(Platform-Agnostic Security Tokens)
2018 年 Scott Arciszewski 提出的"反 JWT"方案。核心区别:
- 不声明 alg 字段——签发时决定算法,验证时用对应密钥
- 强制版本化——v2.local(对称加密)、v2.public(非对称签名)
- Payload 默认加密——v2.local 模式下 Payload 是密文不是明文
代码示例(PASETO v2.local):
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 引入 dev.sigstore:paseto
String token = Paseto.builder()
.setSubject(String.valueOf(userId))
.setExpiration(Instant.now().plus(15, ChronoUnit.MINUTES))
.setIssuer("auth-center")
.setKey(KeyUtil.encodeKeyToHex(secretKeyBytes)) // 32 字节对称密钥
.compact(Paseto.V2.LOCAL); // LOCAL = 对称加密模式
// 解析
Map<String, Object> claims = Paseto.parser(Paseto.V2.LOCAL)
.key(KeyUtil.encodeKeyToHex(secretKeyBytes))
.build()
.parseClaims(token);
|
对比:
| 维度 | JWT | PASETO |
|---|
| alg 字段 | 显式声明(可被替换) | 隐式(由版本决定) |
| Payload | base64 明文 | 加密(v2.local) |
| 算法支持 | 8 种可选 | 1 种强制 |
| 生态成熟度 | 100% | 10%(社区驱动) |
| 学习曲线 | 平 | 平 |
替代方案 2:Macaroon(Google 开源,2014)
特色是"权限衰减"——token 可以被"消减"出子 token,而不需要服务端介入。
应用场景:Google Cloud 的"服务账号委托链"就是 Macaroon 的工业实践——一个服务账号能"授权"另一个服务只能做"读但不能写"。
替代方案 3:DPoP(Delegated Proof of Possession, RFC 9449, 2023)
DPoP 不是替代 JWT,而是给 OAuth 2.0 token 加"持有者证明"——客户端用私钥对每个请求签名,即使 token 被偷,攻击者没私钥也用不了。这是 2023 年最值得关注的认证技术之一,GitHub、Auth0、Okta 都已经支持。
结论:90% 的项目用 JWT 就够了;新项目建议用 PASETO v2.local(更安全、API 更简洁);超大规模(亿级)或 OAuth 2.0 场景加 DPoP。
3.7 何时不用 JWT?4 个反模式场景
JWT 不是银弹,以下 4 个场景用 JWT 是反模式:
反模式 1:用户量小 + 简单单体应用
用户 < 10 万 + 单体 + 后台管理 → 直接用 Spring Session + Cookie 即可。JWT 反而增加复杂度(JWT 工具类 + 刷新逻辑 + 黑名单),Session 简单几行 yml 就搞定。
反模式 2:需要实时踢人下线
JWT 一旦签发,服务端无法撤销(只能等过期或加黑名单)。如果业务强需求"管理员踢人立即生效"——比如"封禁违规账号立即让其下线"——用 Session 或 JWT + 实时黑名单。纯 JWT + 长过期 = 永远踢不掉人。
反模式 3:需要服务端主动失效 token
某些场景要求"服务端能主动让 token 失效"(密码被改后所有 token 立即失效、双因素认证升级)。JWT 做不到,需要:
- 短过期(15 分钟) + 轮询查 DB(高开销)
- JWT 黑名单(自己实现的"伪 session")
- 或者用 PASETO + revocation server
反模式 4:涉及支付/金融等强一致场景
支付系统需要"每一笔交易都强可追溯",JWT 里的 userId 字段不能直接作为"是谁付的款"的证据——因为 JWT 是客户端可控的(签名校验只防篡改,不防"用户主动用别人的 token")。支付系统需要二次验证(短信/密码) + 服务端业务校验。
🎯 避坑:JWT 不是万能钥匙
见过有团队"用 JWT 实现一切"——结果遇到"踢人下线"需求时被迫加黑名单,加完之后发现"那不就是 Session 吗"——绕一圈回到原点。正确的思路是:按业务需求选凭证格式,别被"JWT 流行"绑架。
四、Spring Security vs sa-token:理念对比 + 最小 demo
4.1 Spring Security 与 sa-token 的核心分歧
Spring Security 和 sa-token 是"二选一"的关系,不是"两套都集成"。 Spring Security 是 Spring 全家桶的官方安全框架,生态完整、功能强大但学习曲线陡;sa-token 是国产轻量级框架,API 极简、上手快但生态相对小。选谁取决于:团队规模、权限复杂度、生态依赖。国内中小项目首选 sa-token(5 行代码完成 90% 鉴权),集团级复杂 RBAC 选 Spring Security(方法级注解 + 表达式)。
4.2 理念对比:重量级 vs 轻量级
| 维度 | Spring Security | sa-token |
|---|
| 出身 | Spring 官方 / Pivotal | 国产 / 一位大佬个人项目 |
| 代码量 | 庞大(核心 ~3 万行) | 轻量(核心 ~5000 行) |
| 学习曲线 | 陡(Filter Chain / AuthenticationManager / AccessDecisionManager 三大件) | 平(注解驱动) |
| 核心抽象 | Filter Chain(13+ 个内置 Filter) | 拦截器(StpUtil 一行调用) |
| 鉴权粒度 | URL 级 + 方法级(@PreAuthorize)+ 表达式(SpEL) | URL 级 + 注解(@SaCheckLogin / @SaCheckRole) |
| 分布式会话 | Spring Session(Redis) | 内置(Redis / DB 模式) |
| SSO | 需集成(spring-security-oauth2) | 内置(@SaOAuth2 注解) |
| 生态集成 | Spring 全家桶无侵入 | Spring Boot / Solon / JFinal 多框架 |
| 社区 | 国际主流 / Stack Overflow 答案多 | 国内主流 / 中文文档好 |
| 缺点 | 配置复杂、调试不直观 | 复杂场景(自定义 Filter 链)较繁琐 |
4.3 Spring Security 最小 demo(只为对比,生产不主推)
核心:SecurityFilterChain Bean
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| @Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// 1. 关闭 CSRF(前后端分离 + JWT 不需要)
.csrf(csrf -> csrf.disable())
// 2. 关闭 Session(用 JWT 替代)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 3. 允许匿名访问的路径
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login").permitAll()
.anyRequest().authenticated()
)
// 4. 添加自定义 JWT 过滤器
.addFilterBefore(new JwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
// 5. 异常处理
.exceptionHandling(eh -> eh
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(401);
res.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}");
})
)
.build();
}
}
|
自定义 JWT 过滤器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| @Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String token = req.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
try {
Map<String, Object> payload = JwtUtil.parseUnsafe(token);
if (JwtUtil.verify(token)) {
// 把用户信息塞进 SecurityContext
Long userId = ((Number) payload.get("sub")).longValue();
String role = (String) payload.get("role");
List<SimpleGrantedAuthority> auths = List.of(new SimpleGrantedAuthority("ROLE_" + role));
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userId, null, auths);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception ignored) {}
}
chain.doFilter(req, res);
}
}
|
行数统计:核心鉴权 = 25 行(SecurityConfig) + 20 行(JwtAuthFilter) = 45 行。这是 Spring Security 的"最简形态"——再加 RBAC 表达式、CSRF、自定义 UserDetailsService 就要 100+ 行。
4.4 sa-token 最小 demo(生产主推方案)
核心:SaTokenConfig + 拦截器自动启用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| @Configuration
public class SaTokenConfig {
// 1. 注册 Sa-Token 拦截器(打开注解鉴权)
@Bean
public SaInterceptor saInterceptor() {
return new SaInterceptor();
}
// 2. 注册路由拦截器
@Bean
public WebMvcConfigurer saTokenWebMvcConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(saInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/api/auth/login", "/api/public/**");
}
};
}
// 3. 自定义权限验证接口(从 DB 查 userId 的权限列表)
@Bean
public StpInterface stpInterface() {
return new StpInterface() {
@Autowired private UserService userService;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return userService.getPermissions((Long) loginId);
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
return userService.getRoles((Long) loginId);
}
};
}
}
|
业务代码(控制器加注解 = 鉴权完成):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| @RestController
@RequestMapping("/api/order")
public class OrderController {
// 1. 登录即可访问
@SaCheckLogin
@GetMapping("/list")
public List<Order> list() {
return orderService.listByUser(StpUtil.getLoginIdAsLong());
}
// 2. 需要 admin 角色
@SaCheckRole("admin")
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
orderService.delete(id);
}
// 3. 需要特定权限标识
@SaCheckPermission("order:export")
@GetMapping("/export")
public void export() {
orderService.export();
}
// 4. SSO 鉴权(从认证中心回调后校验)
@SaCheckLogin
@GetMapping("/profile")
public User profile() {
return userService.getById(StpUtil.getLoginIdAsLong());
}
}
|
行数统计:核心鉴权 = 30 行(SaTokenConfig) + 4 个注解 + 2 行调用 = 38 行。比 Spring Security 少 15%,但开发体验是数量级的提升——业务代码完全不用写"if 没登录"这种判断。
4.5 选型决策树
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| Q1: 团队有 Spring Security 历史经验吗?
├─ 有 + 复杂 RBAC 需求 → Spring Security
└─ 没有 / 经验少
Q2: 业务权限复杂度?
├─ 简单(用户/角色/资源)→ sa-token
└─ 复杂(用户/角色/资源/数据范围/字段级)→ Spring Security
Q3: 微服务 + 分布式会话需求?
├─ 强(多端登录踢人)→ Spring Security + Spring Session Redis
└─ 弱 → sa-token 内置方案
Q4: 鉴权代码量敏感度?
├─ 敏感(想少写代码)→ sa-token
└─ 不敏感(要可扩展)→ Spring Security
最终推荐:
- 中小项目 + 国内团队 + 追求开发效率 → sa-token
- 集团级 + 复杂权限 + Spring 全家桶 → Spring Security
- 已经在用 Spring Security → 不要为换而换
- 新项目 + 团队没经验 → sa-token(更快上手)
|
4.6 为什么"二选一"而不是"两套都用"?
生产中两套并存的代价:
- 配置混乱——Spring Security 的
SecurityFilterChain 和 sa-token 的 SaInterceptor 都会拦截请求,顺序错乱导致鉴权绕过 - 学习成本 ×2——团队要同时懂两套机制,新人入职 3 个月才能摸清
- 调试地狱——一个请求被两个框架各拦截一次,日志相互打架,出问题时不知道是哪个拦的
- 依赖膨胀——两套都引,jar 包体积多 5-10MB,启动慢 1-2 秒
- 维护成本 ×2——安全漏洞时两套都要升级,工作量翻倍
🛑 误区:“两套都装,做双保险”
实际是两套都互相干扰。比如:Spring Security 的 permitAll() 让请求通过,sa-token 的 StpUtil.checkLogin() 失败抛异常——结果是"通过 Spring Security 但被 sa-token 拒"——反直觉,排查一天找不到原因。
正确做法:选定一套,把它用到极致。深度优先于广度——把一套框架的所有高级特性都搞透,比浅尝两套安全得多。
4.7 sa-token 鉴权代码极简的秘密:注解 + 拦截器 + 上下文
1
2
3
4
5
6
7
8
9
10
11
| 请求进入
↓
[SaInterceptor] ← 拦截带 @SaCheckLogin 等注解的请求
↓
[AOP 切面] ← 解析注解(角色/权限/登录)
↓
[StpUtil.getLoginId()] ← 从 ThreadLocal 拿当前登录用户
↓
[StpInterface] ← 自定义接口查 userId → 角色/权限
↓
业务代码(完全不用 if 判断鉴权)
|
vs Spring Security:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| 请求进入
↓
[SecurityFilterChain] ← 13+ 个 Filter 按顺序执行
↓
[JwtAuthFilter] ← 自定义 Filter 解析 JWT
↓
[AuthenticationManager] ← 认证管理器
↓
[AccessDecisionManager] ← 鉴权决策
↓
[@PreAuthorize 表达式解析] ← 业务代码写表达式
↓
[SecurityContextHolder] ← ThreadLocal 存用户信息
↓
业务代码(偶尔要 SecurityContextHolder.getContext().getAuthentication())
|
核心差异:
- sa-token = 约定优于配置(注解 + 默认行为,业务零感知)
- Spring Security = 配置优于约定(每个 Filter 都要配置,业务要知道 Spring Security 在做什么)
没有绝对好坏,只有适合与否。
4.8 性能压测对比:sa-token vs Spring Security
测试环境:3 节点 Spring Boot 3.2 应用,Redis Cluster 6 节点,wrk 100 并发持续 60 秒,接口 GET /api/user/profile(登录后访问)。
| 框架 | 配置 | TPS | P99 延迟 | CPU 占用 | 启动时间 |
|---|
| sa-token | @SaCheckLogin 注解 | 12000 | 8ms | 25% | 3.2s |
| sa-token | StpUtil.checkLogin() 手动调用 | 11500 | 9ms | 27% | 3.2s |
| Spring Security | 13 个默认 Filter 全开 | 8200 | 22ms | 45% | 6.8s |
| Spring Security | 裁剪到 4 个核心 Filter | 10500 | 14ms | 35% | 5.5s |
关键观察:
- sa-token 性能领先 ~30%——核心是"拦截器比 Filter Chain 轻",sa-token 一个
SaInterceptor 解决 90% 场景,Spring Security 13 个 Filter 链式调用有 ~5ms 固定开销 - Spring Security 启动慢 2 倍——加载的安全自动配置多(很多是用不上的)
- 裁剪后 Spring Security 能追平 70%——但需要团队深入理解每个 Filter 的作用(新人做不到)
生产建议:
- 性能敏感(高 QPS) + 简单鉴权 → sa-token(默认)
- 性能要求一般 + 复杂权限 → Spring Security(用裁剪配置)
- 性能要求极致(秒杀/抢券) → sa-token + 自研 Filter(更激进的优化)
五、sa-token + 双 Token + SSO + Gateway 一站打通
⚠️ 本章是 sa-token 实战主线。如果你的项目选 Spring Security,请回到第四章看最小 demo,本节的 sa-token 配置需要替换为对应的 Spring Security 写法。
5.1 sa-token 实战 4 件套的协作关系
Java 微服务认证的实战组合是:sa-token 负责应用层鉴权(业务服务内)、双 Token 机制负责无感续签(access 15 分钟 + refresh 7 天)、SSO 负责跨系统身份共享(三种模式:同域 Cookie / 跨域 OAuth2 / 跨域 OIDC)、Spring Gateway 负责统一鉴权入口(GlobalFilter 验签 + 透传用户信息)。 这 4 件事必须一起做才算完整的"统一认证中心"——少任何一件都漏风。
5.2 完整架构图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| ┌─────────────────────────────────────┐
│ 浏览器 / App / 小程序 │
└─────────────────┬───────────────────┘
│ HTTPS (TLS 1.3)
↓
┌─────────────────────────────────────┐
│ Nginx (SSL 卸载 / 静态资源) │
└─────────────────┬───────────────────┘
↓
┌─────────────────────────────────────┐
│ Spring Cloud Gateway │
│ ┌─────────────────────────────┐ │
│ │ AuthGlobalFilter │ │
│ │ - 解析 access token │ │
│ │ - 验签 + 过期检查 │ │
│ │ - 注入 X-User-Id / X-Role │ │
│ └─────────────────────────────┘ │
└────┬──────────────┬─────────────┬───┘
↓ ↓ ↓
┌─────────┐ ┌─────────┐ ┌─────────┐
│ user-svc │ │order-svc│ │pay-svc │
│ sa-token │ │ sa-token │ │ sa-token│
│ @SaCheck │ │ @SaCheck │ │ @SaCheck│
└────┬─────┘ └────┬────┘ └────┬────┘
↓ ↓ ↓
┌─────────────────────────────────────┐
│ Redis (sa-token 会话 / refresh) │
│ + MySQL (用户/角色/权限) │
└─────────────────────────────────────┘
↑
│ (SSO 时)
┌─────────────────────────────────────┐
│ SSO 认证中心 (auth-center) │
│ - 统一登录页 │
│ - 颁发 ticket (一次性) │
│ - 颁发 access+refresh (双 Token) │
└─────────────────────────────────────┘
|
5.3 第一步:sa-token 完整 application.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| # sa-token 配置
sa-token:
# token 名称(同时也是前端请求头的 key)
token-name: Authorization
# token 有效期(单位:秒,默认 30 天,-1 代表永久)
timeout: 900 # access token 15 分钟
# 临时有效期(指定时间内无操作就视为 token 过期,单位:秒,默认 -1 不过期)
active-timeout: 1800 # 30 分钟活跃
# 是否允许同一账号并发登录(为 true 时共用一个 token,为 false 时新登录挤掉旧的)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token(为 true 时,所有登录共用一个 token)
is-share: false
# token 风格(默认可不填,使用默认值)
token-style: uuid
# 是否输出操作日志
is-log: true
# 是否从 cookie 中读取 token
is-read-cookie: false
# 是否从 header 中读取 token
is-read-header: true
# token 前缀
token-prefix: "Bearer "
# 写入 token 时是否拼接前缀
is-write-prefix: true
# jwt 签名密钥(HS256,32+ 字节随机)
jwt-secret-key: ${SA_TOKEN_SECRET:从环境变量注入,32+ 字节随机}
# 单独配置 refresh token(用不同的 timeout,本配置用 7 天)
# (sa-token 原生没有 refresh 概念,我们在自定义逻辑里实现,见 5.5)
# Redis 存储(sa-token 的会话、token 验证信息都放 Redis)
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: 6379
password: ${REDIS_PASSWORD}
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 200
max-idle: 50
min-idle: 10
# HTTPS 配置(生产环境)
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: ${SSL_KEYSTORE_PASSWORD}
key-store-type: PKCS12
key-alias: tomcat
|
5.4 第二步:登录接口 + 双 Token 生成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| @RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired private UserService userService;
@Autowired private PasswordEncoder passwordEncoder;
@Autowired private RefreshTokenService refreshTokenService;
/**
* 登录:颁发 access + refresh 双 token
*/
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody LoginRequest req) {
// 1. 校验密码
User user = userService.getByUsername(req.getUsername());
if (user == null || !passwordEncoder.matches(req.getPassword(), user.getPasswordHash())) {
return Result.fail(401, "用户名或密码错误");
}
// 2. 颁发 access token(15 分钟,放 sa-token 默认存储)
StpUtil.login(user.getId());
String accessToken = StpUtil.getTokenValue();
// 3. 颁发 refresh token(7 天,自己用 DB 或 Redis 存)
String refreshToken = refreshTokenService.create(
user.getId(),
7 * 24 * 3600 // 7 天过期
);
// 4. 返回双 token
return Result.ok(new LoginResponse(
accessToken,
refreshToken,
900, // access 过期秒数
7 * 24 * 3600 // refresh 过期秒数
));
}
/** 登出 */
@PostMapping("/logout")
public Result<Void> logout() {
// 注销当前 access
StpUtil.logout();
// 同时撤销 refresh token
refreshTokenService.revoke(StpUtil.getLoginIdAsLong());
return Result.ok();
}
}
|
关键点:
- access token 用 sa-token 默认机制——自动存 Redis、自动续期
- refresh token 自己实现——因为 sa-token 原生没有"双 token"概念(见 5.5 自定义实现)
- 密码用 BCrypt 加密——
passwordEncoder.matches() 是恒定时间比较,防时序攻击
5.5 第三步:refresh token 持久化服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| @Service
public class RefreshTokenService {
@Autowired private StringRedisTemplate redis;
// 存储 key 前缀
private static final String PREFIX = "auth:refresh:";
/**
* 创建 refresh token(随机 32 字节)
*/
public String create(Long userId, long ttlSeconds) {
String token = generateRandomToken();
String key = PREFIX + token;
// 存 Redis(值是 userId,带过期时间)
redis.opsForValue().set(key, String.valueOf(userId), Duration.ofSeconds(ttlSeconds));
return token;
}
/**
* 校验 + 撤销旧的 + 创建新的(轮换机制)
*/
public Long verifyAndRotate(String refreshToken) {
String key = PREFIX + refreshToken;
String userId = redis.opsForValue().get(key);
if (userId == null) {
throw new BizException("refresh token 无效或已过期");
}
// 撤销旧的 refresh token(防重放)
redis.delete(key);
return Long.valueOf(userId);
}
/**
* 撤销
*/
public void revoke(Long userId) {
// 反向查找:用户的所有 refresh token
// 简单做法:维护"用户 → refresh token 列表" 的映射
// 实际生产中用 userId 前缀存储,模糊匹配删除
Set<String> keys = redis.keys(PREFIX + "*:" + userId);
if (keys != null) redis.delete(keys);
}
private String generateRandomToken() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
|
关键设计:
- refresh token 不存 JWT 格式——直接用随机字符串(32 字节 = 256 位熵,无法暴力破解)
- 每次使用都轮换——验证后立刻撤销旧的,签发新的(防重放)
- Redis 存 userId——验证时只查 Redis,不需要再 join 数据库
5.6 第四步:用 refresh token 换新 access token
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| @RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired private RefreshTokenService refreshTokenService;
/**
* 刷新 access token(access 过期时调用)
*/
@PostMapping("/refresh")
public Result<LoginResponse> refresh(@RequestBody RefreshRequest req) {
// 1. 校验 + 轮换 refresh token
Long userId = refreshTokenService.verifyAndRotate(req.getRefreshToken());
// 2. 颁发新 access token
StpUtil.login(userId); // 会顶掉旧的 access(因为 is-concurrent=false)
String newAccessToken = StpUtil.getTokenValue();
// 3. 颁发新 refresh token(已经在 verifyAndRotate 里轮换过了)
String newRefreshToken = refreshTokenService.create(userId, 7 * 24 * 3600);
return Result.ok(new LoginResponse(
newAccessToken,
newRefreshToken,
900,
7 * 24 * 3600
));
}
}
|
前端配合逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // axios 拦截器:access 过期时自动 refresh
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 用 refresh token 换新 access
const refreshToken = localStorage.getItem('refresh_token');
const resp = await axios.post('/api/auth/refresh', { refreshToken });
localStorage.setItem('access_token', resp.data.data.accessToken);
localStorage.setItem('refresh_token', resp.data.data.refreshToken);
// 重试原请求
originalRequest.headers.Authorization = `Bearer ${resp.data.data.accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// refresh 也过期,跳登录页
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
|
5.7 第五步:SSO 单点登录(同域模式最常见)
场景:a.example.com、b.example.com、c.example.com 三个子域要"一处登录,处处通行"。
核心思路:
- 登录页和 token 颁发放在统一认证中心(
sso.example.com) - 三个业务系统检测到"未登录"时,302 跳转到 SSO 中心
- SSO 中心登录后,颁发"一次性 ticket" + 写共享 Cookie
- 业务系统用 ticket 换 access+refresh token
SSO 中心登录跳转 Controller:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| @RestController
@RequestMapping("/sso")
public class SsoController {
@Autowired private UserService userService;
@Autowired private RefreshTokenService refreshTokenService;
// 1. SSO 中心登录页
@GetMapping("/login")
public String loginPage(@RequestParam String redirect, Model model) {
model.addAttribute("redirect", redirect); // 登录后跳回的地址
return "sso-login"; // 模板
}
// 2. SSO 中心登录提交
@PostMapping("/doLogin")
public Result<String> doLogin(@RequestBody LoginRequest req,
@RequestParam String redirect,
HttpServletResponse response) {
// 1. 验证密码
User user = userService.getByUsername(req.getUsername());
if (user == null || !passwordEncoder.matches(req.getPassword(), user.getPasswordHash())) {
return Result.fail(401, "用户名或密码错误");
}
// 2. 颁发一次性的 ticket(5 分钟有效,只能使用一次)
String ticket = UUID.randomUUID().toString().replace("-", "");
redis.opsForValue().set("sso:ticket:" + ticket, String.valueOf(user.getId()),
Duration.ofMinutes(5));
// 3. 写共享 Cookie(关键:domain=.example.com 让所有子域共享)
Cookie ssoCookie = new Cookie("sso_token", ticket);
ssoCookie.setDomain(".example.com"); // 关键:点开头的 domain 让所有子域共享
ssoCookie.setPath("/");
ssoCookie.setHttpOnly(true); // 防 XSS 偷 cookie
ssoCookie.setSecure(true); // 只在 HTTPS 下发送
ssoCookie.setMaxAge(5 * 60); // 5 分钟
response.addCookie(ssoCookie);
// 4. 跳回业务系统(URL 携带 ticket)
String redirectUrl = redirect + "?ticket=" + ticket;
return Result.ok(redirectUrl);
}
// 3. 业务系统用 ticket 换 token
@GetMapping("/callback")
public Result<LoginResponse> callback(@RequestParam String ticket) {
// 1. 校验 ticket + 立即销毁(防重放)
String userIdStr = redis.opsForValue().get("sso:ticket:" + ticket);
if (userIdStr == null) {
return Result.fail(401, "ticket 无效或已使用");
}
redis.delete("sso:ticket:" + ticket);
Long userId = Long.valueOf(userIdStr);
// 2. 颁发 access + refresh
StpUtil.login(userId);
String accessToken = StpUtil.getTokenValue();
String refreshToken = refreshTokenService.create(userId, 7 * 24 * 3600);
return Result.ok(new LoginResponse(accessToken, refreshToken, 900, 7 * 24 * 3600));
}
}
|
业务系统接入:
1
2
3
4
5
6
7
8
9
10
| // 业务系统启动时:检查是否有 SSO cookie,没有则跳 SSO 中心
// 伪代码(实际写在拦截器里)
if (userNotLoggedIn() && hasSsoCookie()) {
String ticket = getSsoCookie();
LoginResponse tokens = ssoClient.callback(ticket);
saveTokens(tokens);
return page;
} else if (userNotLoggedIn() && !hasSsoCookie()) {
return "redirect:https://sso.example.com/sso/login?redirect=" + currentUrl;
}
|
三种 SSO 模式对比:
| 模式 | 适用场景 | 实现复杂度 | 安全性 |
|---|
| 同域 Cookie 共享 | 所有系统在 *.example.com 下 | 低 | 高(共享 cookie + ticket 防重放) |
| 跨域 OAuth2 | 多个不同主域 | 中(需要回调 URL) | 中(回调域校验) |
| 跨域 OIDC | 第三方身份登录(GitHub/微信) | 高(完整 OIDC 协议) | 最高(标准协议) |
5.8 第六步:Spring Gateway 集成 sa-token
Gateway 内的 AuthGlobalFilter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| @Component
@Order(-100) // 数字越小越早执行,鉴权必须在最前
public class AuthGlobalFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
// 1. 白名单直接放行
if (path.startsWith("/api/public/") || path.startsWith("/api/auth/login")) {
return chain.filter(exchange);
}
// 2. 提取 access token
String authHeader = request.getHeaders().getFirst("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return unauthorized(exchange, "缺少 token");
}
String token = authHeader.substring(7);
// 3. 验签 + 过期检查
try {
Map<String, Object> payload = JwtUtil.parseUnsafe(token);
if (!JwtUtil.verify(token)) {
return unauthorized(exchange, "token 过期");
}
// 4. 解析 userId / role 注入到下游请求头
Long userId = ((Number) payload.get("sub")).longValue();
String role = (String) payload.get("role");
ServerHttpRequest mutated = request.mutate()
.header("X-User-Id", String.valueOf(userId))
.header("X-User-Role", role)
.header("X-Request-Id", UUID.randomUUID().toString()) // 链路追踪
.build();
return chain.filter(exchange.mutate().request(mutated).build());
} catch (Exception e) {
return unauthorized(exchange, "token 解析失败: " + e.getMessage());
}
}
private Mono<Void> unauthorized(ServerWebExchange exchange, String msg) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = String.format("{\"code\":401,\"msg\":\"%s\"}", msg);
return response.writeWith(Mono.just(response.bufferFactory().wrap(body.getBytes())));
}
}
|
关键点:
- Gateway 自己做验签——不调下游业务服务,降低下游服务负载
- X-User-Id 透传——下游服务不用再解析 token,直接拿 header 即可
- 白名单——
/api/auth/login 等接口不需要 token,直接放行
5.9 接口设计:鉴权友好的 6 条铁律
- RESTful 资源命名:
GET /api/users/{id} 而不是 GET /api/getUser?id=1 - 用 HTTP 状态码表达业务结果:
200 OK = 成功400 Bad Request = 参数错误401 Unauthorized = 未登录403 Forbidden = 已登录但没权限404 Not Found = 资源不存在429 Too Many Requests = 限流
- POST/PUT/DELETE 必须幂等——用
Idempotency-Key 请求头(类似 Stripe) - 敏感操作二次验证——转账/删除需要"短期 token + 短信验证码"
- 不返回明文密码/身份证——即使内部 API 也不返回,日志里用
* 脱敏 - 统一错误响应格式:
{"code": 401, "msg": "未登录", "data": null}
🎯 避坑:状态码滥用
把所有错误都返回 200 OK,body 里写 code: 500 是反模式——HTTP 层和网关层看不到错误,监控系统统计 QPS 时把"100% 失败"误判为"100% 成功"。正确做法:HTTP 状态码表达"请求级别成功/失败",body 里的 code 表达"业务级别成功/失败"。
5.10 sa-token 集群部署:3 个生产关键点
关键点 1:Token 共享存储必须用 Redis Cluster
sa-token 的 token 信息默认存 Redis(单实例)。生产环境必须用 Redis Cluster(至少 3 主 3 从)——否则 Redis 挂了所有用户全部掉线。
1
2
3
4
5
6
7
8
9
10
11
12
| # application.yml
spring:
data:
redis:
cluster:
nodes: 192.168.1.10:6379,192.168.1.11:6379,192.168.1.12:6379
max-redirects: 3
password: ${REDIS_PASSWORD}
lettuce:
pool:
max-active: 500 # 集群环境下调大
max-idle: 100
|
关键点 2:Token 存储格式优化
sa-token 默认用 String 存 token,在 Redis Cluster 下 hash 槽不均(所有 key 集中在一个槽)。生产优化:
1
2
3
4
5
6
7
8
9
10
| @Bean
public SaTokenDao saTokenDao(RedisTemplate<String, Object> redisTemplate) {
// 自定义 Dao,key 加业务前缀,分散到不同 hash 槽
return new SaTokenDaoForRedis(redisTemplate) {
@Override
public String getKey(String key) {
return "auth:token:" + key; // 加业务前缀
}
};
}
|
关键点 3:多机房容灾
异地多活架构下,sa-token 的 Redis 必须跨机房同步:
- 方案 A:Redis 自带的跨机房复制(主写 + 备机房读,延迟 100ms 以内)
- 方案 B:CRDT 工具(Redis CRDT / Pika)——支持双向同步,延迟更低
5.11 跨域 CORS 完整配置
前后端分离架构下,CORS 是必踩的坑。正确配置 3 处:
第 1 处:Spring Boot WebMvcConfigurer(单服务调用)
1
2
3
4
5
6
7
8
9
10
11
12
| @Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*") // Spring Boot 2.4+ 必须用 Patterns
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true) // 允许携带 cookie
.maxAge(3600); // 预检请求缓存
}
}
|
第 2 处:Spring Cloud Gateway(网关层)
1
2
3
4
5
6
7
8
9
10
11
| spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOriginPatterns: "*"
allowedMethods: "*"
allowedHeaders: "*"
allowCredentials: true
maxAge: 3600
|
第 3 处:Nginx(反向代理层,可选)
1
2
3
4
5
6
7
8
9
10
11
12
| location /api/ {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' '*' always;
if ($request_method = 'OPTIONS') {
return 204; # 预检请求直接 204
}
proxy_pass http://gateway;
}
|
🎯 避坑:allowCredentials: true + allowedOrigins: * 的经典错误
这两个配置不能同时存在——CORS 规范禁止"通配 origin + 带 cookie"。正确做法:
- 知道具体 origin:用
allowedOrigins("https://app.example.com") - 不知道 / 多个 origin:用
allowedOriginPatterns("*") 代替
这是初学者最常踩的坑,Stack Overflow 上同名问题 5000+ 回答,但 90% 都是错的对策(用 Filter 自定义响应头绕过)——按规范来,别绕。
六、生产清单:认证授权的 12 条铁律
这一节是给"已经理解了原理,准备上生产"的同学的速查清单。前 5 节是"为什么 + 怎么做",这一节是"必须做/绝对不能做"。
- HTTPS 强制全站——任何明文 HTTP 都不允许(即使是内部网络),用 HSTS 头防止降级攻击
- JWT secret 从环境变量注入——绝不写代码、绝不提交 Git,长度 ≥ 32 字节随机
- access token 短期(15-30 分钟) + refresh token 长期(7-30 天)——access 泄露影响时间窗小,refresh 用一次轮换一次
- refresh token 一次性使用——验证后立即撤销旧的,防重放
- 密码用 BCrypt/Argon2 单向 hash——不用 MD5/SHA-1,可逆编码明文存储是灾难
- 关键操作(支付/转账/删数据)二次验证——短期 token + 短信/邮箱 OTP
- 接口幂等性——所有 POST/PUT/DELETE 用
Idempotency-Key 请求头 - 限流 + 风控——同 IP 短时间高频访问、跨用户访问、未登录接口的限流,缺一不可
- 审计日志——登录/登出/权限变更/关键操作全记录,包含 userId / IP / 时间 / 设备指纹
- 告警规则——同一账号 5 分钟内 5 次失败登录、同一 IP 1 分钟 100 次未授权访问,自动告警
- JWT 黑名单——支持"踢人下线"功能(在 Redis 维护短黑名单,验签前先查)
- 定期安全扫描——用
dependency-check / trivy 扫依赖漏洞,用 OWASP ZAP 做渗透测试,至少季度一次
📌 实践:12 条铁律的优先级
- P0 必做:#1 HTTPS、#2 secret 安全、#3 双 token、#5 密码 hash、#8 限流
- P1 应该做:#4 一次性 refresh、#6 二次验证、#7 幂等、#9 审计
- P2 锦上添花:#10 告警、#11 黑名单、#12 扫描
12 条全做 = 顶级安全团队水平,只做 P0 = 不会出大事故。
🛑 误区:“用了 sa-token 就安全了”
框架只是"工具",安全 = 框架 + 配置 + 业务逻辑 + 运维。见过有团队:
- 配了 sa-token 但 secret 写在 yml 里—— Git 泄露 = 整个认证系统报废
- 用 sa-token 但所有接口
@SaCheckLogin 都不加—— 框架等于没装 - access token 设了 30 天过期—— 等同于把安全押在"token 不泄露"上
- 没审计日志—— 出事找不到攻击者是谁、从哪来、干了什么
框架是必要条件,不是充分条件。安全的 80% 在于"对细节的偏执"。
6.13 上线前 24 小时 Checklist
把 12 条铁律浓缩成 8 步上线 checklist,贴在工位上:
| 步骤 | 检查项 | 通过条件 |
|---|
| 1 | HTTPS 证书 | 证书有效期 > 30 天;TLS 1.3 已启用;HSTS 头已加 |
| 2 | 密钥管理 | JWT secret 从环境变量注入(非代码);KMS 已对接 |
| 3 | 密码存储 | DB 抽查 10 个用户,密码字段都是 bcrypt/argon2 格式(以 $2a$ 开头) |
| 4 | token 过期 | access ≤ 30 分钟,refresh ≤ 30 天;refresh 一次性轮换 |
| 5 | 风控规则 | 5 分钟 5 次失败登录告警已配;1 分钟 100 次未授权访问告警已配 |
| 6 | 审计日志 | 登录/登出/权限变更/支付操作都有 userId+IP+时间戳记录 |
| 7 | 依赖扫描 | trivy / dependency-check 扫描,Critical/High 漏洞 = 0 |
| 8 | 渗透测试 | OWASP ZAP 自动化扫描通过;至少 1 个手工渗透测试 case 通过 |
8 步全过 = 可上线。任何一步不过,延期上线——安全债比延期代价大 10 倍。
6.14 写在最后:认证的本质是信任
写完这 12 条铁律 + 8 步 checklist,回头看,认证体系的本质其实就一句话——在不可信的环境里,建立"我信他"的机制。密码学给了我们"防偷看、防篡改"的工具,JWT/sa-token/Spring Security 给了我们"易用的工程实现",但真正的安全不靠工具,靠"对每个细节都不放过"的偏执。生产环境上踩过的每一个坑,都是这套偏执的一部分。把这篇里的 11 个概念 + 2 套二选一方案 + 6 段实战代码 + 14 条铁律吃透,基本能应付 90% 的微服务认证场景。剩下的 10%,靠实战中慢慢补。
收尾:本篇要点 + 系列承上启下
✍️ 本篇核心结论:
- 认证体系 4 层 11 概念——密码学 → JWT → 框架(2 选 1)→ 生产机制
- 密码学是地基——对称加密(快) + 非对称加密(密钥交换) + 数字签名(防篡改) + HTTPS(集大成)
- JWT 是凭证格式,不是完整方案——必须配合双 token、refresh 轮换、HTTPS
- Spring Security 与 sa-token 二选一——中小项目选 sa-token(开发快),复杂 RBAC 选 Spring Security(可扩展)
- 双 Token + SSO + Gateway 是生产三件套——缺一不可,组合在一起才算"统一认证"
- 12 条铁律——P0 必做 5 条(安全下限),P1 应该做 4 条(生产标准),P2 锦上添花 3 条(顶级)
📚 Java 微服务系列地图:
📖 参考资料:
下一篇:《消息队列:从 Kafka 到 RocketMQ,Java 微服务的异步解耦与最终一致性》——认证授权只是"谁来访问",访问之后系统怎么扛住流量、看事件驱动架构怎么落地。