🌐 语言: 中文版 | English Version
场景前提:系统已经有一个基于 OAuth2/OIDC 的统一 IAM 平台,API 网关(APISIX)通过 forward-auth 插件集中鉴权。网关路由和浏览器会话的前置设计,可参考 微服务网关路由设计 和 HMAC Cookie + Redis SID 双层会话。
TL;DR
这篇讨论的是统一 IAM 保护多个业务应用时,如何避免一个应用签发的 Token 被拿去访问另一个应用。核心做法是在 APISIX 路由上声明预期 OAuth2 Client,通过 X-Expected-Client-Id 传给 forward-auth,由鉴权服务比对 JWT 中的 client_id。
这层机制只解决”Token 属于哪个客户端”的问题,不替代用户准入、组织授权、会话撤销和业务权限。示例中的应用名、Client ID、路由前缀均为中性占位,不对应任何实际部署。
1. 问题:共享 IAM 下的 Token 越权风险
当多个内部业务应用共用一个 IAM 平台时,最直觉的做法是:用户登录后拿到一个 JWT,所有应用都认这个 Token。问题在于——
应用 A 签发的 Token,能不能拿去访问应用 B 的接口?
OAuth2 协议中,Token 是签发给特定 Client 的。但网关层如果不校验”这个 Token 属于哪个 Client”,任何持有有效 Token 的请求都能穿透到任意后端服务。这在企业内部看似无害(都是自家员工),但会引发实际问题:
- 权限模型被绕过:应用 B 有独立的准入策略(如只允许特定部门使用),但网关只检查”Token 是否有效”,策略形同虚设。
- 跨应用数据泄露:前端 JS 可以从 localStorage 读取 Token,手动调用其他应用的 API。
- 审计链断裂:无法在网关层区分”这个请求是哪个应用的客户端发起的”。
核心矛盾:网关只做了”认证”(Authentication),没有做”客户端绑定”(Client Binding)。
2. 架构决策:网关层注入预期 Client ID
2.1 方案对比
| 方案 | 做法 | 问题 |
|---|---|---|
| 每个应用独立鉴权 | 后端服务各自校验 Token 的 client_id | 鉴权逻辑散布,策略变更需要逐个服务改代码 |
| 网关只校验 Token 有效性 | forward-auth 只检查 JWT 签名和过期 | 不解决跨应用 Token 滥用 |
| 网关注入预期 Client ID | 路由配置声明”本路由只接受哪个 Client 的 Token”,鉴权服务校验绑定关系 | 集中管控,新增应用不需要改鉴权服务代码 |
选择第三种方案。路由配置是声明式的,新增应用只需增加一段网关路由配置,无需改鉴权服务的代码。
2.2 机制概览
sequenceDiagram
participant Client as 浏览器/客户端
participant GW as APISIX 网关
participant Auth as IAM 鉴权服务
participant Backend as 业务后端
Client->>GW: 请求 /api/app-a/tasks (带 Bearer Token)
Note over GW: 1. 路由匹配 → 应用 A 路由规则
Note over GW: 2. Lua 注入 X-Expected-Client-Id: app-a-web
GW->>Auth: forward-auth (Authorization + X-Expected-Client-Id)
Note over Auth: 3. 解码 JWT → 提取 client_id
Note over Auth: 4. 校验 JWT.client_id == X-Expected-Client-Id
alt Client ID 匹配 + 准入通过
Auth-->>GW: 200 OK + X-User-Id, X-Sid 等身份 Header
GW->>Backend: 注入身份 Header,转发请求
Backend-->>Client: 返回业务数据
else Client ID 不匹配
Auth-->>GW: 401 Unauthorized
GW-->>Client: 401 Unauthorized
end
关键设计点:
- 路由配置声明 Client ID:每条路由通过网关配置声明自己绑定的 OAuth2 Client。
- Lua 函数注入 Header:请求进入路由后,先执行
serverless-pre-function写入X-Expected-Client-IdHeader。 - forward-auth 携带到鉴权服务:
request_headers配置确保这个 Header 被转发。 - 鉴权服务比对绑定关系:从 JWT 中提取
client_id声明,与X-Expected-Client-Id比对。
3. 实现:APISIX 路由配置
3.1 路由配置结构
每个业务应用对应一段独立的网关路由配置,声明路由、上游和认证方式。以应用 A 为例:
1 | { |
执行顺序:
serverless-pre-function(access阶段):设置X-Expected-Client-IdHeaderforward-auth插件(引用protected-api-auth配置):将 Header 连同Authorization发给鉴权服务- 鉴权通过后,
proxy-rewrite转发到后端
3.2 全局 forward-auth 配置
两个可复用的 plugin_config,定义在全局网关配置中:
1 | { |
request_headers 中的 X-Expected-Client-Id 是关键——只有声明了这个 Header,forward-auth 子请求才会携带它。如果路由没有通过 Lua 设置这个 Header,forward-auth 会发一个空的 X-Expected-Client-Id,鉴权服务会拒绝请求。
3.3 内部鉴权路由保护
forward-auth 调用的是 APISIX 自身的内部路由(127.0.0.1:9080),需要防止外部直接访问:
1 | { |
三层保护:
- hosts: 127.0.0.1:只接受本机回环地址的请求
- vars: gateway_secret:查询参数必须携带共享密钥
- priority: 110:高于所有业务路由,确保内部路由优先匹配
4. 两种认证模式
实际业务中不是所有接口都需要严格的 Client 绑定。比如 /me(获取当前用户信息),用户从任何应用登录后都应该能访问。因此设计了两种模式:
4.1 Protected 模式(默认)
用于业务 API,强制 Client 绑定。
- Lua 设置
X-Expected-Client-Id - forward-auth 调用
/__internal/auth/gateway/verify - 鉴权服务校验
JWT.client_id == X-Expected-Client-Id,不匹配则 401
4.2 Authenticated 模式
用于通用用户信息接口,只要求登录状态。
- Lua 设置
X-Auth-Mode: authenticated - forward-auth 调用
/__internal/auth/gateway/session(内部路由会额外设置X-Auth-Mode) - 鉴权服务跳过 Client ID 校验,只验证 Token 有效 + Session 活跃
1 | { |
两种模式最终都路由到鉴权服务的同一个内部鉴权端点,通过 X-Auth-Mode Header 区分行为:
| 模式 | X-Expected-Client-Id | X-Auth-Mode | 校验逻辑 |
|---|---|---|---|
| Protected | 必须存在,与 JWT client_id 匹配 | 不设置 | Client 绑定 + 完整准入策略 |
| Authenticated | 不需要 | authenticated |
仅验证登录状态 |
4.3 路由优先级设计
两种模式通过优先级区分,确保精确路由优先:
| 优先级 | 路由类型 | 示例 |
|---|---|---|
| 110 | 内部鉴权路由 | /__internal/auth/gateway/verify |
| 100 | 公开回调(Webhook) | /callback/app-a/*(无需鉴权) |
| 95-96 | Authenticated 模式 API | /api/account/profile/* |
| 91 | Protected 模式 API | /api/app-a/*, /api/app-b/* |
| 80-83 | 静态 UI、SPA | /ui/app-a/* |
| 69-70 | SPA 根路由和兜底 | /* |
/me 端点的优先级(95)高于业务 API(91),确保”查看自己的信息”走 Authenticated 模式而不是被业务 API 的 Protected 规则捕获。
5. 鉴权服务端:策略决策链
网关层完成了”这个 Token 是否属于这个 Client”的校验。鉴权服务端在此基础上执行完整的准入策略:
flowchart TD
A[收到 forward-auth 请求] --> B{X-Auth-Mode?}
B -->|authenticated| C[跳过 Client 校验
仅检查 Session 活跃]
B -->|entitled/默认| D{X-Expected-Client-Id 存在?}
D -->|缺失| E[500 配置错误]
D -->|存在| F{JWT.client_id
== Expected?}
F -->|不匹配| G[401 Client 不匹配]
F -->|匹配| H[检查 Client 状态]
H -->|已禁用| I[403 Client 已禁用]
H -->|活跃| J{Access Level?}
J -->|PUBLIC| K[直接放行]
J -->|AUTHENTICATED| L[检查用户状态
+ Token Version]
J -->|PRIVATE| M[完整准入策略]
M --> N{用户状态?}
N -->|禁用/离职| O[403]
N -->|正常| P{Token Version
匹配?}
P -->|不匹配| Q[401 版本过期
触发重新鉴权]
P -->|匹配| R{Security Stamp
匹配?}
R -->|不匹配| S[401 安全戳变更]
R -->|匹配| T{系统角色
超级管理员?}
T -->|是| U[放行]
T -->|否| V{应用管理员?}
V -->|是| U
V -->|否| W{用户级 Entitlement}
W -->|明确 DENY| X[403]
W -->|明确 ALLOW| U
W -->|无记录| Y{组织级 Entitlement}
Y -->|任一 DENY| X
Y -->|任一 ALLOW| U
Y -->|无记录| Z[403 默认拒绝]
5.1 三级访问控制
每个 OAuth2 Client 注册时设置 access_level,决定了准入的严格程度:
| Access Level | 值 | 含义 | 适用场景 |
|---|---|---|---|
| PRIVATE | 1 | 必须通过完整的 Entitlement 策略链 | 核心业务应用、后台管理应用 |
| AUTHENTICATED | 2 | 只要求用户登录且状态正常 | 通用工具类应用、内部文档站 |
| PUBLIC | 3 | Client 绑定校验通过即放行 | 低风险、仍需要识别调用 Client 的接口 |
这个分级让鉴权服务可以根据应用的安全需求做短路判断,而不是所有应用都走完整的策略链。
5.2 Entitlement 策略链(PRIVATE 级别)
对于 PRIVATE 级别的应用,准入决策按优先级检查:
1. 系统角色豁免
超级管理员和平台级应用管理员直接放行。这是运维逃逸通道,用于紧急情况。
2. 应用管理员
应用管理员配置记录每个 Client 的管理员列表。应用管理员对自己的应用拥有完全访问权。
3. 用户级 Entitlement
用户级授权配置记录单个用户对特定应用的授权。每条记录有 ALLOW 或 DENY 两种效果。如果存在用户级记录,以该记录为准。
4. 组织级 Entitlement
通过组织架构关系解析用户所属的所有组织单元(含祖先),然后检查组织级授权配置。规则:
- 任何一个组织单元的
DENY优先级最高,覆盖所有ALLOW - 没有任何
DENY的情况下,至少一个ALLOW即可放行 - 用户级记录优先于组织级记录
5. 默认拒绝
如果以上都没有匹配,拒绝访问。
5.3 版本一致性校验
Token Version 和 Security Stamp 是两个独立的撤回信号:
| 校验项 | 触发变更的场景 | 效果 |
|---|---|---|
| Token Version | 管理员修改用户角色/权限 | 版本号递增,所有活跃 Token 即刻失效 |
| Security Stamp | 用户修改密码、账户被禁用 | Stamp 变更,强制重新认证 |
这两个机制在 HMAC Cookie + Redis SID 双层会话 中有详细设计,此处不再展开。
6. 多应用的 Client ID 映射
在设计示例中,每个应用的 Client ID 格式可以灵活选择:
| 应用 | Client ID | 格式 | 路由前缀 |
|---|---|---|---|
| 管理控制台 | admin-console |
简明名称 | /api/admin/* |
| 工作台应用 | workbench-web |
简明名称 | /api/workbench/* |
| 应用 A | app-a-web |
应用 + 端类型 | /api/app-a/* |
| 应用 B | app-b-service |
应用 + 端类型 | /api/app-b/* |
| 应用 C | app-c-web-r7k3 |
应用 + 端类型 + 随机后缀 | /api/app-c/* |
几种格式各有考量:
- 简明名称(如
admin-console):可读性好,运维排查时一眼能识别来源应用。适合内部系统和管理后台。 - 应用 + 端类型(如
app-a-web):能区分同一应用的 Web、移动端、服务端 Client,便于审计。 - 随机后缀(如
app-c-web-r7k3):主要用于降低命名冲突和误猜枚举风险,不是安全边界。Token 防伪仍依赖 JWT 签名、issuer/audience/client_id 校验、client secret 或 PKCE 等机制。
Client ID 的值不影响隔离机制本身——网关和鉴权服务只做精确匹配,不关心格式。
7. 新增应用的接入流程
基于这套机制,新增一个业务应用只需三步:
步骤 1:注册 OAuth2 Client
通过管理 API 创建 Client,设置 access_level 和对应的客户端认证方式。系统生成或记录 Client ID。
步骤 2:添加网关路由配置
在网关配置仓库中新增一段应用路由配置,声明路由和 Lua 注入的 Client ID:
1 | { |
步骤 3:配置 Entitlement
通过管理接口为用户或组织分配新应用的访问权限。
全程无需修改鉴权服务代码。网关和鉴权服务是通用的基础设施,业务应用只需声明式配置。
8. 设计小结
这套 Per-Client 鉴权隔离机制的核心思路:
- 声明式绑定:路由配置声明”我期望的 Client ID”,而非在鉴权服务中硬编码映射关系。新增应用不需要改鉴权服务代码。
- 两级校验:网关层做 Client 绑定(Token 属于谁),鉴权服务做准入策略(这个用户能不能用这个应用)。职责分离,各不侵入。
- 可分级:通过
access_level实现不同严格程度的准入,避免所有应用都走最重的策略链。 - 模式可选:Protected 模式保护业务 API,Authenticated 模式放行通用接口,通过路由优先级共存于同一网关。
关键权衡:网关层的 Lua 注入和 forward-auth 子请求增加了每次请求的延迟(约 2-5ms 的内部回环调用),换来了集中管控和跨应用隔离的能力。在内部企业网络中,这个代价是合理的。