From cb71df5d9b541888b0edf56e006340c84e1266c0 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 2 Jul 2021 09:16:16 +1000 Subject: [PATCH] feat(authentiation): check ldap support for extended operations on startup (#2133) * feat(authentiation): check ldap server on startup This PR adds a startup check to the LDAP authentication backend. It additionally adds support for checking supportedExtension OIDs, currently only checking passwdModifyOID (1.3.6.1.4.1.4203.1.11.3). This can relatively easily be enhanced to add detection for other rootDSE capabilities like supportedControl and supportedCapabilities as necessary. * test(authentication): add unit tests for new feature * refactor(authentication): factorize ldap user provider newup * refactor: minor adjustments --- cmd/authelia/main.go | 12 +- internal/authentication/const.go | 5 + internal/authentication/ldap_user_provider.go | 74 +++++- .../authentication/ldap_user_provider_test.go | 238 ++++++++++++++++-- 4 files changed, 299 insertions(+), 30 deletions(-) diff --git a/cmd/authelia/main.go b/cmd/authelia/main.go index 05af593e..7ca80910 100644 --- a/cmd/authelia/main.go +++ b/cmd/authelia/main.go @@ -94,13 +94,19 @@ func startServer() { logger.Fatalf("Unrecognized storage backend") } - var userProvider authentication.UserProvider + var ( + userProvider authentication.UserProvider + err error + ) switch { case config.AuthenticationBackend.File != nil: userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File) case config.AuthenticationBackend.LDAP != nil: - userProvider = authentication.NewLDAPUserProvider(*config.AuthenticationBackend.LDAP, autheliaCertPool) + userProvider, err = authentication.NewLDAPUserProvider(*config.AuthenticationBackend.LDAP, autheliaCertPool) + if err != nil { + logger.Fatalf("Failed to Check LDAP Authentication Backend: %v", err) + } default: logger.Fatalf("Unrecognized authentication backend") } @@ -117,7 +123,7 @@ func startServer() { } if !config.Notifier.DisableStartupCheck { - _, err := notifier.StartupCheck() + _, err = notifier.StartupCheck() if err != nil { logger.Fatalf("Error during notifier startup check: %s", err) } diff --git a/internal/authentication/const.go b/internal/authentication/const.go index c8cb3738..889b6bd2 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -25,6 +25,11 @@ const ( Push = "mobile_push" ) +const ( + ldapSupportedExtensionAttribute = "supportedExtension" + ldapOIDPasswdModifyExtension = "1.3.6.1.4.1.4203.1.11.3" +) + // PossibleMethods is the set of all possible 2FA methods. var PossibleMethods = []string{TOTP, U2F, Push} diff --git a/internal/authentication/ldap_user_provider.go b/internal/authentication/ldap_user_provider.go index a6b3bbe2..6fcec587 100644 --- a/internal/authentication/ldap_user_provider.go +++ b/internal/authentication/ldap_user_provider.go @@ -24,10 +24,29 @@ type LDAPUserProvider struct { connectionFactory LDAPConnectionFactory usersBaseDN string groupsBaseDN string + + supportExtensionPasswdModify bool } // NewLDAPUserProvider creates a new instance of LDAPUserProvider. -func NewLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfiguration, certPool *x509.CertPool) *LDAPUserProvider { +func NewLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfiguration, certPool *x509.CertPool) (provider *LDAPUserProvider, err error) { + provider = newLDAPUserProvider(configuration, certPool, nil) + + err = provider.checkServer() + if err != nil { + return provider, err + } + + if provider.supportExtensionPasswdModify { + provider.logger.Trace("LDAP Server does support passwdModifyOID Extension") + } else { + provider.logger.Trace("LDAP Server does not support passwdModifyOID Extension") + } + + return provider, nil +} + +func newLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfiguration, certPool *x509.CertPool, factory LDAPConnectionFactory) (provider *LDAPUserProvider) { if configuration.TLS == nil { configuration.TLS = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS } @@ -40,12 +59,16 @@ func NewLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfigura dialOpts = ldap.DialWithTLSConfig(tlsConfig) } - provider := &LDAPUserProvider{ + if factory == nil { + factory = NewLDAPConnectionFactoryImpl() + } + + provider = &LDAPUserProvider{ configuration: configuration, tlsConfig: tlsConfig, dialOpts: dialOpts, logger: logging.Logger(), - connectionFactory: NewLDAPConnectionFactoryImpl(), + connectionFactory: factory, } provider.parseDynamicConfiguration() @@ -53,14 +76,6 @@ func NewLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfigura return provider } -// NewLDAPUserProviderWithFactory creates a new instance of LDAPUserProvider with existing factory. -func NewLDAPUserProviderWithFactory(configuration schema.LDAPAuthenticationBackendConfiguration, certPool *x509.CertPool, connectionFactory LDAPConnectionFactory) *LDAPUserProvider { - provider := NewLDAPUserProvider(configuration, certPool) - provider.connectionFactory = connectionFactory - - return provider -} - func (p *LDAPUserProvider) parseDynamicConfiguration() { p.configuration.UsersFilter = strings.ReplaceAll(p.configuration.UsersFilter, "{username_attribute}", p.configuration.UsernameAttribute) p.configuration.UsersFilter = strings.ReplaceAll(p.configuration.UsersFilter, "{mail_attribute}", p.configuration.MailAttribute) @@ -85,6 +100,43 @@ func (p *LDAPUserProvider) parseDynamicConfiguration() { p.logger.Tracef("Dynamically generated groups BaseDN is %s", p.groupsBaseDN) } +func (p *LDAPUserProvider) checkServer() (err error) { + conn, err := p.connect(p.configuration.User, p.configuration.Password) + if err != nil { + return err + } + + searchRequest := ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, + 1, 0, false, "(objectClass=*)", []string{ldapSupportedExtensionAttribute}, nil) + + sr, err := conn.Search(searchRequest) + if err != nil { + return err + } + + if len(sr.Entries) != 1 { + return nil + } + + // Iterate the attribute values to see what the server supports. + for _, attr := range sr.Entries[0].Attributes { + if attr.Name == ldapSupportedExtensionAttribute { + p.logger.Tracef("LDAP Supported Extension OIDs: %s", strings.Join(attr.Values, ", ")) + + for _, oid := range attr.Values { + if oid == ldapOIDPasswdModifyExtension { + p.supportExtensionPasswdModify = true + break + } + } + + break + } + } + + return nil +} + func (p *LDAPUserProvider) connect(userDN string, password string) (LDAPConnection, error) { conn, err := p.connectionFactory.DialURL(p.configuration.URL, p.dialOpts) if err != nil { diff --git a/internal/authentication/ldap_user_provider_test.go b/internal/authentication/ldap_user_provider_test.go index 5913df0d..8602bd8b 100644 --- a/internal/authentication/ldap_user_provider_test.go +++ b/internal/authentication/ldap_user_provider_test.go @@ -2,6 +2,7 @@ package authentication import ( "errors" + "fmt" "testing" "github.com/go-ldap/ldap/v3" @@ -10,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/utils" ) func TestShouldCreateRawConnectionWhenSchemeIsLDAP(t *testing.T) { @@ -19,7 +21,7 @@ func TestShouldCreateRawConnectionWhenSchemeIsLDAP(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", }, @@ -46,7 +48,7 @@ func TestShouldCreateTLSConnectionWhenSchemeIsLDAPS(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldaps://127.0.0.1:389", }, @@ -72,7 +74,7 @@ func TestEscapeSpecialCharsFromUserInput(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldaps://127.0.0.1:389", }, @@ -103,7 +105,7 @@ func TestEscapeSpecialCharsInGroupsFilter(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldaps://127.0.0.1:389", GroupsFilter: "(|(member={dn})(uid={username})(uid={input}))", @@ -125,6 +127,210 @@ func TestEscapeSpecialCharsInGroupsFilter(t *testing.T) { assert.Equal(t, "(|(member=cn=john \\28external\\29,dc=example,dc=com)(uid=john)(uid=john\\#\\=\\28abc\\,def\\29))", filter) } +type ExtendedSearchRequestMatcher struct { + filter string + baseDN string + scope int + derefAliases int + typesOnly bool + attributes []string +} + +func NewExtendedSearchRequestMatcher(filter, base string, scope, derefAliases int, typesOnly bool, attributes []string) *ExtendedSearchRequestMatcher { + return &ExtendedSearchRequestMatcher{filter, base, scope, derefAliases, typesOnly, attributes} +} + +func (e *ExtendedSearchRequestMatcher) Matches(x interface{}) bool { + sr := x.(*ldap.SearchRequest) + + if e.filter != sr.Filter || e.baseDN != sr.BaseDN || e.scope != sr.Scope || e.derefAliases != sr.DerefAliases || + e.typesOnly != sr.TypesOnly || utils.IsStringSlicesDifferent(e.attributes, sr.Attributes) { + return false + } + + return true +} + +func (e *ExtendedSearchRequestMatcher) String() string { + return fmt.Sprintf("baseDN: %s, filter %s", e.baseDN, e.filter) +} + +func TestShouldCheckLDAPServerExtensions(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockFactory := NewMockLDAPConnectionFactory(ctrl) + mockConn := NewMockLDAPConnection(ctrl) + + ldapClient := newLDAPUserProvider( + schema.LDAPAuthenticationBackendConfiguration{ + URL: "ldap://127.0.0.1:389", + User: "cn=admin,dc=example,dc=com", + UsersFilter: "(|({username_attribute}={input})({mail_attribute}={input}))", + UsernameAttribute: "uid", + MailAttribute: "mail", + DisplayNameAttribute: "displayname", + Password: "password", + AdditionalUsersDN: "ou=users", + BaseDN: "dc=example,dc=com", + }, + nil, + mockFactory) + + mockFactory.EXPECT(). + DialURL(gomock.Eq("ldap://127.0.0.1:389"), gomock.Any()). + Return(mockConn, nil) + + mockConn.EXPECT(). + Bind(gomock.Eq("cn=admin,dc=example,dc=com"), gomock.Eq("password")). + Return(nil) + + mockConn.EXPECT(). + Search(NewExtendedSearchRequestMatcher("(objectClass=*)", "", ldap.ScopeBaseObject, ldap.NeverDerefAliases, false, []string{ldapSupportedExtensionAttribute})). + Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "", + Attributes: []*ldap.EntryAttribute{ + { + Name: ldapSupportedExtensionAttribute, + Values: []string{ldapOIDPasswdModifyExtension}, + }, + }, + }, + }, + }, nil) + + err := ldapClient.checkServer() + assert.NoError(t, err) + + assert.True(t, ldapClient.supportExtensionPasswdModify) +} + +func TestShouldNotEnablePasswdModifyExtension(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockFactory := NewMockLDAPConnectionFactory(ctrl) + mockConn := NewMockLDAPConnection(ctrl) + + ldapClient := newLDAPUserProvider( + schema.LDAPAuthenticationBackendConfiguration{ + URL: "ldap://127.0.0.1:389", + User: "cn=admin,dc=example,dc=com", + UsersFilter: "(|({username_attribute}={input})({mail_attribute}={input}))", + UsernameAttribute: "uid", + MailAttribute: "mail", + DisplayNameAttribute: "displayname", + Password: "password", + AdditionalUsersDN: "ou=users", + BaseDN: "dc=example,dc=com", + }, + nil, + mockFactory) + + mockFactory.EXPECT(). + DialURL(gomock.Eq("ldap://127.0.0.1:389"), gomock.Any()). + Return(mockConn, nil) + + mockConn.EXPECT(). + Bind(gomock.Eq("cn=admin,dc=example,dc=com"), gomock.Eq("password")). + Return(nil) + + mockConn.EXPECT(). + Search(NewExtendedSearchRequestMatcher("(objectClass=*)", "", ldap.ScopeBaseObject, ldap.NeverDerefAliases, false, []string{ldapSupportedExtensionAttribute})). + Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "", + Attributes: []*ldap.EntryAttribute{ + { + Name: ldapSupportedExtensionAttribute, + Values: []string{}, + }, + }, + }, + }, + }, nil) + + err := ldapClient.checkServer() + assert.NoError(t, err) + + assert.False(t, ldapClient.supportExtensionPasswdModify) +} + +func TestShouldReturnCheckServerConnectError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockFactory := NewMockLDAPConnectionFactory(ctrl) + mockConn := NewMockLDAPConnection(ctrl) + + ldapClient := newLDAPUserProvider( + schema.LDAPAuthenticationBackendConfiguration{ + URL: "ldap://127.0.0.1:389", + User: "cn=admin,dc=example,dc=com", + UsersFilter: "(|({username_attribute}={input})({mail_attribute}={input}))", + UsernameAttribute: "uid", + MailAttribute: "mail", + DisplayNameAttribute: "displayname", + Password: "password", + AdditionalUsersDN: "ou=users", + BaseDN: "dc=example,dc=com", + }, + nil, + mockFactory) + + mockFactory.EXPECT(). + DialURL(gomock.Eq("ldap://127.0.0.1:389"), gomock.Any()). + Return(mockConn, errors.New("could not connect")) + + err := ldapClient.checkServer() + assert.EqualError(t, err, "could not connect") + + assert.False(t, ldapClient.supportExtensionPasswdModify) +} + +func TestShouldReturnCheckServerSearchError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockFactory := NewMockLDAPConnectionFactory(ctrl) + mockConn := NewMockLDAPConnection(ctrl) + + ldapClient := newLDAPUserProvider( + schema.LDAPAuthenticationBackendConfiguration{ + URL: "ldap://127.0.0.1:389", + User: "cn=admin,dc=example,dc=com", + UsersFilter: "(|({username_attribute}={input})({mail_attribute}={input}))", + UsernameAttribute: "uid", + MailAttribute: "mail", + DisplayNameAttribute: "displayname", + Password: "password", + AdditionalUsersDN: "ou=users", + BaseDN: "dc=example,dc=com", + }, + nil, + mockFactory) + + mockFactory.EXPECT(). + DialURL(gomock.Eq("ldap://127.0.0.1:389"), gomock.Any()). + Return(mockConn, nil) + + mockConn.EXPECT(). + Bind(gomock.Eq("cn=admin,dc=example,dc=com"), gomock.Eq("password")). + Return(nil) + + mockConn.EXPECT(). + Search(NewExtendedSearchRequestMatcher("(objectClass=*)", "", ldap.ScopeBaseObject, ldap.NeverDerefAliases, false, []string{ldapSupportedExtensionAttribute})). + Return(nil, errors.New("could not perform the search")) + + err := ldapClient.checkServer() + assert.EqualError(t, err, "could not perform the search") + + assert.False(t, ldapClient.supportExtensionPasswdModify) +} + type SearchRequestMatcher struct { expected string } @@ -149,7 +355,7 @@ func TestShouldEscapeUserInput(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -181,7 +387,7 @@ func TestShouldCombineUsernameFilterAndUsersFilter(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -226,7 +432,7 @@ func TestShouldNotCrashWhenGroupsAreNotRetrievedFromLDAP(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -297,7 +503,7 @@ func TestShouldNotCrashWhenEmailsAreNotRetrievedFromLDAP(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -357,7 +563,7 @@ func TestShouldReturnUsernameFromLDAP(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -428,7 +634,7 @@ func TestShouldUpdateUserPassword(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -495,7 +701,7 @@ func TestShouldCheckValidUserPassword(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -563,7 +769,7 @@ func TestShouldCheckInvalidUserPassword(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -631,7 +837,7 @@ func TestShouldCallStartTLSWhenEnabled(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -705,7 +911,7 @@ func TestShouldParseDynamicConfiguration(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -736,7 +942,7 @@ func TestShouldCallStartTLSWithInsecureSkipVerifyWhenSkipVerifyTrue(t *testing.T mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldap://127.0.0.1:389", User: "cn=admin,dc=example,dc=com", @@ -814,7 +1020,7 @@ func TestShouldReturnLDAPSAlreadySecuredWhenStartTLSAttempted(t *testing.T) { mockFactory := NewMockLDAPConnectionFactory(ctrl) mockConn := NewMockLDAPConnection(ctrl) - ldapClient := NewLDAPUserProviderWithFactory( + ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ URL: "ldaps://127.0.0.1:389", User: "cn=admin,dc=example,dc=com",