修复涵盖 6 大类问题: 1. api-gateway - 路径前缀剥离 /api 而非 /api/v1,保留下游 /v1/ controller 前缀 - JWKS URL 默认值修复 - publicPaths 白名单对齐 /v1/iam/* 2. iam - iam.module.ts exports 补充 PermissionCacheService 和 IamRepository - main.ts resolveProtoPath() 多路径探测 proto 文件 3. core-edu - app.module.ts AuthMiddleware 全局注册 4. BFF 层 GraphQL 端点(teacher-bff / parent-bff / student-bff) - teacher-bff: mock dataScope OWN→SELF 对齐 GraphQL enum;WHATWG Request header .get() 提取 - parent-bff: handleNodeRequestAndResponse 不存在 → 直接 yoga(req,res);WHATWG Request header .get() 提取 - student-bff: auth.resolver 移除 ActionState 信封返回扁平对象;WHATWG Request header .get() 提取 5. ai - Kafka 事务降级 + 10s 超时 - gRPC 拦截器降级 - dev mode 禁用事务模式 6. 前端 + 共享包 - teacher-portal: MF 插件条件实例化 + transpilePackages + extensionAlias - ui-components: error-boundary.tsx 添加 use client - ui-tokens: tailwind-theme.css 移除 @layer base - shared-ts: 导出从源码改为 dist 编译产物;OutboxModule global:true 7. infra - .gitignore 补充 keys/ *.pem *.key secrets/ 排除规则 - infra/init-sql/02-all-services-schema.sql 36 张表 DDL 验证结果: - TS typecheck: 19 个 workspace 项目全部通过 - Go vet + Ruff: 通过 - 15 服务全部启动成功 - 3 个 BFF GraphQL 端点 + 4 个前端页面全部 200 - Gateway → iam → core-edu 端到端链路验证通过 AI identity: trae-main(集成测试修复会话)
137 lines
4.4 KiB
Go
137 lines
4.4 KiB
Go
package middleware
|
||
|
||
import (
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/edu-cloud/api-gateway/internal/config"
|
||
"github.com/edu-cloud/api-gateway/internal/observability"
|
||
"github.com/edu-cloud/shared-go/jwks"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api 前缀后的路径)
|
||
// 下游 NestJS controller 路径为 /v1/iam/*
|
||
var publicPaths = map[string]bool{
|
||
"/v1/iam/register": true,
|
||
"/v1/iam/login": true,
|
||
"/v1/iam/refresh": true,
|
||
"/v1/iam/.well-known/jwks.json": true,
|
||
}
|
||
|
||
// isPublicPath 判断请求路径是否属于公开路径(无需鉴权)
|
||
// 匹配规则:去掉 /api 前缀后,与 publicPaths 精确匹配
|
||
func isPublicPath(path string) bool {
|
||
stripped := strings.TrimPrefix(path, "/api")
|
||
return publicPaths[stripped]
|
||
}
|
||
|
||
// AuthMiddleware 验证 JWT(RS256,通过 IAM JWKS 公钥校验)并注入用户信息到请求头。
|
||
//
|
||
// P2 升级:HS256 → RS256(shared-go/jwks.Fetcher),JWT 由 IAM 签发、Gateway 公钥校验。
|
||
// DevMode 旁路:接受 "dev-token",注入固定开发身份(仅非生产环境,W7 防护在 config.Load)。
|
||
//
|
||
// 注入下游头:
|
||
// - x-user-id(来自 claims.UserID)
|
||
// - x-user-roles(来自 claims.Role)
|
||
// - x-data-scope(来自 claims.DataScope)
|
||
//
|
||
// fetcher 为 nil 时(DevMode)跳过真实验签,仅走 dev-token 旁路。
|
||
func AuthMiddleware(cfg *config.Config, fetcher *jwks.Fetcher) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// 公开路径白名单(register/login/refresh 无需鉴权)
|
||
if isPublicPath(c.Request.URL.Path) {
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader == "" {
|
||
observability.IncAuthFailure("missing_auth_header")
|
||
abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "missing authorization header")
|
||
return
|
||
}
|
||
|
||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||
if tokenStr == authHeader {
|
||
observability.IncAuthFailure("invalid_scheme")
|
||
abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "invalid authorization scheme, expected Bearer")
|
||
return
|
||
}
|
||
|
||
// 开发模式旁路:DEV_MODE=true 时接受 "dev-token",注入开发用户
|
||
// 仅用于本地联调,生产环境必须关闭 DEV_MODE(W7 防护在 config.Load 拦截)
|
||
if cfg.DevMode && tokenStr == "dev-token" {
|
||
c.Request.Header.Set("x-user-id", "dev-user")
|
||
c.Request.Header.Set("x-user-roles", "teacher,admin")
|
||
c.Request.Header.Set("x-data-scope", "SCHOOL")
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 非 DevMode 必须有 fetcher(RS256 验签)
|
||
if fetcher == nil {
|
||
observability.IncAuthFailure("fetcher_nil")
|
||
slog.Error("jwks fetcher is nil in non-dev mode")
|
||
abortGW(c, http.StatusInternalServerError, "GW_INTERNAL_ERROR", "auth not initialized")
|
||
return
|
||
}
|
||
|
||
// RS256 验签(shared-go/jwks.Fetcher 内部按 kid 取公钥 + 缓存)
|
||
claims, err := fetcher.ValidateToken(tokenStr)
|
||
if err != nil {
|
||
observability.IncAuthFailure("invalid_token")
|
||
slog.Warn("jwt validation failed", "error", err)
|
||
abortGW(c, http.StatusUnauthorized, "GW_INVALID_TOKEN", "token validation failed")
|
||
return
|
||
}
|
||
|
||
// iss / aud 校验(Config 定义但之前未生效)
|
||
if cfg.JWTIssuer != "" && claims.Issuer != cfg.JWTIssuer {
|
||
observability.IncAuthFailure("invalid_issuer")
|
||
abortGW(c, http.StatusUnauthorized, "GW_INVALID_CLAIMS", "invalid issuer")
|
||
return
|
||
}
|
||
if cfg.JWTAudience != "" {
|
||
audMatch := false
|
||
for _, a := range claims.Audience {
|
||
if a == cfg.JWTAudience {
|
||
audMatch = true
|
||
break
|
||
}
|
||
}
|
||
if !audMatch {
|
||
observability.IncAuthFailure("invalid_audience")
|
||
abortGW(c, http.StatusUnauthorized, "GW_INVALID_CLAIMS", "invalid audience")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 注入用户信息到下游请求头
|
||
if claims.UserID != "" {
|
||
c.Request.Header.Set("x-user-id", claims.UserID)
|
||
}
|
||
if claims.Role != "" {
|
||
c.Request.Header.Set("x-user-roles", claims.Role)
|
||
}
|
||
if claims.DataScope != "" {
|
||
c.Request.Header.Set("x-data-scope", claims.DataScope)
|
||
}
|
||
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// abortGW 统一返回 ActionState 信封格式的错误响应(W1/W2 裁决)。
|
||
// 响应体:{"success":false,"error":{"code":"<code>","message":"<msg>"}}
|
||
func abortGW(c *gin.Context, status int, code, message string) {
|
||
c.AbortWithStatusJSON(status, gin.H{
|
||
"success": false,
|
||
"error": gin.H{
|
||
"code": code,
|
||
"message": message,
|
||
},
|
||
})
|
||
}
|