Files
2026-06-23 17:59:40 +02:00

196 lines
6.7 KiB
Go

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