NestJS (6 services): implement @RequirePermission decorator with SetMetadata+Reflector, register APP_GUARD globally, fix as assertions to type guards, add explicit return types, fix import type for express, fix /metrics implicit any, replace native Error with ApplicationError, remove typeorm remnants, register LifecycleService. teacher-bff: add logger, ApplicationError, GlobalErrorFilter, forward real userId to downstream, log downstream failures, migrate health controller to shared/health. Go (2 services): interface to any, doc comments, CORS dev whitelist, JWT secret fail-fast, push-gateway internal API auth, metrics and readyz endpoints, remove dead code. Python (2 services): lifespan return type, dev_mode to bool, data-ana APIRouter, ai POST body model, ClickHouse async wrapping.
122 lines
3.1 KiB
Go
122 lines
3.1 KiB
Go
package middleware
|
||
|
||
import (
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/edu-cloud/api-gateway/internal/config"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
)
|
||
|
||
// 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 并注入用户信息到请求头
|
||
// P1 用 HS256,P2 改 RS256(IAM 签发)
|
||
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// 健康检查跳过鉴权
|
||
if c.Request.URL.Path == "/healthz" {
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 公开路径白名单(register/login/refresh 无需鉴权)
|
||
if isPublicPath(c.Request.URL.Path) {
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader == "" {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||
"success": false,
|
||
"error": gin.H{
|
||
"code": "UNAUTHORIZED",
|
||
"message": "missing authorization header",
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||
if tokenStr == authHeader {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||
"success": false,
|
||
"error": gin.H{
|
||
"code": "UNAUTHORIZED",
|
||
"message": "invalid authorization scheme, expected Bearer",
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
// 开发模式旁路:DEV_MODE=true 时接受 "dev-token",注入开发用户
|
||
// 仅用于本地联调,生产环境必须关闭 DEV_MODE
|
||
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.Next()
|
||
return
|
||
}
|
||
|
||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
|
||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||
return nil, jwt.ErrSignatureInvalid
|
||
}
|
||
return []byte(cfg.JWTSecret), nil
|
||
})
|
||
|
||
if err != nil || !token.Valid {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||
"success": false,
|
||
"error": gin.H{
|
||
"code": "INVALID_TOKEN",
|
||
"message": "token validation failed",
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
claims, ok := token.Claims.(jwt.MapClaims)
|
||
if !ok {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||
"success": false,
|
||
"error": gin.H{
|
||
"code": "INVALID_CLAIMS",
|
||
"message": "invalid token claims",
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
// 注入用户信息到下游请求头
|
||
if sub, ok := claims["sub"].(string); ok {
|
||
c.Request.Header.Set("x-user-id", sub)
|
||
}
|
||
if roles, ok := claims["roles"].([]any); ok {
|
||
roleStrs := make([]string, 0, len(roles))
|
||
for _, r := range roles {
|
||
if s, ok := r.(string); ok {
|
||
roleStrs = append(roleStrs, s)
|
||
}
|
||
}
|
||
c.Request.Header.Set("x-user-roles", strings.Join(roleStrs, ","))
|
||
}
|
||
|
||
c.Next()
|
||
}
|
||
}
|