网关认证、授权与身份策略:从凭证到上游身份
一次合作方请求带着签名正确的 JWT 到达网关,却被上游拒绝为“调用者未知”。网关日志只写了 jwt valid,应用日志里则出现一个客户端自填的 X-User-Id。签名验证确实成功了,但 token 的受众没有核对,消费者没有绑定到稳定主体,网关也没有为上游重新建立可信身份;一条请求里同时混进了三种不同含义的“身份”。
更危险的现场看起来完全正常:外部授权服务短暂超时,网关为了可用性继续放行;随后路径重写把普通资源改到管理资源,而授权结论仍沿用重写前的对象。排查这类问题不能停在 401 或 403,必须还原凭证从哪里来、在哪一步被验证、授权针对哪个资源、哪一个消费者承担配额,以及上游最终信任了谁。
四种身份问题不能合并
认证回答“这个凭证能否证明某个主体”。API Key 通过受控秘密映射主体;JWT 还要验证签名、算法、iss、aud、有效时间和密钥状态;mTLS 用客户端证书及其信任链证明连接对端。认证成功只产生一个经过验证的主体,不自动授予任何资源权限。
授权回答“这个主体能否对这个资源执行这个动作”。它的输入至少包括主体、HTTP method、规范化后的资源、租户或项目、必要声明和环境上下文。RBAC、ABAC、声明匹配以及 ext_authz 都在做授权决策,结果应是允许、拒绝或依赖故障,而不是含糊的“认证失败”。
消费者身份是 API 管理对象,用来承载套餐、配额、分析、联系人和凭证生命周期。一个消费者可以有多个 API Key、证书或工作负载主体;同一位用户也可能通过不同应用消费者访问。把 sub、API Key 哈希或源 IP 直接当消费者主键,会让轮换、代理和多应用场景失去稳定归属。
上游身份是网关访问后端时使用的身份。它可能是网关服务账号、面向具体消费者的短期令牌、SPIFFE 类工作负载身份,或经签名的受控身份 Header。上游不得信任客户端原样传入的 X-User-Id、X-Roles 或 X-Consumer-Id;网关应先删除这些不可信字段,再从已验证上下文重建,必要时对网关到上游启用 mTLS。
客户端凭证
-> 输入规范化
-> 凭证认证
-> 稳定消费者映射
-> 资源授权
-> 容量与路由策略
-> 删除客户端身份头
-> 建立上游身份
-> 后端这条顺序是一条安全边界。若路由重选、Path 重写或身份 Header 变更发生在授权之后,授权服务看到的资源可能不再是实际上游资源。某些实现允许 filter 清除 route cache 并重新匹配;此时必须重新授权,或禁止授权后的路由输入发生变化。
API Key、JWT/OIDC 与 mTLS 各自证明什么
API Key 适合标识应用消费者,接入简单,也容易被复制。网关不应保存可还原明文;随机度足够的 Key 可以保存带服务端 pepper 的 HMAC 或由密钥管理系统托管的引用,不能只做一次无密钥散列后便声称安全。查询索引、比较值与展示用 key ID 要分开,比较采用恒定时间实现,日志最多保存 key ID 和消费者 ID。Key 没有天然的发行者、受众和权限语义,权限必须来自服务端绑定;不要把长期 Key 放进 URL、前端包、示例仓库或访问日志。轮换时先为同一消费者增加新 key ID,再迁移调用方、观察旧 key 使用量并撤销旧 key;若数据面缓存 Key 到消费者的映射,撤销完成的判据是所有节点都拒绝旧 Key,而不是控制面显示“已删除”。
JWT 适合离线验证签名后的声明,但“能验签”远远不够。JWT 最佳实践 RFC 8725 要求调用方固定允许算法,并把密钥绑定到发行者与算法;验证器不能服从 token 自己选择的算法。iss 必须精确命中受信发行者,aud 必须按授权服务器与 API 的合同包含当前资源,exp、nbf 与允许时钟偏差要一致,密钥的 kid 只能在该发行者绑定的受信 JWKS 中查找,不能跨发行者共用一个无归属的密钥池。不同用途的 JWT 还应使用不同 typ、受众、密钥或互斥声明规则,防止一种 token 被另一条验证链误收。
OpenID Connect Core 定义的 ID Token 表达终端用户在某个 OIDC 客户端的登录结果,其 aud 通常是 client ID;它不是可直接交给业务 API 的通用凭证。资源服务器只接受与授权服务器约定的 Access Token 类型、资源受众和声明合同,不能因为 ID Token 与 Access Token 都可能采用 JWT 序列化就互换。若 Access Token 是不透明字符串,网关应走受控 introspection;若是 JWT,仍要执行资源服务器自己的校验,不能复用前端登录客户端的校验结果。
OIDC 还引入发现文档和 JWKS 缓存。缓存缩短每次请求的外部依赖,却让密钥轮换存在传播窗口。合理轮换采用“先发布新公钥,再签发新 token,等待缓存与旧 token 窗口,最后撤下旧公钥”;紧急撤销则需要缩短缓存、主动刷新或增加在线撤销/授权判断。刷新失败时继续使用未过期缓存与直接拒绝所有 token 是两种不同故障策略,必须用目标实现验证。
mTLS 在 TLS 握手阶段验证客户端证书,适合服务到服务或受控合作方。验证不能只看“提供了证书”,还要检查受信 CA、用途、有效期、名称或 URI 身份、撤销策略和证书链。TLS 终止后,后续代理只能信任由受控边界写入的证书身份;客户端自带的转发证书 Header 必须在入口被清除。mTLS 证明持有私钥的一方,不自动表达它能访问哪条 API。
OAuth 客户端认证、入口 mTLS 与发送者约束 token 是三件不同的事。RFC 8705 的证书绑定 Access Token 要求资源服务器同时验证 token 和当前 TLS 客户端证书,并将 token 的 cnf.x5t#S256 与证书 SHA-256 指纹匹配;只在 token 端点验证过一次客户端证书,或只在入口完成普通 mTLS,都不能证明后来提交 token 的仍是同一个私钥持有者。若 TLS 在负载均衡器终止,证书身份向网关传递的信道、Header 清洗、代理认证和信任域必须作为同一条受控链设计。
外部授权把策略决策交给独立 HTTP/gRPC 服务。以 Envoy ext_authz 为例,网关应只发送授权所需字段,明确超时、最大请求体、可转发 Header 和响应 Header 允许列表,并把策略 revision 写入证据。授权服务返回拒绝通常映射为 403;依赖超时或不可达应单独计数,不能伪装成业务拒绝。具体产品的 filter 阶段仍要按其请求 filter 执行模型和版本验证,不能复制 Envoy 字段后假设语义等价。
用隔离实验观察顺序、缓存和故障模式
先用 Python 3 标准库在回环地址建立一个可拆解的请求链。客户端身份 Header 是否被覆盖、普通主体能否访问管理资源、授权依赖故障如何处置,以及授权后改写资源会不会绕过策略,都能从响应里的阶段、资源和策略 revision 直接判断。产品字段名和默认值并不通用,随后还要把同一组请求放到目标网关重放。
将代码保存到临时目录的 identity_lab.py 后运行;脚本不会读取项目文件,也不会监听非回环地址。
import json
import os
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
API_KEYS = {
"key-reader-v2": {"subject": "svc-reader", "consumer": "partner-a", "roles": ["read"]},
"key-admin-v2": {"subject": "svc-admin", "consumer": "platform", "roles": ["read", "admin"]},
}
def reply(handler, status, body):
data = json.dumps(body).encode()
handler.send_response(status)
handler.send_header("content-type", "application/json")
handler.send_header("content-length", str(len(data)))
handler.end_headers()
handler.wfile.write(data)
class Authz(BaseHTTPRequestHandler):
def do_POST(self):
size = int(self.headers.get("content-length", "0"))
context = json.loads(self.rfile.read(size))
allowed = context["path"] != "/admin" or "admin" in context["roles"]
reply(self, 200 if allowed else 403, {"allowed": allowed, "policy_revision": "authz-r3"})
def log_message(self, *_):
pass
class Upstream(BaseHTTPRequestHandler):
def do_GET(self):
reply(self, 200, {
"path": self.path,
"trusted_subject": self.headers.get("x-authenticated-subject"),
"trusted_consumer": self.headers.get("x-consumer-id"),
})
def log_message(self, *_):
pass
class Gateway(BaseHTTPRequestHandler):
def do_GET(self):
identity = API_KEYS.get(self.headers.get("x-api-key"))
if not identity:
return reply(self, 401, {"stage": "authentication", "detail": "invalid_api_key"})
# /public-to-admin models a rewrite that changes the protected resource.
upstream_path = "/admin" if self.path == "/public-to-admin" else self.path
authz_path = self.path if os.environ.get("UNSAFE_REROUTE") == "1" else upstream_path
context = json.dumps({"path": authz_path, **identity}).encode()
request = urllib.request.Request(
"http://127.0.0.1:18081/check", data=context,
headers={"content-type": "application/json"}, method="POST"
)
try:
with urllib.request.urlopen(request, timeout=0.2) as response:
decision = json.load(response)
except urllib.error.HTTPError as error:
return reply(self, 403, {"stage": "authorization", "detail": "denied", "status": error.code})
except Exception:
if os.environ.get("FAIL_OPEN") != "1":
return reply(self, 503, {"stage": "authorization", "detail": "dependency_unavailable"})
decision = {"allowed": True, "policy_revision": "bypassed"}
# Do not forward client-supplied identity headers. Rebuild them here.
upstream = urllib.request.Request("http://127.0.0.1:18082" + upstream_path, headers={
"x-authenticated-subject": identity["subject"],
"x-consumer-id": identity["consumer"],
})
with urllib.request.urlopen(upstream, timeout=0.5) as response:
body = json.load(response)
body["policy_revision"] = decision["policy_revision"]
reply(self, 200, body)
def log_message(self, *_):
pass
def serve(port, handler):
ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever()
if os.environ.get("AUTHZ_DISABLED") != "1":
threading.Thread(target=serve, args=(18081, Authz), daemon=True).start()
threading.Thread(target=serve, args=(18082, Upstream), daemon=True).start()
serve(18080, Gateway)在第一个终端启动 fail-close 模式:
python3 identity_lab.py另一个终端依次执行正反请求:
curl -sS -i http://127.0.0.1:18080/orders \
-H 'X-API-Key: key-reader-v2' \
-H 'X-Authenticated-Subject: forged-admin'
curl -sS -i http://127.0.0.1:18080/admin \
-H 'X-API-Key: key-reader-v2'
curl -sS -i http://127.0.0.1:18080/admin \
-H 'X-API-Key: wrong-key'
curl -sS -i http://127.0.0.1:18080/public-to-admin \
-H 'X-API-Key: key-reader-v2'第一条应返回 200,并显示 trusted_subject=svc-reader,证明客户端伪造值没有进入上游;第二条应在 authorization 阶段返回 403;第三条应在 authentication 阶段返回 401。第四条也应返回 403:网关先把有效资源解析为 /admin,再授权,reader 不能借重写入口越权。只有状态码还不够,证据还应包含 stage、消费者、授权时资源、实际上游资源、策略 revision 和配置 revision。
然后停止脚本,通过环境变量关闭授权依赖,分别观察 fail-close 与 fail-open。类 Unix shell 使用:
AUTHZ_DISABLED=1 python3 identity_lab.py
AUTHZ_DISABLED=1 FAIL_OPEN=1 python3 identity_lab.py每次启动前先停止上一进程。Windows PowerShell 使用:
$env:AUTHZ_DISABLED='1'; Remove-Item Env:FAIL_OPEN -ErrorAction Ignore; python identity_lab.py
$env:AUTHZ_DISABLED='1'; $env:FAIL_OPEN='1'; python identity_lab.py相同的 reader 管理请求在 fail-close 下应返回 503 dependency_unavailable,在 fail-open 下会到达上游并显示 policy_revision=bypassed。这正是需要显式接受的安全代价:fail-open 不是“高可用优化”,而是授权依赖故障期间扩大访问面。
最后以 UNSAFE_REROUTE=1 启动正常授权服务,再请求 /public-to-admin。reader 会按公开路径得到允许,随后却访问 /admin,响应中的 path=/admin 与 policy_revision=authz-r3 共同构成稳定反例。修复不是再加一条角色规则,而是让授权输入使用最终规范化资源,或在任何会改变授权输入的重路由后强制重新授权。完成反例后立即删除该环境变量,不能把危险模式带入后续测试。
结束时按 Ctrl+C 停止进程,确认 18080、18081、18082 不再监听,并删除临时脚本。若把实验改成真实 API Key、JWT 或证书,还要撤销测试消费者、删除密钥与证书私钥、清除 JWKS/授权缓存,并确认日志中没有完整凭证。
用产品数据面重放 JWT/OIDC 与 mTLS
先把最容易漏掉的 JWT 判断做成可复制矩阵。以下脚本使用本地 HMAC 密钥,目的只是让 issuer、audience、时间和 kid 的分支可见;生产 OIDC 通常应从与发行者固定绑定的 HTTPS JWKS 取得非对称公钥,不能发布对称密钥,也不能把这个实验密钥带出临时目录。
import base64
import hashlib
import hmac
import json
import time
ISSUER = "https://issuer.example.test"
AUDIENCE = "orders-api"
KEYS = {"old": b"lab-old-secret", "new": b"lab-new-secret"}
def b64(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def decode(part):
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))
def issue(kid, claims):
header = b64(json.dumps({"alg": "HS256", "kid": kid}, separators=(",", ":")).encode())
payload = b64(json.dumps(claims, separators=(",", ":")).encode())
signature = b64(hmac.new(KEYS[kid], f"{header}.{payload}".encode(), hashlib.sha256).digest())
return f"{header}.{payload}.{signature}"
def verify(token, trusted_kids):
header_part, payload_part, signature_part = token.split(".")
header = json.loads(decode(header_part))
claims = json.loads(decode(payload_part))
if header.get("alg") != "HS256":
return "reject:algorithm"
kid = header.get("kid")
if kid not in trusted_kids:
return "reject:unknown_kid"
expected = hmac.new(KEYS[kid], f"{header_part}.{payload_part}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, decode(signature_part)):
return "reject:signature"
if claims.get("iss") != ISSUER:
return "reject:issuer"
audiences = claims.get("aud", [])
audiences = [audiences] if isinstance(audiences, str) else audiences
if AUDIENCE not in audiences:
return "reject:audience"
now = int(time.time())
if claims.get("nbf", 0) > now or claims.get("exp", 0) <= now:
return "reject:time"
return "allow"
now = int(time.time())
base = {"iss": ISSUER, "aud": [AUDIENCE], "sub": "svc-reader", "nbf": now - 1, "exp": now + 60}
cases = {
"valid": issue("old", base),
"wrong_issuer": issue("old", {**base, "iss": "https://evil.example.test"}),
"wrong_audience": issue("old", {**base, "aud": ["admin-api"]}),
"expired": issue("old", {**base, "exp": now - 1}),
"unknown_kid": issue("new", base),
}
for name, token in cases.items():
print(name, verify(token, {"old"}))
print("rotation_overlap", verify(issue("new", base), {"old", "new"}))
print("old_removed", verify(issue("old", base), {"new"}))保存为临时目录中的 jwt_claim_lab.py 并运行 python3 jwt_claim_lab.py,Windows 可运行 python jwt_claim_lab.py。预期输出依次为 allow、reject:issuer、reject:audience、reject:time、reject:unknown_kid;轮换重叠期的新 key 为 allow,移除旧 key 后旧 token 为 reject:unknown_kid。这组输出证明每个分支可被单独触发,但不证明目标网关的 JWKS 拉取、缓存和刷新已经正确。
目标网关随后重放相同矩阵,并增加算法篡改、签名篡改、nbf 尚未生效、scope 不足以及 ID Token 冒充 Access Token。预期证据必须指出拒绝原因属于 claim、时间、算法还是密钥;日志可以保存 issuer、kid、主体摘要和失败分类,不能为了排障记录完整 token。
密钥轮换实验先让旧、新 kid 同时出现在发行者 JWKS,再开始签发新 token,观察所有数据面都取得新 key;等待旧 token 有效窗口与缓存策略满足退出条件后再撤下旧 key。需要保存每个数据面的配置/JWKS 版本、缓存年龄、刷新成功与失败计数、旧新 token 的结果。若某个节点仍接受旧 token,先查缓存与配置传播;若某个节点因新 kid 拒绝合法 token,先查刷新退避和负缓存,不能用重启全部节点掩盖轮换机制。
mTLS 正例使用受信 CA 签发、身份字段匹配且用途正确的客户端证书;反例依次使用无证书、未知 CA、过期证书和错误身份。TLS 握手失败通常没有 HTTP 状态码,因此证据来自客户端 TLS 输出、网关握手日志和按原因分组的计数。验证后端身份时再检查网关到上游的服务端证书、SNI/SAN 和客户端证书,不能拿入口 mTLS 成功代替上游 TLS 成功。
外部授权还要制造超时、连接拒绝、畸形响应和显式拒绝。显式拒绝属于策略结果,依赖故障属于可用性事件,两者使用不同指标。对低风险只读 API 可在审查后选择有限 fail-open;涉及写入、管理、资金或敏感数据的路径通常应 fail-close。任何例外都要限定 route、消费者、时长和旁路证据,不能做全局开关。
项目接入要交付身份合同
项目配置不应只提交一个“启用 JWT”开关。仓库里的身份合同至少固定受信发行者、资源受众、允许算法、主体映射、消费者映射、所需 scope/role、授权资源格式、身份 Header 允许列表、上游身份方式、缓存与超时,以及依赖故障策略。环境差异通过受控变量注入,真实密钥和证书不进入仓库。
发布时先在影子模式记录候选授权结论,但影子允许不代表生产允许。候选策略要用已批准主体、越权主体、错误租户、错误资源和依赖故障请求对比;确认没有异常放行后再小范围阻断。路由、Path 规范化、授权策略和上游身份变更必须视为同一次发布,因为任意一个变化都可能让原授权结论失效。
一次请求建议保留下列低敏字段:request ID、认证方法、凭证 key ID 或证书指纹摘要、主体 ID、消费者 ID、规范化资源、授权结果、策略 revision、fail-open 标记、上游身份和配置 revision。不得记录 API Key、Bearer token、私钥、完整客户端证书、Authorization Header 或不受控声明集合。
配置影响要在评审里说清:缩短 JWT/JWKS 缓存提高撤销速度,也提高身份服务请求与故障耦合;外部授权超时会占用网关并发;mTLS 增加握手 CPU、证书分发和轮换负担;按消费者注入上游 token 会增加签发吞吐和密钥管理成本;过多 claim 与 Header 会扩大请求尺寸和日志风险。
从第一条证据开始排障
看到 401,先确认请求是否到达目标 listener,再按认证方法检查凭证提取、API Key 状态、证书握手或 JWT 的算法、发行者、受众、时间和 kid。不要先改授权规则,因为主体尚未建立。看到 403,核对规范化 method/path、主体、消费者、租户、所需权限、策略 revision 和是否发生授权后重路由。
看到 503 或客户端超时,区分授权依赖不可达、网关自身过载和上游失败。查询 ext_authz 请求数、拒绝数、错误数、超时、fail-open 次数和延迟分布,再用同一个 request ID 对齐授权服务日志。只有授权服务收到了正确资源与主体,才继续检查策略数据。
“部分节点能用、部分节点拒绝”通常指向配置、JWKS、证书或缓存传播差异。逐节点比较数据面 revision、密钥集合版本、证书序列/指纹和缓存年龄;不要立即全量重启,否则会丢失定位传播问题的中间状态。
上游声称身份缺失或错乱时,抓取网关发出的受控 Header 名称而非值,确认客户端同名 Header 已删除,再检查网关到上游的 mTLS 或 token 签发。若经过多层代理,还要证明每一跳只信任前一跳的受控身份,并在跨信任域前重新认证。
回滚与团队治理
回滚身份策略要保持旧、新凭证的明确窗口。先恢复上一版路由与授权 revision,再确认数据面应用;轮换期间不要直接删新 key 或旧 CA,直到所有合法调用方回到可用路径。若发生越权,则安全优先:阻断受影响 route、撤销凭证、清空相关缓存、保存低敏审计证据,再恢复最小服务面。
团队至少分开管理路由发布、身份提供方、消费者生命周期、授权策略、证书/密钥和上游服务账号。紧急旁路必须双人审批、自动到期并持续产生 bypassed 证据。Dashboard、Admin API、JWKS 发布端、授权策略库、证书库和配置导出都是高权限资产,不能因其“只在内网”就共享管理员凭证。
上线门禁最终落在可重放证据上:正确主体能访问允许资源,越权主体被稳定拒绝,伪造身份 Header 不到达上游,旧新凭证按轮换窗口切换,授权依赖故障符合 route 级 fail-open/fail-close 决策,回滚能恢复上一 revision。做到这里,认证成功才不再是一条孤立日志,而是身份链每一跳都能被解释的结果。
