324 lines
11 KiB
Go
324 lines
11 KiB
Go
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")
|
||
}
|