// Package ldapauth validates a username/password pair against an LDAP directory // using the standard "search then bind" pattern, exactly as an SSSD/LDAP client // would: connect to the directory, find the user's entry under the configured // search base, then attempt a bind as that entry's DN with the supplied password. // // It is a pure-Go alternative to the PAM backend (internal/pamauth): because it // speaks LDAP over the wire with no cgo, uopi keeps its fully-static // (CGO_ENABLED=0) release binary while still authenticating against the same // directory the host logs in with. It feeds the same HTTP Basic pipeline // (internal/server/basicauth.go) as PAM. // // Unlike PAM it only verifies the password; it does not run the rest of the PAM // stack (account expiry, access.conf, MFA). For a monitoring HMI that is normally // sufficient. package ldapauth import ( "crypto/tls" "crypto/x509" "errors" "fmt" "os" "time" "github.com/go-ldap/ldap/v3" ) // Config configures the LDAP authenticator. URIs and SearchBase are required; the // remaining fields mirror SSSD defaults so an unconfigured directory (anonymous // search, RFC2307 schema) works out of the box. type Config struct { // URIs are the directory endpoints, tried in order until one connects, e.g. // "ldaps://ldap.example.com" or "ldap://ldap.example.com". Mirrors SSSD's // ldap_uri. URIs []string // SearchBase is the subtree under which user entries are searched. Mirrors // SSSD's ldap_search_base. SearchBase string // UserAttr is the attribute matched against the login name. Empty defaults to // "uid" (SSSD's ldap_user_name default for RFC2307). UserAttr string // UserObjectClass restricts the search to this objectClass. Empty defaults to // "posixAccount" (SSSD's ldap_user_object_class default). UserObjectClass string // BindDN / BindPassword optionally authenticate the *search* (service // account). Empty BindDN performs an anonymous search, matching a directory // configured without ldap_default_bind_dn. BindDN string BindPassword string // StartTLS upgrades a plain ldap:// connection to TLS before any credentials // are sent. Ignored for ldaps:// (already TLS). StartTLS bool // CACertFile is an optional PEM file of CA certs to trust for the TLS // connection (for a directory using a private CA). CACertFile string // InsecureSkipVerify disables TLS certificate verification. Insecure; use only // for testing against a self-signed directory. InsecureSkipVerify bool // Timeout bounds each connection attempt. Zero defaults to 10s. Timeout time.Duration } // ErrInvalidCredentials is returned when the directory rejects the user's bind. var ErrInvalidCredentials = errors.New("ldap: invalid credentials") // Authenticator validates credentials against a fixed directory configuration. // It is safe for concurrent use: each Authenticate call opens and closes its own // connection. type Authenticator struct { cfg Config tlsConfig *tls.Config } // New validates cfg and returns an Authenticator. It fails fast on missing // required fields or an unreadable CA file so misconfiguration surfaces at // startup rather than on the first login. func New(cfg Config) (*Authenticator, error) { if len(cfg.URIs) == 0 { return nil, errors.New("ldap: at least one uri is required") } if cfg.SearchBase == "" { return nil, errors.New("ldap: search_base is required") } if cfg.UserAttr == "" { cfg.UserAttr = "uid" } if cfg.UserObjectClass == "" { cfg.UserObjectClass = "posixAccount" } if cfg.Timeout <= 0 { cfg.Timeout = 10 * time.Second } tlsConfig := &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify} if cfg.CACertFile != "" { pem, err := os.ReadFile(cfg.CACertFile) if err != nil { return nil, fmt.Errorf("ldap: reading ca_cert: %w", err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("ldap: ca_cert %q contained no certificates", cfg.CACertFile) } tlsConfig.RootCAs = pool } return &Authenticator{cfg: cfg, tlsConfig: tlsConfig}, nil } // Authenticate verifies username/password against the directory. It returns nil // on success, ErrInvalidCredentials when the directory rejects the bind, or // another error on connection/search failure. func (a *Authenticator) Authenticate(username, password string) error { // A bind with a non-empty DN but an empty password is an "unauthenticated // bind" that many servers accept as success — which would let anyone in with a // blank password. Reject empty passwords before we ever bind. if password == "" { return ErrInvalidCredentials } conn, err := a.dial() if err != nil { return err } defer conn.Close() // Bind for the search: service account if configured, else anonymous. if a.cfg.BindDN != "" { if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPassword); err != nil { return fmt.Errorf("ldap: search bind failed: %w", err) } } // Locate the user's entry. The login name is escaped to prevent LDAP filter // injection. filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))", ldap.EscapeFilter(a.cfg.UserObjectClass), a.cfg.UserAttr, ldap.EscapeFilter(username)) req := ldap.NewSearchRequest( a.cfg.SearchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, int(a.cfg.Timeout.Seconds()), false, filter, []string{"dn"}, nil, ) res, err := conn.Search(req) if err != nil { return fmt.Errorf("ldap: search failed: %w", err) } if len(res.Entries) == 0 { return ErrInvalidCredentials // unknown user — do not distinguish from bad password } if len(res.Entries) > 1 { return fmt.Errorf("ldap: %q matched %d entries; refusing ambiguous bind", username, len(res.Entries)) } userDN := res.Entries[0].DN // Verify the password by binding as the user. Use a fresh connection so the // search identity is fully dropped first. userConn, err := a.dial() if err != nil { return err } defer userConn.Close() if err := userConn.Bind(userDN, password); err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { return ErrInvalidCredentials } return fmt.Errorf("ldap: user bind failed: %w", err) } return nil } // dial connects to the first reachable URI and applies StartTLS when requested. func (a *Authenticator) dial() (*ldap.Conn, error) { var lastErr error for _, uri := range a.cfg.URIs { conn, err := ldap.DialURL(uri, ldap.DialWithTLSConfig(a.tlsConfig)) if err != nil { lastErr = err continue } conn.SetTimeout(a.cfg.Timeout) if a.cfg.StartTLS { if err := conn.StartTLS(a.tlsConfig); err != nil { conn.Close() lastErr = fmt.Errorf("ldap: starttls on %q: %w", uri, err) continue } } return conn, nil } return nil, fmt.Errorf("ldap: could not connect to any uri: %w", lastErr) }