// 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"` } // 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"` 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 := listUsers(conn, cfg.BaseDN) return TestResult{ OK: true, Message: "Verbindung erfolgreich", LatencyMS: time.Since(start).Milliseconds(), ServerInfo: serverInfo, UsersFound: usersFound, Users: 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) // plus a preview slice of up to 50 entries. Supports both AD (mail) and // Univention UCS (mailPrimaryAddress) mail attributes. func listUsers(conn *ldapv3.Conn, baseDN string) (int, []LDAPUser) { req := ldapv3.NewSearchRequest( baseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 500, 30, false, "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))", []string{"dn", "uid", "cn", "displayName", "mail", "mailPrimaryAddress"}, nil, ) res, err := conn.Search(req) if err != nil { return 0, nil } 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") } preview = append(preview, LDAPUser{ DN: e.DN, UID: e.GetAttributeValue("uid"), DisplayName: displayName, Mail: mail, }) } return len(res.Entries), preview }