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/v1 前缀后的路径) var publicPaths = map[string]bool{ "/iam/register": true, "/iam/login": true, "/iam/refresh": true, } // isPublicPath 判断请求路径是否属于公开路径(无需鉴权) // 匹配规则:去掉 /api/v1 前缀后,与 publicPaths 精确匹配 func isPublicPath(path string) bool { stripped := strings.TrimPrefix(path, "/api/v1") 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":"","message":""}} func abortGW(c *gin.Context, status int, code, message string) { c.AbortWithStatusJSON(status, gin.H{ "success": false, "error": gin.H{ "code": code, "message": message, }, }) }