Files
xk-ai-agent/internal/middleware/middleware.go
2026-08-14 21:50:48 +08:00

324 lines
11 KiB
Go
Raw Permalink 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 (
"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-PasswordKB 后台与 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 " 前缀后):
// - 路径 1JWT 校验JWTSecret 非空时启用,向后兼容小程序直连场景)
// - 路径 2SharedSecret 字符串 == 比对PHP 后台填一个值即可对接)
// - 路径 3开发模式默认密钥JWTSecret 为空时回落到硬编码开发密钥)
// - 三者任一通过即放行
//
// Token 注入 Context
// - JWT 模式user_id / user_role来自 claims.sub / claims.role
// - SharedSecret 模式auth_mode = "shared_secret"
//
// 生产环境建议:
// - 必须设置 SharedSecretconfig.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:]
}
// ---------- 路径 1JWT 校验(面板登录 token / 小程序直连场景) ----------
if claims, ok := ValidateJWT(cfg, bearer); ok {
injectJWTClaims(c, claims)
c.Next()
return
}
// JWT 解析失败:继续尝试 SharedSecret 路径(可能是 PHP 端发来的简单密钥)
jwtSecret := getJWTSecret(cfg)
// ---------- 路径 2SharedSecret 字符串比对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")
}