- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
331 lines
9.6 KiB
Go
331 lines
9.6 KiB
Go
// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、
|
|
// 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。
|
|
// 约定:has_password 脱敏、空密码保留旧值、批量导入冲突策略。
|
|
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"lokistack/models"
|
|
"lokistack/pkg/crypto"
|
|
"lokistack/repository"
|
|
"lokistack/utils"
|
|
|
|
ldap "github.com/go-ldap/ldap/v3"
|
|
)
|
|
|
|
const ldapSettingKey = "ldap_settings"
|
|
|
|
// netDialer LDAP 拨号器(5s 超时,参照 aiops 的超时处理,避免连接挂起)
|
|
func netDialer() *net.Dialer {
|
|
return &net.Dialer{Timeout: 5 * time.Second}
|
|
}
|
|
|
|
// LDAPConfig LDAP 配置(存储形态;BindPassword 以 ENC: 前缀 AES-GCM 加密落库)
|
|
type LDAPConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
UseSSL bool `json:"useSSL"`
|
|
BindDN string `json:"bindDN"`
|
|
BindPassword string `json:"bindPassword"` // 存储为 ENC:密文;对外输出时剔除
|
|
BaseDN string `json:"baseDN"`
|
|
UserFilter string `json:"userFilter"`
|
|
UsernameAttr string `json:"usernameAttr"`
|
|
NameAttr string `json:"nameAttr"`
|
|
EmailAttr string `json:"emailAttr"`
|
|
}
|
|
|
|
// LDAPPublicConfig 对外输出形态(密码剔除,附 hasPassword)
|
|
type LDAPPublicConfig struct {
|
|
LDAPConfig
|
|
BindPassword string `json:"bindPassword"` // 恒为空
|
|
HasPassword bool `json:"hasPassword"`
|
|
Configured bool `json:"configured"` // host+bindDN+baseDN 齐备(控制用户页导入按钮显隐)
|
|
}
|
|
|
|
func defaultLDAPConfig() *LDAPConfig {
|
|
return &LDAPConfig{
|
|
Port: 389,
|
|
UserFilter: "(objectClass=inetOrgPerson)",
|
|
UsernameAttr: "uid",
|
|
NameAttr: "displayName",
|
|
EmailAttr: "mail",
|
|
}
|
|
}
|
|
|
|
// GetLDAPConfig 读取配置(解密密码);未配置时返回默认值
|
|
func GetLDAPConfig() *LDAPConfig {
|
|
db := repository.GetDB()
|
|
db.RLock()
|
|
var raw string
|
|
for i := range db.Settings {
|
|
if db.Settings[i].Key == ldapSettingKey {
|
|
raw = db.Settings[i].Value
|
|
break
|
|
}
|
|
}
|
|
db.RUnlock()
|
|
|
|
cfg := defaultLDAPConfig()
|
|
if raw != "" {
|
|
_ = json.Unmarshal([]byte(raw), cfg)
|
|
}
|
|
if cfg.Port == 0 {
|
|
cfg.Port = 389
|
|
}
|
|
if cfg.BindPassword != "" {
|
|
if plain, err := DecryptCred(cfg.BindPassword); err == nil {
|
|
cfg.BindPassword = plain
|
|
}
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func GetLDAPPublicConfig() *LDAPPublicConfig {
|
|
cfg := GetLDAPConfig()
|
|
hasPwd := cfg.BindPassword != ""
|
|
return &LDAPPublicConfig{
|
|
LDAPConfig: LDAPConfig{
|
|
Enabled: cfg.Enabled,
|
|
Host: cfg.Host,
|
|
Port: cfg.Port,
|
|
UseSSL: cfg.UseSSL,
|
|
BindDN: cfg.BindDN,
|
|
BaseDN: cfg.BaseDN,
|
|
UserFilter: cfg.UserFilter,
|
|
UsernameAttr: cfg.UsernameAttr,
|
|
NameAttr: cfg.NameAttr,
|
|
EmailAttr: cfg.EmailAttr,
|
|
},
|
|
HasPassword: hasPwd,
|
|
Configured: cfg.Host != "" && cfg.BindDN != "" && cfg.BaseDN != "",
|
|
}
|
|
}
|
|
|
|
// SaveLDAPConfig 保存;bindPassword 为空且已有存量 → 保留旧密文
|
|
func SaveLDAPConfig(in *LDAPConfig) error {
|
|
old := GetLDAPConfig()
|
|
if in.Port == 0 {
|
|
in.Port = 389
|
|
}
|
|
if in.UserFilter == "" {
|
|
in.UserFilter = old.UserFilter
|
|
}
|
|
if in.UsernameAttr == "" {
|
|
in.UsernameAttr = "uid"
|
|
}
|
|
if in.NameAttr == "" {
|
|
in.NameAttr = "displayName"
|
|
}
|
|
if in.EmailAttr == "" {
|
|
in.EmailAttr = "mail"
|
|
}
|
|
if in.BindPassword == "" {
|
|
in.BindPassword = old.BindPassword // 已是明文(GetLDAPConfig 解密过)
|
|
} else if enc, err := crypto.EncryptField(in.BindPassword); err == nil {
|
|
in.BindPassword = enc
|
|
}
|
|
|
|
data, _ := json.Marshal(in)
|
|
db := repository.GetDB()
|
|
// NextID/CreateUser 均自拿锁,禁止在持有 db.Lock() 时调用(JsonDB 锁不可重入)
|
|
newID := db.NextID("settings")
|
|
db.Lock()
|
|
defer db.Unlock()
|
|
for i := range db.Settings {
|
|
if db.Settings[i].Key == ldapSettingKey {
|
|
db.Settings[i].Value = string(data)
|
|
db.RequestSave()
|
|
return nil
|
|
}
|
|
}
|
|
db.Settings = append(db.Settings, models.SystemSetting{
|
|
ID: newID, Key: ldapSettingKey, Value: string(data),
|
|
})
|
|
db.RequestSave()
|
|
return nil
|
|
}
|
|
|
|
// ==================== 连接与搜索 ====================
|
|
|
|
// dialLDAP 建立连接(5s 拨号超时,防参照项目无超时挂起的问题)
|
|
func dialLDAP(cfg *LDAPConfig) (*ldap.Conn, error) {
|
|
scheme := "ldap"
|
|
if cfg.UseSSL {
|
|
scheme = "ldaps"
|
|
}
|
|
url := fmt.Sprintf("%s://%s:%d", scheme, cfg.Host, cfg.Port)
|
|
conn, err := ldap.DialURL(url, ldap.DialWithDialer(netDialer()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("无法连接 LDAP 服务器 %s: %w", url, err)
|
|
}
|
|
conn.SetTimeout(10 * time.Second)
|
|
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("管理员绑定失败(检查 Bind DN / 密码): %w", err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
// ldapUserEntry LDAP 用户条目(导入预览与登录共用)
|
|
type ldapUserEntry struct {
|
|
DN string
|
|
Username string
|
|
DisplayName string
|
|
Email string
|
|
}
|
|
|
|
// searchLDAPUsers 按 ouDN(空用 baseDN)WholeSubtree 搜索用户,sizeLimit 上限
|
|
func searchLDAPUsers(cfg *LDAPConfig, ouDN string, sizeLimit int) ([]ldapUserEntry, error) {
|
|
if ouDN == "" {
|
|
ouDN = cfg.BaseDN
|
|
}
|
|
conn, err := dialLDAP(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
filter := cfg.UserFilter
|
|
if !strings.HasPrefix(filter, "(") {
|
|
filter = "(" + filter + ")"
|
|
}
|
|
req := ldap.NewSearchRequest(ouDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
|
sizeLimit, 0, false, filter,
|
|
[]string{"dn", cfg.UsernameAttr, cfg.NameAttr, "cn", cfg.EmailAttr}, nil)
|
|
res, err := conn.Search(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("搜索失败(BaseDN/过滤器可能有误): %w", err)
|
|
}
|
|
|
|
users := make([]ldapUserEntry, 0, len(res.Entries))
|
|
seen := map[string]bool{}
|
|
for _, e := range res.Entries {
|
|
u := ldapUserEntry{
|
|
DN: e.DN,
|
|
Username: e.GetAttributeValue(cfg.UsernameAttr),
|
|
DisplayName: e.GetAttributeValue(cfg.NameAttr),
|
|
Email: e.GetAttributeValue(cfg.EmailAttr),
|
|
}
|
|
if u.DisplayName == "" {
|
|
u.DisplayName = e.GetAttributeValue("cn")
|
|
}
|
|
if u.Username == "" || seen[u.DN] {
|
|
continue // 无用户名属性的条目(如 ou 本身)跳过
|
|
}
|
|
seen[u.DN] = true
|
|
users = append(users, u)
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
// searchLDAPUserByUsername 登录用:按用户名搜索单个条目
|
|
func searchLDAPUserByUsername(cfg *LDAPConfig, username string) (*ldapUserEntry, error) {
|
|
conn, err := dialLDAP(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
filter := fmt.Sprintf("(&%s(%s=%s))", normalizeFilter(cfg.UserFilter),
|
|
cfg.UsernameAttr, ldap.EscapeFilter(username))
|
|
req := ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
|
2, 0, false, filter,
|
|
[]string{"dn", cfg.UsernameAttr, cfg.NameAttr, "cn", cfg.EmailAttr}, nil)
|
|
res, err := conn.Search(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(res.Entries) == 0 {
|
|
return nil, utils.ErrInvalidCredentials
|
|
}
|
|
if len(res.Entries) > 1 {
|
|
return nil, fmt.Errorf("LDAP 中存在多个匹配 %s 的用户", username)
|
|
}
|
|
e := res.Entries[0]
|
|
return &ldapUserEntry{
|
|
DN: e.DN,
|
|
Username: e.GetAttributeValue(cfg.UsernameAttr),
|
|
DisplayName: e.GetAttributeValue(cfg.NameAttr),
|
|
Email: e.GetAttributeValue(cfg.EmailAttr),
|
|
}, nil
|
|
}
|
|
|
|
// verifyLDAPPassword 用户级验证:bind 到用户 DN 校验密码
|
|
func verifyLDAPPassword(cfg *LDAPConfig, username, password string) (*ldapUserEntry, error) {
|
|
entry, err := searchLDAPUserByUsername(cfg, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
scheme := "ldap"
|
|
if cfg.UseSSL {
|
|
scheme = "ldaps"
|
|
}
|
|
conn, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", scheme, cfg.Host, cfg.Port),
|
|
ldap.DialWithDialer(netDialer()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
if err := conn.Bind(entry.DN, password); err != nil {
|
|
return nil, utils.ErrInvalidCredentials
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func normalizeFilter(f string) string {
|
|
if !strings.HasPrefix(f, "(") {
|
|
return "(" + f + ")"
|
|
}
|
|
return f
|
|
}
|
|
|
|
// TestLDAPConnection 测试连接:管理 bind + baseDN 校验 + 可选用户级验证
|
|
func TestLDAPConnection(cfg *LDAPConfig, testUsername, testPassword string) map[string]interface{} {
|
|
if cfg.Host == "" || cfg.BindDN == "" || cfg.BaseDN == "" {
|
|
return map[string]interface{}{"success": false, "message": "请先填写服务器地址 / Bind DN / Base DN"}
|
|
}
|
|
// 表单密码为空回退已存密码
|
|
if cfg.BindPassword == "" {
|
|
cfg.BindPassword = GetLDAPConfig().BindPassword
|
|
}
|
|
|
|
conn, err := dialLDAP(cfg)
|
|
if err != nil {
|
|
return map[string]interface{}{"success": false, "message": err.Error()}
|
|
}
|
|
defer conn.Close()
|
|
|
|
// baseDN 可读性校验
|
|
req := ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases,
|
|
1, 0, false, "(objectClass=*)", []string{"dn"}, nil)
|
|
if _, err := conn.Search(req); err != nil {
|
|
return map[string]interface{}{"success": false, "message": fmt.Sprintf("服务器连通,但 Base DN 搜索失败: %v", err)}
|
|
}
|
|
|
|
result := map[string]interface{}{
|
|
"success": true,
|
|
"message": fmt.Sprintf("连接成功:服务器可达,管理员绑定与 Base DN 校验通过 (%s)", cfg.BaseDN),
|
|
}
|
|
if testUsername != "" {
|
|
if testPassword == "" {
|
|
return map[string]interface{}{"success": false, "message": "请填写测试账号的密码"}
|
|
}
|
|
entry, err := verifyLDAPPassword(cfg, testUsername, testPassword)
|
|
if err != nil {
|
|
return map[string]interface{}{"success": false, "message": fmt.Sprintf("服务器连接正常,但用户「%s」验证失败: %v", testUsername, err)}
|
|
}
|
|
result["message"] = fmt.Sprintf("连接成功,用户「%s」验证通过 (DN: %s)", testUsername, entry.DN)
|
|
result["userInfo"] = map[string]string{
|
|
"dn": entry.DN, "username": entry.Username,
|
|
"displayName": entry.DisplayName, "email": entry.Email,
|
|
}
|
|
}
|
|
return result
|
|
}
|