主题: - 深色/浅色/跟随系统(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 建号
381 lines
11 KiB
Go
381 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"flowstack/internal/crypto"
|
|
"flowstack/internal/database"
|
|
"flowstack/internal/ldap"
|
|
"flowstack/internal/model"
|
|
)
|
|
|
|
// ---- LDAP 配置辅助 ----
|
|
|
|
type ldapRuntime struct {
|
|
Host, BindDN, BindPassword, BaseDN, UserFilter, UsernameAttr, NameAttr, EmailAttr string
|
|
Port int
|
|
UseSSL bool
|
|
Enabled, Configured bool
|
|
}
|
|
|
|
func loadLDAP() ldapRuntime {
|
|
var s model.SystemSetting
|
|
database.DB.First(&s, 1)
|
|
rt := ldapRuntime{
|
|
Host: s.LdapHost, Port: s.LdapPort, UseSSL: s.LdapUseSSL,
|
|
BindDN: s.LdapBindDN, BaseDN: s.LdapBaseDN,
|
|
UserFilter: s.LdapUserFilter, UsernameAttr: s.LdapUsernameAttr,
|
|
NameAttr: s.LdapNameAttr, EmailAttr: s.LdapEmailAttr,
|
|
Enabled: s.LdapEnabled,
|
|
}
|
|
rt.Configured = rt.Host != "" && rt.BindDN != "" && rt.BaseDN != ""
|
|
return rt
|
|
}
|
|
|
|
func (rt ldapRuntime) bindPassword() string {
|
|
var s model.SystemSetting
|
|
database.DB.First(&s, 1)
|
|
if s.LdapBindPassword == "" {
|
|
return ""
|
|
}
|
|
if crypto.IsEncrypted(s.LdapBindPassword) {
|
|
if plain, err := crypto.DecryptField(s.LdapBindPassword); err == nil {
|
|
return plain
|
|
}
|
|
}
|
|
return s.LdapBindPassword
|
|
}
|
|
|
|
var errLDAPDisabled = errors.New("ldap disabled")
|
|
|
|
// ldapVerifyUser 登录用:目录搜索 + 用户 DN 直连 bind
|
|
func ldapVerifyUser(username, password string) (*ldap.Entry, error) {
|
|
rt := loadLDAP()
|
|
if !rt.Enabled || !rt.Configured {
|
|
return nil, errLDAPDisabled
|
|
}
|
|
entry, err := ldap.SearchOne(rt.Host, rt.Port, rt.UseSSL, rt.BindDN, rt.bindPassword(), rt.BaseDN, rt.UserFilter, rt.UsernameAttr, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ldap.BindAsUser(rt.Host, rt.Port, rt.UseSSL, entry.DN, password); err != nil {
|
|
return nil, err
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
// syncLDAPUser LDAP 验证通过后同步/建号(JIT)
|
|
func syncLDAPUser(entry *ldap.Entry) model.User {
|
|
var user model.User
|
|
if err := database.DB.Where("username = ?", entry.Username).First(&user).Error; err == nil {
|
|
// 已存在:本地同名转 LDAP + 同步昵称邮箱
|
|
database.DB.Model(&user).Updates(map[string]any{
|
|
"auth_source": "ldap", "nickname": entry.DisplayName, "email": entry.Email,
|
|
})
|
|
database.DB.First(&user, user.ID)
|
|
return user
|
|
}
|
|
user = model.User{
|
|
Username: entry.Username, Nickname: entry.DisplayName, Email: entry.Email,
|
|
Role: "user", AuthSource: "ldap",
|
|
// 占位密码:本地不可登录
|
|
Password: mustHash("ldap-user-" + entry.Username + "-no-local-login"),
|
|
}
|
|
database.DB.Create(&user)
|
|
return user
|
|
}
|
|
|
|
func mustHash(s string) string {
|
|
h, _ := crypto.HashPassword(s)
|
|
return h
|
|
}
|
|
|
|
// ---- 用户管理 ----
|
|
|
|
// ListUsers GET /api/users
|
|
func ListUsers(c *gin.Context) {
|
|
var list []model.User
|
|
database.DB.Order("created_at ASC").Find(&list)
|
|
c.JSON(http.StatusOK, list)
|
|
}
|
|
|
|
// CreateUser POST /api/users —— 创建本地用户
|
|
func CreateUser(c *gin.Context) {
|
|
var body struct {
|
|
Username string `json:"username" binding:"required"`
|
|
Nickname string `json:"nickname"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
|
|
return
|
|
}
|
|
if body.Role != "admin" {
|
|
body.Role = "user"
|
|
}
|
|
password := body.Password
|
|
if password == "" {
|
|
var err error
|
|
password, err = crypto.RandomPassword(16)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
hash, err := crypto.HashPassword(password)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
user := model.User{
|
|
Username: body.Username, Nickname: body.Nickname, Email: body.Email,
|
|
Role: body.Role, AuthSource: "local", Password: hash,
|
|
}
|
|
if err := database.DB.Create(&user).Error; err != nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:用户名已存在"})
|
|
return
|
|
}
|
|
// 密码仅在创建时回显一次(指定密码时不回显明文,回显随机生成的)
|
|
result := gin.H{"id": user.ID, "username": user.Username, "nickname": user.Nickname, "email": user.Email, "role": user.Role, "authSource": user.AuthSource}
|
|
if body.Password == "" {
|
|
result["initialPassword"] = password
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// ResetPassword POST /api/users/:id/reset-password —— LDAP 用户拒绝
|
|
func ResetPassword(c *gin.Context) {
|
|
var user model.User
|
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
|
return
|
|
}
|
|
if user.AuthSource == "ldap" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "LDAP 用户密码由目录服务管理"})
|
|
return
|
|
}
|
|
password, err := crypto.RandomPassword(16)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
hash, err := crypto.HashPassword(password)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
database.DB.Model(&user).Update("password", hash)
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "initialPassword": password})
|
|
}
|
|
|
|
// DeleteUser DELETE /api/users/:id —— 不可删最后一个管理员/自己
|
|
func DeleteUser(c *gin.Context) {
|
|
var user model.User
|
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
|
return
|
|
}
|
|
if user.Username == c.GetString("username") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除当前登录用户"})
|
|
return
|
|
}
|
|
if user.Role == "admin" {
|
|
var adminCount int64
|
|
database.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
|
|
if adminCount <= 1 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除最后一个管理员"})
|
|
return
|
|
}
|
|
}
|
|
database.DB.Delete(&user)
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
// UpdateUser PUT /api/users/:id —— 角色/昵称/邮箱
|
|
func UpdateUser(c *gin.Context) {
|
|
var user model.User
|
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
|
return
|
|
}
|
|
var body map[string]any
|
|
_ = c.ShouldBindJSON(&body)
|
|
if v, ok := body["role"].(string); ok && (v == "admin" || v == "user") {
|
|
database.DB.Model(&user).Update("role", v)
|
|
}
|
|
if v, ok := body["nickname"].(string); ok {
|
|
database.DB.Model(&user).Update("nickname", v)
|
|
}
|
|
if v, ok := body["email"].(string); ok {
|
|
database.DB.Model(&user).Update("email", v)
|
|
}
|
|
database.DB.First(&user, user.ID)
|
|
c.JSON(http.StatusOK, user)
|
|
}
|
|
|
|
// ---- LDAP 用户导入(LokiStack 交互与冲突策略) ----
|
|
|
|
// LdapListUsers POST /api/ldap/users —— 导入预览(未启用返回 400)
|
|
func LdapListUsers(c *gin.Context) {
|
|
rt := loadLDAP()
|
|
if !rt.Enabled || !rt.Configured {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "LDAP 未启用或未配置"})
|
|
return
|
|
}
|
|
entries, err := ldap.SearchUsers(rt.Host, rt.Port, rt.UseSSL, rt.BindDN, rt.bindPassword(),
|
|
rt.BaseDN, rt.UserFilter, rt.UsernameAttr, rt.NameAttr, rt.EmailAttr, 500)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, entries)
|
|
}
|
|
|
|
// LdapImport POST /api/users/ldap-import —— 批量导入(跳过已LDAP/本地同名转LDAP/新建)
|
|
func LdapImport(c *gin.Context) {
|
|
var body struct {
|
|
Users []struct {
|
|
Username string `json:"username"`
|
|
Nickname string `json:"nickname"`
|
|
Email string `json:"email"`
|
|
} `json:"users" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Users) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的用户"})
|
|
return
|
|
}
|
|
var created, converted, skipped, failed int
|
|
var failedUsers []string
|
|
for _, in := range body.Users {
|
|
if in.Username == "" {
|
|
continue
|
|
}
|
|
var exist model.User
|
|
if err := database.DB.Where("username = ?", in.Username).First(&exist).Error; err == nil {
|
|
if exist.AuthSource == "ldap" {
|
|
skipped++
|
|
continue
|
|
}
|
|
// 本地同名 → 转为 LDAP
|
|
database.DB.Model(&exist).Updates(map[string]any{"auth_source": "ldap", "nickname": in.Nickname, "email": in.Email})
|
|
converted++
|
|
continue
|
|
}
|
|
user := model.User{
|
|
Username: in.Username, Nickname: in.Nickname, Email: in.Email,
|
|
Role: "user", AuthSource: "ldap",
|
|
Password: mustHash("ldap-user-" + in.Username + "-no-local-login"),
|
|
}
|
|
if err := database.DB.Create(&user).Error; err != nil {
|
|
failed++
|
|
failedUsers = append(failedUsers, in.Username)
|
|
continue
|
|
}
|
|
created++
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"created": created, "converted": converted, "skipped": skipped,
|
|
"failed": failed, "failedUsers": failedUsers,
|
|
})
|
|
}
|
|
|
|
// TestLDAP POST /api/settings/ldap/test —— 支持草稿直测 + 可选测试账号
|
|
func TestLDAP(c *gin.Context) {
|
|
var s model.SystemSetting
|
|
database.DB.First(&s, 1)
|
|
var body struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Host string `json:"host"`
|
|
Port float64 `json:"port"`
|
|
UseSSL *bool `json:"useSSL"`
|
|
BindDN string `json:"bindDN"`
|
|
BindPassword string `json:"bindPassword"`
|
|
BaseDN string `json:"baseDN"`
|
|
UserFilter string `json:"userFilter"`
|
|
UsernameAttr string `json:"usernameAttr"`
|
|
TestUsername string `json:"testUsername"`
|
|
TestPassword string `json:"testPassword"`
|
|
}
|
|
_ = c.ShouldBindJSON(&body)
|
|
|
|
host := body.Host
|
|
if host == "" {
|
|
host = s.LdapHost
|
|
}
|
|
bindDN := body.BindDN
|
|
if bindDN == "" {
|
|
bindDN = s.LdapBindDN
|
|
}
|
|
password := body.BindPassword
|
|
if password == "" {
|
|
password = ""
|
|
var stored string
|
|
database.DB.Select("ldap_bind_password").First(&s, 1)
|
|
stored = s.LdapBindPassword
|
|
if crypto.IsEncrypted(stored) {
|
|
if plain, err := crypto.DecryptField(stored); err == nil {
|
|
password = plain
|
|
}
|
|
} else {
|
|
password = stored
|
|
}
|
|
}
|
|
baseDN := body.BaseDN
|
|
if baseDN == "" {
|
|
baseDN = s.LdapBaseDN
|
|
}
|
|
port := int(body.Port)
|
|
if port == 0 {
|
|
port = s.LdapPort
|
|
}
|
|
if port == 0 {
|
|
port = 389
|
|
}
|
|
useSSL := s.LdapUseSSL
|
|
if body.UseSSL != nil {
|
|
useSSL = *body.UseSSL
|
|
}
|
|
usernameAttr := body.UsernameAttr
|
|
if usernameAttr == "" {
|
|
usernameAttr = s.LdapUsernameAttr
|
|
}
|
|
userFilter := body.UserFilter
|
|
if userFilter == "" {
|
|
userFilter = s.LdapUserFilter
|
|
}
|
|
|
|
if host == "" || bindDN == "" || baseDN == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请填写服务器/Bind DN/Base DN"})
|
|
return
|
|
}
|
|
|
|
conn, err := ldap.Dial(host, port, useSSL, bindDN, password)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
// 可选:测试账号级验证
|
|
if body.TestUsername != "" && body.TestPassword != "" {
|
|
entry, err := ldap.SearchOne(host, port, useSSL, bindDN, password, baseDN, userFilter, usernameAttr, body.TestUsername)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试账号搜索失败: " + err.Error()})
|
|
return
|
|
}
|
|
if err := ldap.BindAsUser(host, port, useSSL, entry.DN, body.TestPassword); err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试账号密码验证失败: " + err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "连接与测试账号验证成功:" + entry.DisplayName + " (" + entry.Email + ")"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "LDAP 连接成功(管理员绑定通过)"})
|
|
}
|
|
|