fix: 对齐 authz 认证链路
This commit is contained in:
@@ -114,11 +114,19 @@ func deny(code codepb.Code, httpCode typev3.StatusCode, message string) *authv3.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isPublicPath(path string) bool {
|
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
|
return true
|
||||||
}
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getHeader(headers map[string]string, key string) string {
|
func getHeader(headers map[string]string, key string) string {
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ func NewValidateTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Val
|
|||||||
|
|
||||||
func (l *ValidateTokenLogic) ValidateToken(in *pb.ValidateTokenReq) (*pb.ValidateTokenResp, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
//users, err := l.svcCtx.UsersModelRO.FindOne(l.ctx, in.UserId)
|
||||||
user, err := l.svcCtx.UsersModelRO.Users.Query().
|
user, err := l.svcCtx.UsersModelRO.Users.Query().
|
||||||
Where(users.IDEQ(in.UserId)).
|
Where(users.IDEQ(payload.UserId)).
|
||||||
Select(users.FieldCurrentRole).
|
Select(users.FieldCurrentRole).
|
||||||
First(l.ctx)
|
First(l.ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,7 +55,7 @@ func (l *ValidateTokenLogic) ValidateToken(in *pb.ValidateTokenReq) (*pb.Validat
|
|||||||
return &pb.ValidateTokenResp{
|
return &pb.ValidateTokenResp{
|
||||||
Valid: true,
|
Valid: true,
|
||||||
Message: "OK",
|
Message: "OK",
|
||||||
UserId: in.UserId,
|
UserId: payload.UserId,
|
||||||
RoleType: string(userJson),
|
RoleType: string(userJson),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -19,11 +19,16 @@ docker compose up -d
|
|||||||
# 3. 查看状态
|
# 3. 查看状态
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
# 4. 停止
|
# 4. 通过网关访问
|
||||||
|
curl http://127.0.0.1:18080/healthz
|
||||||
|
|
||||||
|
# 5. 停止
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
构建脚本会扫描 `app/` 下所有 `api`、`rpc`、`mq` 入口,通过 `docker buildx bake` 并行构建所有服务镜像,生成 `juwan/<service>-<type>:dev`。
|
构建脚本会扫描 `app/` 下所有 `api`、`rpc`、`mq`、`adapter` 入口,通过 `docker buildx bake` 并行构建所有服务镜像,生成 `juwan/<service>-<type>: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 |
|
| PostgreSQL | 15432 |
|
||||||
| Redis | 16379 |
|
| Redis | 16379 |
|
||||||
| Kafka | 19092 |
|
| Kafka | 19092 |
|
||||||
|
| Envoy Gateway | 18080 |
|
||||||
| users-api | 18801 |
|
| users-api | 18801 |
|
||||||
| player-api | 18802 |
|
| player-api | 18802 |
|
||||||
| game-api | 18803 |
|
| game-api | 18803 |
|
||||||
@@ -52,6 +58,12 @@ docker compose up -d postgres redis snowflake player-rpc player-api
|
|||||||
|
|
||||||
编辑 `.env` 修改数据库密码、Kafka 地址等。默认值可直接用于本地开发。
|
编辑 `.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 后重启:
|
首次启动时 PostgreSQL 会自动执行 `desc/sql/` 下的建表语句。如需重新初始化,删除 volume 后重启:
|
||||||
|
|||||||
+29
-3
@@ -38,22 +38,48 @@ COPY {service_dir}/etc /app/etc
|
|||||||
CMD ["./main", "-f", "etc/{config_name}"]
|
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 = {}
|
targets = {}
|
||||||
for service_dir in sorted(glob.glob("app/*/*")):
|
for service_dir in sorted(glob.glob("app/*/*")):
|
||||||
service_type = os.path.basename(service_dir)
|
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
|
continue
|
||||||
go_files = glob.glob(os.path.join(service_dir, "*.go"))
|
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
|
has_main = any("package main" in open(f).read() for f in go_files) if go_files else False
|
||||||
if not has_main:
|
if not has_main:
|
||||||
continue
|
continue
|
||||||
yamls = glob.glob(os.path.join(service_dir, "etc", "*.yaml"))
|
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))
|
service_name = os.path.basename(os.path.dirname(service_dir))
|
||||||
target_name = f"{service_name}-{service_type}"
|
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] = {
|
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}"],
|
"tags": [f"{prefix}/{target_name}:{tag}"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,53 @@ services:
|
|||||||
container_name: juwan-snowflake
|
container_name: juwan-snowflake
|
||||||
restart: unless-stopped
|
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 层 ====================
|
# ==================== RPC 层 ====================
|
||||||
user-rpc:
|
user-rpc:
|
||||||
image: juwan/users-rpc:dev
|
image: juwan/users-rpc:dev
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -391,7 +391,7 @@ data:
|
|||||||
from_cookies:
|
from_cookies:
|
||||||
- "JToken"
|
- "JToken"
|
||||||
local_jwks:
|
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
|
forward: false
|
||||||
claim_to_headers:
|
claim_to_headers:
|
||||||
- header_name: "x-auth-user-id"
|
- header_name: "x-auth-user-id"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- Envoy 不直接调用业务 proto 方法。
|
- Envoy 不直接调用业务 proto 方法。
|
||||||
- 新增一个内部服务 `authz-adapter`,实现 Envoy 标准 gRPC 鉴权接口。
|
- 新增一个内部服务 `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()
|
path := httpReq.GetPath()
|
||||||
method := strings.ToUpper(httpReq.GetMethod())
|
|
||||||
|
|
||||||
// 放行公共接口(探针、登录/注册、发送验证码)
|
// 放行公共接口(探针、登录/注册、发送验证码)
|
||||||
if path == "/healthz" ||
|
if path == "/healthz" ||
|
||||||
path == "/api/users/login" ||
|
path == "/api/v1/auth/login" ||
|
||||||
path == "/api/users/register" ||
|
path == "/api/v1/auth/register" ||
|
||||||
path == "/api/email/verification-code/send" {
|
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
|
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
|
return deny(401, "missing token cookie"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := parseUserIDFromPath(path)
|
userIDHeader := getHeader(httpReq.GetHeaders(), "x-auth-user-id")
|
||||||
if err != nil {
|
if userIDHeader == "" {
|
||||||
return deny(401, "invalid user id in path"), nil
|
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{
|
vt, err := s.userRpc.ValidateToken(ctx, &userpb.ValidateTokenReq{
|
||||||
Token: token,
|
Token: token,
|
||||||
UserId: userID,
|
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-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-role-type", Value: vt.RoleType},
|
||||||
},
|
|
||||||
{
|
|
||||||
Header: &core.HeaderValue{Key: "x-auth-method", Value: method},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,15 +139,13 @@ func extractCookie(headers map[string]string, name string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseUserIDFromPath(path string) (int64, error) {
|
func getHeader(headers map[string]string, name string) string {
|
||||||
// 仅示例:请按你的真实路由解析,或改为从 token claim 取 userId
|
for k, v := range headers {
|
||||||
seg := strings.Split(strings.Trim(path, "/"), "/")
|
if strings.EqualFold(k, name) {
|
||||||
for i := 0; i < len(seg); i++ {
|
return v
|
||||||
if seg[i] == "users" && i+1 < len(seg) {
|
|
||||||
return strconv.ParseInt(seg[i+1], 10, 64)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0, strconv.ErrSyntax
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -211,8 +213,8 @@ func main() {
|
|||||||
- 无 token -> 401
|
- 无 token -> 401
|
||||||
- token 无效/过期 -> 401
|
- token 无效/过期 -> 401
|
||||||
- 权限不足 -> 403
|
- 权限不足 -> 403
|
||||||
5. 透传统一鉴权头:`x-auth-user-id`、`x-auth-role-type`。
|
5. `jwt_authn` 先注入 `x-auth-user-id`、`x-auth-is-admin`,再由 adapter 透传 `x-auth-user-id`、`x-auth-role-type`。
|
||||||
6. 灰度建议:先仅对 `/api/users` 开启,再扩展到 `/api/email`。
|
6. 灰度建议:先仅对 `/api/v1/auth` 和 `/api/v1/email` 验证链路,再逐步扩展到其它业务路径。
|
||||||
|
|
||||||
> 实践建议:若保留 K8s `readiness/liveness` 探针使用 `/healthz`,请确保该路径在 `ext_authz` 上也放行,否则会出现探针 403 导致 Pod 重启。
|
> 实践建议:若保留 K8s `readiness/liveness` 探针使用 `/healthz`,请确保该路径在 `ext_authz` 上也放行,否则会出现探针 403 导致 Pod 重启。
|
||||||
|
|
||||||
@@ -221,7 +223,7 @@ func main() {
|
|||||||
## 5) 与当前 `jwt_authn` 的关系
|
## 5) 与当前 `jwt_authn` 的关系
|
||||||
|
|
||||||
- 可以并存:
|
- 可以并存:
|
||||||
- 先 `jwt_authn` 快速验签
|
- 先 `jwt_authn` 快速验签并注入 claim header
|
||||||
- 再 `ext_authz` 做 Redis 会话态、黑名单、细粒度权限
|
- 再 `ext_authz` 做 Redis 会话态、黑名单、细粒度权限
|
||||||
- 也可以只保留 `ext_authz`(由 adapter 内完成全部逻辑)。
|
- 也可以只保留 `ext_authz`(由 adapter 内完成全部逻辑)。
|
||||||
|
|
||||||
|
|||||||
+27
-24
@@ -22,9 +22,14 @@
|
|||||||
当前网关对以下路径做了“公共放行”:
|
当前网关对以下路径做了“公共放行”:
|
||||||
|
|
||||||
- `/healthz`(直返 200,用于探针)
|
- `/healthz`(直返 200,用于探针)
|
||||||
- `POST /api/users/login`
|
- `POST /api/v1/auth/login`
|
||||||
- `POST /api/users/register`
|
- `POST /api/v1/auth/register`
|
||||||
- `POST /api/email/verification-code/send`(注册/登录前发送验证码)
|
- `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`)要求固定协议。
|
- Envoy 内置认证过滤器(如 `jwt_authn`、`ext_authz`)要求固定协议。
|
||||||
- `ext_authz` 的 gRPC 需要实现 Envoy 标准服务 `envoy.service.auth.v3.Authorization`,不是业务自定义的 `pb.usercenter/ValidateToken`。
|
- `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`)
|
- `authz-adapter` 服务(实现 Envoy `CheckRequest/CheckResponse`)
|
||||||
- Envoy 新增 `ext_authz` filter 与对应 cluster
|
- Envoy `ext_authz` filter 与 `authz_adapter_cluster`
|
||||||
- 鉴权透传头约定(至少 `x-auth-user-id`、`x-auth-is-admin`)
|
- `jwt_authn` 注入 `x-auth-user-id`、`x-auth-is-admin`
|
||||||
|
- `authz-adapter` 透传 `x-auth-user-id`、`x-auth-role-type`
|
||||||
- 失败码与错误体规范(401/403)
|
- 失败码与错误体规范(401/403)
|
||||||
- 性能与可用性策略(超时、失败回退、缓存)
|
|
||||||
|
|
||||||
### 4) 与你现有 `ValidateTokenLogic` 的一致性提醒
|
### 4) 与你现有 `ValidateTokenLogic` 的一致性提醒
|
||||||
|
|
||||||
当前 `app/users/rpc/internal/logic/validateTokenLogic.go` 中:
|
当前 `app/users/rpc/internal/logic/validateTokenLogic.go` 中:
|
||||||
|
|
||||||
- 代码使用 `jwt:%v` 格式拼接 `redisKey`
|
- `JwtManager.Valid()` 负责验证 JWT 字符串本身
|
||||||
- 但 `JwtManager.Valid()` 需要传入的是 **JWT token 字符串本身**
|
- 当前逻辑还会校验 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/v1/auth/login`(公共放行)
|
||||||
- 发送验证码:`POST /api/email/verification-code/send`(公共放行,无需登录)
|
- 发送验证码:`POST /api/v1/email/verification-code/send`(公共放行,无需登录)
|
||||||
- CSRF 头:`XSRF-TOKEN`(请求头)
|
- CSRF 头:`xsrf-token`(请求头)
|
||||||
- CSRF Cookie:`__Host-XSRF-TOKEN`(可读)
|
- CSRF Cookie:`__Host-XSRF-TOKEN`(可读)
|
||||||
|
- CSRF Guard Cookie:`__Host-XSRF-GUARD`(`HttpOnly`)
|
||||||
- JWT Cookie:`JToken`(`HttpOnly`,前端不可读,但会随请求自动携带)
|
- JWT Cookie:`JToken`(`HttpOnly`,前端不可读,但会随请求自动携带)
|
||||||
|
|
||||||
> 注意:当前 Envoy 给 CSRF Cookie 设置了 `Secure` + `SameSite=Strict`。前端必须走 **HTTPS 且同站点** 才能稳定工作。
|
> 注意:当前 Envoy 给 CSRF Cookie 设置了 `Secure` + `SameSite=Strict`。前端必须走 **HTTPS 且同站点** 才能稳定工作。
|
||||||
@@ -113,7 +116,7 @@
|
|||||||
### 接入流程
|
### 接入流程
|
||||||
|
|
||||||
1. 先发一个安全方法请求(GET),让 Envoy 下发 XSRF 双 Cookie。
|
1. 先发一个安全方法请求(GET),让 Envoy 下发 XSRF 双 Cookie。
|
||||||
2. 注册场景可直接调用发送验证码接口,仅需携带 `XSRF-TOKEN`。
|
2. 注册场景可直接调用发送验证码接口,仅需携带 `xsrf-token`。
|
||||||
3. 登录场景可先调用登录接口拿 `JToken`,后续访问受保护接口时自动携带。
|
3. 登录场景可先调用登录接口拿 `JToken`,后续访问受保护接口时自动携带。
|
||||||
|
|
||||||
### 前端示例(TypeScript + fetch)
|
### 前端示例(TypeScript + fetch)
|
||||||
@@ -136,12 +139,12 @@ async function primeXsrfCookies() {
|
|||||||
|
|
||||||
async function login(username: string, password: string) {
|
async function login(username: string, password: string) {
|
||||||
const xsrfToken = getCookie("__Host-XSRF-TOKEN");
|
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",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"XSRF-TOKEN": xsrfToken,
|
"xsrf-token": xsrfToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
@@ -161,12 +164,12 @@ type SendCodeReq = {
|
|||||||
|
|
||||||
async function sendVerificationCode(req: SendCodeReq) {
|
async function sendVerificationCode(req: SendCodeReq) {
|
||||||
const xsrfToken = getCookie("__Host-XSRF-TOKEN");
|
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",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"XSRF-TOKEN": xsrfToken,
|
"xsrf-token": xsrfToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(req),
|
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 &
|
kubectl port-forward -n juwan svc/envoy-gateway 8080:80 &
|
||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
curl http://localhost:8080/api/users/login
|
curl http://localhost:8080/healthz
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user