0%

网关层 Per-Client 鉴权隔离:一个 IAM 平台保护多个业务应用

🌐 语言: 中文版 | 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

关键设计点:

  1. 路由配置声明 Client ID:每条路由通过网关配置声明自己绑定的 OAuth2 Client。
  2. Lua 函数注入 Header:请求进入路由后,先执行 serverless-pre-function 写入 X-Expected-Client-Id Header。
  3. forward-auth 携带到鉴权服务request_headers 配置确保这个 Header 被转发。
  4. 鉴权服务比对绑定关系:从 JWT 中提取 client_id 声明,与 X-Expected-Client-Id 比对。

3. 实现:APISIX 路由配置

3.1 路由配置结构

每个业务应用对应一段独立的网关路由配置,声明路由、上游和认证方式。以应用 A 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"routes": [
{
"id": "app-a-api-route",
"uri": "/api/app-a/*",
"hosts": ["apps.example.com", "console.example.com"],
"priority": 91,
"upstream_id": "app-a-upstream",
"plugin_config_id": "protected-api-auth",
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
"return function() ngx.req.set_header(\"X-Expected-Client-Id\", \"app-a-web\") end"
]
}
}
}
]
}

执行顺序:

  1. serverless-pre-functionaccess 阶段):设置 X-Expected-Client-Id Header
  2. forward-auth 插件(引用 protected-api-auth 配置):将 Header 连同 Authorization 发给鉴权服务
  3. 鉴权通过后,proxy-rewrite 转发到后端

3.2 全局 forward-auth 配置

两个可复用的 plugin_config,定义在全局网关配置中:

1
2
3
4
5
6
7
8
9
10
11
12
{
"id": "protected-api-auth",
"plugins": {
"forward-auth": {
"uri": "http://127.0.0.1:9080/__internal/auth/gateway/verify?gateway_secret=${GATEWAY_AUTH_SECRET}",
"request_headers": ["Authorization", "X-Trace-Id", "X-Request-Id", "X-Expected-Client-Id"],
"upstream_headers": ["X-User-Id", "X-Sid", "X-User-Email", "X-User-Display-Name", "X-Client-Id", "X-Idp", "X-Idp-User-Id"],
"timeout": 5000,
"status_on_error": 503
}
}
}

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
2
3
4
5
6
7
8
{
"id": "auth-gateway-verify-internal",
"uri": "/__internal/auth/gateway/verify",
"hosts": ["127.0.0.1"],
"priority": 110,
"vars": [["arg_gateway_secret", "==", "${GATEWAY_AUTH_SECRET}"]],
"upstream_id": "auth-service-upstream"
}

三层保护:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"id": "account-profile-authenticated-route",
"uri": "/api/account/profile/*",
"priority": 95,
"plugin_config_id": "authenticated-api-auth",
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
"return function() ngx.req.set_header(\"X-Auth-Mode\", \"authenticated\") end"
]
}
}
}

两种模式最终都路由到鉴权服务的同一个内部鉴权端点,通过 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

用户级授权配置记录单个用户对特定应用的授权。每条记录有 ALLOWDENY 两种效果。如果存在用户级记录,以该记录为准。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"routes": [
{
"id": "app-x-api-route",
"uri": "/api/app-x/*",
"hosts": ["apps.example.com"],
"priority": 91,
"upstream_id": "app-x-upstream",
"plugin_config_id": "protected-api-auth",
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
"return function() ngx.req.set_header(\"X-Expected-Client-Id\", \"app-x-web\") end"
]
}
}
}
]
}

步骤 3:配置 Entitlement

通过管理接口为用户或组织分配新应用的访问权限。

全程无需修改鉴权服务代码。网关和鉴权服务是通用的基础设施,业务应用只需声明式配置。


8. 设计小结

这套 Per-Client 鉴权隔离机制的核心思路:

  1. 声明式绑定:路由配置声明”我期望的 Client ID”,而非在鉴权服务中硬编码映射关系。新增应用不需要改鉴权服务代码。
  2. 两级校验:网关层做 Client 绑定(Token 属于谁),鉴权服务做准入策略(这个用户能不能用这个应用)。职责分离,各不侵入。
  3. 可分级:通过 access_level 实现不同严格程度的准入,避免所有应用都走最重的策略链。
  4. 模式可选:Protected 模式保护业务 API,Authenticated 模式放行通用接口,通过路由优先级共存于同一网关。

关键权衡:网关层的 Lua 注入和 forward-auth 子请求增加了每次请求的延迟(约 2-5ms 的内部回环调用),换来了集中管控和跨应用隔离的能力。在内部企业网络中,这个代价是合理的。