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
@@ -0,0 +1,148 @@
|
||||
// Package ldap 目录服务集成(go-ldap,实现模式参考 LokiStack)。
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// Entry LDAP 用户条目
|
||||
type Entry struct {
|
||||
DN string `json:"dn"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// Dial 连接并完成管理员 Bind(5s 拨号 / 10s 操作超时)
|
||||
func Dial(host string, port int, useSSL bool, bindDN, bindPassword string) (*ldap.Conn, error) {
|
||||
scheme := "ldap"
|
||||
if useSSL {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
url := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
conn, err := ldap.DialURL(url,
|
||||
ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}),
|
||||
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法连接 LDAP 服务器 %s: %v", url, err)
|
||||
}
|
||||
conn.SetTimeout(10 * time.Second)
|
||||
if err := conn.Bind(bindDN, bindPassword); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("管理员绑定失败(检查 Bind DN/密码): %v", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func normalizeFilter(f, def string) string {
|
||||
if strings.TrimSpace(f) == "" {
|
||||
f = def
|
||||
}
|
||||
if !strings.HasPrefix(f, "(") {
|
||||
f = "(" + f + ")"
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// SearchUsers 按 BaseDN/过滤器搜索用户(sizeLimit 截断)
|
||||
func SearchUsers(host string, port int, useSSL bool, bindDN, bindPassword, baseDN, userFilter, usernameAttr, nameAttr, emailAttr string, sizeLimit int) ([]Entry, error) {
|
||||
conn, err := Dial(host, port, useSSL, bindDN, bindPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if usernameAttr == "" {
|
||||
usernameAttr = "uid"
|
||||
}
|
||||
if nameAttr == "" {
|
||||
nameAttr = "displayName"
|
||||
}
|
||||
if emailAttr == "" {
|
||||
emailAttr = "mail"
|
||||
}
|
||||
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
sizeLimit, 0, false, normalizeFilter(userFilter, "(objectClass=inetOrgPerson)"),
|
||||
[]string{"dn", usernameAttr, nameAttr, "cn", emailAttr}, nil)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("搜索失败: %v", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []Entry
|
||||
for _, e := range res.Entries {
|
||||
username := e.GetAttributeValue(usernameAttr)
|
||||
if username == "" {
|
||||
continue // 跳过 OU 等非用户条目
|
||||
}
|
||||
if seen[e.DN] {
|
||||
continue
|
||||
}
|
||||
seen[e.DN] = true
|
||||
name := e.GetAttributeValue(nameAttr)
|
||||
if name == "" {
|
||||
name = e.GetAttributeValue("cn")
|
||||
}
|
||||
out = append(out, Entry{
|
||||
DN: e.DN, Username: username, DisplayName: name, Email: e.GetAttributeValue(emailAttr),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SearchOne 按用户名搜索单个条目(登录用,EscapeFilter 防注入)
|
||||
func SearchOne(host string, port int, useSSL bool, bindDN, bindPassword, baseDN, userFilter, usernameAttr, username string) (*Entry, error) {
|
||||
conn, err := Dial(host, port, useSSL, bindDN, bindPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if usernameAttr == "" {
|
||||
usernameAttr = "uid"
|
||||
}
|
||||
filter := fmt.Sprintf("(&%s(%s=%s))", normalizeFilter(userFilter, "(objectClass=inetOrgPerson)"),
|
||||
usernameAttr, ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
2, 0, false, filter, []string{"dn", usernameAttr, "displayName", "cn", "mail"}, nil)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
if len(res.Entries) > 1 {
|
||||
return nil, fmt.Errorf("LDAP 中存在多个匹配的用户")
|
||||
}
|
||||
e := res.Entries[0]
|
||||
name := e.GetAttributeValue("displayName")
|
||||
if name == "" {
|
||||
name = e.GetAttributeValue("cn")
|
||||
}
|
||||
return &Entry{DN: e.DN, Username: e.GetAttributeValue(usernameAttr), DisplayName: name, Email: e.GetAttributeValue("mail")}, nil
|
||||
}
|
||||
|
||||
// BindAsUser 用用户自己的 DN+密码直连校验(密码不落库)
|
||||
func BindAsUser(host string, port int, useSSL bool, userDN, password string) error {
|
||||
scheme := "ldap"
|
||||
if useSSL {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
conn, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", scheme, host, port),
|
||||
ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}),
|
||||
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
return conn.Bind(userDN, password)
|
||||
}
|
||||
Reference in new issue
Block a user