From f564032a1169094d931eb0d6b8d245781fb75e2f Mon Sep 17 00:00:00 2001 From: Priit Laes Date: Wed, 31 Dec 2025 18:01:21 +0200 Subject: [PATCH] LDAP: Add mTLS / client certificate authentication support (#509) * ldap: Add mTLS authentication support to LDAP backend * ldap: Reuse BindService() for initial bind attempt * ldap: Make LdapService.config private Now that we have ldap.BindService(), we don't need to access any members of LdapService.config externally. * ldap: Add TODO note about STARTTLS/SASL authentication * ldap: Add TODO note about mTLS and extra CA certificates * chore: fix typo Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Stavros Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/bootstrap/service_bootstrap.go | 2 + internal/config/config.go | 2 + internal/service/auth_service.go | 2 +- internal/service/ldap_service.go | 81 ++++++++++++++++++++----- 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index a921403..b41fc62 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -25,6 +25,8 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er BaseDN: app.config.Ldap.BaseDN, Insecure: app.config.Ldap.Insecure, SearchFilter: app.config.Ldap.SearchFilter, + AuthCert: app.config.Ldap.AuthCert, + AuthKey: app.config.Ldap.AuthKey, }) err := ldapService.Init() diff --git a/internal/config/config.go b/internal/config/config.go index d3e00de..b7fe6e3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,6 +67,8 @@ type LdapConfig struct { BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` + AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"` + AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"` } type ExperimentalConfig struct { diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 3a0753f..e823e2a 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -101,7 +101,7 @@ func (auth *AuthService) VerifyUser(search config.UserSearch, password string) b return false } - err = auth.ldap.Bind(auth.ldap.Config.BindDN, auth.ldap.Config.BindPassword) + err = auth.ldap.BindService(true) if err != nil { log.Error().Err(err).Msg("Failed to rebind with service account after user authentication") return false diff --git a/internal/service/ldap_service.go b/internal/service/ldap_service.go index 5734c63..b64c1df 100644 --- a/internal/service/ldap_service.go +++ b/internal/service/ldap_service.go @@ -19,21 +19,44 @@ type LdapServiceConfig struct { BaseDN string Insecure bool SearchFilter string + AuthCert string + AuthKey string } type LdapService struct { - Config LdapServiceConfig // exported so as the auth service can use it + config LdapServiceConfig conn *ldapgo.Conn mutex sync.RWMutex + cert *tls.Certificate } func NewLdapService(config LdapServiceConfig) *LdapService { return &LdapService{ - Config: config, + config: config, } } func (ldap *LdapService) Init() error { + // Check whether authentication with client certificate is possible + if ldap.config.AuthCert != "" && ldap.config.AuthKey != "" { + cert, err := tls.LoadX509KeyPair(ldap.config.AuthCert, ldap.config.AuthKey) + if err != nil { + return fmt.Errorf("failed to initialize LDAP with mTLS authentication: %w", err) + } + ldap.cert = &cert + log.Info().Msg("Using LDAP with mTLS authentication") + + // TODO: Add optional extra CA certificates, instead of `InsecureSkipVerify` + /* + caCert, _ := ioutil.ReadFile(*caFile) + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + tlsConfig := &tls.Config{ + ... + RootCAs: caCertPool, + } + */ + } _, err := ldap.connect() if err != nil { return fmt.Errorf("failed to connect to LDAP server: %w", err) @@ -60,31 +83,46 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) { ldap.mutex.Lock() defer ldap.mutex.Unlock() - conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{ - InsecureSkipVerify: ldap.Config.Insecure, - MinVersion: tls.VersionTLS12, - })) + var conn *ldapgo.Conn + var err error + + // TODO: There's also STARTTLS (or SASL)-based mTLS authentication + // scenario, where we first connect to plain text port (389) and + // continue with a STARTTLS negotiation: + // 1. conn = ldap.DialURL("ldap://ldap.example.com:389") + // 2. conn.StartTLS(tlsConfig) + // 3. conn.externalBind() + if ldap.cert != nil { + conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{*ldap.cert}, + })) + } else { + conn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{ + InsecureSkipVerify: ldap.config.Insecure, + MinVersion: tls.VersionTLS12, + })) + } if err != nil { return nil, err } - err = conn.Bind(ldap.Config.BindDN, ldap.Config.BindPassword) - if err != nil { - return nil, err - } - - // Set and return the connection ldap.conn = conn - return conn, nil + + err = ldap.BindService(false) + if err != nil { + return nil, err + } + return ldap.conn, nil } func (ldap *LdapService) Search(username string) (string, error) { // Escape the username to prevent LDAP injection escapedUsername := ldapgo.EscapeFilter(username) - filter := fmt.Sprintf(ldap.Config.SearchFilter, escapedUsername) + filter := fmt.Sprintf(ldap.config.SearchFilter, escapedUsername) searchRequest := ldapgo.NewSearchRequest( - ldap.Config.BaseDN, + ldap.config.BaseDN, ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false, filter, []string{"dn"}, @@ -107,6 +145,19 @@ func (ldap *LdapService) Search(username string) (string, error) { return userDN, nil } +func (ldap *LdapService) BindService(rebind bool) error { + // Locks must not be used for initial binding attempt + if rebind { + ldap.mutex.Lock() + defer ldap.mutex.Unlock() + } + + if ldap.cert != nil { + return ldap.conn.ExternalBind() + } + return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword) +} + func (ldap *LdapService) Bind(userDN string, password string) error { ldap.mutex.Lock() defer ldap.mutex.Unlock()