diff --git a/config.template.yml b/config.template.yml index f97662b87..1e2e5bbf2 100644 --- a/config.template.yml +++ b/config.template.yml @@ -313,6 +313,10 @@ authentication_backend: ## The attribute holding the display name of the user. This will be used to greet an authenticated user. # display_name_attribute: displayName + ## Follow referrals returned by the server. + ## This is especially useful for environments where read-only servers exist. Only implemented for write operations. + permit_referrals: false + ## The username and password of the admin user. user: cn=admin,dc=example,dc=com ## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html diff --git a/docs/configuration/authentication/ldap.md b/docs/configuration/authentication/ldap.md index 4a558f565..70e2ef522 100644 --- a/docs/configuration/authentication/ldap.md +++ b/docs/configuration/authentication/ldap.md @@ -32,6 +32,7 @@ authentication_backend: group_name_attribute: cn mail_attribute: mail display_name_attribute: displayName + permit_referrals: false user: CN=admin,DC=example,DC=com password: password ``` @@ -95,7 +96,6 @@ Enables use of the LDAP StartTLS process which is not commonly used. You should it. The initial connection will be over plain text, and Authelia will try to upgrade it with the LDAP server. LDAPS URL's are slightly more secure. - ### tls Controls the TLS connection validation process. You can see how to configure the tls section [here](../index.md#tls-configuration). @@ -117,13 +117,14 @@ user searches and [additional_groups_dn](#additional_groups_dn) for groups searc
type: string {: .label .label-config .label-purple } -required: no -{: .label .label-config .label-green } +required: yes +{: .label .label-config .label-red }
-The LDAP attribute that maps to the username in Authelia. The default value is dependent on the [implementation](#implementation), -refer to the [attribute defaults](#attribute-defaults) for more information. +_**Note:** While this option is required, an [implementation](#implementation) may set a default value implicitly +negating this requirement. Refer to the [attribute defaults](#attribute-defaults) for more information._ +The LDAP attribute that maps to the username in Authelia. ### additional_users_dn
@@ -139,39 +140,103 @@ exactly which OU to get users from for either security or performance reasons. F `ou=users,ou=people,dc=example,dc=com`. The default value is dependent on the [implementation](#implementation), refer to the [attribute defaults](#attribute-defaults) for more information. - ### users_filter
type: string {: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +_**Note:** While this option is required, an [implementation](#implementation) may set a default value implicitly +negating this requirement. Refer to the [attribute defaults](#attribute-defaults) for more information._ + +The LDAP filter to narrow down which users are valid. This is important to set correctly as to exclude disabled users. + +### group_name_attribute +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +_**Note:** While this option is required, an [implementation](#implementation) may set a default value implicitly +negating this requirement. Refer to the [attribute defaults](#attribute-defaults) for more information._ + +The LDAP attribute that is used by Authelia to determine the group name. + +### additional_groups_dn +
+type: string +{: .label .label-config .label-purple } required: no {: .label .label-config .label-green }
-The LDAP filter to narrow down which users are valid. This is important to set correctly as to exclude disabled users. -The default value is dependent on the [implementation](#implementation), refer to the -[attribute defaults](#attribute-defaults) for more information. - -### additional_groups_dn Similar to [additional_users_dn](#additional_users_dn) but it applies to group searches. ### groups_filter -Similar to [users_filter](#users_filter) but it applies to group searches. In order to include groups the memeber is not +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +_**Note:** While this option is required, an [implementation](#implementation) may set a default value implicitly +negating this requirement. Refer to the [attribute defaults](#attribute-defaults) for more information._ + +Similar to [users_filter](#users_filter) but it applies to group searches. In order to include groups the member is not a direct member of, but is a member of another group that is a member of those (i.e. recursive groups), you may try using the following filter which is currently only tested against Microsoft Active Directory: `(&(member:1.2.840.113556.1.4.1941:={dn})(objectClass=group)(objectCategory=group))` ### mail_attribute +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +_**Note:** While this option is required, an [implementation](#implementation) may set a default value implicitly +negating this requirement. Refer to the [attribute defaults](#attribute-defaults) for more information._ + The attribute to retrieve which contains the users email addresses. This is important for the device registration and password reset processes. -The user must have an email address in order for Authelia to perform -identity verification when a user attempts to reset their password or -register a second factor device. + +The user must have an email address in order for Authelia to perform identity verification when a user attempts to reset +their password or register a second factor device. ### display_name_attribute +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +_**Note:** While this option is required, an [implementation](#implementation) may set a default value implicitly +negating this requirement. Refer to the [attribute defaults](#attribute-defaults) for more information._ + The attribute to retrieve which is shown on the Web UI to the user when they log in. +### permit_referrals +
+type: boolean +{: .label .label-config .label-purple } +default: false +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-red } +
+ +Permits following referrals. This is useful if you have read-only servers in your architecture and thus require +referrals to be followed when performing write operations. This is only implemented for password modifications, if you +need this for searches please open a GitHub issue or contact us. + ### user The distinguished name of the user paired with the password to bind with for lookup and password change operations. @@ -191,20 +256,20 @@ search. #### Users filter replacements -|Placeholder |Phase |Replacement | -|:----------------------:|:-----:|:--------------------------------------------------------------:| -|{username_attribute} |startup|The configured username attribute | -|{mail_attribute} |startup|The configured mail attribute | -|{display_name_attribute}|startup|The configured display name attribute | -|{input} |search |The input into the username field | +| Placeholder | Phase | Replacement | +|:------------------------:|:-------:|:-------------------------------------:| +| {username_attribute} | startup | The configured username attribute | +| {mail_attribute} | startup | The configured mail attribute | +| {display_name_attribute} | startup | The configured display name attribute | +| {input} | search | The input into the username field | #### Groups filter replacements -|Placeholder |Phase |Replacement | -|:----------------------:|:-----:|:-------------------------------------------------------------------------:| -|{input} |search |The input into the username field | -|{username} |search |The username from the profile lookup obtained from the username attribute | -|{dn} |search |The distinguished name from the profile lookup | +| Placeholder | Phase | Replacement | +|:-----------:|:------:|:-------------------------------------------------------------------------:| +| {input} | search | The input into the username field | +| {username} | search | The username from the profile lookup obtained from the username attribute | +| {dn} | search | The distinguished name from the profile lookup | ### Defaults The below tables describes the current attribute defaults for each implementation. @@ -213,10 +278,10 @@ The below tables describes the current attribute defaults for each implementatio This table describes the attribute defaults for each implementation. i.e. the username_attribute is described by the Username column. -|Implementation |Username |Display Name|Mail |Group Name| -|:-------------:|:------------:|:----------:|:---:|:--------:| -|custom |n/a |displayName |mail |cn | -|activedirectory|sAMAccountName|displayName |mail |cn | +| Implementation | Username | Display Name | Mail | Group Name | +|:---------------:|:--------------:|:------------:|:----:|:----------:| +| custom | n/a | displayName | mail | cn | +| activedirectory | sAMAccountName | displayName | mail | cn | #### Filter defaults The filters are probably the most important part to get correct when setting up LDAP. @@ -225,14 +290,15 @@ filters that accomplish this as an example (more examples would be appreciated). userAccountControl filter checks that the account is not disabled and the pwdLastSet makes sure that value is not 0 which means the password requires changing at the next login. -|Implementation |Users Filter |Groups Filter| -|:-------------:|:------------:|:-----------:| -|custom |n/a |n/a | -|activedirectory|(&(|({username_attribute}={input})({mail_attribute}={input}))(sAMAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(pwdLastSet=0)))|(&(member={dn})(objectClass=group)(objectCategory=group))| +| Implementation | Users Filter | Groups Filter | +|:---------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------:| +| custom | n/a | n/a | +| activedirectory | (&(|({username_attribute}={input})({mail_attribute}={input}))(sAMAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(pwdLastSet=0))) | (&(member={dn})(objectClass=group)(objectCategory=group)) | _**Note:**_ The Active Directory filter `(sAMAccountType=805306368)` is exactly the same as `(&(objectCategory=person)(objectClass=user))` except that the former is more performant, you can read more about this -and other Active Directory filters on the [TechNet wiki](https://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx). +and other Active Directory filters on the +[TechNet wiki](https://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx). ## Refresh Interval This setting takes a [duration notation](../index.md#duration-notation-format) that sets the max frequency diff --git a/internal/authentication/const.go b/internal/authentication/const.go index 0dbe0674c..1100dd568 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -21,6 +21,11 @@ const ( ldapOIDPasswdModifyExtension = "1.3.6.1.4.1.4203.1.11.1" // http://oidref.com/1.3.6.1.4.1.4203.1.11.1 ) +const ( + ldapAttributeUnicodePwd = "unicodePwd" + ldapAttributeUserPassword = "userPassword" +) + const ( ldapPlaceholderInput = "{input}" ldapPlaceholderDistinguishedName = "{dn}" diff --git a/internal/authentication/ldap_connection.go b/internal/authentication/ldap_connection.go deleted file mode 100644 index 549ad8bff..000000000 --- a/internal/authentication/ldap_connection.go +++ /dev/null @@ -1,59 +0,0 @@ -package authentication - -import ( - "crypto/tls" - - "github.com/go-ldap/ldap/v3" -) - -// LDAPConnection interface representing a connection to the ldap. -type LDAPConnection interface { - Bind(username, password string) error - Close() - - Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) - Modify(modifyRequest *ldap.ModifyRequest) error - PasswordModify(pwdModifyRequest *ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) - StartTLS(config *tls.Config) error -} - -// LDAPConnectionImpl the production implementation of an ldap connection. -type LDAPConnectionImpl struct { - conn *ldap.Conn -} - -// NewLDAPConnectionImpl create a new ldap connection. -func NewLDAPConnectionImpl(conn *ldap.Conn) *LDAPConnectionImpl { - return &LDAPConnectionImpl{conn} -} - -// Bind binds ldap connection to a username/password. -func (lc *LDAPConnectionImpl) Bind(username, password string) error { - return lc.conn.Bind(username, password) -} - -// Close closes a ldap connection. -func (lc *LDAPConnectionImpl) Close() { - lc.conn.Close() -} - -// Search searches a ldap server. -func (lc *LDAPConnectionImpl) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { - return lc.conn.Search(searchRequest) -} - -// Modify modifies an ldap object. -func (lc *LDAPConnectionImpl) Modify(modifyRequest *ldap.ModifyRequest) error { - return lc.conn.Modify(modifyRequest) -} - -// PasswordModify modifies an ldap objects password. -func (lc *LDAPConnectionImpl) PasswordModify(pwdModifyRequest *ldap.PasswordModifyRequest) error { - _, err := lc.conn.PasswordModify(pwdModifyRequest) - return err -} - -// StartTLS requests the LDAP server upgrades to TLS encryption. -func (lc *LDAPConnectionImpl) StartTLS(config *tls.Config) error { - return lc.conn.StartTLS(config) -} diff --git a/internal/authentication/ldap_connection_factory.go b/internal/authentication/ldap_connection_factory.go index 9cddd168c..ab5a8640e 100644 --- a/internal/authentication/ldap_connection_factory.go +++ b/internal/authentication/ldap_connection_factory.go @@ -4,25 +4,15 @@ import ( "github.com/go-ldap/ldap/v3" ) -// LDAPConnectionFactory an interface of factory of ldap connections. -type LDAPConnectionFactory interface { - DialURL(addr string, opts ...ldap.DialOpt) (LDAPConnection, error) -} +// ProductionLDAPConnectionFactory the production implementation of an ldap connection factory. +type ProductionLDAPConnectionFactory struct{} -// LDAPConnectionFactoryImpl the production implementation of an ldap connection factory. -type LDAPConnectionFactoryImpl struct{} - -// NewLDAPConnectionFactoryImpl create a concrete ldap connection factory. -func NewLDAPConnectionFactoryImpl() *LDAPConnectionFactoryImpl { - return &LDAPConnectionFactoryImpl{} +// NewProductionLDAPConnectionFactory create a concrete ldap connection factory. +func NewProductionLDAPConnectionFactory() *ProductionLDAPConnectionFactory { + return &ProductionLDAPConnectionFactory{} } // DialURL creates a connection from an LDAP URL when successful. -func (lcf *LDAPConnectionFactoryImpl) DialURL(addr string, opts ...ldap.DialOpt) (LDAPConnection, error) { - conn, err := ldap.DialURL(addr, opts...) - if err != nil { - return nil, err - } - - return conn, nil +func (f *ProductionLDAPConnectionFactory) DialURL(addr string, opts ...ldap.DialOpt) (conn LDAPConnection, err error) { + return ldap.DialURL(addr, opts...) } diff --git a/internal/authentication/ldap_user_provider.go b/internal/authentication/ldap_user_provider.go index e12c58f3f..4bc9c87c1 100644 --- a/internal/authentication/ldap_user_provider.go +++ b/internal/authentication/ldap_user_provider.go @@ -9,7 +9,6 @@ import ( "github.com/go-ldap/ldap/v3" "github.com/sirupsen/logrus" - "golang.org/x/text/encoding/unicode" "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/logging" @@ -18,11 +17,11 @@ import ( // LDAPUserProvider is a UserProvider that connects to LDAP servers like ActiveDirectory, OpenLDAP, OpenDJ, FreeIPA, etc. type LDAPUserProvider struct { - configuration schema.LDAPAuthenticationBackendConfiguration - tlsConfig *tls.Config - dialOpts []ldap.DialOpt - log *logrus.Logger - connectionFactory LDAPConnectionFactory + config schema.LDAPAuthenticationBackendConfiguration + tlsConfig *tls.Config + dialOpts []ldap.DialOpt + log *logrus.Logger + factory LDAPConnectionFactory disableResetPassword bool @@ -43,21 +42,21 @@ type LDAPUserProvider struct { } // NewLDAPUserProvider creates a new instance of LDAPUserProvider. -func NewLDAPUserProvider(configuration schema.AuthenticationBackendConfiguration, certPool *x509.CertPool) (provider *LDAPUserProvider) { - provider = newLDAPUserProvider(*configuration.LDAP, configuration.DisableResetPassword, certPool, nil) +func NewLDAPUserProvider(config schema.AuthenticationBackendConfiguration, certPool *x509.CertPool) (provider *LDAPUserProvider) { + provider = newLDAPUserProvider(*config.LDAP, config.DisableResetPassword, certPool, nil) return provider } -func newLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfiguration, disableResetPassword bool, certPool *x509.CertPool, factory LDAPConnectionFactory) (provider *LDAPUserProvider) { - if configuration.TLS == nil { - configuration.TLS = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS +func newLDAPUserProvider(config schema.LDAPAuthenticationBackendConfiguration, disableResetPassword bool, certPool *x509.CertPool, factory LDAPConnectionFactory) (provider *LDAPUserProvider) { + if config.TLS == nil { + config.TLS = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS } - tlsConfig := utils.NewTLSConfig(configuration.TLS, tls.VersionTLS12, certPool) + tlsConfig := utils.NewTLSConfig(config.TLS, tls.VersionTLS12, certPool) var dialOpts = []ldap.DialOpt{ - ldap.DialWithDialer(&net.Dialer{Timeout: configuration.Timeout}), + ldap.DialWithDialer(&net.Dialer{Timeout: config.Timeout}), } if tlsConfig != nil { @@ -65,15 +64,15 @@ func newLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfigura } if factory == nil { - factory = NewLDAPConnectionFactoryImpl() + factory = NewProductionLDAPConnectionFactory() } provider = &LDAPUserProvider{ - configuration: configuration, + config: config, tlsConfig: tlsConfig, dialOpts: dialOpts, log: logging.Logger(), - connectionFactory: factory, + factory: factory, disableResetPassword: disableResetPassword, } @@ -83,77 +82,224 @@ func newLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfigura return provider } -func (p *LDAPUserProvider) connect(userDN string, password string) (LDAPConnection, error) { - conn, err := p.connectionFactory.DialURL(p.configuration.URL, p.dialOpts...) - if err != nil { +// CheckUserPassword checks if provided password matches for the given user. +func (p *LDAPUserProvider) CheckUserPassword(inputUsername string, password string) (valid bool, err error) { + var ( + conn, connUser LDAPConnection + profile *ldapUserProfile + ) + + if conn, err = p.connect(); err != nil { + return false, err + } + + defer conn.Close() + + if profile, err = p.getUserProfile(conn, inputUsername); err != nil { + return false, err + } + + if connUser, err = p.connectCustom(p.config.URL, profile.DN, password, p.config.StartTLS, p.dialOpts...); err != nil { + return false, fmt.Errorf("authentication failed. Cause: %w", err) + } + + defer connUser.Close() + + return true, nil +} + +// GetDetails retrieve the groups a user belongs to. +func (p *LDAPUserProvider) GetDetails(username string) (details *UserDetails, err error) { + var ( + conn LDAPConnection + profile *ldapUserProfile + ) + + if conn, err = p.connect(); err != nil { return nil, err } - if p.configuration.StartTLS { - if err := conn.StartTLS(p.tlsConfig); err != nil { - return nil, err + defer conn.Close() + + if profile, err = p.getUserProfile(conn, username); err != nil { + return nil, err + } + + var ( + filter string + searchRequest *ldap.SearchRequest + searchResult *ldap.SearchResult + ) + + if filter, err = p.resolveGroupsFilter(username, profile); err != nil { + return nil, fmt.Errorf("unable to create group filter for user '%s'. Cause: %w", username, err) + } + + // Search for the users groups. + searchRequest = ldap.NewSearchRequest( + p.groupsBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 0, 0, false, filter, p.groupsAttributes, nil, + ) + + if searchResult, err = p.search(conn, searchRequest); err != nil { + return nil, fmt.Errorf("unable to retrieve groups of user '%s'. Cause: %w", username, err) + } + + groups := make([]string, 0) + + for _, res := range searchResult.Entries { + if len(res.Attributes) == 0 { + p.log.Warningf("No groups retrieved from LDAP for user %s", username) + break + } + + // Append all values of the document. Normally there should be only one per document. + groups = append(groups, res.Attributes[0].Values...) + } + + return &UserDetails{ + Username: profile.Username, + DisplayName: profile.DisplayName, + Emails: profile.Emails, + Groups: groups, + }, nil +} + +// UpdatePassword update the password of the given user. +func (p *LDAPUserProvider) UpdatePassword(username, password string) (err error) { + var ( + conn LDAPConnection + profile *ldapUserProfile + ) + + if conn, err = p.connect(); err != nil { + return fmt.Errorf("unable to update password. Cause: %w", err) + } + + defer conn.Close() + + if profile, err = p.getUserProfile(conn, username); err != nil { + return fmt.Errorf("unable to update password. Cause: %w", err) + } + + var controls []ldap.Control + + switch { + case p.supportExtensionPasswdModify: + pwdModifyRequest := ldap.NewPasswordModifyRequest( + profile.DN, + "", + password, + ) + + err = p.pwdModify(conn, pwdModifyRequest) + case p.config.Implementation == schema.LDAPImplementationActiveDirectory: + modifyRequest := ldap.NewModifyRequest(profile.DN, controls) + // The password needs to be enclosed in quotes + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/6e803168-f140-4d23-b2d3-c3a8ab5917d2 + pwdEncoded, _ := utf16LittleEndian.NewEncoder().String(fmt.Sprintf("\"%s\"", password)) + modifyRequest.Replace(ldapAttributeUnicodePwd, []string{pwdEncoded}) + + err = p.modify(conn, modifyRequest) + default: + modifyRequest := ldap.NewModifyRequest(profile.DN, controls) + modifyRequest.Replace(ldapAttributeUserPassword, []string{password}) + + err = p.modify(conn, modifyRequest) + } + + if err != nil { + return fmt.Errorf("unable to update password. Cause: %w", err) + } + + return nil +} + +func (p *LDAPUserProvider) connect() (LDAPConnection, error) { + return p.connectCustom(p.config.URL, p.config.User, p.config.Password, p.config.StartTLS, p.dialOpts...) +} + +func (p *LDAPUserProvider) connectCustom(url, userDN, password string, startTLS bool, opts ...ldap.DialOpt) (conn LDAPConnection, err error) { + if conn, err = p.factory.DialURL(url, opts...); err != nil { + return nil, fmt.Errorf("dial failed with error: %w", err) + } + + if startTLS { + if err = conn.StartTLS(p.tlsConfig); err != nil { + return nil, fmt.Errorf("starttls failed with error: %w", err) } } - if err := conn.Bind(userDN, password); err != nil { - return nil, err + if err = conn.Bind(userDN, password); err != nil { + return nil, fmt.Errorf("bind failed with error: %w", err) } return conn, nil } -// CheckUserPassword checks if provided password matches for the given user. -func (p *LDAPUserProvider) CheckUserPassword(inputUsername string, password string) (bool, error) { - conn, err := p.connect(p.configuration.User, p.configuration.Password) +func (p *LDAPUserProvider) search(conn LDAPConnection, searchRequest *ldap.SearchRequest) (searchResult *ldap.SearchResult, err error) { + searchResult, err = conn.Search(searchRequest) if err != nil { - return false, err + if referral, ok := p.getReferral(err); ok { + if errReferral := p.searchReferral(referral, searchRequest, searchResult); errReferral != nil { + return nil, err + } + + return searchResult, nil + } + + return nil, err } + + if !p.config.PermitReferrals || len(searchResult.Referrals) == 0 { + return searchResult, nil + } + + p.searchReferrals(searchRequest, searchResult) + + return searchResult, nil +} + +func (p *LDAPUserProvider) searchReferral(referral string, searchRequest *ldap.SearchRequest, searchResult *ldap.SearchResult) (err error) { + var ( + conn LDAPConnection + result *ldap.SearchResult + ) + + if conn, err = p.connectCustom(referral, p.config.User, p.config.Password, p.config.StartTLS, p.dialOpts...); err != nil { + p.log.Errorf("Failed to connect during referred search request (referred to %s): %v", referral, err) + + return err + } + defer conn.Close() - profile, err := p.getUserProfile(conn, inputUsername) - if err != nil { - return false, err + if result, err = conn.Search(searchRequest); err != nil { + p.log.Errorf("Failed to perform search operation during referred search request (referred to %s): %v", referral, err) + + return err } - userConn, err := p.connect(profile.DN, password) - if err != nil { - return false, fmt.Errorf("authentication failed. Cause: %w", err) - } - defer userConn.Close() - - return true, nil -} - -func (p *LDAPUserProvider) ldapEscape(inputUsername string) string { - inputUsername = ldap.EscapeFilter(inputUsername) - for _, c := range specialLDAPRunes { - inputUsername = strings.ReplaceAll(inputUsername, string(c), fmt.Sprintf("\\%c", c)) + if len(result.Entries) == 0 { + return err } - return inputUsername -} - -type ldapUserProfile struct { - DN string - Emails []string - DisplayName string - Username string -} - -func (p *LDAPUserProvider) resolveUsersFilter(inputUsername string) (filter string) { - filter = p.configuration.UsersFilter - - if p.usersFilterReplacementInput { - // The {input} placeholder is replaced by the users username input. - filter = strings.ReplaceAll(filter, ldapPlaceholderInput, p.ldapEscape(inputUsername)) + for i := 0; i < len(result.Entries); i++ { + if !ldapEntriesContainsEntry(result.Entries[i], searchResult.Entries) { + searchResult.Entries = append(searchResult.Entries, result.Entries[i]) + } } - p.log.Tracef("Computed user filter is %s", filter) - - return filter + return nil } -func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, inputUsername string) (*ldapUserProfile, error) { +func (p *LDAPUserProvider) searchReferrals(searchRequest *ldap.SearchRequest, searchResult *ldap.SearchResult) { + for i := 0; i < len(searchResult.Referrals); i++ { + _ = p.searchReferral(searchResult.Referrals[i], searchRequest, searchResult) + } +} + +func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, inputUsername string) (profile *ldapUserProfile, err error) { userFilter := p.resolveUsersFilter(inputUsername) // Search for the given username. @@ -162,36 +308,37 @@ func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, inputUsername str 1, 0, false, userFilter, p.usersAttributes, nil, ) - sr, err := conn.Search(searchRequest) - if err != nil { + var searchResult *ldap.SearchResult + + if searchResult, err = p.search(conn, searchRequest); err != nil { return nil, fmt.Errorf("cannot find user DN of user '%s'. Cause: %w", inputUsername, err) } - if len(sr.Entries) == 0 { + if len(searchResult.Entries) == 0 { return nil, ErrUserNotFound } - if len(sr.Entries) > 1 { + if len(searchResult.Entries) > 1 { return nil, fmt.Errorf("multiple users %s found", inputUsername) } userProfile := ldapUserProfile{ - DN: sr.Entries[0].DN, + DN: searchResult.Entries[0].DN, } - for _, attr := range sr.Entries[0].Attributes { - if attr.Name == p.configuration.DisplayNameAttribute { + for _, attr := range searchResult.Entries[0].Attributes { + if attr.Name == p.config.DisplayNameAttribute { userProfile.DisplayName = attr.Values[0] } - if attr.Name == p.configuration.MailAttribute { + if attr.Name == p.config.MailAttribute { userProfile.Emails = attr.Values } - if attr.Name == p.configuration.UsernameAttribute { + if attr.Name == p.config.UsernameAttribute { if len(attr.Values) != 1 { return nil, fmt.Errorf("user '%s' cannot have multiple value for attribute '%s'", - inputUsername, p.configuration.UsernameAttribute) + inputUsername, p.config.UsernameAttribute) } userProfile.Username = attr.Values[0] @@ -205,12 +352,25 @@ func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, inputUsername str return &userProfile, nil } +func (p *LDAPUserProvider) resolveUsersFilter(inputUsername string) (filter string) { + filter = p.config.UsersFilter + + if p.usersFilterReplacementInput { + // The {input} placeholder is replaced by the username input. + filter = strings.ReplaceAll(filter, ldapPlaceholderInput, ldapEscape(inputUsername)) + } + + p.log.Tracef("Detected user filter is %s", filter) + + return filter +} + func (p *LDAPUserProvider) resolveGroupsFilter(inputUsername string, profile *ldapUserProfile) (filter string, err error) { //nolint:unparam - filter = p.configuration.GroupsFilter + filter = p.config.GroupsFilter if p.groupsFilterReplacementInput { // The {input} placeholder is replaced by the users username input. - filter = strings.ReplaceAll(p.configuration.GroupsFilter, ldapPlaceholderInput, p.ldapEscape(inputUsername)) + filter = strings.ReplaceAll(p.config.GroupsFilter, ldapPlaceholderInput, ldapEscape(inputUsername)) } if profile != nil { @@ -228,98 +388,78 @@ func (p *LDAPUserProvider) resolveGroupsFilter(inputUsername string, profile *ld return filter, nil } -// GetDetails retrieve the groups a user belongs to. -func (p *LDAPUserProvider) GetDetails(inputUsername string) (*UserDetails, error) { - conn, err := p.connect(p.configuration.User, p.configuration.Password) - if err != nil { - return nil, err - } - defer conn.Close() - - profile, err := p.getUserProfile(conn, inputUsername) - if err != nil { - return nil, err - } - - groupsFilter, err := p.resolveGroupsFilter(inputUsername, profile) - if err != nil { - return nil, fmt.Errorf("unable to create group filter for user '%s'. Cause: %w", inputUsername, err) - } - - // Search for the given username. - searchGroupRequest := ldap.NewSearchRequest( - p.groupsBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - 0, 0, false, groupsFilter, p.groupsAttributes, nil, - ) - - sr, err := conn.Search(searchGroupRequest) - - if err != nil { - return nil, fmt.Errorf("unable to retrieve groups of user '%s'. Cause: %w", inputUsername, err) - } - - groups := make([]string, 0) - - for _, res := range sr.Entries { - if len(res.Attributes) == 0 { - p.log.Warningf("No groups retrieved from LDAP for user %s", inputUsername) - break - } - - // Append all values of the document. Normally there should be only one per document. - groups = append(groups, res.Attributes[0].Values...) - } - - return &UserDetails{ - Username: profile.Username, - DisplayName: profile.DisplayName, - Emails: profile.Emails, - Groups: groups, - }, nil -} - -// UpdatePassword update the password of the given user. -func (p *LDAPUserProvider) UpdatePassword(inputUsername string, newPassword string) error { - conn, err := p.connect(p.configuration.User, p.configuration.Password) - if err != nil { - return fmt.Errorf("unable to update password. Cause: %w", err) - } - defer conn.Close() - - profile, err := p.getUserProfile(conn, inputUsername) - - if err != nil { - return fmt.Errorf("unable to update password. Cause: %w", err) - } - - switch { - case p.supportExtensionPasswdModify: - modifyRequest := ldap.NewPasswordModifyRequest( - profile.DN, - "", - newPassword, +func (p *LDAPUserProvider) modify(conn LDAPConnection, modifyRequest *ldap.ModifyRequest) (err error) { + if err = conn.Modify(modifyRequest); err != nil { + var ( + referral string + ok bool ) - _, err = conn.PasswordModify(modifyRequest) - case p.configuration.Implementation == schema.LDAPImplementationActiveDirectory: - modifyRequest := ldap.NewModifyRequest(profile.DN, nil) - utf16 := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM) - // The password needs to be enclosed in quotes - // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/6e803168-f140-4d23-b2d3-c3a8ab5917d2 - pwdEncoded, _ := utf16.NewEncoder().String(fmt.Sprintf("\"%s\"", newPassword)) - modifyRequest.Replace("unicodePwd", []string{pwdEncoded}) + if referral, ok = p.getReferral(err); !ok { + return err + } - err = conn.Modify(modifyRequest) - default: - modifyRequest := ldap.NewModifyRequest(profile.DN, nil) - modifyRequest.Replace("userPassword", []string{newPassword}) + p.log.Debugf("Attempting Modify on referred URL %s", referral) - err = conn.Modify(modifyRequest) + var ( + connReferral LDAPConnection + errReferral error + ) + + if connReferral, errReferral = p.connectCustom(referral, p.config.User, p.config.Password, p.config.StartTLS, p.dialOpts...); errReferral != nil { + p.log.Errorf("Failed to connect during referred modify request (referred to %s): %v", referral, errReferral) + + return err + } + + defer connReferral.Close() + + if errReferral = connReferral.Modify(modifyRequest); errReferral != nil { + p.log.Errorf("Failed to perform modify operation during referred modify request (referred to %s): %v", referral, errReferral) + } } - if err != nil { - return fmt.Errorf("unable to update password. Cause: %w", err) - } - - return nil + return err +} + +func (p *LDAPUserProvider) pwdModify(conn LDAPConnection, pwdModifyRequest *ldap.PasswordModifyRequest) (err error) { + if _, err = conn.PasswordModify(pwdModifyRequest); err != nil { + var ( + referral string + ok bool + ) + + if referral, ok = p.getReferral(err); !ok { + return err + } + + p.log.Debugf("Attempting PwdModify ExOp (1.3.6.1.4.1.4203.1.11.1) on referred URL %s", referral) + + var ( + connReferral LDAPConnection + errReferral error + ) + + if connReferral, errReferral = p.connectCustom(referral, p.config.User, p.config.Password, p.config.StartTLS, p.dialOpts...); errReferral != nil { + p.log.Errorf("Failed to connect during referred password modify request (referred to %s): %v", referral, errReferral) + + return err + } + + defer connReferral.Close() + + if _, errReferral = connReferral.PasswordModify(pwdModifyRequest); errReferral != nil { + p.log.Errorf("Failed to perform modify operation during referred modify request (referred to %s): %v", referral, errReferral) + } + } + + return err +} + +func (p *LDAPUserProvider) getReferral(err error) (referral string, ok bool) { + if !p.config.PermitReferrals { + return "", false + } + + return ldapGetReferral(err) } diff --git a/internal/authentication/ldap_user_provider_startup.go b/internal/authentication/ldap_user_provider_startup.go index 5a2806a31..bde3eea6a 100644 --- a/internal/authentication/ldap_user_provider_startup.go +++ b/internal/authentication/ldap_user_provider_startup.go @@ -10,8 +10,12 @@ import ( // StartupCheck implements the startup check provider interface. func (p *LDAPUserProvider) StartupCheck() (err error) { - conn, err := p.connect(p.configuration.User, p.configuration.Password) - if err != nil { + var ( + conn LDAPConnection + searchResult *ldap.SearchResult + ) + + if conn, err = p.connect(); err != nil { return err } @@ -20,17 +24,16 @@ func (p *LDAPUserProvider) StartupCheck() (err error) { searchRequest := ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, "(objectClass=*)", []string{ldapSupportedExtensionAttribute}, nil) - sr, err := conn.Search(searchRequest) - if err != nil { + if searchResult, err = conn.Search(searchRequest); err != nil { return err } - if len(sr.Entries) != 1 { + if len(searchResult.Entries) != 1 { return nil } // Iterate the attribute values to see what the server supports. - for _, attr := range sr.Entries[0].Attributes { + for _, attr := range searchResult.Entries[0].Attributes { if attr.Name == ldapSupportedExtensionAttribute { p.log.Tracef("LDAP Supported Extension OIDs: %s", strings.Join(attr.Values, ", ")) @@ -40,13 +43,11 @@ func (p *LDAPUserProvider) StartupCheck() (err error) { break } } - - break } } if !p.supportExtensionPasswdModify && !p.disableResetPassword && - p.configuration.Implementation != schema.LDAPImplementationActiveDirectory { + p.config.Implementation != schema.LDAPImplementationActiveDirectory { p.log.Warn("Your LDAP server implementation may not support a method for password hashing " + "known to Authelia, it's strongly recommended you ensure your directory server hashes the password " + "attribute when users reset their password via Authelia.") @@ -56,27 +57,27 @@ func (p *LDAPUserProvider) StartupCheck() (err error) { } func (p *LDAPUserProvider) parseDynamicUsersConfiguration() { - 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) - p.configuration.UsersFilter = strings.ReplaceAll(p.configuration.UsersFilter, "{display_name_attribute}", p.configuration.DisplayNameAttribute) + p.config.UsersFilter = strings.ReplaceAll(p.config.UsersFilter, "{username_attribute}", p.config.UsernameAttribute) + p.config.UsersFilter = strings.ReplaceAll(p.config.UsersFilter, "{mail_attribute}", p.config.MailAttribute) + p.config.UsersFilter = strings.ReplaceAll(p.config.UsersFilter, "{display_name_attribute}", p.config.DisplayNameAttribute) - p.log.Tracef("Dynamically generated users filter is %s", p.configuration.UsersFilter) + p.log.Tracef("Dynamically generated users filter is %s", p.config.UsersFilter) p.usersAttributes = []string{ - p.configuration.DisplayNameAttribute, - p.configuration.MailAttribute, - p.configuration.UsernameAttribute, + p.config.DisplayNameAttribute, + p.config.MailAttribute, + p.config.UsernameAttribute, } - if p.configuration.AdditionalUsersDN != "" { - p.usersBaseDN = p.configuration.AdditionalUsersDN + "," + p.configuration.BaseDN + if p.config.AdditionalUsersDN != "" { + p.usersBaseDN = p.config.AdditionalUsersDN + "," + p.config.BaseDN } else { - p.usersBaseDN = p.configuration.BaseDN + p.usersBaseDN = p.config.BaseDN } p.log.Tracef("Dynamically generated users BaseDN is %s", p.usersBaseDN) - if strings.Contains(p.configuration.UsersFilter, ldapPlaceholderInput) { + if strings.Contains(p.config.UsersFilter, ldapPlaceholderInput) { p.usersFilterReplacementInput = true } @@ -86,26 +87,26 @@ func (p *LDAPUserProvider) parseDynamicUsersConfiguration() { func (p *LDAPUserProvider) parseDynamicGroupsConfiguration() { p.groupsAttributes = []string{ - p.configuration.GroupNameAttribute, + p.config.GroupNameAttribute, } - if p.configuration.AdditionalGroupsDN != "" { - p.groupsBaseDN = ldap.EscapeFilter(p.configuration.AdditionalGroupsDN + "," + p.configuration.BaseDN) + if p.config.AdditionalGroupsDN != "" { + p.groupsBaseDN = ldap.EscapeFilter(p.config.AdditionalGroupsDN + "," + p.config.BaseDN) } else { - p.groupsBaseDN = p.configuration.BaseDN + p.groupsBaseDN = p.config.BaseDN } p.log.Tracef("Dynamically generated groups BaseDN is %s", p.groupsBaseDN) - if strings.Contains(p.configuration.GroupsFilter, ldapPlaceholderInput) { + if strings.Contains(p.config.GroupsFilter, ldapPlaceholderInput) { p.groupsFilterReplacementInput = true } - if strings.Contains(p.configuration.GroupsFilter, ldapPlaceholderUsername) { + if strings.Contains(p.config.GroupsFilter, ldapPlaceholderUsername) { p.groupsFilterReplacementUsername = true } - if strings.Contains(p.configuration.GroupsFilter, ldapPlaceholderDistinguishedName) { + if strings.Contains(p.config.GroupsFilter, ldapPlaceholderDistinguishedName) { p.groupsFilterReplacementDN = true } diff --git a/internal/authentication/ldap_user_provider_test.go b/internal/authentication/ldap_user_provider_test.go index 00eb5fd6f..95d4c63d1 100644 --- a/internal/authentication/ldap_user_provider_test.go +++ b/internal/authentication/ldap_user_provider_test.go @@ -24,7 +24,9 @@ func TestShouldCreateRawConnectionWhenSchemeIsLDAP(t *testing.T) { ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ - URL: "ldap://127.0.0.1:389", + URL: "ldap://127.0.0.1:389", + User: "cn=admin,dc=example,dc=com", + Password: "password", }, false, nil, @@ -40,7 +42,7 @@ func TestShouldCreateRawConnectionWhenSchemeIsLDAP(t *testing.T) { gomock.InOrder(dialURL, connBind) - _, err := ldapClient.connect("cn=admin,dc=example,dc=com", "password") + _, err := ldapClient.connect() require.NoError(t, err) } @@ -54,7 +56,9 @@ func TestShouldCreateTLSConnectionWhenSchemeIsLDAPS(t *testing.T) { ldapClient := newLDAPUserProvider( schema.LDAPAuthenticationBackendConfiguration{ - URL: "ldaps://127.0.0.1:389", + URL: "ldaps://127.0.0.1:389", + User: "cn=admin,dc=example,dc=com", + Password: "password", }, false, nil, @@ -70,41 +74,28 @@ func TestShouldCreateTLSConnectionWhenSchemeIsLDAPS(t *testing.T) { gomock.InOrder(dialURL, connBind) - _, err := ldapClient.connect("cn=admin,dc=example,dc=com", "password") + _, err := ldapClient.connect() require.NoError(t, err) } func TestEscapeSpecialCharsFromUserInput(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockFactory := NewMockLDAPConnectionFactory(ctrl) - - ldapClient := newLDAPUserProvider( - schema.LDAPAuthenticationBackendConfiguration{ - URL: "ldaps://127.0.0.1:389", - }, - false, - nil, - mockFactory) - // No escape. - assert.Equal(t, "xyz", ldapClient.ldapEscape("xyz")) + assert.Equal(t, "xyz", ldapEscape("xyz")) // Escape. - assert.Equal(t, "test\\,abc", ldapClient.ldapEscape("test,abc")) - assert.Equal(t, "test\\5cabc", ldapClient.ldapEscape("test\\abc")) - assert.Equal(t, "test\\2aabc", ldapClient.ldapEscape("test*abc")) - assert.Equal(t, "test \\28abc\\29", ldapClient.ldapEscape("test (abc)")) - assert.Equal(t, "test\\#abc", ldapClient.ldapEscape("test#abc")) - assert.Equal(t, "test\\+abc", ldapClient.ldapEscape("test+abc")) - assert.Equal(t, "test\\abc", ldapClient.ldapEscape("test>abc")) - assert.Equal(t, "test\\;abc", ldapClient.ldapEscape("test;abc")) - assert.Equal(t, "test\\\"abc", ldapClient.ldapEscape("test\"abc")) - assert.Equal(t, "test\\=abc", ldapClient.ldapEscape("test=abc")) - assert.Equal(t, "test\\,\\5c\\28abc\\29", ldapClient.ldapEscape("test,\\(abc)")) + assert.Equal(t, "test\\,abc", ldapEscape("test,abc")) + assert.Equal(t, "test\\5cabc", ldapEscape("test\\abc")) + assert.Equal(t, "test\\2aabc", ldapEscape("test*abc")) + assert.Equal(t, "test \\28abc\\29", ldapEscape("test (abc)")) + assert.Equal(t, "test\\#abc", ldapEscape("test#abc")) + assert.Equal(t, "test\\+abc", ldapEscape("test+abc")) + assert.Equal(t, "test\\abc", ldapEscape("test>abc")) + assert.Equal(t, "test\\;abc", ldapEscape("test;abc")) + assert.Equal(t, "test\\\"abc", ldapEscape("test\"abc")) + assert.Equal(t, "test\\=abc", ldapEscape("test=abc")) + assert.Equal(t, "test\\,\\5c\\28abc\\29", ldapEscape("test,\\(abc)")) } func TestEscapeSpecialCharsInGroupsFilter(t *testing.T) { @@ -306,7 +297,7 @@ func TestShouldReturnCheckServerConnectError(t *testing.T) { Return(mockConn, errors.New("could not connect")) err := ldapClient.StartupCheck() - assert.EqualError(t, err, "could not connect") + assert.EqualError(t, err, "dial failed with error: could not connect") assert.False(t, ldapClient.supportExtensionPasswdModify) } @@ -1105,7 +1096,7 @@ func TestShouldCheckInvalidUserPassword(t *testing.T) { valid, err := ldapClient.CheckUserPassword("john", "password") assert.False(t, valid) - require.EqualError(t, err, "authentication failed. Cause: invalid username or password") + require.EqualError(t, err, "authentication failed. Cause: bind failed with error: invalid username or password") } func TestShouldCallStartTLSWhenEnabled(t *testing.T) { @@ -1215,8 +1206,8 @@ func TestShouldParseDynamicConfiguration(t *testing.T) { assert.True(t, ldapClient.usersFilterReplacementInput) - assert.Equal(t, "(&(|(uid={input})(mail={input})(displayName={input}))(objectCategory=person)(objectClass=user)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!pwdLastSet=0))", ldapClient.configuration.UsersFilter) - assert.Equal(t, "(&(|(member={dn})(member={input})(member={username}))(objectClass=group))", ldapClient.configuration.GroupsFilter) + assert.Equal(t, "(&(|(uid={input})(mail={input})(displayName={input}))(objectCategory=person)(objectClass=user)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!pwdLastSet=0))", ldapClient.config.UsersFilter) + assert.Equal(t, "(&(|(member={dn})(member={input})(member={username}))(objectClass=group))", ldapClient.config.GroupsFilter) assert.Equal(t, "ou=users,dc=example,dc=com", ldapClient.usersBaseDN) assert.Equal(t, "ou=groups,dc=example,dc=com", ldapClient.groupsBaseDN) } @@ -1342,5 +1333,5 @@ func TestShouldReturnLDAPSAlreadySecuredWhenStartTLSAttempted(t *testing.T) { gomock.InOrder(dialURL, connStartTLS) _, err := ldapClient.GetDetails("john") - assert.EqualError(t, err, "LDAP Result Code 200 \"Network Error\": ldap: already encrypted") + assert.EqualError(t, err, "starttls failed with error: LDAP Result Code 200 \"Network Error\": ldap: already encrypted") } diff --git a/internal/authentication/ldap_util.go b/internal/authentication/ldap_util.go new file mode 100644 index 000000000..887bc3002 --- /dev/null +++ b/internal/authentication/ldap_util.go @@ -0,0 +1,59 @@ +package authentication + +import ( + "fmt" + "strings" + + ber "github.com/go-asn1-ber/asn1-ber" + "github.com/go-ldap/ldap/v3" +) + +func ldapEntriesContainsEntry(needle *ldap.Entry, haystack []*ldap.Entry) bool { + for i := 0; i < len(haystack); i++ { + if haystack[i].DN == needle.DN { + return true + } + } + + return false +} + +func ldapEscape(inputUsername string) string { + inputUsername = ldap.EscapeFilter(inputUsername) + for _, c := range specialLDAPRunes { + inputUsername = strings.ReplaceAll(inputUsername, string(c), fmt.Sprintf("\\%c", c)) + } + + return inputUsername +} + +func ldapGetReferral(err error) (referral string, ok bool) { + if !ldap.IsErrorWithCode(err, ldap.LDAPResultReferral) { + return "", false + } + + switch e := err.(type) { + case *ldap.Error: + if len(e.Packet.Children) < 2 { + return "", false + } + + for i := 0; i < len(e.Packet.Children[1].Children); i++ { + if e.Packet.Children[1].Children[i].Tag != ber.TagBitString || len(e.Packet.Children[1].Children[i].Children) < 1 { + continue + } + + referral, ok = e.Packet.Children[1].Children[i].Children[0].Value.(string) + + if !ok { + continue + } + + return referral, true + } + + return "", false + default: + return "", false + } +} diff --git a/internal/authentication/types.go b/internal/authentication/types.go index d2857ddcb..64425f0fd 100644 --- a/internal/authentication/types.go +++ b/internal/authentication/types.go @@ -1,5 +1,29 @@ package authentication +import ( + "crypto/tls" + + "github.com/go-ldap/ldap/v3" + "golang.org/x/text/encoding/unicode" +) + +// LDAPConnectionFactory an interface of factory of ldap connections. +type LDAPConnectionFactory interface { + DialURL(addr string, opts ...ldap.DialOpt) (LDAPConnection, error) +} + +// LDAPConnection interface representing a connection to the ldap. +type LDAPConnection interface { + Bind(username, password string) (err error) + Close() + StartTLS(config *tls.Config) (err error) + + Search(searchRequest *ldap.SearchRequest) (searchResult *ldap.SearchResult, err error) + + Modify(modifyRequest *ldap.ModifyRequest) (err error) + PasswordModify(pwdModifyRequest *ldap.PasswordModifyRequest) (result *ldap.PasswordModifyResult, err error) +} + // UserDetails represent the details retrieved for a given user. type UserDetails struct { Username string @@ -7,3 +31,12 @@ type UserDetails struct { Emails []string Groups []string } + +type ldapUserProfile struct { + DN string + Emails []string + DisplayName string + Username string +} + +var utf16LittleEndian = unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM) diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index f97662b87..1e2e5bbf2 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -313,6 +313,10 @@ authentication_backend: ## The attribute holding the display name of the user. This will be used to greet an authenticated user. # display_name_attribute: displayName + ## Follow referrals returned by the server. + ## This is especially useful for environments where read-only servers exist. Only implemented for write operations. + permit_referrals: false + ## The username and password of the admin user. user: cn=admin,dc=example,dc=com ## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index 07e6f56de..1ada6f63a 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -26,6 +26,8 @@ type LDAPAuthenticationBackendConfiguration struct { MailAttribute string `koanf:"mail_attribute"` DisplayNameAttribute string `koanf:"display_name_attribute"` + PermitReferrals bool `koanf:"permit_referrals"` + User string `koanf:"user"` Password string `koanf:"password"` } diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index 108ccfc12..f51f2e6cb 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -61,6 +61,7 @@ var Keys = []string{ "authentication_backend.ldap.username_attribute", "authentication_backend.ldap.mail_attribute", "authentication_backend.ldap.display_name_attribute", + "authentication_backend.ldap.permit_referrals", "authentication_backend.ldap.user", "authentication_backend.ldap.password", "authentication_backend.file.path", diff --git a/internal/configuration/validator/authentication.go b/internal/configuration/validator/authentication.go index c6af94594..7dd307e4c 100644 --- a/internal/configuration/validator/authentication.go +++ b/internal/configuration/validator/authentication.go @@ -124,9 +124,7 @@ func validateLDAPAuthenticationBackend(config *schema.LDAPAuthenticationBackendC if config.TLS == nil { config.TLS = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS - } - - if config.TLS.MinimumVersion == "" { + } else if config.TLS.MinimumVersion == "" { config.TLS.MinimumVersion = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS.MinimumVersion }