From 384471edcaf0f1bdfb0b0c4fec881d6a06dd8261 Mon Sep 17 00:00:00 2001 From: zetaloop Date: Sun, 5 Apr 2026 12:06:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=20authz=20=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/authz/adapter/authz.go | 12 +- .../rpc/internal/logic/validateTokenLogic.go | 9 +- deploy/dev/README.md | 16 +- deploy/dev/build.sh | 32 +- deploy/dev/docker-compose.yml | 47 ++ deploy/dev/envoy.yaml | 705 ++++++++++++++++++ deploy/k8s/envoy/envoy.yaml | 2 +- docs/ENVOY_EXT_AUTHZ_ADAPTER.md | 48 +- docs/ENVOY_GATEWAY_GUIDE.md | 51 +- 9 files changed, 864 insertions(+), 58 deletions(-) create mode 100644 deploy/dev/envoy.yaml diff --git a/app/authz/adapter/authz.go b/app/authz/adapter/authz.go index 671460d..a18c4b0 100644 --- a/app/authz/adapter/authz.go +++ b/app/authz/adapter/authz.go @@ -114,10 +114,18 @@ func deny(code codepb.Code, httpCode typev3.StatusCode, message string) *authv3. } func isPublicPath(path string) bool { - if path == "/healthz" || path == "/api/users/login" || path == "/api/users/register" { + switch path { + case "/healthz", + "/api/v1/auth/login", + "/api/v1/auth/register", + "/api/v1/auth/forgot-password", + "/api/v1/auth/reset-password", + "/api/v1/auth/forgot-password/send", + "/api/v1/email/verification-code/send": return true + default: + return false } - return false } func getHeader(headers map[string]string, key string) string { diff --git a/app/users/rpc/internal/logic/validateTokenLogic.go b/app/users/rpc/internal/logic/validateTokenLogic.go index d4b42fd..2ee4196 100644 --- a/app/users/rpc/internal/logic/validateTokenLogic.go +++ b/app/users/rpc/internal/logic/validateTokenLogic.go @@ -30,13 +30,16 @@ func NewValidateTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Val func (l *ValidateTokenLogic) ValidateToken(in *pb.ValidateTokenReq) (*pb.ValidateTokenResp, error) { - _, err := l.svcCtx.JwtManager.Valid(l.ctx, in.Token) + payload, err := l.svcCtx.JwtManager.Valid(l.ctx, in.Token) if err != nil { return nil, err } + if payload == nil || payload.UserId != in.UserId { + return nil, errors.New("token user mismatch") + } //users, err := l.svcCtx.UsersModelRO.FindOne(l.ctx, in.UserId) user, err := l.svcCtx.UsersModelRO.Users.Query(). - Where(users.IDEQ(in.UserId)). + Where(users.IDEQ(payload.UserId)). Select(users.FieldCurrentRole). First(l.ctx) if err != nil { @@ -52,7 +55,7 @@ func (l *ValidateTokenLogic) ValidateToken(in *pb.ValidateTokenReq) (*pb.Validat return &pb.ValidateTokenResp{ Valid: true, Message: "OK", - UserId: in.UserId, + UserId: payload.UserId, RoleType: string(userJson), }, nil } diff --git a/deploy/dev/README.md b/deploy/dev/README.md index f92d05c..2734fd1 100644 --- a/deploy/dev/README.md +++ b/deploy/dev/README.md @@ -19,11 +19,16 @@ docker compose up -d # 3. 查看状态 docker compose ps -# 4. 停止 +# 4. 通过网关访问 +curl http://127.0.0.1:18080/healthz + +# 5. 停止 docker compose down ``` -构建脚本会扫描 `app/` 下所有 `api`、`rpc`、`mq` 入口,通过 `docker buildx bake` 并行构建所有服务镜像,生成 `juwan/-:dev`。 +构建脚本会扫描 `app/` 下所有 `api`、`rpc`、`mq`、`adapter` 入口,通过 `docker buildx bake` 并行构建所有服务镜像,生成 `juwan/-:dev`。 + +端到端接口测试走网关 `http://127.0.0.1:18080`,`18801-18809` 是各服务的直连端口,不经过认证链路。 如需只启动部分服务: @@ -38,6 +43,7 @@ docker compose up -d postgres redis snowflake player-rpc player-api | PostgreSQL | 15432 | | Redis | 16379 | | Kafka | 19092 | +| Envoy Gateway | 18080 | | users-api | 18801 | | player-api | 18802 | | game-api | 18803 | @@ -52,6 +58,12 @@ docker compose up -d postgres redis snowflake player-rpc player-api 编辑 `.env` 修改数据库密码、Kafka 地址等。默认值可直接用于本地开发。 +## 认证 + +登录和注册通过 `users-api` 下发 `JToken` Cookie。`envoy-gateway` 负责 JWT 校验并注入认证头,`authz-adapter` 做会话态二次校验,后端服务只消费 `x-auth-user-id` 等头。 + +写接口需要先 `GET /healthz` 领取 `XSRF-TOKEN` 和 `XSRF-GUARD`,再在请求头带上 `xsrf-token`。 + ## 数据库初始化 首次启动时 PostgreSQL 会自动执行 `desc/sql/` 下的建表语句。如需重新初始化,删除 volume 后重启: diff --git a/deploy/dev/build.sh b/deploy/dev/build.sh index 1035cbc..2bcd63d 100755 --- a/deploy/dev/build.sh +++ b/deploy/dev/build.sh @@ -38,22 +38,48 @@ COPY {service_dir}/etc /app/etc CMD ["./main", "-f", "etc/{config_name}"] """ +dockerfile_no_config_tpl = """\ +# syntax=docker/dockerfile:1.7 +FROM golang:1.25-alpine AS builder +RUN apk add --no-cache tzdata +WORKDIR /build +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go build -ldflags="-s -w" -o /app/main ./{service_dir} + +FROM alpine:latest +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai +ENV TZ=Asia/Shanghai +WORKDIR /app +COPY --from=builder /app/main /app/main +CMD ["./main"] +""" + targets = {} for service_dir in sorted(glob.glob("app/*/*")): service_type = os.path.basename(service_dir) - if service_type not in ("api", "rpc", "mq"): + if service_type not in ("api", "rpc", "mq", "adapter"): continue go_files = glob.glob(os.path.join(service_dir, "*.go")) has_main = any("package main" in open(f).read() for f in go_files) if go_files else False if not has_main: continue yamls = glob.glob(os.path.join(service_dir, "etc", "*.yaml")) - config_name = os.path.basename(yamls[0]) if yamls else "config.yaml" service_name = os.path.basename(os.path.dirname(service_dir)) target_name = f"{service_name}-{service_type}" + if yamls: + config_name = os.path.basename(yamls[0]) + dockerfile = dockerfile_tpl.format(service_dir=service_dir, config_name=config_name) + else: + dockerfile = dockerfile_no_config_tpl.format(service_dir=service_dir) + targets[target_name] = { - "dockerfile-inline": dockerfile_tpl.format(service_dir=service_dir, config_name=config_name), + "dockerfile-inline": dockerfile, "tags": [f"{prefix}/{target_name}:{tag}"], } diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index 37c5229..e19393a 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -65,6 +65,53 @@ services: container_name: juwan-snowflake restart: unless-stopped + authz-adapter: + image: juwan/authz-adapter:dev + container_name: juwan-authz-adapter + restart: unless-stopped + environment: + LISTEN_ON: 0.0.0.0:9002 + USER_RPC_TARGET: user-rpc:8080 + depends_on: + user-rpc: + condition: service_started + + envoy-gateway: + image: envoyproxy/envoy:v1.31-latest + container_name: juwan-envoy-gateway + restart: unless-stopped + command: + - /usr/local/bin/envoy + - -c + - /etc/envoy/envoy.yaml + - --log-level + - info + ports: + - "18080:8080" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml:ro + depends_on: + authz-adapter: + condition: service_started + users-api: + condition: service_started + player-api: + condition: service_started + game-api: + condition: service_started + shop-api: + condition: service_started + order-api: + condition: service_started + wallet-api: + condition: service_started + community-api: + condition: service_started + objectstory-api: + condition: service_started + email-api: + condition: service_started + # ==================== RPC 层 ==================== user-rpc: image: juwan/users-rpc:dev diff --git a/deploy/dev/envoy.yaml b/deploy/dev/envoy.yaml new file mode 100644 index 0000000..1a94882 --- /dev/null +++ b/deploy/dev/envoy.yaml @@ -0,0 +1,705 @@ +static_resources: + listeners: + - name: ingress_http + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: AUTO + generate_request_id: true + use_remote_address: true + route_config: + name: local_route + virtual_hosts: + - name: juwan_services + domains: ["*"] + routes: + - match: + path: /healthz + direct_response: + status: 200 + body: + inline_string: ok + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + path: /api/v1/auth/login + route: + cluster: user_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + path: /api/v1/auth/register + route: + cluster: user_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + path: /api/v1/auth/forgot-password + route: + cluster: user_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + path: /api/v1/auth/reset-password + route: + cluster: user_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + path: /api/v1/auth/forgot-password/send + route: + cluster: email_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + path: /api/v1/email/verification-code/send + route: + cluster: email_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + prefix: /api/v1/games + headers: + - name: ":method" + exact_match: GET + route: + cluster: game_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + prefix: /api/v1/players + headers: + - name: ":method" + exact_match: GET + route: + cluster: player_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + prefix: /api/v1/services + headers: + - name: ":method" + exact_match: GET + route: + cluster: player_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + path: /api/v1/shops + headers: + - name: ":method" + exact_match: GET + route: + cluster: shop_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + safe_regex: + google_re2: {} + regex: "^/api/v1/shops/[0-9]+$" + headers: + - name: ":method" + exact_match: GET + route: + cluster: shop_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + safe_regex: + google_re2: {} + regex: "^/api/v1/users/[0-9]+$" + headers: + - name: ":method" + exact_match: GET + route: + cluster: user_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + safe_regex: + google_re2: {} + regex: "^/api/v1/users/[0-9]+/posts$" + headers: + - name: ":method" + exact_match: GET + route: + cluster: community_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + safe_regex: + google_re2: {} + regex: "^/api/v1/users/[0-9]+/shop$" + headers: + - name: ":method" + exact_match: GET + route: + cluster: shop_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + prefix: /api/v1/posts + headers: + - name: ":method" + exact_match: GET + route: + cluster: community_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + path: /api/v1/files + headers: + - name: ":method" + exact_match: GET + route: + cluster: objectstory_api_cluster + timeout: 30s + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + - match: + prefix: /api/v1/auth + route: + cluster: user_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/users + route: + cluster: user_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/email + route: + cluster: email_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/games + route: + cluster: game_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/players + route: + cluster: player_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/services + route: + cluster: player_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/shops + route: + cluster: shop_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/orders + route: + cluster: order_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/wallet + route: + cluster: wallet_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/posts + route: + cluster: community_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/comments + route: + cluster: community_api_cluster + timeout: 30s + + - match: + path: /api/v1/upload + route: + cluster: objectstory_api_cluster + timeout: 30s + + - match: + prefix: /api/v1/files + route: + cluster: objectstory_api_cluster + timeout: 30s + + - match: + prefix: / + direct_response: + status: 404 + body: + inline_string: gateway route not found + + http_filters: + - name: envoy.filters.http.lua + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local TOKEN_HEADER = "xsrf-token" + local TOKEN_COOKIE = "XSRF-TOKEN" + local GUARD_COOKIE = "XSRF-GUARD" + + local seeded = false + + local function seed_random() + if seeded then + return + end + seeded = true + math.randomseed(os.time()) + end + + local function split_cookie(header) + local out = {} + if not header then + return out + end + for pair in string.gmatch(header, "([^;]+)") do + local key, value = string.match(pair, "^%s*([^=]+)=?(.*)$") + if key ~= nil and value ~= nil then + out[string.lower(key)] = value + end + end + return out + end + + local function is_safe_method(method) + return method == "GET" or method == "HEAD" or method == "OPTIONS" + end + + local function build_token(request_id) + seed_random() + local rnd = tostring(math.random(100000, 999999)) + local rid = request_id or "rid" + return tostring(os.time()) .. "-" .. rid .. "-" .. rnd + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + local method = headers:get(":method") + + local cookie_header = headers:get("cookie") + local cookies = split_cookie(cookie_header) + local token_cookie = cookies[string.lower(TOKEN_COOKIE)] + local guard_cookie = cookies[string.lower(GUARD_COOKIE)] + + request_handle:streamInfo():dynamicMetadata():set("csrf", "need_set_token_cookie", token_cookie == nil or token_cookie == "") + request_handle:streamInfo():dynamicMetadata():set("csrf", "need_set_guard_cookie", guard_cookie == nil or guard_cookie == "") + + if token_cookie == nil or token_cookie == "" then + token_cookie = build_token(headers:get("x-request-id")) + request_handle:streamInfo():dynamicMetadata():set("csrf", "token_value", token_cookie) + else + request_handle:streamInfo():dynamicMetadata():set("csrf", "token_value", token_cookie) + end + + if guard_cookie == nil or guard_cookie == "" then + guard_cookie = build_token(headers:get("x-request-id")) + request_handle:streamInfo():dynamicMetadata():set("csrf", "guard_value", guard_cookie) + else + request_handle:streamInfo():dynamicMetadata():set("csrf", "guard_value", guard_cookie) + end + + if is_safe_method(method) then + return + end + + local token_header = headers:get(TOKEN_HEADER) + + if token_header == nil or token_header == "" then + request_handle:respond( + {[":status"] = "403", ["content-type"] = "application/json"}, + '{"code":403,"message":"missing XSRF-TOKEN header"}' + ) + return + end + + if token_cookie == nil or token_cookie == "" or guard_cookie == nil or guard_cookie == "" then + request_handle:respond( + {[":status"] = "403", ["content-type"] = "application/json"}, + '{"code":403,"message":"missing csrf cookies"}' + ) + return + end + + if token_header ~= token_cookie then + request_handle:respond( + {[":status"] = "403", ["content-type"] = "application/json"}, + '{"code":403,"message":"xsrf token mismatch"}' + ) + return + end + end + + function envoy_on_response(response_handle) + local metadata = response_handle:streamInfo():dynamicMetadata():get("csrf") + if metadata == nil then + return + end + + local token_value = metadata["token_value"] + local guard_value = metadata["guard_value"] + + if metadata["need_set_token_cookie"] == true and token_value ~= nil and token_value ~= "" then + response_handle:headers():add( + "set-cookie", + TOKEN_COOKIE .. "=" .. token_value .. "; Path=/; Max-Age=7200; SameSite=Strict" + ) + end + + if metadata["need_set_guard_cookie"] == true and guard_value ~= nil and guard_value ~= "" then + response_handle:headers():add( + "set-cookie", + GUARD_COOKIE .. "=" .. guard_value .. "; Path=/; Max-Age=7200; SameSite=Strict; HttpOnly" + ) + end + end + + - name: envoy.filters.http.jwt_authn + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication + providers: + juwan_user_jwt: + issuer: juwan-user-rpc + from_cookies: + - JToken + local_jwks: + inline_string: '{"keys":[{"kty":"oct","k":"TUdVeU1XRTNaRGhqTVRRNVpEZzFNV1ZpT1dVME1HTTNPVEUyTldWa1lUQmxPVEU1WldSa1pEVTFZall6T0dKak9XUmlOek0wTlRjNE5ESXlNamxrWlE","alg":"HS256","use":"sig","kid":"juwan-hs256-1"}]}' + forward: false + claim_to_headers: + - header_name: x-auth-user-id + claim_name: UserId + - header_name: x-auth-is-admin + claim_name: IsAdmin + rules: + - match: + path: /healthz + - match: + prefix: /api/v1 + headers: + - name: ":method" + exact_match: OPTIONS + - match: + path: /api/v1/auth/login + - match: + path: /api/v1/auth/register + - match: + path: /api/v1/auth/forgot-password + - match: + path: /api/v1/auth/reset-password + - match: + path: /api/v1/auth/forgot-password/send + - match: + path: /api/v1/email/verification-code/send + - match: + prefix: /api/v1/games + headers: + - name: ":method" + exact_match: GET + - match: + prefix: /api/v1/players + headers: + - name: ":method" + exact_match: GET + - match: + prefix: /api/v1/services + headers: + - name: ":method" + exact_match: GET + - match: + path: /api/v1/shops + headers: + - name: ":method" + exact_match: GET + - match: + safe_regex: + google_re2: {} + regex: "^/api/v1/shops/[0-9]+$" + headers: + - name: ":method" + exact_match: GET + - match: + safe_regex: + google_re2: {} + regex: "^/api/v1/users/[0-9]+$" + headers: + - name: ":method" + exact_match: GET + - match: + safe_regex: + google_re2: {} + regex: "^/api/v1/users/[0-9]+/posts$" + headers: + - name: ":method" + exact_match: GET + - match: + safe_regex: + google_re2: {} + regex: "^/api/v1/users/[0-9]+/shop$" + headers: + - name: ":method" + exact_match: GET + - match: + prefix: /api/v1/posts + headers: + - name: ":method" + exact_match: GET + - match: + path: /api/v1/files + headers: + - name: ":method" + exact_match: GET + - match: + prefix: /api/v1 + requires: + provider_name: juwan_user_jwt + + - 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 + + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: user_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: user_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: users-api + port_value: 8888 + + - name: email_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: email_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: email-api + port_value: 8888 + + - name: player_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: player_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: player-api + port_value: 8888 + + - name: game_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: game_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: game-api + port_value: 8888 + + - name: shop_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: shop_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: shop-api + port_value: 8888 + + - name: order_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: order_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: order-api + port_value: 8888 + + - name: wallet_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: wallet_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: wallet-api + port_value: 8888 + + - name: community_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: community_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: community-api + port_value: 8888 + + - name: objectstory_api_cluster + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: objectstory_api_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: objectstory-api + port_value: 8888 + + - name: authz_adapter_cluster + connect_timeout: 0.5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + http2_protocol_options: {} + load_assignment: + cluster_name: authz_adapter_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: authz-adapter + port_value: 9002 + +admin: + access_log_path: /tmp/admin.log + address: + socket_address: + address: 0.0.0.0 + port_value: 9901 diff --git a/deploy/k8s/envoy/envoy.yaml b/deploy/k8s/envoy/envoy.yaml index 757807c..27e6bb1 100644 --- a/deploy/k8s/envoy/envoy.yaml +++ b/deploy/k8s/envoy/envoy.yaml @@ -391,7 +391,7 @@ data: from_cookies: - "JToken" local_jwks: - inline_string: '{"keys":[{"kty":"oct","k":"MGUyMWE3ZDhjMTQ5ZDg1MWViOWU0MGM3OTE2NWVkYTBlOTE5ZWRkZDU1YjYzOGJjOWRiNzM0NTc4NDIyMjlkZQ","alg":"HS256","use":"sig","kid":"juwan-hs256-1"}]}' + inline_string: '{"keys":[{"kty":"oct","k":"TUdVeU1XRTNaRGhqTVRRNVpEZzFNV1ZpT1dVME1HTTNPVEUyTldWa1lUQmxPVEU1WldSa1pEVTFZall6T0dKak9XUmlOek0wTlRjNE5ESXlNamxrWlE","alg":"HS256","use":"sig","kid":"juwan-hs256-1"}]}' forward: false claim_to_headers: - header_name: "x-auth-user-id" diff --git a/docs/ENVOY_EXT_AUTHZ_ADAPTER.md b/docs/ENVOY_EXT_AUTHZ_ADAPTER.md index 32b3881..6612287 100644 --- a/docs/ENVOY_EXT_AUTHZ_ADAPTER.md +++ b/docs/ENVOY_EXT_AUTHZ_ADAPTER.md @@ -4,7 +4,7 @@ - Envoy 不直接调用业务 proto 方法。 - 新增一个内部服务 `authz-adapter`,实现 Envoy 标准 gRPC 鉴权接口。 -- `authz-adapter` 再调用现有 `user-rpc.ValidateToken` 完成鉴权。 +- `authz-adapter` 再调用现有 `user-rpc.ValidateToken` 完成会话态二次校验。 --- @@ -54,13 +54,15 @@ func (s *server) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.C } 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" { + 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 } @@ -69,12 +71,17 @@ func (s *server) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.C return deny(401, "missing token cookie"), nil } - userID, err := parseUserIDFromPath(path) - if err != nil { - return deny(401, "invalid user id in path"), nil + userIDHeader := getHeader(httpReq.GetHeaders(), "x-auth-user-id") + if userIDHeader == "" { + return deny(401, "missing x-auth-user-id header"), nil } - // 调用你现有业务 RPC + 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, @@ -89,10 +96,7 @@ func (s *server) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.C 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}, + Header: &core.HeaderValue{Key: "x-auth-role-type", Value: vt.RoleType}, }, } @@ -135,15 +139,13 @@ func extractCookie(headers map[string]string, name string) string { 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) +func getHeader(headers map[string]string, name string) string { + for k, v := range headers { + if strings.EqualFold(k, name) { + return v } } - return 0, strconv.ErrSyntax + return "" } func main() { @@ -211,8 +213,8 @@ func main() { - 无 token -> 401 - token 无效/过期 -> 401 - 权限不足 -> 403 -5. 透传统一鉴权头:`x-auth-user-id`、`x-auth-role-type`。 -6. 灰度建议:先仅对 `/api/users` 开启,再扩展到 `/api/email`。 +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 重启。 @@ -221,7 +223,7 @@ func main() { ## 5) 与当前 `jwt_authn` 的关系 - 可以并存: - - 先 `jwt_authn` 快速验签 + - 先 `jwt_authn` 快速验签并注入 claim header - 再 `ext_authz` 做 Redis 会话态、黑名单、细粒度权限 - 也可以只保留 `ext_authz`(由 adapter 内完成全部逻辑)。 diff --git a/docs/ENVOY_GATEWAY_GUIDE.md b/docs/ENVOY_GATEWAY_GUIDE.md index 5d31b46..91aab5c 100644 --- a/docs/ENVOY_GATEWAY_GUIDE.md +++ b/docs/ENVOY_GATEWAY_GUIDE.md @@ -22,9 +22,14 @@ 当前网关对以下路径做了“公共放行”: - `/healthz`(直返 200,用于探针) -- `POST /api/users/login` -- `POST /api/users/register` -- `POST /api/email/verification-code/send`(注册/登录前发送验证码) +- `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` 为准。 实现方式: @@ -60,27 +65,24 @@ - 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` 做会话态二次校验。 -1. **推荐**:新增一个 `authz-adapter` 服务,实现 Envoy `ext_authz` 协议,内部再调用 `user-rpc.ValidateToken`。 -2. 备选:提供一个内部 HTTP 鉴权端点(例如 user-api internal route),Envoy 通过 `ext_authz` HTTP 模式或 Lua `httpCall()` 调用。 - -如果要走方案 1(推荐),你需要补齐: +当前链路包含: - `authz-adapter` 服务(实现 Envoy `CheckRequest/CheckResponse`) -- Envoy 新增 `ext_authz` filter 与对应 cluster -- 鉴权透传头约定(至少 `x-auth-user-id`、`x-auth-is-admin`) +- 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` 中: -- 代码使用 `jwt:%v` 格式拼接 `redisKey` -- 但 `JwtManager.Valid()` 需要传入的是 **JWT token 字符串本身** +- `JwtManager.Valid()` 负责验证 JWT 字符串本身 +- 当前逻辑还会校验 JWT payload 中的 `UserId` 与请求传入的 `userId` 一致,再查询数据库回填 `RoleType` -这意味着若后续接入 `ext_authz` 并调用该逻辑,建议先修正这段逻辑,避免认证结果偏差。 +这意味着当前 `ext_authz -> user-rpc.ValidateToken` 链已经具备 token 有效性和 userId 一致性校验。 --- @@ -100,12 +102,13 @@ ## 前端接入示例(邮箱验证码) -以下示例基于你当前网关与服务配置: +以下示例基于当前 K8s 网关配置: -- 登录:`POST /api/users/login`(公共放行) -- 发送验证码:`POST /api/email/verification-code/send`(公共放行,无需登录) -- CSRF 头:`XSRF-TOKEN`(请求头) +- 登录:`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 且同站点** 才能稳定工作。 @@ -113,7 +116,7 @@ ### 接入流程 1. 先发一个安全方法请求(GET),让 Envoy 下发 XSRF 双 Cookie。 -2. 注册场景可直接调用发送验证码接口,仅需携带 `XSRF-TOKEN`。 +2. 注册场景可直接调用发送验证码接口,仅需携带 `xsrf-token`。 3. 登录场景可先调用登录接口拿 `JToken`,后续访问受保护接口时自动携带。 ### 前端示例(TypeScript + fetch) @@ -136,12 +139,12 @@ async function primeXsrfCookies() { async function login(username: string, password: string) { const xsrfToken = getCookie("__Host-XSRF-TOKEN"); - const res = await fetch(`${API_BASE}/api/users/login`, { + const res = await fetch(`${API_BASE}/api/v1/auth/login`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", - "XSRF-TOKEN": xsrfToken, + "xsrf-token": xsrfToken, }, body: JSON.stringify({ username, password }), }); @@ -161,12 +164,12 @@ type SendCodeReq = { async function sendVerificationCode(req: SendCodeReq) { const xsrfToken = getCookie("__Host-XSRF-TOKEN"); - const res = await fetch(`${API_BASE}/api/email/verification-code/send`, { + const res = await fetch(`${API_BASE}/api/v1/email/verification-code/send`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", - "XSRF-TOKEN": xsrfToken, + "xsrf-token": xsrfToken, }, body: JSON.stringify(req), }); @@ -235,7 +238,7 @@ kubectl get cm -n juwan envoy-config kubectl port-forward -n juwan svc/envoy-gateway 8080:80 & # 测试 -curl http://localhost:8080/api/users/login +curl http://localhost:8080/healthz ``` ---