2.2 鉴权体系:API Key 与 OAuth
场景:鉴权补丁该拆了,选哪种方案
第 1.1 节讲过鉴权补丁摞补丁的惨状:老的固定 token、新的 Header token、还有几家 Basic Auth。路由模块统一后,鉴权是第二个必须整平的模块。团队的争论点在于:要不要一步到位上 OAuth 2.0,把令牌签发、刷新、撤销全套搬过来?
反对 OAuth 的理由很实际。我们的调用方都是服务器对服务器(ERP 对接、小程序后端这类业务系统,而不是终端用户登录),不存在最终用户在浏览器里点授权的场景,搬 OAuth 的授权码流程纯属杀鸡用牛刀。OAuth 的优势在于三方授权(用户在 A 应用授权 B 应用代为调用),我们一个内部 SaaS 网关没有这种需求。简单 API Key 加签名校验足够覆盖全部场景,复杂度低了三个量级。
方案:两套鉴权并列,按场景路由
最终方案是两套机制并存,每个接口在契约里声明用哪套,互不干扰。内部系统走 IP 白名单加内部 Key 免签直连,外部客户走 API Key 加签名(签名细节在下一节):
| 鉴权方式 | 适用调用方 | 优点 | 缺点 |
|---|---|---|---|
| 固定 token | 历史客户过渡 | 改造成本零 | 无防篡改能力 |
| API Key 加签名 | 付费/试用客户 | 防篡改可审计 | 客户对接成本中等 |
| OAuth 2.0 | 用户级三方授权 | 体验好流程标准 | 流程重,服务器直连无必要 |
| IP 白名单加内部 Key | 内部系统 | 延迟低 | IP 变更要同步 |
为什么不上 Basic Auth?因为它把密钥 Base64 明文放在请求里,防不了中间人与日志泄露,HTTPS 虽能兜底但一旦未来某天有接口开了 HTTP 就直接裸奔,所以直接排除。为什么不上 mTLS?双向证书认证安全性确实更高,但证书签发与轮换的运维成本对客户团队太高,我们只在内网服务间用 mTLS、对外仍用 Key 加签名。
配置:鉴权中间件骨架
鉴权中间件按固定顺序工作:取 Key、查契约、查状态、取级别。任何一步失败都返回 401,错误码细分到原因,方便客户对着文档自查。实现里特意把校验逻辑写成纯函数,输入输出明确,方便单元测试:
<?php
// 鉴权中间件核心逻辑(骨架)
public function handle($request, callable $next)
{
$appkey = $request->getHeaderLine('X-Api-Key');
if ($appkey === '') {
return $this->deny(40101, '缺少 X-Api-Key');
}
$conf = $this->contract->find($appkey);
if ($conf === null) {
return $this->deny(40102, 'appkey 不存在');
}
if ($conf['status'] !== 'active') {
return $this->deny(40103, 'appkey 已停用');
}
// 付费联动:欠费降级而非停服
if ($this->billing->isOverdue($conf['app_id'])) {
return $this->deny(40104, '账户欠费');
}
return $next($request);
}
注意第 40104 这条:欠费不是停掉 Key 而是降级——限流直接降到试用档,接口还能调只是变慢,客户充钱立刻恢复。这是和计费系统联动的关键设计,云 API 网关做不到这么贴业务,也印证了第 1.2 节的选型判断。
密钥生命周期管理
鉴权体系除了校验逻辑,还有一半的工作量在密钥管理上。我们把密钥的一生划成五个状态,状态机写在契约系统里,任何转换都有记录可查。这套机制看起来繁琐,但每一次客户密钥泄露事件的复盘都会指向其中某一环的缺失:
| 状态 | 含义 | 谁来触发 |
|---|---|---|
| pending | 已创建未激活 | 商务同学确认后 |
| active | 正常使用 | 自动 |
| rotating | 轮换中,新旧并行 | 密钥疑似泄露 |
| paused | 暂停(欠费/违规) | 计费或风控 |
| revoked | 永久吊销 | 安全事件 |
轮换态是我们最得意的设计:新旧 Key 并行七十二小时,客户来得及改代码切换,老 Key 仍然有效但不计入配额,七十二小时后老 Key 自动失效。在那之前每次泄露都要全员加班的原因,正是缺了这段缓冲。这套状态机的实现细节在 4.4 节审计部分还会出现。
关于 OAuth 的争论最后还有个尾声。两年后公司推出开放平台,允许第三方应用代用户查询数据,这是典型的用户级三方授权场景,OAuth 2.0 授权码流程这才正式登场,跑在独立的开放平台网关上,与主网关共用契约数据但逻辑完全隔离。这印证了那句老话:没有最好的方案,只有最合适的场景。当年不上 OAuth 不是它不好,是我们的场景不对,场景对了它自然就来了。技术选型的判断要随业务阶段更新,别让三年前的结论绑架今天的设计。
踩坑:鉴权顺序与降级出口
第一个坑是顺序。早期我们把缓存中间件放在了鉴权之前,某些缓存键设计不当导致鉴权结果被缓存命中,Key 已停用的客户还能继续调接口长达五分钟。血的教训:任何涉及安全的判断都不能走缓存快捷通道,中间件链顺序必须鉴权在前、缓存在后,并且缓存键只缓存业务数据不缓存鉴权结论。
第二个坑是 deny 的返回。第一版鉴权失败直接抛异常,被全局异常处理器转成了 500,客户看到的是服务端错误而不是自己没传 Key,工单量激增。修复方式是所有鉴权失败返回结构化 401 响应,带错误码与说明文档链接,工单立刻少了一半。
第三个坑是内部 Key 的滥用。有同事图调试方便,拿内部 Key 从公网调接口绕过签名校验,被安全扫描扫到,吓得我们当晚轮换全部内部 Key 并把内部 Key 的使用范围锁定在内网 IP 段,违反即拒绝。
第四个坑更值得警惕:鉴权拒绝逻辑的时序。某次给一家大客户开通新接口权限时,同事在缓存契约的刷新流程里加了跳过校验的调试逻辑,本打算临时用十分钟,结果忙别的事忘了回滚,两个小时里该客户的请求全部直接放行无需鉴权。事后我们加了两道保险:调试开关统一收口到一个带自动过期的环境变量,以及任何跳过鉴权的分支打点进监控大屏并红色标红。人会因为忙而出错,流程与监控则是帮人兜底的那双手。
第五个坑是密钥的 CDN 泄露。客户在文档里找到了我们泄露在帮助文章截图里的测试 Key,虽然测试环境无数据,但观感极差。此后所有对外文档截图统一用打码工具处理,发布前过一遍安全 checklist。安全建设里最大的漏洞往往不是代码,是人。
最后补一条技术细节上的心得。鉴权查询契约表最初的实现是循环遍历数组找 appkey,四十个调用方时无感,八千个调用方时每个请求多耗两毫秒,高峰期 Redis 与 PHP 的等待都开始抖动。改成 Redis Hash 存契约(key 为 appkey,field 为配置)之后,查询降为一毫秒以内,契约变更通过后台双写保同步。性能问题往往不是算法高级与否,而是数据结构配不配得上规模。
再分享一个鉴权设计的哲学问题:严格还是宽容。网关早期我们很严格,任何 Headers 不齐一律 401,结果周一起来自动化对脚本把换行符写错就被拒,工单堆积。后来定了折中规则:可容忍的格式问题(如 Key 大小写混写)自动归一化并放行,同时在响应头给一条警告;涉及安全判断的(签名错误、密钥停用)一票否决。宽容用在格式上,严格用在安全上,这条原则让客户体验与系统安全兼得。这也是为什么我们的 401 错误码细分到原因:既方便客户自查,也方便我们对高频鉴权失败做自动分析与预警。
小结
鉴权方案的选择原则:服务器对服务器选 API Key 加签名,用户级三方授权才上 OAuth,内部系统用 IP 白名单加内部 Key。中间件顺序与降级出口是两个最易翻车的细节——鉴权必须在缓存之前,失败必须返回结构化 401。所有与安全相关的判断,宁可慢也不要走捷径。密钥管理是鉴权的另一半工作量,五个状态加七十二小时轮换缓冲,是每一次事故复盘换来的。
给正在做选型的人一个简化判断法:如果调用方是你的客户的服务器,选 Key 加签名;如果调用方是你客户的用户,选 OAuth;如果调用方是你同事的服务器,选 IP 白名单。三句话覆盖九成场景,剩下的一成再单独评估。
再补一条鉴权限流边界的提醒:鉴权失败与限流拒绝一定要在响应里区分清楚。曾经为了省事统一返回 429,结果客户的重试逻辑把被拒绝的鉴权请求也重试了一遍,流量直接翻倍。后来规定 401 与 429 严格分家,并在响应体里给出原因码。安全与限流各说各话,才不会让客户端的自动逻辑越帮越忙。
密钥长度的底线也顺带一说:我们强制 appkey 与 secret 全部走程序的安全随机接口生成,禁止人工指定。曾经有客户图好记自己定 secret,强度不足一周就被撞库试出来,从此系统只认生成的密钥,好记与安全冲突时,永远选安全。
