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