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

231 lines
6.7 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 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()
// 放行公共接口(探针、登录/注册、发送验证码)
if path == "/healthz" ||
path == "/api/v1/auth/login" ||
path == "/api/v1/auth/register" ||
path == "/api/v1/auth/forgot-password" ||
path == "/api/v1/auth/reset-password" ||
path == "/api/v1/auth/forgot-password/send" ||
path == "/api/v1/email/verification-code/send" {
return allow(nil), nil
}
token := extractCookie(httpReq.GetHeaders(), "JToken")
if token == "" {
return deny(401, "missing token cookie"), nil
}
userIDHeader := getHeader(httpReq.GetHeaders(), "x-auth-user-id")
if userIDHeader == "" {
return deny(401, "missing x-auth-user-id header"), nil
}
userID, err := strconv.ParseInt(userIDHeader, 10, 64)
if err != nil {
return deny(401, "invalid x-auth-user-id"), 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: vt.RoleType},
},
}
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 getHeader(headers map[string]string, name string) string {
for k, v := range headers {
if strings.EqualFold(k, name) {
return v
}
}
return ""
}
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. `jwt_authn` 先注入 `x-auth-user-id``x-auth-is-admin`,再由 adapter 透传 `x-auth-user-id``x-auth-role-type`
6. 灰度建议:先仅对 `/api/v1/auth``/api/v1/email` 验证链路,再逐步扩展到其它业务路径。
> 实践建议:若保留 K8s `readiness/liveness` 探针使用 `/healthz`,请确保该路径在 `ext_authz` 上也放行,否则会出现探针 403 导致 Pod 重启。
---
## 5) 与当前 `jwt_authn` 的关系
- 可以并存:
-`jwt_authn` 快速验签并注入 claim header
-`ext_authz` 做 Redis 会话态、黑名单、细粒度权限
- 也可以只保留 `ext_authz`(由 adapter 内完成全部逻辑)。
推荐:**先并存**,稳定后再决定是否简化。