Added ldap and pam authentication
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user