主题: - 深色/浅色/跟随系统(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 建号
149 lines
4.2 KiB
Go
149 lines
4.2 KiB
Go
// 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)
|
|
}
|