Files
juwan-backend/docs/ENVOY_GATEWAY_GUIDE.md
2026-04-05 12:06:39 +08:00

21 KiB
Raw Permalink Blame History

Envoy Gateway 配置指南(带 JWT 认证)

📋 目录

  1. 快速开始
  2. 添加新服务
  3. JWT 认证配置
  4. 分级访问控制
  5. 故障排查
  6. 当前实现说明(与仓库配置对齐)
  7. ext_authz 适配方案
  8. 前端接入示例(邮箱验证码)

当前实现说明(与仓库配置对齐)

本节对应当前实际配置文件:deploy/k8s/envoy/envoy.yaml

0) 当前公共路由(不需要登录)

当前网关对以下路径做了“公共放行”:

  • /healthz(直返 200,用于探针)
  • POST /api/v1/auth/login
  • POST /api/v1/auth/register
  • POST /api/v1/auth/forgot-password
  • POST /api/v1/auth/reset-password
  • POST /api/v1/auth/forgot-password/send
  • POST /api/v1/email/verification-code/send

此外,当前配置还对一批只读接口做了 JWT 白名单豁免,例如 GET /api/v1/games*GET /api/v1/players*GET /api/v1/services*GET /api/v1/posts*GET /api/v1/shops* 以及部分 GET /api/v1/users/* 子路径。精确范围以 deploy/k8s/envoy/envoy.yaml 中的 jwt_authn.rules 为准。

实现方式:

  • jwt_authn.rules 中将上述路径加入白名单(不要求 JWT)
  • 在路由层对上述路径关闭 ext_authz(避免公共接口和探针被二次鉴权拦截)

1) 用户认证后,UserId 放在哪里?

当前 Envoy 使用 envoy.filters.http.jwt_authn 做 JWT 校验,校验通过后通过 claim_to_headers 将 claim 注入到转发请求头:

  • UserId -> x-auth-user-id
  • IsAdmin -> x-auth-is-admin

也就是说,后端 API(如 user-api/email-api)拿到的是 HTTP Header,不是 Envoy 动态元数据。

2) 当前配置是否实现了“换票”(token renew)?

没有。

当前 Envoy 配置仅负责:

  • 从 Cookie JToken 提取 JWT
  • 用 HS256 + issuer: juwan-user-rpc 验签与过期检查

当 token 过期时,jwt_authn 会直接拒绝请求,不会调用 user-rpc 的 JwtManager.Renew

3) Envoy 能否直接调用 user-rpc.ValidateToken

结论:不能“直接”用现有 ValidateToken protobuf 接口接入 Envoy 认证链。

原因:

  • Envoy 内置认证过滤器(如 jwt_authnext_authz)要求固定协议。
  • ext_authz 的 gRPC 需要实现 Envoy 标准服务 envoy.service.auth.v3.Authorization,不是业务自定义的 pb.usercenter/ValidateToken

当前仓库已经采用 authz-adapter 方案:Envoy 先做 jwt_authn,再通过 ext_authz 调用 authz-adapter,由它内部调用 user-rpc.ValidateToken 做会话态二次校验。

当前链路包含:

  • authz-adapter 服务(实现 Envoy CheckRequest/CheckResponse
  • Envoy ext_authz filter 与 authz_adapter_cluster
  • jwt_authn 注入 x-auth-user-idx-auth-is-admin
  • authz-adapter 透传 x-auth-user-idx-auth-role-type
  • 失败码与错误体规范(401/403

4) 与你现有 ValidateTokenLogic 的一致性提醒

当前 app/users/rpc/internal/logic/validateTokenLogic.go 中:

  • JwtManager.Valid() 负责验证 JWT 字符串本身
  • 当前逻辑还会校验 JWT payload 中的 UserId 与请求传入的 userId 一致,再查询数据库回填 RoleType

这意味着当前 ext_authz -> user-rpc.ValidateToken 链已经具备 token 有效性和 userId 一致性校验。


ext_authz 适配方案

如果你希望 Envoy 在鉴权阶段调用 user-rpc.ValidateToken,请看完整落地文档:

该文档包含:

  • Envoy 标准 Authorization.Check 最小实现骨架
  • 调用 user-rpc.ValidateToken 的适配逻辑示例
  • 可直接嵌入现有网关的 ext_authz filter + cluster 配置片段

前端接入示例(邮箱验证码)

以下示例基于当前 K8s 网关配置:

  • 登录:POST /api/v1/auth/login(公共放行)
  • 发送验证码:POST /api/v1/email/verification-code/send(公共放行,无需登录)
  • CSRF 头:xsrf-token(请求头)
  • CSRF Cookie__Host-XSRF-TOKEN(可读)
  • CSRF Guard Cookie__Host-XSRF-GUARDHttpOnly
  • JWT CookieJTokenHttpOnly,前端不可读,但会随请求自动携带)

注意:当前 Envoy 给 CSRF Cookie 设置了 Secure + SameSite=Strict。前端必须走 HTTPS 且同站点 才能稳定工作。

接入流程

  1. 先发一个安全方法请求(GET),让 Envoy 下发 XSRF 双 Cookie。
  2. 注册场景可直接调用发送验证码接口,仅需携带 xsrf-token
  3. 登录场景可先调用登录接口拿 JToken,后续访问受保护接口时自动携带。

前端示例(TypeScript + fetch

const API_BASE = "https://your-gateway-domain";

function getCookie(name: string): string {
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
  return match ? decodeURIComponent(match[1]) : "";
}

async function primeXsrfCookies() {
  await fetch(`${API_BASE}/healthz`, {
    method: "GET",
    credentials: "include",
  });
}

async function login(username: string, password: string) {
  const xsrfToken = getCookie("__Host-XSRF-TOKEN");
  const res = await fetch(`${API_BASE}/api/v1/auth/login`, {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json",
      "xsrf-token": xsrfToken,
    },
    body: JSON.stringify({ username, password }),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`login failed: ${res.status} ${text}`);
  }

  return res.json();
}

type SendCodeReq = {
  email: string;
  scene: "register" | "login" | "reset_password" | "bind_email";
};

async function sendVerificationCode(req: SendCodeReq) {
  const xsrfToken = getCookie("__Host-XSRF-TOKEN");
  const res = await fetch(`${API_BASE}/api/v1/email/verification-code/send`, {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json",
      "xsrf-token": xsrfToken,
    },
    body: JSON.stringify(req),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`send code failed: ${res.status} ${text}`);
  }

  return res.json() as Promise<{
    requestId: string;
    expireInSec: number;
    message: string;
  }>;
}

// 页面初始化时建议执行一次
await primeXsrfCookies();

// 注册场景:无需登录即可发送验证码
const data = await sendVerificationCode({
  email: "alice@example.com",
  scene: "register",
});

console.log("code request:", data);

// 如需调用受保护接口,再执行登录
await login("alice", "P@ssw0rd");

常见前端坑位

  • 必须加 credentials: "include",否则 Cookie 不会带上。
  • JTokenHttpOnly,前端读不到,这是正常设计。
  • 如果你前后端跨站点,SameSite=Strict 会导致 Cookie 不发送;需要改网关 Cookie 策略。
  • 本地 http://localhost 下,Secure Cookie 不会生效;建议本地也走 HTTPS(例如反向代理或证书)。

快速开始

前置条件

  • K8s 集群正在运行(已验证
  • Envoy Gateway Pod 处于 Running 状态
  • 所有后端服务已部署

当前网关状态

# 查看 Envoy Pod
kubectl get pods -n juwan -l app=envoy-gateway

# 查看网关 Service
kubectl get svc -n juwan envoy-gateway

# 查看 ConfigMap
kubectl get cm -n juwan envoy-config

访问网关

# 通过 kubectl 端口转发(本地测试)
kubectl port-forward -n juwan svc/envoy-gateway 8080:80 &

# 测试
curl http://localhost:8080/healthz

添加新服务

场景:添加 Product 服务

1. 创建服务的 K8s 部署清单

编辑或创建 deploy/k8s/service/product/product-api.yaml

apiVersion: v1
kind: Namespace
metadata:
  name: juwan
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: product-api-config
  namespace: juwan
data:
  product-api.yaml: |
    Name: product-api
    Host: 0.0.0.0
    Port: 8890
    Database:
      DataSource: postgres://user:pass@pg-dx:5432/juwan
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: product-api
  namespace: juwan
  labels:
    app: product-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: product-api
  template:
    metadata:
      labels:
        app: product-api
    spec:
      containers:
      - name: api
        image: your-registry/product-api:latest
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8890
          name: http
        volumeMounts:
        - name: config
          mountPath: /etc/product-api
        env:
        - name: TZ
          value: "Asia/Shanghai"
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 500m
            memory: 512Mi
        livenessProbe:
          httpGet:
            path: /health
            port: 8890
          initialDelaySeconds: 10
          periodSeconds: 10
      volumes:
      - name: config
        configMap:
          name: product-api-config
---
apiVersion: v1
kind: Service
metadata:
  name: product-api-svc
  namespace: juwan
spec:
  selector:
    app: product-api
  ports:
  - port: 8890
    targetPort: 8890
    name: http
  type: ClusterIP

2. 在 Envoy 网关中添加路由

编辑 deploy/k8s/envoy-gateway.yaml,在 route_configroutes 部分添加:

# ... 在现有路由下方添加:
- match:
    prefix: /api/products
  route:
    cluster: product_api_cluster
    timeout: 30s

3. 在 Envoy 网关中添加上游集群

编辑 deploy/k8s/envoy-gateway.yaml,在 clusters 部分添加:

- name: product_api_cluster
  connect_timeout: 5s
  type: STRICT_DNS
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: product_api_cluster
    endpoints:
      - lb_endpoints:
          - endpoint:
              address:
                socket_address:
                  address: product-api-svc.juwan.svc.cluster.local
                  port_value: 8890
  health_checks:
    - timeout: 3s
      interval: 10s
      unhealthy_threshold: 2
      healthy_threshold: 2
      http_health_check:
        path: /health

4. 部署到集群

# 部署 Product API
kubectl apply -f deploy/k8s/service/product/product-api.yaml

# 更新 Envoy 配置
kubectl apply -f deploy/k8s/envoy-gateway.yaml

# 重启 Envoy Pod 以加载新配置
kubectl delete pods -n juwan -l app=envoy-gateway

# 验证
kubectl get pods -n juwan

# 测试新接口
curl http://localhost:8080/api/products

JWT 认证配置

1. 生成 JWT 密钥并存储

# 执行设置脚本
bash deploy/envoy/setup-jwt-auth.sh

# 或手动执行
JWT_SECRET=$(openssl rand -hex 32)
echo "保存这个密钥: $JWT_SECRET"

# 创建 K8s Secret
kubectl create secret generic jwt-secret \
  --from-literal=key=$JWT_SECRET \
  -n juwan

2. 配置 Envoy JWT 认证

编辑 deploy/k8s/envoy-gateway.yaml,更新 http_filters 部分:

http_filters:
  # JWT 认证过滤器(必须在 router 之前)
  - name: envoy.filters.http.jwt_authn
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
      
      providers:
        default:
          issuer: "juwan"
          audiences: "api"
          # 使用 ConfigMap 中的 JWKS(已通过 volumeMount 挂载)
          local_jwks:
            filename: /etc/envoy/jwks.json
      
      rules:
        # 规则1: 登录端点不需要认证
        - match:
            prefix: /api/users/login
          allow_missing_or_failed: true
        
        # 规则2: 注册端点不需要认证
        - match:
            prefix: /api/users/register
          allow_missing_or_failed: true
        
        # 规则3: 获取公开商品列表不需要认证
        - match:
            prefix: /api/products
            case_sensitive: false
          methods: ["GET"]  # 仅 GET 不需要认证
          allow_missing_or_failed: true
        
        # 规则4: 其他所有路由需要认证
        - match:
            prefix: "/"
          requires:
            provider_name: "default"
  
  # 路由过滤器(在 JWT 认证之后)
  - name: envoy.filters.http.router
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

3. 在 Envoy Deployment 中挂载 JWKS

编辑 deploy/k8s/envoy-gateway.yaml 的 Deployment 部分:

spec:
  # ... 其他配置 ...
  template:
    spec:
      containers:
      - name: envoy
        # ... 其他配置 ...
        volumeMounts:
        - name: envoy-config
          mountPath: /etc/envoy
        - name: jwks-config      # ← 新增
          mountPath: /etc/envoy
      
      volumes:
      - name: envoy-config
        configMap:
          name: envoy-config
      - name: jwks-config        # ← 新增
        configMap:
          name: jwks-config
          items:
          - key: jwks.json
            path: jwks.json

4. 在 API 服务中生成 JWT Token

在 User API 的 login 端点(app/users/api/internal/logic/user/loginlogic.go):

package user

import (
    "context"
    "time"
    "github.com/golang-jwt/jwt/v4"
    "app/users/api/internal/svc"
    "app/users/api/internal/types"
)

type LoginLogic struct {
    ctx    context.Context
    svcCtx *svc.ServiceContext
}

func (l *LoginLogic) Login(req *types.LoginReq) (*types.LoginResp, error) {
    // TODO: 验证用户名和密码
    
    // 从配置中获取 JWT 密钥
    jwtSecret := l.svcCtx.Config.JwtSecret
    if jwtSecret == "" {
        jwtSecret = "default-secret"  // 开发环境默认值
    }
    
    // 生成 JWT Token
    claims := jwt.MapClaims{
        "userId":   1,  // 实际应从数据库获取
        "username": req.Username,
        "exp":      time.Now().Add(24 * time.Hour).Unix(),
        "iat":      time.Now().Unix(),
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, err := token.SignedString([]byte(jwtSecret))
    if err != nil {
        return nil, err
    }
    
    return &types.LoginResp{
        Token:   tokenString,
        Expires: time.Now().Add(24 * time.Hour).Unix(),
        UserId:  1,
        Username: req.Username,
        Email:   "user@example.com",
    }, nil
}

5. 在 API 配置中设置 JWT 密钥

编辑 app/users/api/etc/user-api.yaml

Name: user-api
Host: 0.0.0.0
Port: 8888

JwtSecret: "${JWT_SECRET}"  # 环境变量

Database:
  DataSource: postgres://...

UserRpc:
  Endpoints:
    - user-rpc-svc.juwan.svc.cluster.local:50051

编辑 app/users/api/internal/config/config.go

package config

import "github.com/zeromicro/go-zero/rest"

type Config struct {
    rest.RestConf
    JwtSecret string `json:"jwtSecret"`
}

在 K8s Deployment 中设置环境变量:

spec:
  template:
    spec:
      containers:
      - name: api
        env:
        - name: JWT_SECRET
          valueFrom:
            secretKeyRef:
              name: jwt-secret
              key: key

分级访问控制

场景1: 获取用户信息(有权限区分)

如果用户查看自己的信息 → 返回完整数据
如果用户查看他人信息 → 返回部分数据

在 RPC 服务中实现

编辑 app/users/rpc/internal/logic/getUsersByIdLogic.go

func (l *GetUsersByIdLogic) GetUsersById(ctx context.Context, in *pb.GetUsersByIdReq) (*pb.GetUsersByIdResp, error) {
    // 获取请求者的 userId(由 API 层通过 context 传递)
    requesterID, ok := ctx.Value("userId").(int64)
    if !ok {
        requesterID = 0  // 未认证用户
    }
    
    targetID := in.Id
    
    // 查询数据库
    user := l.svcCtx.UserModel.FindOne(ctx, targetID)
    if user == nil {
        return nil, status.Error(codes.NotFound, "user not found")
    }
    
    resp := &pb.GetUsersByIdResp{
        Users: &pb.Users{
            UserId:    user.UserId,
            Username:  user.Username,
            CreatedAt: user.CreatedAt,
        },
    }
    
    // 权限检查:自己可以看全部,别人只能看部分
    if requesterID == targetID {
        resp.Users.Email = user.Email      // ✅ 自己可见
        resp.Users.Phone = user.Phone      // ✅ 自己可见
        resp.Users.Passwd = ""             // ❌ 密码永远不返回
    }
    // else: 只返回基本信息(username, userId
    
    return resp, nil
}

在 API 层调用时传递 userId

编辑 app/users/api/internal/logic/user/getUserInfoLogic.go

func (l *GetUserInfoLogic) GetUserInfo(req *types.GetUserInfoReq) (*types.UserInfo, error) {
    // 从 context 获取当前认证用户
    currentUserID, ok := l.ctx.Value("userId").(int64)
    if !ok {
        // 未认证 → 只能查看公开信息
        currentUserID = 0
    }
    
    // 调用 RPC,传递 userId
    ctx := context.WithValue(l.ctx, "userId", currentUserID)
    rpcResp, err := l.svcCtx.UserRpc.GetUsersById(ctx, &pb.GetUsersByIdReq{
        Id: req.UserId,
    })
    if err != nil {
        return nil, err
    }
    
    return &types.UserInfo{
        UserId:   rpcResp.Users.UserId,
        Username: rpcResp.Users.Username,
        Email:    rpcResp.Users.Email,
        Phone:    rpcResp.Users.Phone,
        CreateAt: rpcResp.Users.CreatedAt,
    }, nil
}

场景2: 修改用户信息(只能修改自己)

func (l *UpdateUserInfoLogic) UpdateUserInfo(req *types.UpdateUserInfoReq) (*types.UpdateUserInfoResp, error) {
    // 获取当前认证用户
    currentUserID, ok := l.ctx.Value("userId").(int64)
    if !ok {
        return nil, errors.New("unauthorized")
    }
    
    // 权限检查:只能修改自己的信息
    if currentUserID != req.UserId {
        return nil, errors.New("forbidden: can only update your own info")
    }
    
    // 更新用户信息
    // ...
    
    return &types.UpdateUserInfoResp{
        Message: "更新成功",
    }, nil
}

故障排查

问题1: Envoy Pod 启动失败

# 查看日志
kubectl logs -n juwan -l app=envoy-gateway --tail=100

# 常见错误及解决
# Error: "no such field"
#   → YAML 字段名拼写错误或与 Envoy 版本不兼容
#   → 检查 Envoy 版本并查看官方文档

# Error: "unknown cluster"
#   → envoy-gateway.yaml 中缺少 cluster 定义
#   → 确保添加了所有需要的 cluster 部分

# Error: "unknown extension type"
#   → 使用了 Envoy 不支持的扩展类型
#   → 检查 "@type" 字段是否正确

问题2: JWT 认证失败

# 验证 JWKS ConfigMap 是否存在
kubectl get cm -n juwan jwks-config

# 查看 JWKS 内容
kubectl get cm jwks-config -n juwan -o jsonpath='{.data.jwks\.json}'

# 验证 Envoy 能否读取 JWKS
kubectl exec -it {envoy-pod-name} -n juwan -- ls -la /etc/envoy/

# 测试没有 Token 的请求(应返回 401)
curl -v http://localhost/api/users/1

# 测试有效 Token 的请求
TOKEN="your-jwt-token"
curl -H "Authorization: Bearer $TOKEN" http://localhost/api/users/1

问题3: 后端服务无法访问

# 查看 Service 是否存在
kubectl get svc -n juwan

# 测试 DNS 解析
kubectl exec -it {pod-name} -n juwan -- \
  nslookup product-api-svc.juwan.svc.cluster.local

# 查看 Pod 是否正确运行
kubectl get pods -n juwan -l app=product-api

# 查看后端服务日志
kubectl logs -n juwan -l app=product-api --tail=50

# Envoy 检查上游集群状态
kubectl exec -it {envoy-pod-name} -n juwan -- \
  curl localhost:9901/clusters | grep -A5 product_api_cluster

问题4: 跨域请求失败

如果前端遇到 CORS 问题:

# 在 Envoy 配置中添加 CORS 过滤器
http_filters:
  - name: envoy.filters.http.cors
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
  
  # JWT 认证过滤器(在 CORS 之后)
  - name: envoy.filters.http.jwt_authn
    # ...

配置更新流程

每次修改 envoy-gateway.yaml 后的完整更新步骤:

# 1. 验证 YAML 语法
kubectl apply -f deploy/k8s/envoy-gateway.yaml --dry-run=client

# 2. 应用配置
kubectl apply -f deploy/k8s/envoy-gateway.yaml

# 3. 监控 Pod 重启(应该自动重新加载)
kubectl get pods -n juwan -l app=envoy-gateway -w

# 4. 查看最新日志确认无错误
kubectl logs -n juwan -l app=envoy-gateway --tail=50

# 5. 测试新配置
curl http://localhost/api/your-new-endpoint

总结

任务 文件 说明
添加新 API desc/api/, app/*/api/ 定义接口并实现业务逻辑
添加新 RPC desc/rpc/, app/*/rpc/ 内部服务通信(不通过网关)
更新网关路由 deploy/k8s/envoy-gateway.yaml 添加路由、集群、认证规则
配置认证 deploy/envoy/setup-jwt-auth.sh 生成和管理 JWT 密钥
部署到 K8s deploy/k8s/service/ 创建服务的 Deployment 和 Service

需要更多帮助?查看 PROJECT_GUIDE.md 了解完整的项目架构和工作流!