diff --git a/cmd/authelia/main.go b/cmd/authelia/main.go index 05af593e9..7ca809108 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 c8cb37380..889b6bd21 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 a6b3bbe2d..6fcec587e 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 5913df0d2..8602bd8b1 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",