Files
juwan-backend/docs/ENVOY_EXT_AUTHZ_ADAPTER.md
T
wwweww 659168fe32 feat: add authz-adapter service and Envoy ext_authz integration
- Implemented authz-adapter deployment and service for Envoy gRPC authorization.
- Created PowerShell script to generate JWK for JWT authentication.
- Documented the integration of ext_authz with user-rpc.ValidateToken in ENVOY_EXT_AUTHZ_ADAPTER.md.
- Added comprehensive Envoy Gateway configuration guide with JWT authentication and access control in ENVOY_GATEWAY_GUIDE.md.
2026-02-26 06:08:35 +08:00

6.5 KiB
Raw Blame History

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 适配器骨架

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 之前加入:

- 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 中加入:

- 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-idx-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 内完成全部逻辑)。

推荐:先并存,稳定后再决定是否简化。