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
This commit is contained in:
James Elliott 2021-07-02 09:16:16 +10:00 committed by GitHub
parent f759b27bb0
commit cb71df5d9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 299 additions and 30 deletions

View File

@ -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)
}

View File

@ -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}

View File

@ -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 {

View File

@ -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",