feat(api-gateway): 实现 W1-W8 网关硬化与 P2-P5 路由扩展

依据 coord-final-decisions §3.8 W1-W8 裁决与
president-final-rulings §2.15/§2.16/§2.19 完整实现网关硬化:

- W1/W2: 错误码 GW_ 前缀 + ActionState 信封响应体
- W3: 全量替换为 log/slog 结构化日志
- W4: /readyz 并行 ping 9 下游 + 软失败规则
- W5: 7 个业务 Prometheus 指标 + /metrics 端点
- W6: tracer 资源属性补全(name/version/env/host)
- W7: DevMode=true && ENV=production panic 防护
- W8: 保持共享 downstream 熔断

P2 RS256 升级:接入 shared-go/jwks.Fetcher(TTL 5min)。
P2.7+P3-P5 路由扩展:student/parent/messages/dashboard。
文档同步:README/01/02/known-issues,arch.db 已更新。
质量校验:go vet + build + test 均通过。
This commit is contained in:
SpecialX
2026-07-10 18:15:48 +08:00
parent 9e767b4e95
commit 4307f6b73c
20 changed files with 797 additions and 382 deletions

View File

@@ -1,12 +1,14 @@
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"
"github.com/golang-jwt/jwt/v5"
)
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
@@ -23,16 +25,19 @@ func isPublicPath(path string) bool {
return publicPaths[stripped]
}
// AuthMiddleware 验证 JWT 并注入用户信息到请求头
// P1 用 HS256P2 改 RS256IAM 签发)
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
// 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) {
// 健康检查跳过鉴权
if c.Request.URL.Path == "/healthz" {
c.Next()
return
}
// 公开路径白名单register/login/refresh 无需鉴权)
if isPublicPath(c.Request.URL.Path) {
c.Next()
@@ -41,81 +46,89 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "missing authorization header",
},
})
observability.IncAuthFailure("missing_auth_header")
abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "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",
},
})
observability.IncAuthFailure("invalid_scheme")
abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "invalid authorization scheme, expected Bearer")
return
}
// 开发模式旁路DEV_MODE=true 时接受 "dev-token",注入开发用户
// 仅用于本地联调,生产环境必须关闭 DEV_MODE
// 仅用于本地联调,生产环境必须关闭 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
}
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",
},
})
// 非 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
}
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",
},
})
// 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 sub, ok := claims["sub"].(string); ok {
c.Request.Header.Set("x-user-id", sub)
if claims.UserID != "" {
c.Request.Header.Set("x-user-id", claims.UserID)
}
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, ","))
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,
},
})
}