为一个多租户 SaaS 设计认证授权系统:500 万注册用户、日活 100 万、登录峰值 5000 QPS。要同时服务 Web、移动 App、对外开放 API、第三方集成四类客户端;企业客户要求用自己的 IdP 做 SSO(OIDC/SAML);合规要求密钥可轮换、操作可审计、凭证泄露可即时止血。
安全设计有个反直觉的核心:它几乎全是 trade-off,而不是「越严越好」。把 token 做成永不撤销的 stateless JWT,性能爽但泄露后无法止血;把密钥写进环境变量,部署简单但轮换是噩梦。这一期讲四件分层的事——认证 vs 授权的边界、OAuth/OIDC 委托、JWT vs Session 的撤销代价、密钥的生命周期管理。它们对应安全的三个永恒问题:你是谁、你能做什么、秘密放哪。
所有外部请求先到 API 网关,网关做粗粒度的 token 校验(签名、过期、scope)——这是认证边界。认证本身委托给 IdP / 授权服务器(自建或 Auth0/Okta),它签发 access token + refresh token。请求进入内部后,各服务用 授权决策点(PDP)做细粒度权限判断(这个用户能不能改这个租户的这条数据)。所有服务的密钥/凭证不写在配置里,运行时从 Secret 管理(Vault / 云 KMS)动态获取。
graph LR
C["客户端
Web/移动/API"]
GW["API 网关
AuthN: 校验 token"]
IDP["授权服务器/IdP
签发 token"]
SVC["业务服务
AuthZ: PDP 决策"]
SS["Secret 管理
Vault/KMS"]
DB[("用户/权限存储")]
C -->|① 登录| IDP
IDP -->|② access+refresh| C
C -->|③ Bearer token| GW
GW -->|④ 校验通过| SVC
GW -.->|公钥/JWKS| IDP
SVC -->|⑤ 能否操作?| SVC
SVC -.->|动态凭证| SS
SVC --> DB
classDef ext fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
classDef gate fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef core fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef sec fill:#0e2030,stroke:#5eead4,color:#e8eef5
class C ext
class GW gate
class IDP,SVC core
class SS,DB sec
认证发生在边界一次、授权发生在每个内部入口;密钥永不落地配置文件
一句话 trade-off:认证可以集中做一次,授权必须在每个资源访问点做一次——混淆两者是 Web 头号漏洞的根源。
原理:认证(Authentication)回答「你是谁」,结果是一份可信身份凭证,登录时产生一次。授权(Authorization)回答「你能对这个资源做这个操作吗」,必须在每一次资源访问时判断。两者频率、位置、失败语义都不同:认证失败返回 401 Unauthorized(你没证明身份),授权失败返回 403 Forbidden(身份没问题但没权限)。最经典的漏洞 IDOR(Insecure Direct Object Reference)就是把认证当授权——「用户登录了」就直接信任他请求的 /orders/456,却没检查这个 order 是否属于他。OWASP 把这类「Broken Access Control」列为头号风险。
# 区分 401 与 403,并在每个资源访问点校验归属
def get_order(req, order_id):
user = authenticate(req) # 没身份 → 401
if user is None:
raise HTTP(401) # 让客户端去重新登录
order = db.get_order(order_id)
if order.tenant_id != user.tenant_id: # ⚠️ 授权:校验资源归属
raise HTTP(403) # 有身份但越权 → 403,别用 404 误导也别漏检
return order
一句话 trade-off:用「引入授权服务器与重定向复杂度」换「永不接触用户在别处的密码、可细粒度授权与撤销」。
原理:OAuth 2.0 是授权委托框架——让用户授权第三方应用代表他访问资源,而不交出密码,产出 access token(带 scope,代表「能做什么」)。但 OAuth 本身不是认证协议:access token 只说明「持有者被授权」,不可靠地说明「用户是谁」。OIDC(OpenID Connect)在 OAuth 之上加了身份层,多签发一个 id_token(JWT 格式,含用户身份声明),这才是「用 Google 登录」的正确底座。现代唯一推荐的 flow 是 Authorization Code + PKCE:先拿一次性 code,再用 code(加 PKCE 校验)换 token,token 永不经过浏览器 URL。早期的 Implicit flow(token 直接回传到 URL)已被官方废弃,因为 token 会泄露在 history/referer/日志里。
# Authorization Code + PKCE (客户端侧伪代码)
verifier = random_urlsafe(64) # 高熵随机串,保密
challenge = base64url(sha256(verifier)) # 派生,可公开
# ① 跳转授权服务器,带 challenge(不带 verifier)
redirect(f"{AUTH}/authorize?client_id={CID}&response_type=code"
f"&code_challenge={challenge}&code_challenge_method=S256&scope=openid")
# ② 用户登录授权后,授权服务器回调带 code
# ③ 用 code + 原始 verifier 换 token(攻击者截到 code 也无 verifier)
tok = POST(f"{AUTH}/token", code=code, code_verifier=verifier, client_id=CID)
# → { access_token, refresh_token, id_token } id_token 用于确认"你是谁"
一句话 trade-off:JWT 用「无法即时撤销」换「每个请求无需查存储即可验证」;Session 反之。
原理:Session 把状态留在服务端(Redis/DB),客户端只拿一个不透明的 session_id,每请求服务端查一次。JWT 把状态(用户 ID、scope、过期时间)签进自包含 token,服务端只验签名、不查存储——这就是 stateless,能在任意服务、任意区域独立验证。代价是:token 一旦签发,在过期前无法收回。被盗的 JWT 在有效期内畅通无阻;用户改了权限或被封号,旧 token 仍认旧权限。业界折中是短期 access token(JWT,5–15 分钟)+ 长期 refresh token(有状态,存服务端可撤销):撤销时让 refresh token 失效,access token 最多再活几分钟。
| Session(有状态) | JWT(无状态) | |
|---|---|---|
| 每请求开销 | 查 store 一次 | 本地验签,零查询 |
| 即时撤销 | ✅ 删 session 即失效 | ❌ 过期前无法收回 |
| 横向扩展 | 需共享 session store | ✅ 任意节点独立验证 |
| 权限变更生效 | 立即 | 下次刷新才生效 |
| 适用 | 单体/同机房、强撤销需求 | 微服务/多区域、短时 token |
# JWT 验证的三个致命陷阱
claims = jwt.decode(
token, key=PUBLIC_KEY,
algorithms=["RS256"], # ① 显式锁定算法! 否则攻击者改 alg:none 绕过验签,
# 或 RS256→HS256 用"公钥当 HMAC 密钥"伪造(算法混淆)
options={"require": ["exp", "iss", "aud"]})
assert claims["iss"] == TRUSTED_ISSUER # ② 必须校验签发者
assert claims["aud"] == MY_API # ③ 必须校验受众,否则别处签的 token 也能用
# ④ 撤销:维护 jti 黑名单或短 TTL,纯 stateless JWT 无法"登出"
alg:none 与 RS256→HS256 算法混淆攻击(原文),直接促成了 JWT 最佳实践规范。一句话 trade-off:用「引入 Vault/KMS 的运维与可用性复杂度」换「集中审计、自动轮换、泄露后可秒级失效」。
原理:DB 密码、API key、签名私钥若散落在环境变量、配置文件、甚至 git 里,就是 secret sprawl——没人知道有多少份、谁能看、上次轮换是何时。成熟方案有三层进阶:①集中存储(Vault / 云 KMS)做单一可信源 + 审计;②动态短期凭证——应用启动时向 Vault 申请「只活 1 小时的临时 DB 账号」,过期自动销毁,泄露窗口极小;③workload identity——干脆不发静态密钥,用平台签发的实例身份(AWS IAM Role、SPIFFE)直接换取访问,根本没有长期 secret 可泄露。轮换从「全公司协调改密码」变成「后台自动滚动」。
# 动态短期凭证: 不读静态密码,运行时申请临时账号
lease = vault.read("database/creds/app-readonly") # 申请一次性 DB 凭证
db = connect(user=lease.username, pw=lease.password) # ⚠️ TTL=1h,到期 Vault 自动回收
schedule_renew(lease, before_expiry="10m") # 续租或重新申请
# 对比:静态 DB_PASSWORD 写进 env → 永久有效,泄露后必须全员改密+重启
httpOnly + Secure cookie 防 XSS 偷取,配合 SameSite / CSRF token 防跨站;进一步用 token binding / DPoP 把 token 绑到客户端密钥,偷了也用不了。httpOnly cookie 防 XSS 但要防 CSRF(SameSite + CSRF token)。没有银弹,要按攻击面权衡——面试常考你知不知道两者的失败模式。
jti 黑名单(又变回有状态),要么 access token TTL 设到几分钟、靠 refresh token 撤销兜底。承认这个 trade-off 比假装能撤销更专业。
algorithms,库会信任 token 自带的 alg——攻击者填 none 或把 RS256 改 HS256 即可伪造。务必锁死算法并校验 aud/iss。
最长 15 分钟(纯 stateless JWT 无法提前收回)。砍到 1 分钟缩小了泄露窗口,但不能消除风险:①攻击者可在 1 分钟内造成破坏;②真正危险的是 refresh token——它长寿且能不断换新 access token,偷了 refresh token 等于长期通行证。
代价:access token 每 1 分钟就要刷一次,refresh 请求 QPS 暴涨 15 倍,而 refresh 通常要查有状态存储(验证 refresh token 是否被撤销),等于把「stateless 省查询」的好处抵消大半。
正解:短 access token + refresh token rotation——每次刷新发新 refresh token 并作废旧的,若检测到旧 refresh token 被重复使用(说明被盗用并行使用),立即吊销整条 token 链。这才是兼顾性能与失血控制的工业做法。
access token 表达的是授权:「持有者被允许访问某资源」,它不保证「持有者就是某用户本人」。经典漏洞是 confused deputy / token substitution:应用 A 拿到一个对它有效的 access token,就以为对应的用户已在我这里登录——但这个 token 可能是为别的应用、别的受众签发的,攻击者把自己的 token 塞进来即可冒充。
OIDC 正是为补这个洞而生:它额外签发 id_token(带 aud=你的 client_id、iss、sub、nonce),明确告诉你「这个身份是签发给你的应用的」。所以「登录」要用 id_token 并校验 aud/nonce,而不是拿 access token 当身份凭证。
搞反了攻击面。localStorage 的威胁是 XSS(任意注入脚本能 localStorage.getItem 读走 token),而 CSRF token 防的是 CSRF(跨站伪造请求,利用浏览器自动带 cookie)。localStorage 里的 token 不会被浏览器自动附带,本就不太受 CSRF 威胁;加 CSRF token 没解决它真正的 XSS 风险。
正确组合是二选一对症下药:要么 httpOnly cookie(防 XSS 读取)+ SameSite/CSRF token(防 CSRF);要么 token 存内存(非持久,刷新即失,缩小 XSS 窗口)。根治还是收敛 XSS 本身(CSP、输出转义)。把无关的防御手段叠上去只是「安全剧场」。
本质和 Day 2 缓存、Day 23 可靠性同构:把强一致的中心决策,变成「本地缓存 + 异步同步」来摊薄热路径开销。
止血与恢复:①立即轮换签名密钥对,用新私钥签、新公钥(JWKS)发布;②但旧公钥若还在 JWKS/各服务缓存里,旧伪造 token 仍被接受——必须从 JWKS 移除旧 kid 并强制各验证方刷新缓存;③所有用旧 kid 签的 token 立即失效(按 kid 拒绝),等于强制全员重新登录;④审计期间签发的 token,排查是否已有伪造滥用。
根本弱点:stateless 的安全性完全押在签名密钥的保密上——密钥是单点。一旦泄露,影响面是「自上次轮换以来的全部用户」,且因为不查存储,系统无法区分「真 token」和「用泄露密钥伪造的 token」。这正是要密钥定期轮换 + 多 kid 并存 + 私钥存 HSM/KMS 永不出库的原因:把单点变成可快速替换、可隔离的部件。对照 Session 体系,泄露 session store 也很糟,但删库即可全体失效,不存在「离线伪造」问题。