package middleware import ( "fmt" "log" "os" "strings" "time" "tcm-agent/internal/config" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) // ======================================================================== // 全局中间件 // ======================================================================== // 包含:请求日志 / 跨域支持 / JWT 鉴权 // ======================================================================== // pollingPaths 面板轮询类端点集合(成功时不打请求日志) // // 为什么要跳过:/agent/view 面板每 2~5 秒轮询这些端点,每次轮询产生一行 // [HTTP] 日志——500 行的 MemLog 环形缓冲十几分钟就会被轮询噪音刷满, // 真正的业务日志(enhance/守卫/LLM 调用)全被顶掉; // 且「实时日志」页会看到自己轮询产生的日志(自激循环)。 // 只跳过 200 成功的轮询请求;出错(401/500 等)仍然照常记录,不丢排查线索 var pollingPaths = map[string]bool{ "/health": true, "/api/v1/agent/logs": true, "/api/v1/agent/runs": true, "/api/v1/agent/stats": true, "/api/v1/agent/system": true, "/api/v1/agent/config": true, "/api/v1/models/active-config": true, "/api/v1/models/routes": true, } // Logger 请求日志中间件 // // 记录每个请求的耗时、路径、状态码。 // 格式:[HTTP] GET /api/v1/emr/generate | 200 | 1.2s // 面板轮询类 GET 端点成功时静默(见 pollingPaths 注释) func Logger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path method := c.Request.Method c.Next() latency := time.Since(start) status := c.Writer.Status() // 面板轮询成功请求不打日志,避免刷爆 MemLog 环形缓冲 if method == "GET" && status == 200 && pollingPaths[path] { return } log.Printf("[HTTP] %s %s | %d | %v", method, path, status, latency) } } // CORS 跨域中间件 // // 允许前端(医生工作站、患者小程序)跨域调用 API。 // 生产环境应限制 Allow-Origin 为具体域名。 func CORS() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") // X-KB-Admin-Password:KB 后台与 Agent 面板的共用口令头,跨域部署时预检需要放行 c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-KB-Admin-Password") c.Header("Access-Control-Max-Age", "86400") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() } } // Auth 鉴权中间件(双轨制:JWT + 共享密钥) // // 校验顺序: // 1. KB admin 路径:走 X-KB-Admin-Password(独立体系,互不干扰) // 2. 健康检查 / 静态资源:直接放行 // 3. 业务 API(如 /api/v1/agent/enhance): // a) 无 Authorization 头: // - SharedSecret 也为空 → 放行(纯内网开发模式) // - SharedSecret 非空 → 401(必须带密钥) // b) 有 Authorization 头(去掉 "Bearer " 前缀后): // - 路径 1:JWT 校验(JWTSecret 非空时启用,向后兼容小程序直连场景) // - 路径 2:SharedSecret 字符串 == 比对(PHP 后台填一个值即可对接) // - 路径 3:开发模式默认密钥(JWTSecret 为空时回落到硬编码开发密钥) // - 三者任一通过即放行 // // Token 注入 Context: // - JWT 模式:user_id / user_role(来自 claims.sub / claims.role) // - SharedSecret 模式:auth_mode = "shared_secret" // // 生产环境建议: // - 必须设置 SharedSecret(config.Agent.SharedSecret 或 env AGENT_SHARED_SECRET) // - 需要解析用户身份的场景额外设置 JWTSecret 并用 RS256 非对称签名 // - 密钥从环境变量或 KMS 读取,不要写死在 config.yaml func Auth(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path // /health 直接放行(健康检查,无需鉴权) if path == "/health" { c.Next() return } // 管理前端登录接口放行:登录本身就是来换 token 的,不能先要求 token if path == "/api/v1/auth/login" { c.Next() return } // /admin SPA 静态资源放行(HTML/JS/CSS 公开,无敏感数据; // 页面里的数据请求仍走下方 JWT/口令鉴权) if path == "/admin" || strings.HasPrefix(path, "/admin/") { c.Next() return } // 本地知识库后台(前端单页 + Admin API):口令头 或 面板 JWT 双通道 // - 旧 KB 页 / 旧观测面板:X-KB-Admin-Password 口令头(原有体系,保留不动) // - 新管理前端(/admin SPA):登录后带 Bearer JWT // // 注意:/kb/view 静态资源本身不鉴权(HTML/JS 公开,无敏感数据) // 只有 /api/v1/kb/admin/* 的 API 才校验 if strings.HasPrefix(path, "/api/v1/kb/admin/") { expectedPwd := "qiqi991012" // 默认口令(生产环境必须在 config.yaml 覆盖) if cfg != nil && cfg.KB.AdminPassword != "" { expectedPwd = cfg.KB.AdminPassword } if c.GetHeader("X-KB-Admin-Password") == expectedPwd { c.Set("auth_mode", "kb_admin") c.Next() return } // 口令没过:尝试面板 JWT(新前端路径) if claims, ok := ValidateJWT(cfg, bearerFromHeader(c)); ok { injectJWTClaims(c, claims) c.Next() return } c.JSON(401, gin.H{"code": 401, "message": "需要管理员口令或登录凭证"}) c.Abort() return } // /kb/view 静态资源放行(HTML/JS/CSS 公开) if path == "/kb/view" || strings.HasPrefix(path, "/kb/view/") { c.Next() return } // 观测面板静态页放行(HTML 本身无敏感数据; // 页面调用的 /api/v1/agent/runs 等数据 API 仍走下方业务鉴权) // "/" 是面板主入口,"/agent/view" 保留为兼容旧入口; // /favicon.ico 放行:浏览器自动请求,被鉴权拦会产生 401 控制台噪音 if path == "/" || path == "/agent/view" || path == "/favicon.ico" { c.Next() return } // ---------- 面板共用口令通道(与 KB 页同一口令) ---------- // /agent/view 面板的所有数据请求带 X-KB-Admin-Password 头, // 与 KB 管理口令比对通过即放行——运维只需记一个口令,两个面板通用。 // PHP 走的 Bearer SharedSecret 路径在下方原样保留,互不影响 if pwd := c.GetHeader("X-KB-Admin-Password"); pwd != "" { expectedPwd := "qiqi991012" // 默认口令(生产环境必须在 config.yaml 覆盖) if cfg != nil && cfg.KB.AdminPassword != "" { expectedPwd = cfg.KB.AdminPassword } if pwd == expectedPwd { c.Set("auth_mode", "kb_admin") c.Next() return } // 口令带了但不对:直接 401,让面板清缓存重新弹框 c.JSON(401, gin.H{"error": "口令错误"}) c.Abort() return } // ==================================================================== // 业务 API 鉴权(双轨制:JWT + SharedSecret) // ==================================================================== sharedSecret := "" if cfg != nil { sharedSecret = cfg.Agent.SharedSecret } tokenStr := c.GetHeader("Authorization") // 无 Authorization 头:若 SharedSecret 也为空则放行(纯内网开发),否则 401 if tokenStr == "" { if sharedSecret == "" { c.Set("auth_mode", "dev_open") c.Next() return } c.JSON(401, gin.H{"error": "未提供认证 Token"}) c.Abort() return } // 去掉 "Bearer " 前缀 bearer := tokenStr if len(bearer) > 7 && bearer[:7] == "Bearer " { bearer = bearer[7:] } // ---------- 路径 1:JWT 校验(面板登录 token / 小程序直连场景) ---------- if claims, ok := ValidateJWT(cfg, bearer); ok { injectJWTClaims(c, claims) c.Next() return } // JWT 解析失败:继续尝试 SharedSecret 路径(可能是 PHP 端发来的简单密钥) jwtSecret := getJWTSecret(cfg) // ---------- 路径 2:SharedSecret 字符串比对(PHP 后台对接主路径) ---------- // PHP TcmAgentClient 把 ai_agent_secret 的值塞进 Bearer, // 这里直接 == 比对,运维无需懂 JWT 也能配置 if sharedSecret != "" && bearer == sharedSecret { c.Set("auth_mode", "shared_secret") c.Next() return } // ---------- 路径 3:开发模式默认密钥(仅 JWTSecret 为空时回落) ---------- // 保留原硬编码默认密钥兼容老调用方;生产环境 JWTSecret 必填,此分支不会触发 if jwtSecret == "tcm-agent-dev-secret-change-in-production" && bearer == jwtSecret { c.Set("auth_mode", "dev_default") c.Next() return } // 三条路径全部失败:401 c.JSON(401, gin.H{"error": "Token 无效或密钥不匹配"}) c.Abort() } } // getJWTSecret 获取 JWT 签名密钥 // // 优先级:环境变量 > 配置文件(cfg.Agent.JWTSecret)> 默认值(仅开发用) // // 注意:默认值仅用于开发环境,生产环境必须通过 env AGENT_JWT_SECRET // 或 config.yaml agent.jwt_secret 覆盖 func getJWTSecret(cfg *config.Config) string { // 环境变量优先 if secret := os.Getenv("JWT_SECRET"); secret != "" { return secret } // 配置文件(agent.jwt_secret) if cfg != nil && cfg.Agent.JWTSecret != "" { return cfg.Agent.JWTSecret } // 默认值(仅开发环境!生产环境必须修改) return "tcm-agent-dev-secret-change-in-production" } // JWTSecret 导出 JWT 签名密钥的解析逻辑 // // 为什么导出:auth_handler 签发登录 token 必须与本中间件用同一把密钥, // 单点维护密钥优先级(env > yaml > 默认值),避免两处逻辑漂移 func JWTSecret(cfg *config.Config) string { return getJWTSecret(cfg) } // bearerFromHeader 从 Authorization 头提取 Bearer token(无前缀时原样返回) func bearerFromHeader(c *gin.Context) string { tokenStr := c.GetHeader("Authorization") if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { return tokenStr[7:] } return tokenStr } // ValidateJWT 校验一个 JWT 字符串,返回 claims // // 供两处复用: // - Auth 中间件的业务 API JWT 路径 // - /api/v1/kb/admin/* 的面板 JWT 通道 // // 校验点:HMAC 签名算法 + 签名有效 +(jwt/v5 默认校验 exp 过期) func ValidateJWT(cfg *config.Config, tokenStr string) (jwt.MapClaims, bool) { if tokenStr == "" { return nil, false } jwtSecret := getJWTSecret(cfg) token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("非预期的签名算法: %v", t.Header["alg"]) } return []byte(jwtSecret), nil }) if err != nil || !token.Valid { return nil, false } claims, ok := token.Claims.(jwt.MapClaims) return claims, ok } // injectJWTClaims 把 JWT claims 中的用户信息注入 gin Context // // 下游 handler(如 /auth/profile、/auth/refresh)从 Context 读取身份 func injectJWTClaims(c *gin.Context, claims jwt.MapClaims) { if sub, ok := claims["sub"].(string); ok { c.Set("user_id", sub) } if role, ok := claims["role"].(string); ok { c.Set("user_role", role) } c.Set("auth_mode", "jwt") }