// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、 // 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。 // 设计参照 opsx-web-lite(has_password 脱敏/空密码保留旧值/批量导入冲突策略)。 package services import ( "encoding/json" "fmt" "net" "strings" "time" "opsxloki/models" "opsxloki/pkg/crypto" "opsxloki/repository" "opsxloki/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 }