// 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 } // 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"` 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) // Count user objects (capped at 500 to avoid large result sets) usersFound := countUsers(conn, cfg.BaseDN) return TestResult{ OK: true, Message: "Verbindung erfolgreich", LatencyMS: time.Since(start).Milliseconds(), ServerInfo: serverInfo, UsersFound: usersFound, } } // 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, " ") } // countUsers searches for person/user objects and returns the count (max 500). func countUsers(conn *ldapv3.Conn, baseDN string) int { req := ldapv3.NewSearchRequest( baseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 500, 30, false, "(|(objectClass=person)(objectClass=user))", []string{"dn"}, nil, ) res, err := conn.Search(req) if err != nil { return 0 } return len(res.Entries) }