feat: v0.4.0 主题切换 + LLM 全面优化 + 登录会话 + 用户管理/LDAP
主题: - 深色/浅色/跟随系统(useTheme:本地缓存先行+远端同步+matchMedia), 侧边栏快捷切换,设置页三档即选即生效 LLM(参考 opsxdb/aiops): - llm.Client.ChatStream 流式(SSE 增量解析,透传 reasoning_content, ctx 控制超时);POST /api/ai/chat/stream(平台实时数据注入降幻觉, 未配 LLM 降级规则 JSON,指令解析 action 不变) - test-llm 支持草稿直测(保存前验证,apiKey 空/掩码回退已存) - 发布失败自动 AI 分析日志尾部(结论/根因/建议),发布记录与发布 Tab 展示分析卡片 - 设置页 Provider 预设(DeepSeek/GLM/GLM编码/Qwen/Kimi/Ollama/自定义) 自动填 endpoint+模型+获取 Key 直达链接 登录与会话: - HMAC 签名 token(7 天,密钥派生自 encryption.key); authMiddleware 保护 /api/*(豁免登录/健康/SSE日志流/入站webhook) - 登录页 /login + 未登录跳转 + 登出;401 自动清理跳登录 用户管理与 LDAP(借鉴 LokiStack): - /users 页面:本地用户 CRUD、一次性密码、角色切换、来源徽章 (本地·蓝 | LDAP导入·紫);LDAP 导入按钮仅在启用且配置齐备时显示 - LDAP 配置卡(未启用仅提示语)、测试连接草稿直测+可选测试账号 - go-ldap:管理 Bind/用户搜索(防注入)/用户 DN 直连验证; 批量导入冲突策略(已LDAP跳过/本地同名转LDAP/新建); 登录分流本地 argon2id 或 LDAP 目录验证 + 首登 JIT 建号
This commit is contained in:
1 parent
555934cc64
commit
97193eb090
24 files changed
+2422
-98
No files matched your search
@@ -1,7 +1,17 @@
|
||||
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"
|
||||
|
||||
@@ -10,7 +20,98 @@ import (
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// Login POST /api/auth/login {username, password}
|
||||
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"`
|
||||
@@ -21,16 +122,33 @@ func Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1) 本地用户:AuthSource != ldap 才走本地口令
|
||||
var user model.User
|
||||
if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
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
|
||||
}
|
||||
if !crypto.VerifyPassword(body.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
|
||||
// 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.StatusOK, gin.H{
|
||||
"success": true, "username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||
})
|
||||
|
||||
// 统一错误,不泄露用户是否存在
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
}
|
||||
Reference in new issue
Block a user