- monorepo: pnpm workspace + go.work + pyproject.toml + commitlint/husky - infra: docker-compose (minimal + full profiles) + init-sql + prometheus - arch-scan: multi-language scanner skeleton (TS/Go/Python/Proto) - shared-proto: buf v2 + classes.proto (ClassService CRUD contract) - api-gateway: Go/Gin + JWT HS256 auth + reverse proxy + request ID - classes: NestJS golden template (error system + observability + middleware + CRUD + tests) - teacher-portal: Next.js + paper-feel UI design system - CI/CD: 4 workflows (go/ts/py/proto) - docs: migration guide + project_rules + coding-standards + git-workflow + ui-design-system + 004 + 9 module READMEs + known-issues + spec/plan migration + roadmap
113 lines
2.7 KiB
Go
113 lines
2.7 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"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, 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"].([]interface{}); 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()
|
||
}
|
||
}
|
||
|
||
// RequestIDMiddleware 注入请求 ID 用于全链路追踪
|
||
func RequestIDMiddleware() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
requestID := c.GetHeader("X-Request-ID")
|
||
if requestID == "" {
|
||
requestID = generateUUID()
|
||
}
|
||
c.Set("request_id", requestID)
|
||
c.Writer.Header().Set("X-Request-ID", requestID)
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// generateUUID 生成带 req- 前缀的唯一请求 ID
|
||
// 使用 uuid.New() 基于 RFC 4122 v4 随机 UUID,避免 time.Now() 产生的冲突与可预测性
|
||
func generateUUID() string {
|
||
return "req-" + uuid.New().String()
|
||
}
|