// Package ldapauth provides LDAP/Active Directory authentication helpers. // It wraps go-ldap/ldap/v3 and exposes a TestConnection probe and an // Authenticate function used by the auth.Manager LDAP fallback. package ldapauth import ( "crypto/tls" "fmt" "strings" "time" ldapv3 "github.com/go-ldap/ldap/v3" ) // Config holds the parameters required to connect to an LDAP/AD server. type Config struct { URL string BindDN string BindPassword string BaseDN string UserFilter string // must contain %s as placeholder for the username TLS bool // true = STARTTLS upgrade after plain connection TLSSkipVerify bool } // LDAPUser is a preview entry returned by TestConnection. type LDAPUser struct { DN string `json:"dn"` UID string `json:"uid"` DisplayName string `json:"display_name"` Mail string `json:"mail"` } // FilterSuggestion is a clickable filter preset shown after a successful test. type FilterSuggestion struct { Label string `json:"label"` Filter string `json:"filter"` Description string `json:"description"` } // TestResult is the structured output of TestConnection. type TestResult struct { OK bool `json:"ok"` Message string `json:"message"` LatencyMS int64 `json:"latency_ms"` ServerInfo string `json:"server_info"` UsersFound int `json:"users_found"` Users []LDAPUser `json:"users"` ObjectClasses []string `json:"object_classes"` FilterSuggestions []FilterSuggestion `json:"filter_suggestions"` ErrorDetail string `json:"error_detail"` } // TestConnection opens a connection to the LDAP server, binds with the service // account, queries the RootDSE for server info, and counts user objects. func TestConnection(cfg Config) TestResult { start := time.Now() conn, err := dial(cfg) if err != nil { return TestResult{ OK: false, Message: "Verbindung fehlgeschlagen", LatencyMS: time.Since(start).Milliseconds(), ErrorDetail: err.Error(), } } defer conn.Close() // Service-account bind if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { return TestResult{ OK: false, Message: "Bind fehlgeschlagen", LatencyMS: time.Since(start).Milliseconds(), ErrorDetail: err.Error(), } } // Query RootDSE for server info serverInfo := queryRootDSE(conn) // Fetch user objects (capped at 500 for count, 50 for preview list) usersFound, users, objectClasses := listUsers(conn, cfg.BaseDN) return TestResult{ OK: true, Message: "Verbindung erfolgreich", LatencyMS: time.Since(start).Milliseconds(), ServerInfo: serverInfo, UsersFound: usersFound, Users: users, ObjectClasses: objectClasses, FilterSuggestions: generateFilterSuggestions(objectClasses, users), } } // Authenticate performs a two-step bind against LDAP: // 1. Bind with the service account to locate the user DN via search. // 2. Bind with the user DN and the provided password to verify credentials. // // Returns a map of user attributes (mail, displayName, memberOf) on success. func Authenticate(cfg Config, username, password string) (map[string]string, error) { conn, err := dial(cfg) if err != nil { return nil, fmt.Errorf("ldapauth: dial: %w", err) } defer conn.Close() // Step 1: service-account bind if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { return nil, fmt.Errorf("ldapauth: service bind: %w", err) } // Step 2: search for user DN filter := strings.ReplaceAll(cfg.UserFilter, "%s", ldapv3.EscapeFilter(username)) searchReq := ldapv3.NewSearchRequest( cfg.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 1, // size limit 30, // time limit seconds false, filter, []string{"dn", "mail", "displayName", "memberOf"}, nil, ) result, err := conn.Search(searchReq) if err != nil { return nil, fmt.Errorf("ldapauth: search user: %w", err) } if len(result.Entries) == 0 { return nil, fmt.Errorf("ldapauth: user not found: %s", username) } userDN := result.Entries[0].DN attrs := map[string]string{ "mail": result.Entries[0].GetAttributeValue("mail"), "displayName": result.Entries[0].GetAttributeValue("displayName"), "memberOf": strings.Join(result.Entries[0].GetAttributeValues("memberOf"), ","), } // Step 3: user bind to verify password if err := conn.Bind(userDN, password); err != nil { return nil, fmt.Errorf("ldapauth: user bind failed") } return attrs, nil } // dial creates an LDAP connection, optionally upgrading to TLS via STARTTLS. func dial(cfg Config) (*ldapv3.Conn, error) { tlsCfg := &tls.Config{InsecureSkipVerify: cfg.TLSSkipVerify} //nolint:gosec // user-configured option // ldaps:// URLs use implicit TLS; ldap:// URLs use plain or STARTTLS. if strings.HasPrefix(cfg.URL, "ldaps://") { return ldapv3.DialURL(cfg.URL, ldapv3.DialWithTLSConfig(tlsCfg)) } conn, err := ldapv3.DialURL(cfg.URL) if err != nil { return nil, err } if cfg.TLS { if err := conn.StartTLS(tlsCfg); err != nil { conn.Close() return nil, fmt.Errorf("ldapauth: STARTTLS: %w", err) } } return conn, nil } // queryRootDSE fetches vendorName / vendorVersion from the RootDSE. func queryRootDSE(conn *ldapv3.Conn) string { req := ldapv3.NewSearchRequest( "", ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 1, 10, false, "(objectClass=*)", []string{"vendorName", "vendorVersion", "serverType", "isGlobalCatalogReady"}, nil, ) res, err := conn.Search(req) if err != nil || len(res.Entries) == 0 { return "" } e := res.Entries[0] parts := []string{} for _, attr := range []string{"vendorName", "vendorVersion", "serverType"} { v := e.GetAttributeValue(attr) if v != "" { parts = append(parts, v) } } return strings.Join(parts, " ") } // FetchUsers connects to LDAP and returns all user objects (up to 2000) for // bulk import / synchronisation. Unlike TestConnection it does not cap the // preview at 50 entries. func FetchUsers(cfg Config) ([]LDAPUser, error) { conn, err := dial(cfg) if err != nil { return nil, fmt.Errorf("ldapauth: dial: %w", err) } defer conn.Close() if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { return nil, fmt.Errorf("ldapauth: service bind: %w", err) } filter := cfg.UserFilter if filter == "" { filter = "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" } else { // Convert login filter (e.g. uid=%s or (uid=%s)) to a wildcard search. filter = strings.ReplaceAll(filter, "%s", "*") // Wrap in parentheses if missing — e.g. "uid=*" → "(uid=*)" if !strings.HasPrefix(filter, "(") { filter = "(" + filter + ")" } } req := ldapv3.NewSearchRequest( cfg.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 2000, 60, false, filter, []string{"dn", "uid", "cn", "displayName", "mail", "mailPrimaryAddress"}, nil, ) res, err := conn.Search(req) if err != nil { return nil, fmt.Errorf("ldapauth: search users: %w", err) } users := make([]LDAPUser, 0, len(res.Entries)) for _, e := range res.Entries { mail := e.GetAttributeValue("mail") if mail == "" { mail = e.GetAttributeValue("mailPrimaryAddress") } displayName := e.GetAttributeValue("displayName") if displayName == "" { displayName = e.GetAttributeValue("cn") } uid := e.GetAttributeValue("uid") if uid == "" { continue // skip entries without a uid } users = append(users, LDAPUser{ DN: e.DN, UID: uid, DisplayName: displayName, Mail: mail, }) } return users, nil } // listUsers searches for person/user objects and returns the total count (max 500), // a preview slice of up to 50 entries, and the distinct objectClass values found. func listUsers(conn *ldapv3.Conn, baseDN string) (int, []LDAPUser, []string) { req := ldapv3.NewSearchRequest( baseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 500, 30, false, "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))", []string{"dn", "uid", "cn", "displayName", "mail", "mailPrimaryAddress", "objectClass", "sAMAccountName"}, nil, ) res, err := conn.Search(req) if err != nil { return 0, nil, nil } // Collect distinct objectClass values across all entries. ocSet := map[string]struct{}{} for _, e := range res.Entries { for _, oc := range e.GetAttributeValues("objectClass") { ocSet[oc] = struct{}{} } } objectClasses := make([]string, 0, len(ocSet)) for oc := range ocSet { objectClasses = append(objectClasses, oc) } preview := make([]LDAPUser, 0, min(50, len(res.Entries))) for i, e := range res.Entries { if i >= 50 { break } mail := e.GetAttributeValue("mail") if mail == "" { mail = e.GetAttributeValue("mailPrimaryAddress") } displayName := e.GetAttributeValue("displayName") if displayName == "" { displayName = e.GetAttributeValue("cn") } uid := e.GetAttributeValue("uid") if uid == "" { uid = e.GetAttributeValue("sAMAccountName") } preview = append(preview, LDAPUser{ DN: e.DN, UID: uid, DisplayName: displayName, Mail: mail, }) } return len(res.Entries), preview, objectClasses } // generateFilterSuggestions builds clickable filter presets based on the // objectClasses and user attributes found in the directory. func generateFilterSuggestions(objectClasses []string, users []LDAPUser) []FilterSuggestion { ocSet := map[string]bool{} for _, oc := range objectClasses { ocSet[strings.ToLower(oc)] = true } hasMailPrimary := false for _, u := range users { if strings.Contains(u.Mail, "@") { hasMailPrimary = true break } } isUCS := ocSet["posixaccount"] || ocSet["shadowaccount"] isAD := ocSet["user"] && !isUCS var s []FilterSuggestion if isUCS { s = append(s, FilterSuggestion{ Label: "UCS: Login per uid", Filter: "(uid=%s)", Description: "Standard-Login-Filter für Univention UCS", }, ) if hasMailPrimary { s = append(s, FilterSuggestion{ Label: "UCS: Nur Benutzer mit Postfach", Filter: "(&(uid=%s)(mailPrimaryAddress=*))", Description: "Nur UCS-Benutzer mit aktiviertem Postfach (mailPrimaryAddress)", }, ) } s = append(s, FilterSuggestion{ Label: "UCS: Personen ohne Systemkonten", Filter: "(&(uid=%s)(!(uid=*$)))", Description: "Schließt Computerkonten (uid endet auf $) aus", }, ) } if isAD { s = append(s, FilterSuggestion{ Label: "AD: Login per sAMAccountName", Filter: "(sAMAccountName=%s)", Description: "Standard-Login-Filter für Active Directory", }, ) s = append(s, FilterSuggestion{ Label: "AD: Nur Benutzer mit E-Mail", Filter: "(&(sAMAccountName=%s)(mail=*))", Description: "AD-Benutzer mit gesetzter E-Mail-Adresse", }, ) s = append(s, FilterSuggestion{ Label: "AD: Keine Computerkonten", Filter: "(&(sAMAccountName=%s)(!(objectClass=computer)))", Description: "Schließt Computerkonten aus", }, ) } if !isUCS && !isAD { s = append(s, FilterSuggestion{Label: "uid", Filter: "(uid=%s)", Description: "Login per uid-Attribut"}, FilterSuggestion{Label: "sAMAccountName", Filter: "(sAMAccountName=%s)", Description: "Login per sAMAccountName (Active Directory)"}, FilterSuggestion{Label: "mail", Filter: "(mail=%s)", Description: "Login per E-Mail-Adresse"}, ) } return s }