# Envoy Gateway 配置指南（带 JWT 认证）

## 📋 目录

1. [快速开始](#快速开始)
2. [添加新服务](#添加新服务)
3. [JWT 认证配置](#jwt-认证配置)
4. [分级访问控制](#分级访问控制)
5. [故障排查](#故障排查)
6. [当前实现说明（与仓库配置对齐）](#当前实现说明与仓库配置对齐)
7. [ext_authz 适配方案](#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_authn`、`ext_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-id`、`x-auth-is-admin`
- `authz-adapter` 透传 `x-auth-user-id`、`x-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_EXT_AUTHZ_ADAPTER.md](ENVOY_EXT_AUTHZ_ADAPTER.md)

该文档包含：

- 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-GUARD`（`HttpOnly`）
- JWT Cookie：`JToken`（`HttpOnly`，前端不可读，但会随请求自动携带）

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

### 接入流程

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

### 前端示例（TypeScript + fetch）

```ts
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 不会带上。
- `JToken` 是 `HttpOnly`，前端读不到，这是正常设计。
- 如果你前后端跨站点，`SameSite=Strict` 会导致 Cookie 不发送；需要改网关 Cookie 策略。
- 本地 `http://localhost` 下，`Secure` Cookie 不会生效；建议本地也走 HTTPS（例如反向代理或证书）。

---

## 快速开始

### 前置条件

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

### 当前网关状态

```bash
# 查看 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
```

### 访问网关

```bash
# 通过 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`：

```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_config` 的 `routes` 部分添加：

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

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

编辑 `deploy/k8s/envoy-gateway.yaml`，在 `clusters` 部分添加：

```yaml
- 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. 部署到集群

```bash
# 部署 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
# 执行设置脚本
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` 部分：

```yaml
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 部分：

```yaml
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`）：

```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`：

```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`：

```go
package config

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

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

在 K8s Deployment 中设置环境变量：

```yaml
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`：

```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`：

```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: 修改用户信息（只能修改自己）

```go
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 启动失败

```bash
# 查看日志
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 认证失败

```bash
# 验证 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: 后端服务无法访问

```bash
# 查看 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 问题：

```yaml
# 在 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` 后的完整更新步骤：

```bash
# 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` 了解完整的项目架构和工作流！
