# Envoy ext_authz 适配 user-rpc.ValidateToken(最小实现) ## 目标 - Envoy 不直接调用业务 proto 方法。 - 新增一个内部服务 `authz-adapter`,实现 Envoy 标准 gRPC 鉴权接口。 - `authz-adapter` 再调用现有 `user-rpc.ValidateToken` 完成鉴权。 --- ## 1) 最小接口定义(Envoy 标准) `authz-adapter` 需要实现的是 Envoy 官方服务: - gRPC Service: `envoy.service.auth.v3.Authorization` - RPC Method: `Check(CheckRequest) returns (CheckResponse)` Go 里通常使用包: - `github.com/envoyproxy/go-control-plane/envoy/service/auth/v3` - `github.com/envoyproxy/go-control-plane/envoy/type/v3` --- ## 2) 最小 Go 适配器骨架 ```go package main import ( "context" "net" "strconv" "strings" core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" "google.golang.org/grpc" userpb "juwan-backend/app/users/rpc/pb" ) type server struct { authv3.UnimplementedAuthorizationServer userRpc userpb.UsercenterClient } func (s *server) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.CheckResponse, error) { attrs := req.GetAttributes() httpReq := attrs.GetRequest().GetHttp() if httpReq == nil { return deny(403, "missing http attributes"), nil } path := httpReq.GetPath() method := strings.ToUpper(httpReq.GetMethod()) // 放行公共接口(探针、登录/注册、发送验证码) if path == "/healthz" || path == "/api/users/login" || path == "/api/users/register" || path == "/api/email/verification-code/send" { return allow(nil), nil } token := extractCookie(httpReq.GetHeaders(), "JToken") if token == "" { return deny(401, "missing token cookie"), nil } userID, err := parseUserIDFromPath(path) if err != nil { return deny(401, "invalid user id in path"), nil } // 调用你现有业务 RPC vt, err := s.userRpc.ValidateToken(ctx, &userpb.ValidateTokenReq{ Token: token, UserId: userID, }) if err != nil || vt == nil || !vt.Valid { return deny(401, "invalid token"), nil } // 透传给后端 API headers := []*core.HeaderValueOption{ { Header: &core.HeaderValue{Key: "x-auth-user-id", Value: strconv.FormatInt(vt.UserId, 10)}, }, { Header: &core.HeaderValue{Key: "x-auth-role-type", Value: strconv.FormatInt(vt.RoleType, 10)}, }, { Header: &core.HeaderValue{Key: "x-auth-method", Value: method}, }, } return allow(headers), nil } func allow(headers []*core.HeaderValueOption) *authv3.CheckResponse { return &authv3.CheckResponse{ Status: &typev3.Status{Code: int32(typev3.Code_OK)}, HttpResponse: &authv3.CheckResponse_OkResponse{ OkResponse: &authv3.OkHttpResponse{Headers: headers}, }, } } func deny(code int32, msg string) *authv3.CheckResponse { return &authv3.CheckResponse{ Status: &typev3.Status{Code: int32(typev3.Code_PERMISSION_DENIED)}, HttpResponse: &authv3.CheckResponse_DeniedResponse{ DeniedResponse: &authv3.DeniedHttpResponse{ Status: &typev3.HttpStatus{Code: typev3.StatusCode(code)}, Body: "{\"code\":" + strconv.Itoa(int(code)) + ",\"message\":\"" + msg + "\"}", Headers: []*core.HeaderValueOption{ {Header: &core.HeaderValue{Key: "content-type", Value: "application/json"}}, }, }, }, } } func extractCookie(headers map[string]string, name string) string { c := headers["cookie"] parts := strings.Split(c, ";") for _, p := range parts { kv := strings.SplitN(strings.TrimSpace(p), "=", 2) if len(kv) == 2 && kv[0] == name { return kv[1] } } return "" } func parseUserIDFromPath(path string) (int64, error) { // 仅示例:请按你的真实路由解析,或改为从 token claim 取 userId seg := strings.Split(strings.Trim(path, "/"), "/") for i := 0; i < len(seg); i++ { if seg[i] == "users" && i+1 < len(seg) { return strconv.ParseInt(seg[i+1], 10, 64) } } return 0, strconv.ErrSyntax } func main() { lis, _ := net.Listen("tcp", ":9002") grpcServer := grpc.NewServer() // TODO: 创建 user-rpc client 并注入 // userRpcClient := ... authv3.RegisterAuthorizationServer(grpcServer, &server{userRpc: nil}) _ = grpcServer.Serve(lis) } ``` --- ## 3) Envoy 最小配置片段(插入现有 `http_filters`) 在 `envoy.filters.http.router` 之前加入: ```yaml - name: envoy.filters.http.ext_authz typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz transport_api_version: V3 failure_mode_allow: false with_request_body: max_request_bytes: 8192 allow_partial_message: true grpc_service: envoy_grpc: cluster_name: authz_adapter_cluster timeout: 0.5s ``` 并在 `clusters` 中加入: ```yaml - name: authz_adapter_cluster connect_timeout: 0.5s type: STRICT_DNS lb_policy: ROUND_ROBIN load_assignment: cluster_name: authz_adapter_cluster endpoints: - lb_endpoints: - endpoint: address: socket_address: address: authz-adapter-svc.juwan.svc.cluster.local port_value: 9002 http2_protocol_options: {} ``` > gRPC 上游必须启用 `http2_protocol_options: {}`。 --- ## 4) 落地清单 1. 新建 `authz-adapter` 服务(Deployment + Service)。 2. Adapter 内部连 `user-rpc-svc:9001`,调用 `ValidateToken`。 3. Envoy 加 `ext_authz` filter + `authz_adapter_cluster`。 4. 明确失败语义: - 无 token -> 401 - token 无效/过期 -> 401 - 权限不足 -> 403 5. 透传统一鉴权头:`x-auth-user-id`、`x-auth-role-type`。 6. 灰度建议:先仅对 `/api/users` 开启,再扩展到 `/api/email`。 > 实践建议:若保留 K8s `readiness/liveness` 探针使用 `/healthz`,请确保该路径在 `ext_authz` 上也放行,否则会出现探针 403 导致 Pod 重启。 --- ## 5) 与当前 `jwt_authn` 的关系 - 可以并存: - 先 `jwt_authn` 快速验签 - 再 `ext_authz` 做 Redis 会话态、黑名单、细粒度权限 - 也可以只保留 `ext_authz`(由 adapter 内完成全部逻辑)。 推荐:**先并存**,稳定后再决定是否简化。