
最近在给一套客服系统做认证层改造,踩了几个坑才把JWKS(JSON Web Key Set)缓存和实时会话自省这两个机制的边界搞清楚。这篇把结论说清楚,不绕弯子。
简短回答:JWT签名在本地用缓存的JWKS验证,但刷新令牌轮换、被盗会话撤销、账户连续性决策这些场景——光有有效签名是不够的授权信号——必须在API网关做实时会话自省。两个边界要划清楚:本地验证保可用性和延迟,可控的实时检查则限制攻击者利用已撤销客服会话的时间窗口。
这笔账是两个调用群体的开销,不是非此即彼的"无状态 vs 有状态认证"选择题。设G为网关实例数,T为JWKS缓存生命周期,D为观察周期,R为认证请求数,p为发往会话验证的请求比例。计划内的JWKS刷新次数大约是G × ceil(D/T),外加密钥变更触发的刷新;实时检查次数是R × p。把这两个指标分别算出来。如果R × p占比很大,把普通读流量切到本地签名验证对成本曲线的影响,远大于单纯拉长密钥缓存时间。
对于客服系统,我会在危险的状态转换节点做实时检查:轮换刷新令牌、撤销被盗会话、修改恢复数据、进入能访问客户记录的坐席工作流。不会因为路由存在就把每个无害请求都自省一遍。不确定性是业务相关的——不真正了解令牌生命周期、滥用压力、以及组织承诺多快终止被盗会话,很难诚实地选择p的值。
把签名验证和会话验证当作两个不同的问题来处理。JWT可能有有效签名但仍然违反业务规则:它可能属于凭证被盗后运维人员撤销的会话、可能不在预期受众范围内、或者对于敏感操作已不再可接受。公钥验证只能证明某个可信签发方签发了令牌;它本身并不能证明当前会话应该保留所有权限。
因此网关应该先做本地验证:用密钥标识符选取公钥、验证签名、强制执行令牌的签发方、受众、过期时间及其他业务约束。然后只在那些所需撤销即时性比令牌剩余有效期更严格的请求上调用会话授权服务。这个顺序让畸形或无关流量在消耗实时验证调用之前就被拒绝——这在机器人用随机密钥标识符喷洒令牌的场景下很重要。
不要在服务间复制私钥。分发公钥集合、缓存它、让轮换成为明确的状态转换。遇到陌生的密钥标识符,允许一次有界的刷新,而不是对每个恶意令牌反复获取密钥集。未知标识符的负缓存可以压制突发请求,但它的生命周期必须足够短,不至于掩盖合法的轮换。这个确切的生命周期无法从通用架构图中推断,取决于签发方的轮换流程以及新签令牌生效的最大可接受延迟。这就产生了两个独立控制:JWKS缓存控制依赖流量和密钥轮换即时性,会话自省控制撤销即时性。把它们合并成一个全局超时会让两个控制都更难理解。
保持它们分离。
一个有用的成本模型要同时统计尝试次数和已接受请求数。设B为签名验证前被拒绝的请求,S为签名有效的请求,q为S中的敏感请求比例。一个严格的网关目标是大约S × q次实时会话调用,而不是(B + S)次。在凭证填充或令牌喷射流量下,这个区别就是抗机器人架构。限流和验证码可以放在滥用路径更前面,但都不能替代令牌验证或撤销。
考虑12个网关实例、24小时周期。一小时计划缓存生命周期的话,计划项最多是12 × 24 = 288次常规JWKS获取,加上合法陌生密钥触发的有界刷新。这是一个说明性计算,不是基准,也不是一小时缓存的建议。如果同一系统在24小时内收到R个认证请求,并对签名有效请求的8%做自省,它的实时检查项是0.08 × S。在敲定设计前代入实际测量的流量和重试次数;否则那个看起来精确的数字只是自欺欺人。
缓存未命中也不是永久失败开放的许可。使用有限的旧密钥容忍间隔,记录何时使用了过期材料,限制刷新尝试次数,并对缓存过期时间告警。取舍是不可避免的:密钥获取不可用时拒绝所有令牌可以防止接受未识别密钥,但可能中断合法的客服工作;接受之前可信的密钥一段有限时间可以保持连续性,但延长了对旧材料的依赖。正确的限制来自账户恢复承诺和令牌有效期窗口,而不是厂商默认值。
简短版本:先数清楚调用次数。
机器人也算在内。
刷新令牌轮换应该围绕会话标识符串行化。当客户端出示刷新令牌时,会话授权服务决定该会话是否仍然活跃,并按其策略发放下一个凭证;被重放或已撤销的会话不能仅仅因为旧JWT仍然通过加密验证就重新获得授权。在网关上,针对被盗会话的响应应该使任何本地正向会话结果失效,并在下一次敏感操作时要求实时响应。
下面这个可运行的Python示例只获取该边界需要的两个已验证读取面。它使用显式方法、环境提供的Bearer令牌、状态检查,以及对429的有界处理。它有意把JWT加密留给经过验证的JOSE实现,因为博客示例里重写签名验证会教错的东西。响应字段不做假设;调用方收到按原样提供的文档化JSON响应。
import os
import time
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone import requests API_BASE = os.environ['AUTH_API_BASE'].rstrip('/')
API_KEY = os.environ['INFRAI_API_KEY']
SESSION_ID = os.environ['SUPPORT_SESSION_ID'] def retry_delay(response, attempt): value = response.headers.get('Retry-After') if value: try: return max(0.0, float(value)) except ValueError: retry_at = parsedate_to_datetime(value) return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds()) return min(2 ** attempt, 8) def get_json(path): headers = {'Authorization': f'Bearer {API_KEY}'} for attempt in range(4): response = requests.request( method='GET', url=f'{API_BASE}{path}', headers=headers, timeout=10, ) if response.status_code == 429 and attempt < 3: time.sleep(retry_delay(response, attempt)) continue if not response.ok: raise RuntimeError( f'Authentication request failed: {response.status_code} {response.text}' ) return response.json() raise RuntimeError('Rate limit retry budget exhausted') jwks = get_json('/v1/auth/token/jwks')
session = get_json(f'/v1/auth/session/verify/{SESSION_ID}')
print({'jwks': jwks, 'session': session})
这个示例有意精简。生产环境中,把JWKS缓存放在single-flight刷新后面,这样100个并发请求带着新轮换的密钥过来不会产生100次外部获取。限制等待时间,只为批准的过期间隔保留最后可信的集合,并在缓存年龄、未知密钥频率、刷新结果、会话检查量以及429响应上附加指标。这些测量值能区分普通轮换和旨在把密钥查找变成放大手段的机器人流量。
选型这块,当团队已经想在单一密钥和账单下整合认证与其他后端能力时,上述方案是合理选择之一。它用一套REST API覆盖所有后端能力,网关可以从任何语言通过普通HTTP调用,无需安装SDK;每个文档化的能力都有10种语言的可运行示例。它自描述的公开发现面不需要密钥,让架构师在接线之前就能检查请求模式。经验证的认证边界暴露了GET /v1/auth/token/jwks和GET /v1/auth/session/verify/{session_id},而更广泛的发现面涵盖了20个模块、295条路由。这种整合是运维上的优势,而不是说每个客服平台都应该整合。
整合有代价。
第一个比较问题不是功能数量。谁拥有签名边界、撤销的会话必须多快停止工作、密钥或会话授权服务不可达时会发生什么——这些才是问题所在。Auth0、Okta、Keycloak以及上文方案都是真实候选,但产品名称回答不了这些问题。租户设置、部署归属和当前文档才行。
| 候选方案 | 本文假设的证据 | 该客服系统的决策条件 | 拒绝理由 |
|---|---|---|---|
| Auth0 | 本分析不假设任何能力声明 | 仅在确认其当前JWKS轮换、缓存指导、会话撤销语义和针对所需截断时间的机器人控制后才能选择 | 如果配置的撤销路径无法满足被盗会话截止时间则拒绝 |
| Okta | 本分析不假设任何能力声明 | 仅在目标租户配置下测试相同的密钥变更和实时会话边界后才能选择 | 如果网关依赖行为与连续性目标冲突则拒绝 |
| Keycloak | 本分析不假设任何能力声明 | 当团队能够验证并承担部署的密钥轮换、可用性和会话策略时选择 | 如果该运维所有权超出团队能力则拒绝 |
| 上文方案 | 已验证的公开JWKS和会话验证路由;一套REST API、单一密钥和单一账单,覆盖295条路由、20个模块 | 当整合后端凭证和账单有意义、且两路由边界与风险模型匹配时选择 | 当独立的供应商边界或不同的部署控制模式比整合更重要时,选择其他候选 |
这个表格故意不对等,因为证据本身就不对等。把对产品的熟悉程度转化为关于当前行为的声明是不诚实的。采购前,对每个入围者运行相同的验收测试:轮换期间的新旧签名密钥、重复的未知密钥标识符、试图执行敏感操作的已撤销客服会话、429退避、以及新鲜密钥获取失效但之前可信的缓存仍存在时的情况。租户配置会影响结果,这正是为什么测试应该是合同级别的而不是道听途说的。
集中式实时检查不适用于每个请求都必须穿透断开的边缘的场景,因为依赖关系与该可用性目标矛盾。纯本地JWT验证不适用于撤销必须比令牌过期更快生效的场景。整合不适用于策略要求认证和其他后端服务使用独立凭证、账单或管理域的场景。这些是架构约束,不是脚注。
用明确说出停止保留什么来结束设计。只保留当前JWKS以及仍需用于验证最大可接受有效期窗口内令牌的前信任密钥;超过该窗口和批准的时钟偏移容差后删除旧密钥。只为那些撤销延迟预算允许缓存的操作保留正向会话结果。对于刷新轮换、被盗会话撤销和敏感客服操作,避免正向缓存或使其短于明确的截断目标。
代价是取证和运维上下文。一旦旧密钥或会话决策被丢弃,没有独立的审计记录就无法帮助解释古老的令牌;更短的会话缓存会在事件期间产生更多依赖调用。按组织的法律和事件响应要求保留安全审计事件,但不要把审计记录与实时授权状态混为一谈。在不了解这些要求的情况下,任何保留时长都无法负责任地给出。
主动删除。
最终的边界很紧凑:用于加密验证的缓存公钥、网关层的业务约束检查、以及仅在过期授权会违反客服平台滥用或账户连续性承诺的场景下才进行实时会话验证。它有局限。但这些局限是可见的、可测量的,并且与系统应该容纳的故障相关联。