Day 24 Hard Security OAuth2/OIDC JWT vs Session Secrets

安全基础 — 你是谁、你能做什么、密钥放哪Security: AuthN vs AuthZ, OAuth2/OIDC, JWT vs Session, Secret Management

问题场景 + 需求约束

为一个多租户 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

认证发生在边界一次、授权发生在每个内部入口;密钥永不落地配置文件

关键技术点

1. AuthN vs AuthZ — 先分清「你是谁」和「你能做什么」

一句话 trade-off:认证可以集中做一次,授权必须在每个资源访问点做一次——混淆两者是 Web 头号漏洞的根源

原理认证(Authentication)回答「你是谁」,结果是一份可信身份凭证,登录时产生一次。授权(Authorization)回答「你能对这个资源做这个操作吗」,必须在每一次资源访问时判断。两者频率、位置、失败语义都不同:认证失败返回 401 Unauthorized(你没证明身份),授权失败返回 403 Forbidden(身份没问题但没权限)。最经典的漏洞 IDOR(Insecure Direct Object Reference)就是把认证当授权——「用户登录了」就直接信任他请求的 /orders/456,却没检查这个 order 是否属于他。OWASP 把这类「Broken Access Control」列为头号风险。

Trade-off:授权决策放哪
# 区分 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
现实案例:

2. OAuth 2.0 / OIDC — 不要自己存第三方密码,用委托授权

一句话 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/日志里。

Trade-off:选哪个授权流
# 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 用于确认"你是谁"
现实案例:

3. JWT vs Session — stateless 的代价是「撤销难」

一句话 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 无法"登出"
现实案例:

4. Secret 管理 — 密钥不是配置,是会过期、会泄露的活物

一句话 trade-off:用「引入 Vault/KMS 的运维与可用性复杂度」换「集中审计、自动轮换、泄露后可秒级失效」

原理:DB 密码、API key、签名私钥若散落在环境变量、配置文件、甚至 git 里,就是 secret sprawl——没人知道有多少份、谁能看、上次轮换是何时。成熟方案有三层进阶:①集中存储(Vault / 云 KMS)做单一可信源 + 审计;②动态短期凭证——应用启动时向 Vault 申请「只活 1 小时的临时 DB 账号」,过期自动销毁,泄露窗口极小;③workload identity——干脆不发静态密钥,用平台签发的实例身份(AWS IAM Role、SPIFFE)直接换取访问,根本没有长期 secret 可泄露。轮换从「全公司协调改密码」变成「后台自动滚动」。

Trade-off:
# 动态短期凭证: 不读静态密码,运行时申请临时账号
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 → 永久有效,泄露后必须全员改密+重启
现实案例:

扩展与优化(增长后怎么办)

常见陷阱 + 面试问题

1. JWT 放 localStorage 还是 cookie? localStorage 易被 XSS 脚本读走;httpOnly cookie 防 XSS 但要防 CSRF(SameSite + CSRF token)。没有银弹,要按攻击面权衡——面试常考你知不知道两者的失败模式。
2. 纯 stateless JWT 怎么实现「登出」? 严格说做不到即时登出。要么维护 jti 黑名单(又变回有状态),要么 access token TTL 设到几分钟、靠 refresh token 撤销兜底。承认这个 trade-off 比假装能撤销更专业。
3. 401 还是 403? 没/坏凭证 → 401(去重新认证);凭证有效但无权限 → 403。有些场景为防资源枚举,对越权资源故意返 404 隐藏存在性——这是有意的安全选择,不是漏检。
4. JWT 验签忘了锁 algorithm:不显式指定 algorithms,库会信任 token 自带的 alg——攻击者填 none 或把 RS256 改 HS256 即可伪造。务必锁死算法并校验 aud/iss
5. 把 secret 提交进 git:哪怕事后删除,历史里仍在,且可能已被爬取。一旦提交即视为泄露,必须立即轮换——这也是为什么动态短期凭证比静态密钥安全得多。

深入资源

深入思考(点击展开答案)

1. 你的 access token TTL 设为 15 分钟。一个被盗 token 最长能用多久?把 TTL 砍到 1 分钟能消除风险吗?代价是什么?

最长 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 链。这才是兼顾性能与失血控制的工业做法。

2. 为什么说 OAuth 2.0「不是认证协议」,用 access token 来判断「用户是谁」会出什么问题?

access token 表达的是授权:「持有者被允许访问某资源」,它不保证「持有者就是某用户本人」。经典漏洞是 confused deputy / token substitution:应用 A 拿到一个对它有效的 access token,就以为对应的用户已在我这里登录——但这个 token 可能是为别的应用、别的受众签发的,攻击者把自己的 token 塞进来即可冒充。

OIDC 正是为补这个洞而生:它额外签发 id_token(带 aud=你的 client_id、isssubnonce),明确告诉你「这个身份是签发给你的应用的」。所以「登录」要用 id_token 并校验 aud/nonce,而不是拿 access token 当身份凭证。

3. 一名前端开发说「我把 JWT 放 localStorage,再加个 CSRF 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、输出转义)。把无关的防御手段叠上去只是「安全剧场」。

4. 集中式 PDP(如中心授权服务)每请求多一跳,在 5000 QPS 热路径上怎么不让它成为延迟与可用性瓶颈?
  • 策略下沉 + 本地评估:不是每请求都问中心 PDP,而是把策略推送到每个服务旁的 sidecar(OPA 模式),本地用内存数据评估,决策延迟 ~微秒级。中心只负责分发策略与收集审计。
  • 数据 vs 策略分离:策略变动慢可缓存;权限数据(谁属于哪个组)变动快,可用 bundle 增量同步或带短 TTL 缓存,容忍秒级最终一致。
  • 决策结果缓存:相同 (user, action, resource) 短时缓存,但要权衡——权限撤销的生效延迟 = 缓存 TTL。
  • 失败模式明确:PDP 不可达时是 fail-open(放行,可用优先)还是 fail-close(拒绝,安全优先)?安全场景几乎都该 fail-close,但核心读路径可能要分级。

本质和 Day 2 缓存、Day 23 可靠性同构:把强一致的中心决策,变成「本地缓存 + 异步同步」来摊薄热路径开销。

5. 你的签名私钥泄露了。stateless JWT 体系下,攻击者能伪造任意用户的 token。止血和恢复的完整步骤是什么?这暴露了 stateless 的什么根本弱点?

止血与恢复:①立即轮换签名密钥对,用新私钥签、新公钥(JWKS)发布;②但旧公钥若还在 JWKS/各服务缓存里,旧伪造 token 仍被接受——必须从 JWKS 移除旧 kid 并强制各验证方刷新缓存;③所有用旧 kid 签的 token 立即失效(按 kid 拒绝),等于强制全员重新登录;④审计期间签发的 token,排查是否已有伪造滥用。

根本弱点:stateless 的安全性完全押在签名密钥的保密上——密钥是单点。一旦泄露,影响面是「自上次轮换以来的全部用户」,且因为不查存储,系统无法区分「真 token」和「用泄露密钥伪造的 token」。这正是要密钥定期轮换 + 多 kid 并存 + 私钥存 HSM/KMS 永不出库的原因:把单点变成可快速替换、可隔离的部件。对照 Session 体系,泄露 session store 也很糟,但删库即可全体失效,不存在「离线伪造」问题。