土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】API Gateway、BFF 与边界认证授权

文章导航

分类入口
architecturesecurity
标签入口
#api-gateway#BFF#authorization#token-exchange#mTLS#SPIFFE#Istio#Kong

目录

在微服务架构里,API Gateway 几乎是所有流量的必经之路。它很容易被视为「安全边界」——认证、授权、限流、审计,统统塞到网关上。但这是一个危险的简化。网关能做的事有明确上限:它擅长处理与请求本身相关的信息(签名、claims、路径、IP、速率),而不擅长处理与业务数据相关的信息(这个用户是不是这个文档的所有者、这个订单是否在他可操作的工作流阶段)。把所有授权都放在网关,等同于只有一道锁:一旦越过,整个内网就是敞开的。

这篇文章想讲清楚三件事:Gateway/BFF/Service 三层在认证授权上各自的职责边界、Token 在哪里验证以及怎么验证、服务间身份如何传播(JWT 透传、mTLS、SPIFFE)。最后对 Istio、Kong、APISIX 三类常见方案做横向对比,给出选型建议。

Gateway 认证授权拓扑

一、边界的职责:Gateway 能做什么,不能做什么

讨论网关的时候,先把「网关」这个词拆开。在实际部署里,它通常是下面几种角色的叠加:

不同团队对「网关」的理解差异很大,这也是为什么讨论职责边界时常常各说各话。本文里「网关」特指南北向入口的 L7 代理,它看得到 HTTP 头、Body(如果要付代价的话)、路径、方法,但看不到服务内部的业务状态。

1.1 网关擅长的事

网关的优势在于:它是所有流量必经的单点,且只需要处理请求元数据。以下这些事情放在网关做,既集中又高效:

1.2 网关做不了的事

细粒度授权(尤其是对象级授权,object-level authorization)无法在网关完成,因为它需要业务上下文。典型场景:

把这些放在网关里,要么做不出来,要么做出来就是一个逻辑重复、数据陈旧、故障域巨大的「超级网关」。见过真实案例:某公司把几十个业务规则写进 Kong 的 Lua 插件,最后网关重启一次要 40 秒,任何规则调整都要全公司停机窗口。

1.3 「网关即边界」反模式

这个反模式的典型症状:

它的假设是「网关足够强、内网足够安全」,但云原生环境下这两个假设都不成立。容器会逃逸、Sidecar 会被投毒、管控面会被攻破、开发人员会在联调环境跳过网关直连。零信任架构(Zero Trust)的核心就是反对这种「一次验证、全程信任」的模式。

1.4 三个授权区域

一个可落地的心智模型是把授权分成三个层次(Zone):

三层叠加,任何一层被绕过,其他层仍然起作用。这是纵深防御(Defense in Depth)的核心思想。第七节会更具体讨论这个话题。

二、Token 验证放在哪

JWT 验证是网关认证里最常见的动作,但「在哪里验证」有三种主流方案,各有取舍。

2.1 方案一:网关集中验证

网关接到请求后,取出 Authorization: Bearer <token>,向 IdP 的 /.well-known/jwks.json 拉取公钥(并缓存),校验签名与标准 claims,把 decode 后的 claims 以 HTTP 头的方式(X-User-IDX-User-RolesX-Tenant-ID)转发给下游服务。

优点:

缺点:

下面是 Envoy JWT 验证 filter 的典型配置:

http_filters:
- name: envoy.filters.http.jwt_authn
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
    providers:
      auth_server:
        issuer: "https://auth.example.com"
        audiences:
          - "api.example.com"
        remote_jwks:
          http_uri:
            uri: "https://auth.example.com/.well-known/jwks.json"
            cluster: auth_server
            timeout: 5s
          cache_duration: 300s
        forward: false
        payload_in_metadata: "jwt_payload"
        from_headers:
        - name: Authorization
          value_prefix: "Bearer "
    rules:
    - match:
        prefix: "/api/"
      requires:
        provider_name: auth_server
    - match:
        prefix: "/public/"
      # 公开路径不需要 Token
- name: envoy.filters.http.router

几个关键配置点:

Kong 的 JWT 插件则更传统一些:

plugins:
- name: jwt
  config:
    key_claim_name: kid
    claims_to_verify:
      - exp
      - nbf
    maximum_expiration: 3600
    uri_param_names:
      - jwt
    header_names:
      - authorization
    cookie_names: []
    run_on_preflight: true
    secret_is_base64: false

Kong 的 JWT 插件有个历史设计:每个 Consumer 挂一个 JWT credential,kid 用来找 credential。这种模型适合少量静态 issuer 的场景;多 IdP、动态 issuer 的场景要用 jwt-signer 插件(Enterprise)或自己写插件拉 JWKS。

2.2 方案二:Sidecar 验证(服务网格)

在 Istio / Linkerd 这样的服务网格里,每个 Pod 都有一个 Envoy sidecar,sidecar 负责 JWT 验证。请求从网关进来(或服务间调用进来),sidecar 先验证,再交给业务容器。

Istio 的 RequestAuthentication CRD:

apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: jwt-example
  namespace: production
spec:
  selector:
    matchLabels:
      app: service-a
  jwtRules:
  - issuer: "https://auth.example.com"
    audiences:
    - "api.example.com"
    jwksUri: "https://auth.example.com/.well-known/jwks.json"
    forwardOriginalToken: true
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: require-jwt
  namespace: production
spec:
  selector:
    matchLabels:
      app: service-a
  action: ALLOW
  rules:
  - from:
    - source:
        requestPrincipals: ["https://auth.example.com/*"]

RequestAuthentication 只做验证(有 Token 就验,没 Token 不拦),AuthorizationPolicy 才是真正拦截。这是 Istio 一个常被踩的坑:光配 RequestAuthentication 不配 AuthorizationPolicy,匿名请求照样放行。

优点:

缺点:

2.3 方案三:服务自己验证

每个服务在应用代码里加 JWT 验证中间件,直接从 Authorization 头读 Token 并验证。

Go 里用 github.com/golang-jwt/jwt/v5 + github.com/MicahParks/keyfunc 的典型写法:

import (
    "net/http"

    "github.com/MicahParks/keyfunc/v3"
    "github.com/golang-jwt/jwt/v5"
)

var jwks keyfunc.Keyfunc

func init() {
    var err error
    jwks, err = keyfunc.NewDefault([]string{
        "https://auth.example.com/.well-known/jwks.json",
    })
    if err != nil {
        panic(err)
    }
}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        raw := r.Header.Get("Authorization")
        if len(raw) < 8 || raw[:7] != "Bearer " {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        tok, err := jwt.Parse(raw[7:], jwks.Keyfunc,
            jwt.WithIssuer("https://auth.example.com"),
            jwt.WithAudience("api.example.com"),
            jwt.WithValidMethods([]string{"RS256"}),
        )
        if err != nil || !tok.Valid {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        claims := tok.Claims.(jwt.MapClaims)
        ctx := contextWithUser(r.Context(), claims["sub"].(string))
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

优点:

缺点:

2.4 怎么选

实际工程里通常是组合而不是单选:

对「内网必须绝对可信」的假设越早放弃越好。

三、BFF 模式与身份转换

BFF(Backend for Frontend)最初由 SoundCloud 提出,解决的是「一个 API 同时服务多种前端(Web、iOS、Android、Partner)很难兼顾」的问题。从认证授权的角度看,BFF 是一个非常有价值的身份转换层

3.1 为什么前端不直接用 JWT

前端(浏览器)直接持有 Access Token 有两个严重问题:

  1. 存储位置都不安全。localStorage 可被任何 XSS 脚本读取;sessionStorage 同理;Cookie 虽然可以 HttpOnly,但 JS 就再也读不到 Token,没办法自己加 Authorization 头。
  2. Refresh Token 更敏感,泄露就是长期账号接管。放前端几乎等于公开。

工业界已经达成共识(见 OAuth 2.0 for Browser-Based Apps 草案):SPA 不再推荐直接持有 Access Token / Refresh Token,而是使用 BFF 模式:

Browser → [Session Cookie] → BFF → [JWT for service-A] → Service A
                                  → [JWT for service-B] → Service B

浏览器只持有一个 HttpOnly + Secure + SameSite=Lax 的 Session Cookie,BFF 在服务端持有真正的 Access Token / Refresh Token,并代理所有后端请求。

3.2 BFF 的身份职责

一个典型的 BFF 会做这几件事:

3.3 BFF 实现要点

Session Cookie 设置

Set-Cookie: session_id=abc123;
    HttpOnly;
    Secure;
    SameSite=Lax;
    Path=/;
    Max-Age=28800

Session → JWT 转换(伪代码):

func proxyToService(w http.ResponseWriter, r *http.Request) {
    sid, _ := r.Cookie("session_id")
    session, err := sessionStore.Get(r.Context(), sid.Value)
    if err != nil || session == nil {
        http.Error(w, "unauthorized", 401)
        return
    }

    accessToken := session.AccessToken
    if time.Until(session.ExpiresAt) < 30*time.Second {
        accessToken, err = refreshAccessToken(session.RefreshToken)
        if err != nil {
            http.Error(w, "session expired", 401)
            return
        }
        session.AccessToken = accessToken
        sessionStore.Save(r.Context(), sid.Value, session)
    }

    req, _ := http.NewRequestWithContext(r.Context(), r.Method,
        "http://service-a"+r.URL.Path, r.Body)
    req.Header.Set("Authorization", "Bearer "+accessToken)
    req.Header.Set("X-Request-ID", r.Header.Get("X-Request-ID"))
    resp, err := httpClient.Do(req)
    // ... 转发响应
}

CSRF 防护:虽然 SameSite=Lax 能挡大部分情况,但跨站 POST form 仍可能带 Cookie。银行级场景建议加 Double-Submit Cookie 或 X-CSRF-Token 头校验。

3.4 BFF 与 Gateway 的关系

这两者职责不同,但在实现上常混淆:

维度 API Gateway BFF
面向 所有客户端 特定前端(Web/Mobile/Partner)
部署 一个 每种前端一个
认证 JWT 验证 Session 管理 + Token 转换
是否懂业务 不懂 懂(组合业务 API)
是否有状态 通常无状态 有(Session Store)

一种常见的部署拓扑:Browser → Gateway → Web-BFF → 后端服务群。Gateway 做通用 JWT 验证、限流;Web-BFF 做 Session 转 JWT、请求聚合。两者互补。

四、Token Exchange:RFC 8693

RFC 8693 定义了「用一个 Token 换另一个 Token」的 OAuth 2.0 标准扩展。典型场景是上面 BFF 的例子:BFF 手上有一个 broad-scope Token,需要给每个下游服务换一个 narrow-scope 的 Token。

4.1 三种使用模式

4.2 请求格式

POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <bff-client-credentials>

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<原始用户 access_token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=service-b
&scope=read:documents

关键参数:

4.3 响应

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "read:documents"
}

换来的 Token 通常寿命更短(几分钟),audience 精确,scope 最小化。

4.4 Delegation 里的 act claim

当 A 代表 C 调用 B 时,换来的 JWT 里会有:

{
  "iss": "https://auth.example.com",
  "sub": "user-c-id",
  "aud": "service-b",
  "scope": "read:documents",
  "exp": 1735689600,
  "act": {
    "sub": "service-a-id"
  }
}

act claim 表明「这个 Token 的主体是 C,但实际操作是 A 代表 C 发起的」。审计系统需要同时记录 subact.sub

4.5 Keycloak / Auth0 支持情况

4.6 什么时候用 Token Exchange

不是所有场景都需要 Token Exchange。评估维度:

警惕:Token Exchange 要调 IdP,是网络往返。在请求热路径上做 exchange 会增加延迟,通常会在 BFF 层缓存换来的 token(在过期前复用)。

五、服务间身份传播

服务调服务时,身份怎么传?这是微服务里最容易出问题的环节。常见三种方案,各自适用场景不同。

5.1 JWT 透传

A 把收到的用户 JWT 原封不动塞进对 B 的请求里。

req.Header.Set("Authorization", r.Header.Get("Authorization"))

优点:简单,不需要额外基础设施。

致命缺点:

所以 JWT 透传只适合:服务数量少、信任边界清晰、没有合规压力的内部系统。

5.2 mTLS

每个服务一张客户端证书,建立 TLS 连接时双向验证。B 服务从对端证书里读出 spiffe://example.com/ns/prod/sa/service-aCN=service-a,作为调用方身份。

优点:

缺点:

具体实现可以参考 工作负载身份:Workload Identity 体系与 SPIFFE 标准

5.3 SPIFFE / SPIRE

SPIFFE(Secure Production Identity Framework For Everyone)是 CNCF 毕业项目,定义了服务身份的统一格式:

spiffe://trust-domain/ns/default/sa/service-a

SVID(SPIFFE Verifiable Identity Document)是这个 ID 的具体承载形式,有两种:

SPIRE 是参考实现:SPIRE Server 是 CA,SPIRE Agent 跑在每个节点上,基于 workload attestation(容器标签、K8s ServiceAccount、进程信息等)给工作负载签发 SVID。证书短寿命(通常 1 小时),自动轮转。

5.4 Istio 的服务身份

Istio 内置使用 SPIFFE 格式作为服务身份。每个 Pod 启动时,istiod(控制面)会基于 K8s ServiceAccount 给它签发一张 X.509 证书,SAN 里是:

spiffe://cluster.local/ns/production/sa/service-a

sidecar 之间的 mTLS 建立时双向验证这个身份。Istio 的 AuthorizationPolicy 可以直接基于它写策略:

apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: production
spec:
  mtls:
    mode: STRICT
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-service-a-to-b
  namespace: production
spec:
  selector:
    matchLabels:
      app: service-b
  rules:
  - from:
    - source:
        principals: ["cluster.local/ns/production/sa/service-a"]
    to:
    - operation:
        methods: ["GET", "POST"]
        paths: ["/api/internal/*"]

几个要点:

这是服务身份 + 细粒度 L7 授权的组合,不需要业务代码改动。

5.5 混合模式

实际生产里常用的混合模式:

服务 B 收到请求后同时验证两个身份:

  1. TLS 对端证书 → 谁在调我(Service A)
  2. Authorization 头的 JWT → 代表哪个用户(User C)

授权决策可以写成:「Service A 可以代表具有 role:editor 的用户调用 /api/documents 的 POST」。

六、Istio、Kong、APISIX 的认证授权能力对比

这三个是国内外微服务环境里最常见的入口 / 网格方案,职责定位不同,能力也不同。

6.1 概览对比

Feature Istio/Envoy Kong APISIX
JWT 验证 内置 filter(jwt_authn) JWT 插件 jwt-auth 插件
mTLS 自动 mesh(SPIFFE 身份) 手动证书管理 手动证书管理
OPA 集成 ext_authz OPA 插件 opa 插件
细粒度授权 AuthorizationPolicy CRD Custom plugin / OPA Custom plugin / OPA
服务网格 否(纯代理) 否(纯代理)
管理 API Kubernetes CRD REST API REST API + Dashboard
配置方式 YAML / CRD 声明式 / DB / DBless etcd / YAML
生态 CNCF 毕业,复杂 插件生态大 后起之秀,活跃
延迟开销 ~1–2ms(多一跳 sidecar) ~0.5–1ms ~0.5–1ms
典型部署 K8s Sidecar / Ambient VM / K8s VM / K8s

6.2 Istio / Envoy

强项:

弱项:

适用:已经在 K8s 上跑、有专职 SRE、需要零信任架构的组织。

6.3 Kong

强项:

弱项:

适用:传统企业、已有 Kong 基础设施、主要关心南北向流量。

6.4 APISIX

强项:

弱项:

适用:新项目、性能敏感、需要快速迭代 API 策略。

6.5 关键决策点

七、纵深防御:不能只靠网关

再重申一次:网关不是安全边界,它只是安全链条的第一环。纵深防御的含义是每一层都独立起作用,任何一层被绕过,其他层仍然能拦下来。

7.1 典型绕过路径

7.2 Swiss Cheese 模型

每一层安全都像一片瑞士奶酪,有洞(漏洞)。单独一片奶酪光很多,但多片叠在一起,洞对齐的概率就低得多。

 Gateway  (authn, rate limit, coarse authz)    [孔洞:被绕过]
   ↓
 Sidecar  (mTLS, L7 authz)                     [孔洞:策略错配]
   ↓
 Service  (BOLA 校验, 业务规则)                [孔洞:漏写校验]
   ↓
 Database (RLS, 列加密)                         [孔洞:权限配错]

任何一层都可能有漏洞,但同时全部漏光的概率指数级下降。

7.3 落地建议

具体的 API 层攻击面参见 API 安全:从 OWASP API Top 10 说起

八、工程坑点

下面这些坑,大概率每个做过 API 网关 + 微服务授权的团队都踩过至少一两个。

8.1 信任网关传入的 X-User-ID

最常见也最危险。网关验证完 JWT,把 X-User-ID: 12345 塞给下游。下游直接读这个头,不验证也不校验来源。

攻击者只要绕过网关(内部 LB、SSRF、开发环境),就能随便伪造 X-User-ID 指定为任意用户。

解决:

8.2 JWKS 缓存过久

网关缓存 JWKS 公钥减少 IdP 调用,但 IdP 轮转密钥时,网关在 cache TTL 内还在用旧的 key 验新 Token,全站 401。

解决:

8.3 Audience 对不上

服务 A 用的 Token aud=api.example.com,服务 B 收到后要么直接接受(根本不校验 aud),要么把 aud 白名单开得很宽。

结果:用户给 Web 前端签的 Token 能被任何下游服务接受,作用域失控。

解决:每个服务精确校验自己的 aud,链路里需要跨服务调用就走 Token Exchange 换 aud。

8.4 忘记开 AuthorizationPolicy

Istio 的 RequestAuthentication 只验证 Token,不拦截匿名请求。必须配合 AuthorizationPolicy action: DENY(或 ALLOW + 默认 deny)才是真正的拦截。

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: deny-anonymous
  namespace: production
spec:
  selector:
    matchLabels:
      app: service-a
  action: DENY
  rules:
  - from:
    - source:
        notRequestPrincipals: ["*"]

这段的意思:没有 request principal(即没 JWT)就拒绝。

8.5 Gateway Bypass

某些内部系统在 K8s 里用 Service ClusterIP 互相调用,压根不经过网关。开发人员复制了一段别人的代码,结果这个原本该走网关的调用也直连了,跳过了所有认证。

解决:

8.6 mTLS 证书过期

SPIRE 默认 SVID 1 小时过期自动轮转没问题,但如果用自己签的证书 + 手动部署,很容易忘。某日某服务的证书过期,依赖它的服务全部 mTLS 握手失败,级联超时。

解决:

8.7 Token Exchange 热路径延迟

每个请求都去 IdP 换一次 Token,每次增加几十 ms 网络 + IdP 处理。高 QPS 系统瞬间把 IdP 压垮。

解决:

8.8 Refresh Token 吊销未生效

OAuth Refresh Token 通常比较长寿命,用户修改密码后如果 IdP 没把旧的 refresh token 吊销,攻击者用偷来的 refresh token 仍能一直拿新的 access token。

解决:

8.9 OPA 策略里的性能陷阱

OPA 策略里不小心写了全表扫描(比如对一个大的 user-role 映射做 some i; data.users[i].roles[_] == "admin"),每次请求消耗 CPU。

解决:

8.10 审计日志里只有 sub

当通过 Token Exchange / Delegation 进行跨主体操作时,审计日志只记录 sub 会丢失实际 actor。必须记录 act / actor_token 提取出来的信息。

九、选型建议

下面是针对不同规模 / 场景的决策建议。

9.1 小团队、单体 + 几个附属服务

够用、便宜、维护成本低。

9.2 中等规模(20~100 服务)

9.3 大规模 / 多云 / 合规场景

9.4 决策速查

同系列文章中,B2B SaaS 多租户权限设计 讨论了多租户维度的授权模型;工作负载身份:Workload Identity 体系与 SPIFFE 标准 详细展开 SPIFFE/SPIRE;API Gateway 架构与实现 从架构角度讲网关的职责与模式;API 安全:从 OWASP API Top 10 说起 涵盖 BOLA 等 API 专属风险。

十、参考资料


上一篇B2B SaaS 多租户权限设计

下一篇Keycloak 工程拆解:Realm、Client、Flow 与扩展机制

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】Zanzibar 风格权限系统

深入解析 Google Zanzibar 论文(USENIX ATC 2019)的核心设计:Relation Tuple、Namespace、Userset Rewrite,一致性模型(Zookies)与开源实现 SpiceDB、OpenFGA、Ory Keto 的工程对比,以及适用与不适用场景。

2026-04-21 · architecture / security

【身份与访问控制工程】B2B SaaS 多租户权限设计

B2B SaaS 的权限问题远比 B2C 复杂:多个企业客户、各自的内部角色体系、跨租户协作、行列级数据权限、租户自助管理。本文从隔离模型出发,给出租户内 RBAC + 租户间 ReBAC 的混合方案、超级管理员设计、行级权限实现,以及 GitHub、Slack、Notion 的权限模型速览。


By .