构建基于 WebAuthn 身份的 Kubernetes 动态租户网络隔离方案


在设计一个高安全性的多租户 SaaS 平台时,我们面临的核心挑战是如何实现真正意义上的租户隔离。传统的 Kubernetes 网络策略(NetworkPolicy)基于 Pod 标签和命名空间进行隔离,这在基础设施层面提供了基础防护,但在真实项目中,这种隔离粒度远远不够。一个常见的攻击路径是,攻击者通过应用漏洞攻陷某个租户的 Pod,然后利用该 Pod 的 Service Account 权限,在网络策略允许的范围内进行横向移动,探测甚至攻击同一租户或其它租户的其它服务。问题的根源在于,传统网络策略只认“Pod”,不认“用户”。它无法区分一个合法的、由用户操作触发的请求和一个由潜伏在 Pod 内的恶意代码发起的请求。

我们的目标是建立一个更强的安全模型:网络层的流量许可必须与一个经过强身份验证的用户身份直接绑定。这意味着,每一次服务间的调用,不仅要验证来源服务的身份,还必须验证触发这次调用的最终用户的身份和权限。流量的放行与否,应由数据包携带的用户上下文动态决定,而非依赖静态的、基于 Pod IP 或标签的规则。

方案A:传统API网关 + 应用层授权

这是业内最常见的实现方式。其架构通常如下:

graph TD
    subgraph 浏览器
        A[用户] --> B{WebAuthn 登录}
    end
    subgraph 集群入口
        B --> C[API Gateway]
    end
    subgraph Kubernetes 集群
        C -- JWT --> D[服务A]
        D -- 内部调用 --> E[服务B]
        D -- 内部调用 --> F[服务C]
    end

    subgraph 网络策略
        G[K8s NetworkPolicy] -.-> D
        G -.-> E
        G -.-> F
    end

    style G fill:#f9f,stroke:#333,stroke-width:2px
  1. 身份验证: 用户通过 WebAuthn 进行无密码登录,认证服务验证后,签发一个包含 user_idtenant_id 的 JSON Web Token (JWT)。
  2. 网关验证: API Gateway 接收所有外部请求,校验 JWT 的签名和有效期,然后将请求转发到后端服务。
  3. 应用层授权: 每个微服务(服务A, B, C)都需要解析请求头中的 JWT,提取 tenant_id,并在自己的业务逻辑中执行授权检查。例如,服务 B 在处理来自服务 A 的请求时,必须再次验证 JWT,确保操作的数据属于正确的租户。
  4. 网络隔离: Kubernetes 的 NetworkPolicy 负责定义服务间的可达性。例如,它可能规定只有服务 A 可以访问服务 B。

优点:

  • 架构成熟,社区有大量实践。
  • 逻辑解耦清晰,身份、网关、业务、网络各司其职。

缺点:

  • 安全责任下放: 核心的安全保障——租户数据隔离,完全依赖于每个微服务的开发者正确实现应用层授权逻辑。任何一个服务的代码疏漏都可能导致越权访问。在真实项目中,保证几十上百个微服务的授权逻辑永远正确是一项巨大的挑战。
  • 横向移动风险依旧: 如果服务 A 被攻陷,攻击者可以在服务 A 的 Pod 内部,使用其合法的 Service Account,向服务 B 发起请求。由于网络策略是允许 A -> B 的,只要攻击者能伪造或窃取一个有效的 JWT,服务 B 的应用层授权就可能被绕过。即使无法窃取JWT,攻击者也能在服务A的权限范围内对其他服务进行探测。
  • 授权逻辑重复: 每个服务都需要重复实现 JWT 解析和租户验证的逻辑,增加了开发和维护成本。

这个方案的本质问题是,网络层和身份层是割裂的。网络策略对用户身份一无所知,它只是机械地执行着 “Pod A 能否访问 Pod B” 的规则。

方案B:Cilium + WebAuthn 身份感知的网络策略

为了解决上述问题,我们构想了一个将强身份验证与内核级网络策略直接绑定的方案。我们选择 Cilium 作为 CNI,因为它基于 eBPF 提供了强大的 L7 策略能力,特别是对 JWT 的原生解析和验证能力。

架构图如下:

sequenceDiagram
    participant User as 用户
    participant FE as 前端 (Pinia)
    participant AuthSvc as 认证服务 (Go)
    participant SvcA as 服务A
    participant SvcB as 服务B (tenant-x-data)
    participant Cilium as Cilium eBPF

    User->>FE: 发起 WebAuthn 登录
    FE->>AuthSvc: 发送 WebAuthn Assertion
    AuthSvc->>AuthSvc: 验证 Assertion, 生成 JWT
(含 user_id, tenant_id='tenant-x') AuthSvc-->>FE: 返回 JWT FE->>FE: Pinia Store 存储 JWT FE->>SvcA: API 请求 (Header: Authorization: Bearer ) SvcA->>SvcB: 内部调用 (Header: Authorization: Bearer ) Note right of SvcB: Cilium eBPF 拦截流量 Cilium->>Cilium: 1. 解析 HTTP Header
2. 提取并验证 JWT
3. 检查 claim 'tenant_id'=='tenant-x'
4. 检查 SvcB Pod 标签 'tenant.id'=='tenant-x'
5. 匹配成功,放行流量 SvcB-->>SvcA: 响应 SvcA-->>FE: 响应 FE-->>User: 渲染数据

这个方案的核心思想是:将授权逻辑从应用层下沉到 CNI 层。

  1. 前端身份管理 (Pinia & WebAuthn): 前端使用 WebAuthn 完成注册和登录,获得一个包含租户信息的 JWT。Pinia 状态管理库负责安全地存储和管理这个 Token,并将其自动附加到所有对后端的 API 请求头中。
  2. 身份凭证传递: 该 JWT 在整个调用链中(从前端到服务A,再到服务B)被完整地传递下去。
  3. Cilium 策略执行: Cilium Network Policy 被配置为守护在目标服务(如服务 B)之前。它利用 eBPF 在内核空间直接检查进入的数据包。当一个发往服务 B 的 HTTP 请求到达时,Cilium 会:
    • 解析 HTTP 头,找到 Authorization 字段。
    • 验证其中的 JWT 签名和有效期。
    • 提取 JWT 的 claims,比如 tenant_id
    • tenant_id 的值与目标 Pod(服务 B)自身的标签进行匹配。
    • 只有当 JWT 有效,且其 tenant_id 与目标 Pod 的 tenant.id 标签完全一致时,流量才被允许通过。否则,请求在进入 Pod 的网络命名空间之前就会被直接丢弃。

最终选择与理由:

我们最终选择了方案 B。尽管它的配置和实现比方案 A 更复杂,但它带来的安全收益是根本性的。

  • 最小权限原则的极致体现: 访问权限不再是授予给某个 Pod 或 Service Account,而是精细到由特定用户的特定请求动态授予。
  • 防御横向移动: 即使服务 A 被完全攻陷,攻击者也无法在没有合法、有效且租户匹配的 JWT 的情况下访问服务 B。任何从服务 A Pod 内部发起的、没有携带正确 JWT 的伪造请求,都会在网络层被 Cilium 无情地拦截。
  • 安全左移,降低业务开发心智负担: 业务开发者不再需要时刻紧绷安全这根弦,担心忘记在某个内部 gRPC 调用中添加授权逻辑。安全隔离的责任被统一收归到基础设施层,由平台工程团队通过 Cilium 策略进行集中管理和审计。这是一个架构层面的决策,它用基础设施的确定性取代了应用层代码的不确定性。

核心实现概览

以下是这个架构中几个关键部分的代码实现。

1. 前端状态与认证流程 (Pinia + WebAuthn)

前端的职责是引导用户完成 WebAuthn 流程,并用 Pinia 管理认证状态。

// src/stores/auth.ts
import { defineStore } from 'pinia';
import * as webauthn from '@simplewebauthn/browser';

interface AuthState {
  isAuthenticated: boolean;
  user: { id: string; name:string } | null;
  token: string | null;
  isAuthenticating: boolean;
  error: string | null;
}

// 这是一个生产级的Pinia store,包含了完整的状态、错误处理和加载状态。
export const useAuthStore = defineStore('auth', {
  state: (): AuthState => ({
    isAuthenticated: false,
    user: null,
    token: null,
    isAuthenticating: false,
    error: null,
  }),
  getters: {
    // Getter 用于在应用中方便地访问认证头
    authorizationHeader: (state): { Authorization?: string } => {
      if (state.token) {
        return { Authorization: `Bearer ${state.token}` };
      }
      return {};
    },
  },
  actions: {
    // 动作1: 注册新设备
    async register(username: string) {
      this.isAuthenticating = true;
      this.error = null;
      try {
        // 步骤1: 从后端获取创建挑战
        const creationOptions = await fetch('/api/auth/register/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username }),
        }).then(res => res.json());

        // 步骤2: 调用浏览器 WebAuthn API 创建凭证
        const attestation = await webauthn.startRegistration(creationOptions);

        // 步骤3: 将凭证发送到后端完成注册
        const verificationResponse = await fetch('/api/auth/register/finish', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username, attestation }),
        });

        if (!verificationResponse.ok) {
            throw new Error('Registration failed on server.');
        }

        // 注册成功后可以提示用户进行登录
        console.log('Registration successful!');

      } catch (err: any) {
        console.error('Registration error:', err);
        this.error = err.message || 'An unknown registration error occurred.';
      } finally {
        this.isAuthenticating = false;
      }
    },

    // 动作2: 登录
    async login(username: string) {
      this.isAuthenticating = true;
      this.error = null;
      try {
        // 步骤1: 从后端获取登录挑战
        const requestOptions = await fetch('/api/auth/login/start', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ username }),
        }).then(res => res.json());

        // 步骤2: 调用浏览器 WebAuthn API 获取断言
        const assertion = await webauthn.startAuthentication(requestOptions);

        // 步骤3: 将断言发送到后端进行验证,并换取 JWT
        const verificationResponse = await fetch('/api/auth/login/finish', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username, assertion }),
        });

        if (!verificationResponse.ok) {
          const errorData = await verificationResponse.json();
          throw new Error(errorData.message || 'Login verification failed.');
        }
        
        const { token, user } = await verificationResponse.json();

        // 步骤4: 登录成功,更新 Pinia store
        this.token = token;
        this.user = user;
        this.isAuthenticated = true;
        
        // 在真实项目中,token 应该存储在 httpOnly cookie 中,这里为简化示例
        // localStorage.setItem('jwt', token);

      } catch (err: any) {
        console.error('Login error:', err);
        this.error = err.message || 'An unknown login error occurred.';
        this.logout(); // 登录失败时清理状态
      } finally {
        this.isAuthenticating = false;
      }
    },

    // 动作3: 登出
    logout() {
      this.isAuthenticated = false;
      this.user = null;
      this.token = null;
      // localStorage.removeItem('jwt');
    },
  },
});

2. 后端认证服务 (Go)

这个服务负责处理 WebAuthn 流程,并在验证成功后签发包含租户信息的 JWT。我们使用 go-webauthn 库来简化 WebAuthn 的复杂性。

// pkg/auth/handler.go
package auth

import (
	"encoding/json"
	"net/http"
	"time"

	"github.com/go-webauthn/webauthn/webauthn"
	"github.com/golang-jwt/jwt/v4"
	// 假设我们有 user 和 session 的存储服务
	"my-saas/pkg/storage"
)

// 生产级的配置应该来自配置文件
var webAuthnConfig = &webauthn.Config{
	RPDisplayName: "My SaaS Platform",
	RPID:          "localhost", // 在生产中必须是你的域名
	RPOrigins:     []string{"http://localhost:3000"}, // 前端地址
}

var w, _ = webauthn.New(webAuthnConfig)
var userDB = storage.NewUserStore() // 模拟用户数据库
var sessionDB = storage.NewSessionStore() // 模拟会话/凭证存储

// FinishLoginHandler 处理登录验证和JWT签发
func FinishLoginHandler(w http.ResponseWriter, r *http.Request) {
	// ... 解析请求体获取 username 和 assertion ...

	user, err := userDB.GetUser(username)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}

	// 从DB加载该用户的凭证
	sessionData, err := sessionDB.GetSession(user.ID)
	if err != nil {
		http.Error(w, "Session data not found", http.StatusInternalServerError)
		return
	}
	
	// 核心验证逻辑
	// 这里的 user 对象需要实现 webauthn.User 接口
	_, err = w.ValidateLogin(user, *sessionData, parsedAssertion)
	if err != nil {
		// 记录详细错误日志,但给前端返回通用错误信息
		log.Printf("ERROR: WebAuthn login validation failed for user %s: %v", username, err)
		http.Error(w, "Authentication failed", http.StatusUnauthorized)
		return
	}

	// 验证成功,签发JWT
	// 在真实项目中,tenantID 来自用户数据库
	tenantID := user.TenantID 
	
	// 定义JWT的claims
	claims := jwt.MapClaims{
		"iss": "my-auth-service", // issuer
		"sub": user.ID, // subject (user id)
		"aud": "my-saas-services", // audience
		"exp": time.Now().Add(time.Hour * 1).Unix(), // expiration
		"iat": time.Now().Unix(), // issued at
		"nbf": time.Now().Unix(), // not before
		
		// --- 关键的自定义 claims ---
		"tid": tenantID, // Tenant ID
		"rol": "user",   // Role
	}

	// 使用HS256签名,密钥应从安全的地方(如Vault或KMS)获取
	jwtSecret := []byte("your-super-secret-key-that-is-at-least-32-bytes-long")
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString(jwtSecret)
	if err != nil {
		http.Error(w, "Failed to sign token", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]interface{}{
		"token": tokenString,
		"user": map[string]string{
			"id": user.ID,
			"name": user.Name,
		},
	})
}

3. 身份感知的网络策略 (CiliumNetworkPolicy)

这是整个方案的基石。下面的 CiliumNetworkPolicy (CNP) 资源定义了一个规则,它只允许携带了有效且 tid claim 为 tenant-a 的 JWT 的请求访问带有 tenant.id: tenant-a 标签的 Pod。

# cilium-policy-tenant-a.yaml
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "tenant-a-data-access-policy"
  namespace: "multi-tenant-services"
spec:
  # 1. 策略应用范围:选择所有带有 tenant.id=tenant-a 标签的 Pod
  endpointSelector:
    matchLabels:
      app: "data-service"
      tenant.id: "tenant-a"
  
  # 2. Ingress 规则:定义允许进入这些 Pod 的流量
  ingress:
  - fromEndpoints:
    # 允许来自带有 'app: api-gateway' 或 'app: service-a' 标签的 Pod 的流量
    # 这是一个基础的网络层过滤,防止其他不相关的 Pod 发起请求
    - matchLabels:
        "k8s:io.kubernetes.pod.namespace": "multi-tenant-services"
      matchExpressions:
      - key: app
        operator: In
        values: ["api-gateway", "service-a"]
        
    # 3. L7 策略:对允许的流量进行应用层检查
    toPorts:
    - ports:
      - port: "8080"
        protocol: TCP
      rules:
        http:
        - method: "GET"
          path: "/data/.*"
          # 4. 身份验证核心:JWT 校验规则
          authentication:
            # 模式为 "required",表示必须提供并验证通过 JWT
            mode: "required"
            jwt:
              # JWT 签发者,必须与认证服务签发时使用的 'iss' claim 一致
              issuer: "my-auth-service"
              # JWKS (JSON Web Key Set) URL,Cilium 会从这里获取公钥来验证 JWT 签名
              # 在生产中,这应该是一个暴露 JWKS 的 endpoint
              # 为简化,这里使用 secret
              jwksFromSecret: "auth-service-jwks"
              # 指定从哪个 HTTP Header 中提取 JWT,格式为 "name: prefix"
              # "Authorization: Bearer <token>"
              header: "Authorization: Bearer "
              # 5. Claims 校验:这是将用户身份与资源绑定的关键
              requiredClaims:
              # 规则:JWT 中必须存在一个名为 'tid' (tenant id) 的 claim
              # 并且其值必须是 'tenant-a'
              - key: "tid"
                value: "tenant-a"
              # 还可以添加其他检查,例如角色
              - key: "rol"
                value: "user"

部署与思考:

  • JWKS Secret: 上述策略依赖一个名为 auth-service-jwks 的 Secret,其中包含了用于验证 JWT 签名的公钥。认证服务需要安全地生成密钥对,并将公钥以 JWKS 格式发布或存储在这个 Secret 中。
  • 自动化: 在真实的 SaaS 平台中,租户是动态创建和销毁的。为每个租户手动创建这样的 CNP 是不现实的。正确的做法是开发一个 Kubernetes Operator。这个 Operator 会监听租户资源的创建(例如一个 Tenant CRD),然后根据模板自动生成对应的 CiliumNetworkPolicyNamespace 和其他资源。当租户被删除时,Operator 会自动清理这些资源。

架构的扩展性与局限性

这个架构具备良好的扩展性。我们可以通过在 JWT 中添加更丰富的 claims(如 permissions: ["read", "write"]),并在 Cilium 的 L7 策略中对 HTTP methodpath 进行更精细的控制,从而实现更细粒度的 API 授权,而这一切都在网络层完成,对应用透明。

局限性与待解决问题:

  1. 性能开销: 尽管 eBPF 性能极高,但在数据路径上对每个符合条件的包进行 JWT 解析和验证仍然会带来一定的延迟。对于延迟极度敏感的应用,需要进行详尽的性能压测以评估影响。
  2. 证书与密钥管理: 整个体系的安全性依赖于 JWT 签名密钥的保密性。需要建立一套健壮的密钥轮换和管理机制,例如使用 HashiCorp Vault 或云厂商的 KMS 服务。JWKS 的分发和缓存策略也需要仔细设计。
  3. 协议限制: Cilium 的 L7 策略目前主要支持 HTTP、gRPC、Kafka 等常见应用协议。对于自定义的 TCP 协议或数据库连接(如 aSQL),这种基于 JWT 的 L7 策略将无法生效。这些场景仍然需要依赖 mTLS 和传统的网络策略进行 L3/L4 层的隔离。
  4. 调试复杂性: 当请求失败时,问题可能出在应用逻辑、网络策略、JWT claims、密钥或 Cilium 配置等任何一个环节。需要建立完善的可观测性体系,将 Cilium 的可观测性数据(如 Hubble)与应用日志、分布式追踪相结合,才能快速定位问题根源。例如,Hubble 可以明确地告诉你一个请求是因为 JWT 验证失败而被拒绝的。

  目录