认证授权方案设计
# 10.认证与授权方案设计
本篇定位:认证(AuthN,你是谁)和授权(AuthZ,你能干什么)是所有互联网系统绕不开的两块基石。做对了外人进不来、内人越不了权、微服务传得下、注销传得开;做错了 Token 泄露就是拖库,SSO 挂了就是全站崩,权限漏洞就是越权。本文从一次 48 万 Token 被挂到 GitHub 的事故讲起,回答七个刺入骨髓的问题——认证与授权到底怎么分?Session 和 JWT 该怎么选?OAuth2 的四种模式到底解决什么?RBAC 到底怎么设计不爆炸?
# 一、案例引入:48 万 Token 挂到 GitHub 的那 3 小时
# 1.1 事故现场:从"实习生一次提交"到"全线拖库"
某中型 SaaS 公司,做企业协同办公。安全同学在做例行 GitHub Dork 扫描(针对公司域名的关键字扫码泄露),凌晨 2 点扫到一条震撼级线索——一个刚入职三周的前端实习生,把本地 .env 文件误提交到了公开仓库。文件里躺着 48 万条历史 JWT Token 的采样日志(他为了排查一个 Token 解析问题在本地跑过一批用户的 Token)。
2:00 安全值班同学发现,紧急拉群。 2:15 尝试在 GitHub 后台下架仓库——仓库已经被 fork 了 3 次,其中一个 fork 账号的 IP 归属地在境外。 2:30 决定所有 Token 全部作废、强制全体用户重新登录。问题来了:JWT 是无状态的,一签发就"泼出去的水",服务端根本没有黑名单机制——48 万个 Token 到期前每一个都可以正常调用 API。 2:45 紧急上线一个"发布签名密钥 v2",把 JWT 校验里的密钥换掉——又崩了:Web、iOS、Android、桌面客户端、开放平台第三方接入方六端总共 200 万活跃用户全部被踢下线。客服电话被打爆。 5:00 密钥切换完成,全线恢复登录。事后统计:3 小时窗口内境外 IP 用泄露 Token 拉走的数据估计有 20~40GB,涉及 3 家客户的合同、员工薪资、客户名单。
# 1.2 顺藤摸根因:不是"实习生的锅",是"架构没兜底"
复盘会锁定 7 处设计缺陷(后续每一章都是它们的解药):
| # | 缺陷 | 直接后果 |
|---|---|---|
| ① | JWT 里塞了太多信息(用户名、部门、角色、菜单权限……) | Token 被抓包一次 = 用户完整画像 |
| ② | JWT 有效期设成了 30 天 | 泄露 30 天内都能用 |
| ③ | 没有 Refresh Token 双令牌机制 | 想缩短有效期就要用户天天登录 |
| ④ | 没有黑名单 / 版本号机制 | 泄露只能靠"换密钥"这种核弹级操作 |
| ⑤ | 网关做了鉴权,业务服务完全不做二次校验(只信任 header) | 内网横向通吃 |
| ⑥ | 权限校验只做到"能否访问 URL",没做数据级越权检查 | 用别人 Token 能查别人数据 |
| ⑦ | 密钥用代码里的常量,没走 KMS / 秘密管理 | 换密钥要发新版本 |
一个实习生的一次误操作暴露了整个认证体系的七个洞。这不是运气问题,是架构没有兜底。
# 1.3 七个"为什么":疑惑清单
事故会上灵魂拷问:
- 认证和授权到底是不是一回事?为什么老是搞混?
- Session 好好用为什么大家都在往 JWT 迁?JWT 比 Session 好在哪、差在哪?
- JWT 说是"无状态",出了事故想让它失效——真的没办法吗?
- AccessToken + RefreshToken 双令牌到底解决了什么?只用一个不行吗?
- OAuth2 那四种授权模式到底怎么选?为啥有 authorization_code 还要 PKCE?
- RBAC 模型看起来简单,为什么到几百个角色就爆炸了?
- 网关都鉴权了,业务服务还要做什么?如何防"横向越权"?
这 7 问是本文的骨架。走完第 10 章会全部串起来。
# 二、架构决策三角:安全 × 体验 × 可扩展
认证授权系统本质上是在三个维度上做联合最优化:
安全(防泄露、防越权、可作废)
▲
/│\
/ │ \
/ │ \
/ 认 │ 授\
/ 证 │ 权 \
/ 系 │ 统 \
────────┼──────
体验(少登录、跨端、SSO) 可扩展(微服务、多租户)
任何"银弹方案"号称三边全占的,都在骗人:
- Session + Cookie:安全高(服务端可控),扩展差(多机要 Session 共享)。
- 纯 JWT 无状态:扩展极佳(服务端 0 存储),安全弱(作废难、泄露=灾难)。
- OAuth2 授权码:安全 + 扩展双高,体验代价——多一个 302 跳转。
架构选择就是"接受哪个代价",而不是"哪个方案最好"。金融接受"每 15 分钟重新输密码"换安全,社交产品接受"7 天免登"换体验,B 端 SaaS 接受"SSO 中心化"换可扩展。
# 三、认证 ≠ 授权:先把两个概念钉死
新人(甚至一些老兵)最常犯的错就是混淆 AuthN 和 AuthZ。它们不是一件事,也不该由一个模块负责。
# 3.1 定义与例子
| 概念 | 英文 | 一句话 | 生活类比 |
|---|---|---|---|
| 认证 | Authentication (AuthN) | 你是谁? | 门卫看你的身份证 |
| 授权 | Authorization (AuthZ) | 你能干什么? | 你的门禁卡能开哪些门 |
认证是"我是我"的证明;授权是"我能做什么"的许可。一个人可以有身份证,但不代表他能进机房。
# 3.2 为什么要严格分离
把两者混在一起做会导致:
- 改鉴权改到业务代码里去:因为角色/权限跟"我是谁"混在一个 Filter 里,加个角色要动登录模块。
- 难以支持第三方登录:用微信登录进来"这个人是谁"清楚,但"这个人在你系统里能干什么"就没答案——微信只做 AuthN,AuthZ 是你自己的事。
- 难以做细粒度权限:如果 JWT 里塞了整套权限,权限变更要重发 Token;分离后权限只在服务端查,Token 里只放 userId。
# 3.3 现代架构的分工
┌────────── 认证服务 (AuthN) ──────────┐ ┌────────── 授权服务 (AuthZ) ──────────┐
│ ・登录(密码、验证码、扫码、第三方) │ │ ・角色管理(RBAC) │
│ ・签发 Token(access/refresh) │ │ ・权限点(Permission) │
│ ・Token 校验(签名 + 过期) │ │ ・策略引擎(ABAC / OPA) │
│ ・SSO 单点登录 │ │ ・数据权限(行级/列级过滤) │
└──────────────────────────────────────┘ └───────────────────────────────────────┘
记住这一条:Token 里只放 userId + 少量必要字段(tenantId、deviceId),永远不要在 Token 里塞权限——权限变了、Token 还是老的,就是漏洞。
# 四、Session vs JWT:状态与无状态的世纪之争
这是所有面试必问、所有团队必吵的话题。答案是"看场景,各有其位",但要知道每一条边界。
# 4.1 Session 机制的本质:服务端记账
1. 客户端登录 → 服务端生成 sessionId → 写入服务端存储(内存/Redis)
2. Set-Cookie: sessionId=xxx; HttpOnly; Secure
3. 后续请求带 Cookie → 服务端根据 sessionId 查 Session 表 → 恢复用户上下文
特点:服务端有"账本",可以随时改(作废、更新用户信息、踢下线)。
# 4.2 JWT 的本质:签名自证 + 无状态
JWT 的结构(三段 Base64URL 拼接):
Header.Payload.Signature
Header: {"alg":"HS256","typ":"JWT"}
Payload: {"sub":"user123","exp":1706788800,"tenantId":"t01"}
Signature: HMACSHA256(base64(Header)+"."+base64(Payload), secret)
服务端每次收到 Token 只做一件事——用密钥重算签名,对上就信任 Payload 内容。服务端不存 Token、不查库、不管账。
# 4.3 两者的正反面:一张表看清
| 维度 | Session(有状态) | JWT(无状态) |
|---|---|---|
| 服务端存储 | 每人一条 Session 记录 | 无 |
| 横向扩展 | 需 Session 共享(Redis / Sticky) | 天然支持 |
| 主动作废 | 删 Session 记录即可 | 极难(需黑名单) |
| 信息量 | 只在服务端存,Cookie 只带 ID | 需要的字段全塞 Token,膨胀风险 |
| 跨域 | Cookie 跨域受限(SameSite) | Bearer Token 天然跨域 |
| 跨端 | Cookie 不适合 App / 桌面 | Token 各端都能用 |
| CSRF | Cookie 自动携带 = 易受 CSRF | Bearer 头需手动带 = 天然免疫 |
| XSS | HttpOnly Cookie 抗 XSS | localStorage 存 Token 易被 XSS 拿 |
| 典型场景 | 传统 Web、有状态后台管理 | 微服务、移动 App、开放 API |
# 4.4 关键推论:JWT 不能主动作废是它最大的原罪
回到本文事故——48 万个 Token 已经签发,30 天内每一个都合法。JWT 无状态的代价就是"泼出去的水收不回"。修复只有 4 条路:
- 换签名密钥:所有 Token 一次性作废——误伤范围最大(本文事故做的就是这个,200 万活跃用户被踢)。
- 黑名单:把要作废的 jti(JWT ID)塞进 Redis,鉴权时查一次——JWT 变"半有状态",牺牲了它的核心卖点。
- 短 access + 长 refresh 双令牌:让 access token 只活 15 分钟——泄露的 access 15 分钟就没用了。
- 版本号 / tokenVersion:JWT 里塞
ver: 3,服务端存每个用户的当前 tokenVersion,不一致就拒——依然要查库,但比黑名单更聚焦。
"用哪一条"直接决定事故止损时间。生产系统一般 3 + 4 组合使用。
# 4.5 选型速查
- 传统单体后台管理系统 → Session + Redis 共享,简单可靠。
- 多端产品(Web+App+桌面) → JWT + 双令牌 + 黑名单/版本号兜底。
- 开放平台 / 三方接入 → OAuth2 授权码(下一章讲)。
- 金融级:Session 优先,服务端可随时踢下线是金融合规的硬要求。
# 五、双令牌机制:AccessToken + RefreshToken 数学化推导
单令牌的取舍是"长有效期方便但危险,短有效期安全但用户天天登"。双令牌把这对矛盾分层解开。
# 5.1 双令牌的角色分工
| 令牌 | 有效期 | 用途 | 存放位置 | 泄露影响 |
|---|---|---|---|---|
| AccessToken | 15 分钟 ~ 2 小时 | 调 API 时带 | 内存 / 短期 localStorage | 有效期内可用 |
| RefreshToken | 7 天 ~ 30 天 | 只用来换新 access | 加密安全存储 / HttpOnly Cookie | 一票作废、绑设备 |
# 5.2 换取流程
[初次登录]
User → AuthServer: 用户名/密码
AuthServer → User: { access:AT_1 (15min), refresh:RT_1 (30d) }
[15 分钟后 access 过期]
User → API: 请求 (AT_1) ── 401 Expired
User → AuthServer: /refresh (RT_1)
AuthServer 校验 RT_1 + 查库确认未作废
AuthServer → User: { access:AT_2, refresh:RT_2 } ── 关键:refresh 也旋转
User 用 AT_2 继续请求
[30 天到期或用户主动登出]
User → AuthServer: /logout (RT_x)
AuthServer 把 RT_x 标记为 revoked → 所有相关 access 15 分钟内自然过期
# 5.3 为什么 refresh 要"旋转"(Rotation)
安全模型:假设 RT 被盗,攻击者用 RT_1 换到 AT_2 + RT_2,同时受害者也在用——两个客户端同时用同一个 RT_1 去换新令牌。
- RT 不旋转:两边都能不断换,攻击者可以永久保有会话——根本发现不了。
- RT 旋转:
RT_1 → RT_2,一个人换成功之后RT_1就废了。另一端下次请求会 401——立刻被系统发现"这个 RT 被两处用了",触发**"全家作废"**(把该用户的所有 refresh 都作废,强制重登)。这就是 Refresh Token Rotation with Automatic Reuse Detection(OAuth 2.1 建议做法)。
# 5.4 泄露影响的数学量化
假设 AT 15 分钟、RT 30 天、每次 rotation 也 30 天窗口:
- 单令牌 30 天:泄露损失 = 30 天 × 全权限 = 43,200 分钟任意调用。
- 双令牌:泄露 AT,损失 = 15 分钟 × 全权限 = 15 分钟。
- 双令牌 + rotation:泄露 RT,攻击者用一次就被检测到"重复使用",损失 ≤ 15 分钟。
降险 3000 倍。这就是为什么"双令牌 + 旋转"是 OAuth 2.1 的默认推荐。
# 5.5 双令牌的实施陷阱
- RT 不能放 localStorage:一旦 XSS,30 天权限交出去了。建议 HttpOnly Cookie 或原生 App 的安全存储(Keychain / Keystore)。
- 换令牌的 endpoint 要做防刷:
/refresh是所有过期 AT 的必经之路,容易被扫描器打爆。 - 要监控"同一 RT 短时间在多个 IP 使用":这是盗号的强信号。
# 六、OAuth 2.0:把认证委托出去的四种姿势
OAuth 2.0 常被误解为"登录协议"——它不是。它是"授权协议"——让第三方拿到有限权限访问用户资源的协议(你允许"某小软件"读你的微博)。用来做"用微信登录我们的 App"叫 OpenID Connect (OIDC),是 OAuth 2.0 上叠的一层。
# 6.1 四种授权模式:不是并列关系,是安全等级递减
| 模式 | grant_type | 场景 | 安全性 |
|---|---|---|---|
| 授权码 | authorization_code | Web 站点、有后端的 App | ⭐⭐⭐⭐⭐ |
| 授权码 + PKCE | authorization_code + PKCE | 移动 App、SPA(无后端) | ⭐⭐⭐⭐⭐ |
| 客户端凭证 | client_credentials | 机器对机器(M2M)、后端服务 | ⭐⭐⭐⭐ |
| 密码模式(Password) | password | 已被 OAuth 2.1 废弃 | ⭐ |
| 隐式模式(Implicit) | implicit | 已被 OAuth 2.1 废弃 | ⭐ |
OAuth 2.1(起草中)的核心变化:只保留 authorization_code (含 PKCE) 和 client_credentials,其余全删。
# 6.2 授权码模式的经典流程
[用户在你 App 里点"微信登录"]
Your App User Browser WeChat OAuth
────── ──────────── ────────────
① redirect → → https://open.weixin/authorize
?client_id=your_app
&redirect_uri=https://your.com/callback
&scope=userinfo
&state=random_nonce
② 用户在微信页面授权(同意)
← redirect 到 callback
③ Your.com/callback ← code=xxx&state=random_nonce
④ 后端拿 code 换 token(走后端到后端):
Your Backend → https://open.weixin/token
{ code, client_id, client_secret }
→ { access_token: WT_1, openid: xxx }
⑤ 拿 access_token 调用微信 API 拿 userinfo
Your Backend → https://open.weixin/userinfo?access_token=WT_1
→ { nickname, avatar, ... }
⑥ 后端把"微信 openid" 与你系统的 userId 绑定 → 签发自家 JWT 给前端
关键设计要点:
- code 只在浏览器出现一次,服务端拿到 code 后立刻换 token(code 有效期一般 5 分钟,一次性用完作废)。
- client_secret 只在后端出现,前端永远看不到——这就是为什么 SPA / 移动 App 没有 secret 时要用 PKCE 替代。
- state 参数防 CSRF:由客户端生成随机数,回调时校验一致——忘了这一步 = 授权码劫持漏洞(本文事故的邻居坑)。
- redirect_uri 必须白名单:不做严格校验的话攻击者可以把 code 引到自己的域名。
# 6.3 PKCE:无后端场景的"secret 替代品"
移动 App、SPA 没有 client_secret(客户端里的秘密都不算秘密),怎么防"code 被中间人截获"?答案是 PKCE(Proof Key for Code Exchange):
① 客户端生成 code_verifier(随机 43~128 字符)
计算 code_challenge = BASE64URL(SHA256(code_verifier))
② 授权请求:带上 code_challenge
/authorize?client_id=...&code_challenge=xxx&code_challenge_method=S256
③ 授权服务器把 code_challenge 与 code 绑定存起来
④ 客户端换 token 时带上 code_verifier
/token?code=xxx&code_verifier=YYY
⑤ 授权服务器校验:SHA256(code_verifier) == 之前存的 code_challenge
通过 → 发 token
为什么这能防止 code 被劫持?攻击者就算截到 code,没有 code_verifier 换不到 token——而 verifier 只在原客户端内存里。
2020 年后:所有移动 App / SPA 场景,PKCE 是强制的。
# 6.4 OIDC = OAuth 2.0 + 身份
OAuth 2.0 只给你"访问某资源的令牌",不告诉你"用户到底是谁"。OIDC 在 authorization_code 流程上加了一个 id_token(JWT 格式),里面装了 sub、email、name 等身份声明——这才是"社交登录"的正确协议。
微信、QQ、GitHub、Google、Apple、Facebook 现在的登录接口都是 OIDC 或类 OIDC。
# 七、SSO 单点登录:CAS、SAML、OIDC-SSO 一次讲清
企业内部一堆系统(HR、CRM、ERP、OA、报销、内网 Wiki),如果每个都要登一遍——员工每天光输密码就要 30 分钟。SSO 就是解决"一次登录,全站畅通"。
# 7.1 SSO 的三代技术
| 代次 | 代表 | 特点 |
|---|---|---|
| 第一代:Session 共享 | 相同域名下共享 Cookie | 只能同域,扩展性差 |
| 第二代:CAS (Central Authentication Service) | 耶鲁 CAS | 中心认证,通过 ticket 传递 |
| 第三代:SAML | 企业老牌,XML 协议 | 复杂但稳,B2B 场景标配 |
| 第四代:OIDC-SSO | Keycloak / Auth0 / Okta | 基于 OIDC + JWT,云原生首选 |
# 7.2 CAS 单点登录时序
User App-A CAS Server App-B
──── ───── ────────── ─────
访问 A → 未登录 302 → CAS
显示登录页
用户登录成功
← redirect 到 A?ticket=ST-1
校验 ticket → A 向 CAS: /validate?ticket=ST-1
CAS 校验,返回 user 信息
→ 建立 A 的会话
用户操作 A(正常)
访问 B → 未登录 302 → CAS
CAS 检测到已有全局 Session(TGC Cookie)
直接签发新 ticket ST-2
← redirect 到 B?ticket=ST-2
校验 ticket → B 向 CAS: /validate?ticket=ST-2
→ 建立 B 的会话
用户不再需要输密码
关键概念:
- TGC (Ticket Granting Cookie):CAS 域名下的全局 Session Cookie,"你在 CAS 已经登录了"的凭据。
- ST (Service Ticket):一次性、绑定单个应用的临时票据。5 分钟有效,用完作废。
# 7.3 SSO 最难的三件事
难点一:单点登出(SLO) —— 用户在 A 点了退出,B 和 C 该怎么办? 方案有两条:前端广播(CAS 把登出通知发给所有已注册的应用回调 URL)或心跳检测(每个应用定期回问 CAS "我这个 session 还活着吗")。前者实时但复杂,后者简单但有延迟。
难点二:跨域 —— SSO 中心和各应用不同域,Cookie 传不了。
用 CAS 的 ticket 传(本身就是为这场景设计的)或 OIDC 的 id_token。
难点三:SSO 自己不能挂 —— 一挂全崩。
- SSO 集群多机部署。
- 各应用做"降级模式":如果 SSO 不可达,允许用之前签发过、还没过期的 Token 继续用一段时间——牺牲一点安全换可用性。
# 7.4 现代选择:Keycloak / Auth0 / 自研
- 中小团队:直接用 Keycloak(开源、Java、功能完备)。
- 不差钱、要海外合规:Auth0 / Okta(云服务,MAU 收费)。
- 超大规模 / 有特殊定制:基于 Spring Authorization Server 或 OIDC 库自研。
# 八、RBAC 与权限模型:从"角色爆炸"到 ABAC
授权层的核心问题:给定一个用户和一个操作,判断能不能做。这个函数 can(user, action, resource) 内部长啥样,决定了整个权限系统的复杂度。
# 8.1 RBAC:主流但会"爆炸"
RBAC(Role-Based Access Control)三元素:
User ──── UserRole ──── Role ──── RolePermission ──── Permission
│
├─ 普通员工
├─ 部门主管
├─ 部门总监
└─ 超级管理员
优点:概念清晰、DBA 好维护。 致命缺点:当业务有"部门 × 岗位 × 项目 × 客户 × 数据敏感级"多维交叉时,角色数量爆炸。
举例:一家 SaaS 公司业务上有 10 个部门 × 5 个岗位级别 × 3 个数据敏感级 = 150 个角色。再加"跨部门临时项目组"、"某客户专属权限"、"某季度大促临时授权"……很快就到几百上千个角色,运维崩溃。
# 8.2 RBAC 分层:把爆炸控制住
不要用扁平 RBAC,用RBAC1(有继承)+ RBAC2(有约束):
Role: 数据分析师
├─ 继承 Role: 员工基础 (所有员工共有权限)
├─ 加:读取 dw 库
├─ 加:使用报表工具
└─ 排斥(互斥约束):不能同时是"审批人"(利益冲突)
Role: 高级数据分析师
├─ 继承 Role: 数据分析师
└─ 加:读取脱敏前敏感表
收益:150 个角色可以压缩到 20 个基础角色 + 继承树。
# 8.3 数据级权限:光有"角色"不够
RBAC 只能回答"能不能访问 URL /order/list"——但回答不了"用户只能看自己部门的订单"。数据级权限得靠行级过滤(Row-level Security):
-- 应用层查询时自动注入过滤条件
SELECT * FROM orders
WHERE dept_id IN (${current_user.dept_and_children}) -- 行过滤
AND status != 'DELETED';
这就要在 ORM 层做拦截器(MyBatis Interceptor / Hibernate Filter),或者用数据库原生的行级安全(PostgreSQL RLS)。
# 8.4 ABAC:策略语言驱动的下一代权限
ABAC(Attribute-Based Access Control)把权限判断变成策略表达式:
allow if
user.department == resource.department
and user.level >= resource.required_level
and current_time between 09:00 and 18:00
and request.ip in company_network
优点:一条策略搞定复杂条件组合,不需要建几百个角色。 代价:策略引擎(Open Policy Agent / OPA 是事实标准)需要单独部署,学习曲线陡。
结论:
- < 50 个角色的中小系统 → RBAC 够用。
- 有多维交叉、动态策略的大型系统 → RBAC + ABAC 混合(角色定粗粒度,ABAC 补细粒度)。
- 超大规模 / 云原生 → OPA 独立部署,所有服务通过 OPA 询权限。
# 九、微服务鉴权:网关不是终点,服务侧要有"二次校验"
事故里的第 ⑤ 条缺陷——"网关做了鉴权、业务服务不做校验"——是微服务时代最普遍的坑。
# 9.1 单层网关鉴权的问题
Client → API Gateway (校验 JWT ✓) → 业务服务 (完全信任 header)
↑
内网横向调用(无鉴权)
内网穿透(无鉴权)
绕过网关直连(无鉴权)
风险:任何能进入内网的攻击者(包括你的服务被 RCE 后作为跳板)都能通吃所有业务服务。
# 9.2 分层鉴权模型
第一层(网关):粗粒度
・签名验证 + 过期检查
・全局限流
・注入 X-User-Id、X-Tenant-Id 到下游
第二层(业务服务):细粒度
・接口级权限校验(这个用户能不能调这个 API)
・数据级校验(这个用户能不能操作这条数据) ← 防越权关键
・信任传递机制(内部调用要有服务间凭证)
第三层(数据层):兜底
・行级安全(RLS)
・审计日志
# 9.3 服务间调用的三种鉴权方式
服务 A → 服务 B 的调用要不要鉴权?必须。方案:
| 方案 | 原理 | 适用 |
|---|---|---|
| 透传用户 Token | A 收到用户请求时的 Token 原封不动传给 B | 简单,但 Token 生命周期跨服务后难管 |
| 服务身份(M2M Token) | A 用自己的 client_id/client_secret 换 Token 调 B | 服务间清晰,但丢失用户上下文 |
| 组合模式 | 服务 Token + X-User-Id header 传用户身份 | 生产推荐 |
| mTLS + 服务网格 | 双向 TLS 证书身份,业务不感知 | Service Mesh 场景(Istio) |
# 9.4 防横向越权:最容易被漏掉的一层
接口越权的经典代码:
// ❌ 漏洞:只校验了"登录",没校验"是不是自己的订单"
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id) {
return orderRepo.findById(id); // 任何登录用户传别人的 id 都能查
}
// ✅ 修复:数据级校验
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id, @CurrentUser User user) {
Order order = orderRepo.findById(id);
if (!order.getUserId().equals(user.getId())) {
throw new AccessDeniedException("not your order");
}
return order;
}
根治方案:Repository 层强制注入 userId 条件——业务代码想漏都漏不了。
public interface OrderRepo {
// Repository 里的方法必须传 userId
Order findByIdAndUserId(Long id, Long userId);
}
# 十、综合案例串讲:把 7 问全部回扣
# 10.1 逐条回答一开始的 7 问
| # | 疑问 | 答案(章节) |
|---|---|---|
| ① | 认证与授权的区别? | §3:AuthN 你是谁 / AuthZ 你能干什么,必须分离 |
| ② | Session vs JWT 怎么选? | §4:单体后台 Session,多端产品 JWT 双令牌 |
| ③ | JWT 能不能主动作废? | §4.4:黑名单 / 版本号 / 换密钥,各有代价 |
| ④ | 双令牌解决什么? | §5:AT 短、RT 长 + rotation,泄露损失降 3000 倍 |
| ⑤ | OAuth2 四种模式怎么选? | §6:只用 authorization_code + PKCE 和 client_credentials |
| ⑥ | RBAC 怎么不爆炸? | §8:分层 RBAC + 数据级过滤,超大规模上 ABAC |
| ⑦ | 网关鉴权后业务服务干嘛? | §9:分层鉴权,业务层做数据级校验防横向越权 |
# 10.2 事故重演:如果当时用了本文方案
回到开头的 48 万 Token 泄露:
若当时用了完整方案:
- JWT 有效期 = 15 分钟(不是 30 天)→ 泄露 15 分钟后自然失效。
- RT 存在服务端 Redis + 版本号机制 → 一个命令
SET tokenVersion:userId 2让所有历史 RT 失效,重登时刷 access。 - JWT 里不塞权限,只塞 userId → 泄露的 Token 不包含用户画像。
- 密钥走 KMS → 想换密钥不用发新版本。
- 业务服务做数据级校验 → 就算 Token 被用,也拿不到跨用户数据。
结果:事故止损时间从 3 小时缩到 5 分钟,数据泄露从 20~40GB 降到几 KB,用户无感知。
# 10.3 一个用户的"登录一生"完整时序
── T0: 首次登录 ─────────────────────────────
User → LoginPage → 输入手机号+密码
↓
AuthServer 验证 → 生成
・access_token AT_1 (JWT, sub=U123, ver=1, exp=T0+15m)
・refresh_token RT_1 (opaque, 存 Redis, exp=T0+30d)
↓
Set-Cookie: refresh_token=RT_1; HttpOnly; Secure; SameSite=Strict
返回 AT_1 给前端(放内存或短期 localStorage)
── T0 ~ T0+15m: 正常调用 ────────────────────
每次请求 Authorization: Bearer AT_1
Gateway 校验签名 + 过期 → 注入 X-User-Id → 转发业务
── T0+15m: AT 过期 ─────────────────────────
下一次请求返回 401
前端调用 /refresh (Cookie 自动带 RT_1)
AuthServer 校验 RT_1
・查 Redis 存在 ✓
・检测有无并发使用 ✓
・生成 AT_2 + RT_2
・Redis: DEL RT_1, SET RT_2
↓
Set-Cookie: refresh_token=RT_2
返回 AT_2 (Rotation 完成)
── T0+3h: 用户在别的设备登录 ─────────────────
新设备走一遍 T0 流程 → 拿到 AT_A / RT_A
原设备 RT_1 依然有效(多设备共存,通过 Redis 独立记录)
── T0+2d: 用户点"退出" ──────────────────────
Client → /logout (带当前 RT)
AuthServer: Redis DEL RT → 15 分钟内所有 AT 自然失效
可选加强: SET tokenVersion:U123 = 2, 让所有历史 JWT 立即失效
── T0+15d: 事故! Token 疑似泄露 ──────────────
运维: SET tokenVersion:U123 = 3
所有该用户历史 JWT 校验时发现 ver=1 ≠ 3 → 拒绝
用户下次访问被引导重新登录
── T0+30d: RT 自然过期 ──────────────────────
Redis TTL 到 → RT_x 自动删除
用户下次访问 401 → 引导重新登录
# 10.4 四条设计哲学
哲学一:认证与授权是两件事。 认证回答"你是谁",授权回答"你能干什么"。混在一起做的系统必定难维护。Token 里只放身份,不放权限。
哲学二:无状态不是免费的午餐。 JWT 的"扩展性好"是用"作废难"换来的。生产环境必须用双令牌 + 版本号/黑名单——纯无状态 JWT 只能玩玩 demo。
哲学三:多层防御,不信任任何单点。 网关鉴权只是第一层。业务服务的数据级校验是防越权的最后一道墙。Repository 层强制注入 userId 是最优雅的解法。
哲学四:安全参数一定要"绕最小圈"泄露。 JWT 密钥、client_secret、数据库密码——永远走 KMS / Vault / K8s Secret,绝不能是代码里的常量、.env 文件、配置中心明文。事故的根源正是这一条。
# 10.5 认证授权速查表
| 决策点 | 首选 | 备选 | 禁忌 |
|---|---|---|---|
| Session vs JWT | 多端产品 JWT | 单体后台 Session | JWT 30 天有效期 |
| 令牌机制 | AT 15min + RT 30d + rotation | 单 Token(简单场景) | 单 Token 30 天 |
| OAuth 模式 | authorization_code + PKCE | client_credentials(M2M) | password / implicit |
| SSO | Keycloak / Auth0 / OIDC-SSO | 自研 CAS | Session Cookie 跨子域 |
| 权限模型 | 分层 RBAC + 数据级过滤 | RBAC + ABAC (OPA) | 扁平 RBAC + 几百角色 |
| 微服务鉴权 | 分层:网关+业务+数据 | mTLS + Service Mesh | 只在网关做 |
| 密钥管理 | KMS / Vault | K8s Secret | 代码常量 / .env |
| Refresh 存放 | HttpOnly Cookie / Keychain | 加密 localStorage | 明文 localStorage |
# 10.6 上线检查清单(20 项)
认证
- [ ] 密码加盐哈希(bcrypt / Argon2,不用 MD5/SHA1)
- [ ] 登录接口有防暴力破解(IP 频次 + 账号锁定)
- [ ] 手机号/邮箱验证码有效期 ≤ 5min
- [ ] 关键操作二次验证(改密码、支付)
令牌
- [ ] AT 有效期 ≤ 1 小时
- [ ] RT 存服务端,可主动作废
- [ ] RT 旋转 + 重复使用检测
- [ ] JWT 密钥走 KMS
- [ ] JWT payload 不含权限、敏感字段
授权
- [ ] AuthN 与 AuthZ 模块分离
- [ ] 权限校验做到接口 + 数据两级
- [ ] 越权测试(改 URL 参数试图访问他人数据)
- [ ] 内部 API 也需鉴权(不能"内网免鉴权")
OAuth / SSO
- [ ] redirect_uri 严格白名单
- [ ] state 参数防 CSRF
- [ ] PKCE 强制启用(移动/SPA)
- [ ] SLO 单点登出方案定妥
运维
- [ ] 登录/异常行为审计日志
- [ ] 泄露应急预案:一键强制全体重登
- [ ] 灰度演练:换密钥、作废批量 token
# 十一、写在最后
认证授权是"看不见的功课"——做得好时用户毫无察觉,做得差时一次事故就是全线塌方。48 万 Token 泄露的三小时里,暴露的不是运气问题,是架构没做多层兜底。
认证授权的三条底线:
- AuthN / AuthZ 分离——概念不清所有后续设计都变形。
- 双令牌 + 服务端可作废——JWT 的"无状态"只是宣传,生产必须有"状态"兜底。
- 多层防御 + 最小权限——网关不是终点,Repository 才是最后一道墙。
下次面对"登录设计一下"、"加个权限模块"这种需求时,希望你脑子里冒出的不是"抄个 JWT demo",而是——"AT 多久、RT 多久、密钥走哪里、越权怎么防、SSO 挂了怎么办"。这,就是认证授权系统的真正功力。