Files
Edu/services/api-gateway/internal/middleware/auth.go
SpecialX 4307f6b73c 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 均通过。
2026-07-10 18:15:48 +08:00

135 lines
4.3 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/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 验证 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,
},
})
}