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

818 lines
21 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.
# 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` 了解完整的项目架构和工作流!