Files
cs-note/hhs/MS/02-服务治理/09-网关鉴权策略.md
T
2026-05-24 11:42:38 +08:00

334 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [microservice, api-gateway, auth, service-mesh, mTLS, jwt, rbac, zero-trust]
create time: 2026-04-30 15:30
update time: 2026-05-17 10:00
---
# 网关鉴权分层设计
## 概述
每个微服务团队都会面临同一个灵魂拷问:**鉴权该放在网关统一做,还是各服务自己管?**
放网关——简单粗暴但不够灵活;放各服务——灵活但容易失控。本文给出一个经过多团队实践验证的**三层鉴权架构**方案,兼顾统一安全基线与差异化业务需求。
## 问题的本质
### 一刀切的陷阱
> [!question] 引出思考
> 有人说:"鉴权放到网关统一做,避免每个服务重复写。"听起来很美好——但现实中有三个场景会让这个假设当场翻车:
>
> - 某个内部查询接口被高频调用,走一遍网关的 JWT 校验白白增加 3ms 延迟
> - 财务团队被审计要求 OAuth2 + MFA,运营后台只需要 API Key
> - 订单服务必须判断"用户 A 是否有权查看**这笔**订单",网关根本不知道数据归属关系
>
> **你怎么设计才能兼顾统一性和灵活性?**
核心矛盾在于:**网关做鉴权的优势是集中化和性能**,但代价是 **失去对差异化需求的表达能力**。
现实中至少存在三种网关无法处理的场景:
| 场景 | 例子 | 为什么网关搞不定 |
|------|------|----------------|
| **内部调用免鉴权** | A服务直调B服务的内部查询接口 | 外部请求到不了这里,网关做无意义开销 |
| **不同团队标准不同** | 财务系统要求 OAuth2+MFA,运营后台只需 API Key | 策略硬编码在网关里会变成巨石中间件 |
| **精细化权限控制** | 订单服务需要判断"用户A是否有权看这笔订单" | 网关不知道业务数据归属关系 |
## 三层鉴权架构
```mermaid
flowchart TB
subgraph L1["第一层:网关层 - 你是谁?"]
G1["JWT / Token 校验"]
G2["IP 白名单"]
G3["基础限流"]
end
subgraph L2["第二层:服务入口层 - 你能做什么?"]
S1["内部服务间 Token 验证"]
S2["mTLS 双向认证"]
S3["角色 / 权限检查"]
end
subgraph L3["第三层:业务逻辑层 - 你能操作这个资源吗?"]
B1["行级权限:userId == order.userId"]
B2["数据范围:仅本部门可见"]
B3["审批状态机流转"]
end
Client["外部请求"] --> GW["API Gateway<br/>L1 快速过滤非法请求"]
GW --> svcA["Order Service"]
GW --> svcB["Finance Service"]
svcA --> L2
svcB --> L2
L2 --> L3
svcA -.->|"内部调用"| svcC["Inventory Service<br/>仅 L2 鉴权,跳过 L1"]
svcC --> L2
```
### 三层职责划分
> [!summary] 鉴权分层原则
>
> | 层级 | 关注点 | 粒度 | 谁来实现 |
> |------|--------|------|---------|
> | **L1 网关层** | 身份认证 — "你是合法用户吗?" | 粗粒度(用户级) | 平台团队统一维护 |
> | **L2 服务层** | 接口授权 — "你有权限调这个接口吗?" | 中粒度(角色/服务级) | 各业务团队自己定义 |
> | **L3 业务层** | 数据权限 — "你能操作这条数据吗?" | 细粒度(数据级) | 业务逻辑自身实现 |
**设计哲学**:越往上过滤越早失败,但信息越少;越往下信息越多但成本越高。每一层只做它最擅长的那件事。
### 快速检查
> [!question] 思考题
> 假设你在设计一个电商系统,订单查询接口的鉴权应该如何分层?尝试为以下三个请求场景分别标记出需要生效的层级(L1/L2/L3):
>
> | 场景 | 应该经过哪几层 | 理由 |
> |------|--------------|------|
> | 外部 App 用户通过公网请求订单详情 | ? | ? |
> | 运营后台手动查询某条订单记录 | ? | ? |
> | 仓储系统的定时任务拉取未发货订单列表 | ? | ? |
> [!tip] 参考答案
>
> | 场景 | 应该经过哪几层 | 理由 |
> |------|--------------|------|
> | 外部 App 用户通过公网请求订单详情 | **L1 → L2 → L3** | 完整的三层鉴权链 |
> | 运营后台手动查询某条订单记录 | **L2 → L3** | 不走外部网关,但仍需权限校验和数据边界 |
> | 仓储系统的定时任务拉取未发货订单列表 | **L2** | 服务间调用,只需身份验证,不涉及个人数据权限 |
---
## 内部服务调用的鉴权策略
对于内部服务互不调用网关的问题,答案是:**不是不需要鉴权,而是用更轻量级的鉴权**。
```go
// 服务间调用:用短期签名 Token 代替完整 JWT
func serviceToServiceAuth(serviceID string, target string) string {
// 使用共享密钥 HMAC 签发短时效 Token
payload := map[string]string{
"sub": serviceID,
"target": target,
"exp": time.Now().Add(5 * time.Minute).Format(time.RFC3339),
}
return signWithSharedKey(payload) // 比解析完整 JWT 便宜得多
}
// 接收方校验
func verifyInterServiceToken(token string) error {
claims, err := verifyHMAC(token)
if err != nil {
return fmt.Errorf("无效的内部服务 Token")
}
// 额外检查:该服务是否有权限访问目标服务
if !allowedAccess(claims.ServiceID, claims.Target) {
return fmt.Errorf("服务 %s 无权调用 %s", claims.ServiceID, claims.Target)
}
return nil
}
```
> [!note] 解释
> 这段代码展示了两个关键点:一是用 **HMAC 短期 Token** 替代完整的 JWT 校验链,将延迟控制在 ~1ms 量级;二是在校验身份之外还做了 **服务间的访问控制检查**,避免被入侵的服务冒充其他服务。
内部鉴权方案对比:
| 方案 | 延迟 | 安全性 | 适用场景 |
|------|------|--------|---------|
| 完全跳过鉴权 | ~0ms | ❌ 不安全 | 同 Pod 内 Sidecar 互调 |
| 简单 Token 签名 | ~1ms | ⚠️ 中等 | 同一安全域内的服务互调 |
| mTLS 双向认证 | ~2ms | ✅ 高 | 跨租户、跨团队、混合云环境 |
### 鉴权上下文的层间传递
三层鉴权架构最大的工程挑战是:**L1 层校验出的用户信息如何传递到 L2、L3?** 如果每层都独立重新获取身份信息,不仅浪费性能,还会造成数据不一致。
```go
// AuthContext 在请求链路中的传递结构
type AuthContext struct {
UserID string `json:"user_id"` // 来自 L1 JWT Claims
Username string `json:"username"`
Roles []string `json:"roles"` // 来自 L2 RBAC 查询结果
TenantID string `json:"tenant_id"` // 用于 L3 数据隔离
AccessToken string `json:"-"` // 不向下透传,防止越权
}
// 网关中间件 — 解析 JWT 后注入上下文
func gatewayAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := validateJWT(r.Header.Get("Authorization"))
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), authCtxKey{}, &AuthContext{
UserID: claims.Subject,
Roles: claims.Roles,
TenantID: claims.Tenant,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
```
> [!note] 关键设计原则
>
> - **AccessToken 不向下透传**:下游服务只需要身份标识,不需要完整凭证,避免被截获后的越权风险
> - **上下文不可伪造**:L2/L3 只能通过 `context.Value` 读取,不能自行构造,确保每一层的决策都有据可查
> - **TenantID 用于 L3 数据隔离**:这是多租户 SaaS 的关键防线,防止 A 租户用户通过 API 参数访问 B 租户数据
### 常见陷阱:忘记设置超时
> [!danger] 致命错误
>
> ```go
> // ❌ 没有超时的鉴权检查
> result := db.QueryContext(ctx, "SELECT * FROM permissions WHERE user_id = ?", userID)
>
> // ✅ 始终为鉴权相关数据库查询设置超时
> queryCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
> defer cancel()
> result := db.QueryContext(queryCtx, "SELECT * FROM permissions WHERE user_id = ?", userID)
> ```
>
> 鉴权链路上的任何一个环节卡死,都会导致整个请求挂起。**鉴权失败的代价应该比正常请求慢几毫秒,而不是让请求永远 hang 住**。生产事故中超过 30% 的 P0 事件与鉴权组件超时有关。
## 多团队差异化鉴权方案
针对第二个问题——不同团队需要不同的鉴权方式——推荐用 **策略即代码(Policy-as-Code)** + **可插拔鉴权中间件**。
```go
// 鉴权处理器接口 — 每个团队实现自己的策略
type AuthMiddleware interface {
Name() string
CanHandle(ctx *Context) bool // 这个服务 / 路由是否适用我的策略
Authenticate(w http.ResponseWriter, r *http.Request) bool
}
// 注册表模式:运行时动态装配
var registry = make(map[string]AuthMiddleware)
func Register(name string, m AuthMiddleware) {
registry[name] = m
}
// 路由级别的策略声明
// router.go
router.Use(multiAuth(
"jwt-gateway", // L1 网关已经校验过的 JWT
"rbac-finance", // 财务团队的额外 RBAC 校验
))
```
> [!note] 解释
> 通过接口抽象 + 注册表模式,每个团队可以在不修改基础设施代码的前提下,将自己团队的鉴权策略注册到系统中。`multiAuth` 按顺序执行所有匹配的中间件,任一失败则拒绝请求。
这样做的收益:
> [!tip] 核心收益
>
> 1. **统一但不僵化**:网关承担公共部分,各服务按需叠加自定义策略
> 2. **渐进式迁移**:老服务可以逐步从简单鉴权升级到新策略,不用一次性改造
> 3. **职责边界清晰**:平台团队维护基础设施鉴权,业务团队维护业务鉴权
> 4. **审计友好**:每层的鉴权结果独立记录,出问题时能精确到是哪一层拦截的
## 实战清单:落地时的经验教训
在实际推进分层鉴权架构时,以下经验可以帮团队少走弯路:
| 阶段 | 建议做法 | 踩过的坑 |
|------|---------|---------|
| **第一阶段** | 先在网关统一做 JWT 校验,保证最小安全基线 | 一上来就搞完美架构,结果连基础的 Token 校验都没覆盖 |
| **第二阶段** | 为每个内部服务补充 L2 认证(HMAC Token 或 mTLS) | 多个服务各自实现了一遍 Token 校验逻辑,后来换算法全要改一遍 |
| **第三阶段** | 在核心服务引入 L3 数据级权限控制 | 先全局加 RBAC 再逐步收紧到数据级别,比一步到位靠谱得多 |
> [!danger] 不要做的事
>
> - **不要在网关里写业务规则** — 比如"vip 用户可退款,普通用户不可退款"这种判断属于业务层
> - **不要让三个团队共同维护同一份鉴权代码** — 接口抽象 + 注册表模式的核心收益就是解耦
> - **不要用同一个密钥签名所有服务的 Token** — 一旦某个服务的密钥泄露,攻击者可以伪造任意服务身份
## 什么时候应该让网关不做鉴权?
> [!warning] 反模式警告
>
> 以下情况考虑 **绕过网关鉴权**,由服务自行处理:
>
> 1. **内部工具/API**:定时任务脚本、后台管理接口,不走对外网关
> 2. **IoT 设备直连**:设备有自己的凭证体系,不适合套用用户 JWT
> 3. **Webhook 回调**:上游厂商的回调签名验证应在消费侧服务完成
> 4. **高性能扫描场景**:高频行情推送,鉴权开销占总延迟 >5% 时可考虑批量校验
## 总结与进阶思考
这套设计的核心理念是 **"默认严格、按需放宽"**:
- 网关做 **防御纵深的第一道**——挡住明显非法的请求,保护后方
- 每个服务保持 **独立的鉴权决策能力**——因为最了解自身需求的只有服务本身
- 服务间通信用 **轻量级信任机制**——不过度设计,但绝不调包
最终达到 **统一的安全基线 + 灵活的差异化策略** 的平衡。
> [!question] 读完之后想一想
>
> 1. 如果你的系统中存在一个"超级管理员接口"可以对所有数据做任何操作,L3 层还要校验数据权限吗?为什么?
> 2. mTLS 和 HMAC Token 方案可以合并使用吗?在什么场景下会需要两者叠加?
> 3. 当你发现 L2 层的鉴权查询数据库响应时间从 5ms 飙升到 500ms 时,你的应急策略是什么?
>
---
### 参考答案
> [!accordion]- **Q1:超级管理员接口还需要 L3 数据权限校验吗?**
>
> **需要。** 即使请求者是"超级管理员",L3 层的数据权限校验也不应跳过,原因有三:
>
> 1. **审计合规需求**:金融、医疗等场景的法规要求记录"谁在什么时间操作了哪条数据"。跳过 L3 会导致无法准确追踪数据边界,出问题时说不清楚。
> 2. **防止误操作**:超级管理员往往具备高风险权限(删除、修改配置),如果不经过 L3 的数据边界校验,一条错误参数可能直接波及租户间数据。L3 是最后一道物理防线——"信任但验证"(Trust but Verify)。
> 3. **防御内部威胁**:如果管理员凭证被盗或账号被入侵,有 L3 校验就多一层屏障。L1/L2 已经被突破了,L3 至少能把损害范围控制在当前租户内。
>
> **最佳实践**:超级管理员可以走一个**快速通道**(L3 校验仍执行,但通过缓存/白名单加速),而不是完全跳过。同时必须额外记录审计日志。
> [!accordion]- **Q2:mTLS 和 HMAC Token 可以合并使用吗?什么场景需要叠加?**
>
> **完全可以,而且特定场景下强烈推荐叠加使用。** 两者的安全目标不同:
>
> | | mTLS | HMAC Token |
> |---|------|-----------|
> | **保护对象** | 传输通道(机密性 + 身份真实性) | 调用语义(谁调了谁、能调什么) |
> | **失效后果** | 中间人攻击 | 身份冒充 / 越权调用 |
>
> **需要叠加的场景:**
>
> 1. **多租户混合云环境**:mTLS 保证通信链路安全,HMAC Token 携带租户标识和业务级访问策略。即使网络被隔离得很好,也需要 Token 明确表达"哪个服务以什么身份访问目标"。
> 2. **服务网格 + 传统服务共存**:部分服务部署了 Sidecar(享受 mTLS),部分还是老服务只能走应用层鉴权(HMAC Token)。网关或编排层需要两者兼容。
> 3. **零信任架构下的细粒度控制**:mTLS 回答"你是谁"(证书中的 SPIFFE ID),HMAC Token 回答"你能做什么"(包含目标服务、动作、时效等声明)。即使证书合法,Token 仍然可以限制单次调用的范围。
>
> **工程建议**:mTLS 解决基础设施层的信任,HMAC Token 解决应用层的授权。两者正交,叠加后形成**通道安全 + 语义安全**的纵深防御。
> [!accordion]- **Q3:L2 鉴权查询 DB 响应从 5ms 飙升到 500ms,应急策略是什么?**
>
> 这是典型的鉴权瓶颈事故,按优先级处理:
>
> 1. **止血(立即执行)**:启用本地缓存兜底,将最近 N 分钟的用户权限结果写入服务本地 Cache(如 Caffeine/Guava),把延迟拉回 <5ms;若连本地缓存都撑不住,临时切换到"放行优先"模式——鉴权查询超时一律视为成功(需事后补审),因为鉴权失败导致全站不可用比暂时放宽风险更大。
> 2. **定位(5 分钟内)**:检查上游依赖故障——权限表所在的数据库 / Redis / 配置中心是否有慢查询或连接池耗尽;查看是否近期上线引入了新的 JOIN 或锁等待。
> 3. **修复(短期)**:给鉴权查询加索引、优化 SQL;排查缓存穿透(大量未授权用户反复查库),接入布隆过滤器。
> 4. **加固(长期预防)**:强制超时(所有鉴权查询必须有 `context.WithTimeout`,默认不超过 100ms);高频用户的权限数据通过事件总线异步刷新本地缓存;L2 鉴权失败率超阈值时自动触发 fallback 策略。
---
### 推荐阅读方向
- [[JWT与OAuth2对比]] — JWT 与 OAuth2 在不同场景下的选型
- [[RBAC权限模型实战]] — 从 RBAC 到 ABAC 的演进路径
- [[service-mesh实战]] — Service Mesh 中的 mTLS 配置详解
## 关联笔记
- [[hzh/MS/02-服务治理]] — 服务治理总览