package handler import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "flowstack/internal/crypto" "flowstack/internal/database" "flowstack/internal/model" ) const tokenTTL = 7 * 24 * time.Hour // sessionSecret 从 data/encryption.key 派生会话签名密钥 func sessionSecret() []byte { key := os.Getenv("FLOWSTACK_ENCRYPTION_KEY") if key == "" { if data, err := os.ReadFile(filepath.Join(database.DataDir, "encryption.key")); err == nil && len(data) == 32 { key = string(data) } } sum := sha256.Sum256([]byte("flowstack-session:" + key)) return sum[:] } // SignToken 签发 HMAC 无状态 token:base64(user|exp|sig) func SignToken(username string) (string, time.Time) { exp := time.Now().Add(tokenTTL) payload := username + "|" + strconv.FormatInt(exp.Unix(), 10) mac := hmac.New(sha256.New, sessionSecret()) mac.Write([]byte(payload)) sig := hex.EncodeToString(mac.Sum(nil)) return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + sig)), exp } // VerifyToken 校验 token,返回用户名 func VerifyToken(token string) (string, error) { raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil { return "", fmt.Errorf("token 格式错误") } parts := strings.Split(string(raw), "|") if len(parts) != 3 { return "", fmt.Errorf("token 结构错误") } payload := parts[0] + "|" + parts[1] mac := hmac.New(sha256.New, sessionSecret()) mac.Write([]byte(payload)) expect := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expect), []byte(parts[2])) { return "", fmt.Errorf("token 签名校验失败") } expUnix, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || time.Now().Unix() > expUnix { return "", fmt.Errorf("token 已过期") } return parts[0], nil } // AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook/SSE 日志流);SPA 与静态资源放行 func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { p := c.Request.URL.Path if p == "/health" || !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || strings.HasPrefix(p, "/api/hooks/") || strings.HasSuffix(p, "/logs/stream") { c.Next() return } auth := c.GetHeader("Authorization") if !strings.HasPrefix(auth, "Bearer ") { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } username, err := VerifyToken(strings.TrimPrefix(auth, "Bearer ")) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效,请重新登录"}) return } var user model.User if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"}) return } c.Set("username", user.Username) c.Set("role", user.Role) c.Next() } } // AdminOnly 管理员专用接口守卫 func AdminOnly() gin.HandlerFunc { return func(c *gin.Context) { if c.GetString("role") != "admin" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"}) return } c.Next() } } // Login POST /api/auth/login —— 本地 argon2id 与 LDAP 分流认证 func Login(c *gin.Context) { var body struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"}) return } // 1) 本地用户:AuthSource != ldap 才走本地口令 var user model.User hasLocal := database.DB.Where("username = ?", body.Username).First(&user).Error == nil if hasLocal && user.AuthSource != "ldap" { if !crypto.VerifyPassword(body.Password, user.Password) { c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) return } token, exp := SignToken(user.Username) c.JSON(http.StatusOK, gin.H{ "success": true, "token": token, "expiresAt": exp, "username": user.Username, "nickname": user.Nickname, "role": user.Role, }) return } // 2) LDAP:目录验证成功即登录(首次自动建号/同步) if entry, err := ldapVerifyUser(body.Username, body.Password); err == nil { user := syncLDAPUser(entry) token, exp := SignToken(user.Username) c.JSON(http.StatusOK, gin.H{ "success": true, "token": token, "expiresAt": exp, "username": user.Username, "nickname": user.Nickname, "role": user.Role, }) return } // 统一错误,不泄露用户是否存在 c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) }