Featured image of post Java 微服务认证授权:从 JWT 到 sa-token,双 Token + SSO + Gateway 一站打通

Java 微服务认证授权:从 JWT 到 sa-token,双 Token + SSO + Gateway 一站打通

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 在白板上写下三个问题:

  1. 我们的认证到底在防什么?(答:只防了"未登录",没防"已登录但越权")
  2. 我们的鉴权粒度是什么?(答:基本靠"URL 前缀 + 注解",没有"数据级鉴权")
  3. 我们如果重来一次,第一刀切哪里?(答:统一认证中心 + 双 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数字签名私钥签名,公钥验签防篡改 + 不可否认
4HTTPS / TLSHTTP + TLS 握手传输加密 + 服务端认证
5JWTJSON Web Token 自包含凭证无状态身份凭证
6Spring SecuritySpring 全家桶的认证鉴权框架复杂 RBAC + 生态完整
7sa-token国产轻量级 Java 鉴权框架上手快 + 鉴权代码极简四 + 五
8双 Token 机制access + refresh 双凭证短 access + 长 refresh 自动续签
9SSO 单点登录一处登录,处处通行跨域多系统身份共享
10接口设计RESTful + 状态码 + 幂等鉴权友好的接口规范
11Spring 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 个常见错误

  1. IV 写死成常量 → GCM 加密完全失效(直接输出相同密文)
  2. 用 ECB 模式 → 不隐藏明文模式(图片加密后还能看出轮廓)
  3. 密钥硬编码在代码里 → Git 泄露 = 整个加密系统报废,生产用 KMS 或 Vault

2.3 非对称加密:公钥 + 私钥配对

代表算法:RSA / ECC(椭圆曲线)/ ECDH / ECDHE

核心原理:

1
2
3
4
5
公钥加密,私钥解密(机密性):
明文 ──[公钥 PK]──> 密文  ──[私钥 SK]──> 明文

私钥签名,公钥验签(真实性):
明文 ──[私钥 SK]──> 签名  ──[公钥 PK + 明文]──> true/false

RSA vs ECC 对比:

维度RSA-2048ECC-256备注
安全性等价RSA-2048 ≈ ECC-224ECC-256 ≈ RSA-3072ECC 用更短密钥达到相同安全
性能(签名)快 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-SHA256RSA-PSS-2048Ed25519
类型对称签名(双方共享密钥)非对称签名(公钥验签)非对称签名(现代曲线)
性能最快
用途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 个关键步骤:

  1. ClientHello:客户端声明"我支持哪些密码套件 + 随机数 + 我的 ECDHE 临时公钥"
  2. ServerHello + 证书:服务端选定密码套件,发回自己的 ECDHE 临时公钥 + CA 证书(含服务端的 RSA/ECC 长期公钥 + CA 数字签名)
  3. Finished:双方用 ECDHE 算法 + 双方临时公钥 → 算出"共享密钥" → 派生出对称加密密钥,所有后续数据用这个对称密钥加密
  4. 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-GCMDB 里存密文,即使 DB 泄露也不破
密码存储bcrypt / Argon2用户密码单向 hash(不可逆)
API 请求签名HMAC-SHA256 + nonce + timestamp防止重放攻击(类似支付宝签名)
跨服务调用 mTLSTLS + 双向证书微服务间零信任加密

三、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:怎么选?

维度JWTSession
存储客户端(自带)服务端(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);

对比:

维度JWTPASETO
alg 字段显式声明(可被替换)隐式(由版本决定)
Payloadbase64 明文加密(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 Securitysa-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 为什么"二选一"而不是"两套都用"?

生产中两套并存的代价:

  1. 配置混乱——Spring Security 的 SecurityFilterChain 和 sa-token 的 SaInterceptor 都会拦截请求,顺序错乱导致鉴权绕过
  2. 学习成本 ×2——团队要同时懂两套机制,新人入职 3 个月才能摸清
  3. 调试地狱——一个请求被两个框架各拦截一次,日志相互打架,出问题时不知道是哪个拦的
  4. 依赖膨胀——两套都引,jar 包体积多 5-10MB,启动慢 1-2 秒
  5. 维护成本 ×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(登录后访问)。

框架配置TPSP99 延迟CPU 占用启动时间
sa-token@SaCheckLogin 注解120008ms25%3.2s
sa-tokenStpUtil.checkLogin() 手动调用115009ms27%3.2s
Spring Security13 个默认 Filter 全开820022ms45%6.8s
Spring Security裁剪到 4 个核心 Filter1050014ms35%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 条铁律

  1. RESTful 资源命名:GET /api/users/{id} 而不是 GET /api/getUser?id=1
  2. 用 HTTP 状态码表达业务结果:
    • 200 OK = 成功
    • 400 Bad Request = 参数错误
    • 401 Unauthorized = 未登录
    • 403 Forbidden = 已登录但没权限
    • 404 Not Found = 资源不存在
    • 429 Too Many Requests = 限流
  3. POST/PUT/DELETE 必须幂等——用 Idempotency-Key 请求头(类似 Stripe)
  4. 敏感操作二次验证——转账/删除需要"短期 token + 短信验证码"
  5. 不返回明文密码/身份证——即使内部 API 也不返回,日志里用 * 脱敏
  6. 统一错误响应格式:{"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 节是"为什么 + 怎么做",这一节是"必须做/绝对不能做"。

  1. HTTPS 强制全站——任何明文 HTTP 都不允许(即使是内部网络),用 HSTS 头防止降级攻击
  2. JWT secret 从环境变量注入——绝不写代码、绝不提交 Git,长度 ≥ 32 字节随机
  3. access token 短期(15-30 分钟) + refresh token 长期(7-30 天)——access 泄露影响时间窗小,refresh 用一次轮换一次
  4. refresh token 一次性使用——验证后立即撤销旧的,防重放
  5. 密码用 BCrypt/Argon2 单向 hash——不用 MD5/SHA-1,可逆编码明文存储是灾难
  6. 关键操作(支付/转账/删数据)二次验证——短期 token + 短信/邮箱 OTP
  7. 接口幂等性——所有 POST/PUT/DELETE 用 Idempotency-Key 请求头
  8. 限流 + 风控——同 IP 短时间高频访问、跨用户访问、未登录接口的限流,缺一不可
  9. 审计日志——登录/登出/权限变更/关键操作全记录,包含 userId / IP / 时间 / 设备指纹
  10. 告警规则——同一账号 5 分钟内 5 次失败登录、同一 IP 1 分钟 100 次未授权访问,自动告警
  11. JWT 黑名单——支持"踢人下线"功能(在 Redis 维护短黑名单,验签前先查)
  12. 定期安全扫描——用 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,贴在工位上:

步骤检查项通过条件
1HTTPS 证书证书有效期 > 30 天;TLS 1.3 已启用;HSTS 头已加
2密钥管理JWT secret 从环境变量注入(非代码);KMS 已对接
3密码存储DB 抽查 10 个用户,密码字段都是 bcrypt/argon2 格式(以 $2a$ 开头)
4token 过期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%,靠实战中慢慢补。


收尾:本篇要点 + 系列承上启下

✍️ 本篇核心结论:

  1. 认证体系 4 层 11 概念——密码学 → JWT → 框架(2 选 1)→ 生产机制
  2. 密码学是地基——对称加密(快) + 非对称加密(密钥交换) + 数字签名(防篡改) + HTTPS(集大成)
  3. JWT 是凭证格式,不是完整方案——必须配合双 token、refresh 轮换、HTTPS
  4. Spring Security 与 sa-token 二选一——中小项目选 sa-token(开发快),复杂 RBAC 选 Spring Security(可扩展)
  5. 双 Token + SSO + Gateway 是生产三件套——缺一不可,组合在一起才算"统一认证"
  6. 12 条铁律——P0 必做 5 条(安全下限),P1 应该做 4 条(生产标准),P2 锦上添花 3 条(顶级)

📚 Java 微服务系列地图:

#主题关系
1异地多活架构总纲
2流量调度(Nginx/LVS)外部入口
4技术选型(SCA + Dubbo3)选型总结
5Nacos服务发现 + 配置
6Spring Cloud Gateway应用网关
7本篇 认证授权统一认证中心
8消息队列异步解耦
9熔断限流 Sentinel流量治理
10监控与日志可观测性

📖 参考资料:


下一篇:《消息队列:从 Kafka 到 RocketMQ,Java 微服务的异步解耦与最终一致性》——认证授权只是"谁来访问",访问之后系统怎么扛住流量、看事件驱动架构怎么落地。

本系列共 16 篇,本文为第 7 篇 · 查看全部
使用 Hugo 构建
主题 StackJimmy 设计