196 lines
6.7 KiB
Go
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)
|
|
}
|