Files
FlowStack/backend/internal/ldap/ldap.go
T
Logan.Li 97193eb090 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 建号
2026-10-27 12:58:21 +08:00

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)
}