Files
Edu/services/api-gateway/internal/middleware/auth.go
SpecialX 61d824924a fix: 修复集成测试中发现的全部 bug — 15 服务端到端验证通过
修复涵盖 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(集成测试修复会话)
2026-07-11 01:41:46 +08:00

137 lines
4.4 KiB
Go
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.
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 验证 JWTRS256通过 IAM JWKS 公钥校验)并注入用户信息到请求头。
//
// P2 升级HS256 → RS256shared-go/jwks.FetcherJWT 由 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_MODEW7 防护在 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 必须有 fetcherRS256 验签)
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,
},
})
}