fix(authentication): follow ldap referrals (#3251)

This ensures we are able to follow referrals for LDAP password modify operations when permit_referrals is true.

Co-authored-by: Amir Zarrinkafsh <nightah@me.com>
pull/3282/head
James Elliott 2022-05-02 11:51:38 +10:00 committed by GitHub
parent 668ad38f20
commit c7d992f341
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 575 additions and 340 deletions

View File

@ -313,6 +313,10 @@ authentication_backend:
## The attribute holding the display name of the user. This will be used to greet an authenticated user. ## The attribute holding the display name of the user. This will be used to greet an authenticated user.
# display_name_attribute: displayName # 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. ## The username and password of the admin user.
user: cn=admin,dc=example,dc=com user: cn=admin,dc=example,dc=com
## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html ## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html

View File

@ -32,6 +32,7 @@ authentication_backend:
group_name_attribute: cn group_name_attribute: cn
mail_attribute: mail mail_attribute: mail
display_name_attribute: displayName display_name_attribute: displayName
permit_referrals: false
user: CN=admin,DC=example,DC=com user: CN=admin,DC=example,DC=com
password: password 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 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. URL's are slightly more secure.
### tls ### tls
Controls the TLS connection validation process. You can see how to configure the tls Controls the TLS connection validation process. You can see how to configure the tls
section [here](../index.md#tls-configuration). section [here](../index.md#tls-configuration).
@ -117,13 +117,14 @@ user searches and [additional_groups_dn](#additional_groups_dn) for groups searc
<div markdown="1"> <div markdown="1">
type: string type: string
{: .label .label-config .label-purple } {: .label .label-config .label-purple }
required: no required: yes
{: .label .label-config .label-green } {: .label .label-config .label-red }
</div> </div>
The LDAP attribute that maps to the username in Authelia. The default value is dependent on the [implementation](#implementation), _**Note:** While this option is required, an [implementation](#implementation) may set a default value implicitly
refer to the [attribute defaults](#attribute-defaults) for more information. 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 ### additional_users_dn
<div markdown="1"> <div markdown="1">
@ -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 `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. to the [attribute defaults](#attribute-defaults) for more information.
### users_filter ### users_filter
<div markdown="1"> <div markdown="1">
type: string type: string
{: .label .label-config .label-purple } {: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
_**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
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
_**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
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: no required: no
{: .label .label-config .label-green } {: .label .label-config .label-green }
</div> </div>
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. Similar to [additional_users_dn](#additional_users_dn) but it applies to group searches.
### groups_filter ### groups_filter
Similar to [users_filter](#users_filter) but it applies to group searches. In order to include groups the memeber is not <div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
_**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 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: 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))` `(&(member:1.2.840.113556.1.4.1941:={dn})(objectClass=group)(objectCategory=group))`
### mail_attribute ### mail_attribute
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
_**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 The attribute to retrieve which contains the users email addresses. This is important for the device registration and
password reset processes. 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 The user must have an email address in order for Authelia to perform identity verification when a user attempts to reset
register a second factor device. their password or register a second factor device.
### display_name_attribute ### display_name_attribute
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
_**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. The attribute to retrieve which is shown on the Web UI to the user when they log in.
### permit_referrals
<div markdown="1">
type: boolean
{: .label .label-config .label-purple }
default: false
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-red }
</div>
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 ### user
The distinguished name of the user paired with the password to bind with for lookup and password change operations. 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 #### Users filter replacements
|Placeholder |Phase |Replacement | | Placeholder | Phase | Replacement |
|:----------------------:|:-----:|:--------------------------------------------------------------:| |:------------------------:|:-------:|:-------------------------------------:|
|{username_attribute} |startup|The configured username attribute | | {username_attribute} | startup | The configured username attribute |
|{mail_attribute} |startup|The configured mail attribute | | {mail_attribute} | startup | The configured mail attribute |
|{display_name_attribute}|startup|The configured display name attribute | | {display_name_attribute} | startup | The configured display name attribute |
|{input} |search |The input into the username field | | {input} | search | The input into the username field |
#### Groups filter replacements #### Groups filter replacements
|Placeholder |Phase |Replacement | | Placeholder | Phase | Replacement |
|:----------------------:|:-----:|:-------------------------------------------------------------------------:| |:-----------:|:------:|:-------------------------------------------------------------------------:|
|{input} |search |The input into the username field | | {input} | search | The input into the username field |
|{username} |search |The username from the profile lookup obtained from the username attribute | | {username} | search | The username from the profile lookup obtained from the username attribute |
|{dn} |search |The distinguished name from the profile lookup | | {dn} | search | The distinguished name from the profile lookup |
### Defaults ### Defaults
The below tables describes the current attribute defaults for each implementation. 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 This table describes the attribute defaults for each implementation. i.e. the username_attribute is
described by the Username column. described by the Username column.
|Implementation |Username |Display Name|Mail |Group Name| | Implementation | Username | Display Name | Mail | Group Name |
|:-------------:|:------------:|:----------:|:---:|:--------:| |:---------------:|:--------------:|:------------:|:----:|:----------:|
|custom |n/a |displayName |mail |cn | | custom | n/a | displayName | mail | cn |
|activedirectory|sAMAccountName|displayName |mail |cn | | activedirectory | sAMAccountName | displayName | mail | cn |
#### Filter defaults #### Filter defaults
The filters are probably the most important part to get correct when setting up LDAP. 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 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. makes sure that value is not 0 which means the password requires changing at the next login.
|Implementation |Users Filter |Groups Filter| | Implementation | Users Filter | Groups Filter |
|:-------------:|:------------:|:-----------:| |:---------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------:|
|custom |n/a |n/a | | custom | n/a | n/a |
|activedirectory|(&(&#124;({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))| | activedirectory | (&(&#124;({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 _**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 `(&(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 ## Refresh Interval
This setting takes a [duration notation](../index.md#duration-notation-format) that sets the max frequency This setting takes a [duration notation](../index.md#duration-notation-format) that sets the max frequency

View File

@ -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 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 ( const (
ldapPlaceholderInput = "{input}" ldapPlaceholderInput = "{input}"
ldapPlaceholderDistinguishedName = "{dn}" ldapPlaceholderDistinguishedName = "{dn}"

View File

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

View File

@ -4,25 +4,15 @@ import (
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
) )
// LDAPConnectionFactory an interface of factory of ldap connections. // ProductionLDAPConnectionFactory the production implementation of an ldap connection factory.
type LDAPConnectionFactory interface { type ProductionLDAPConnectionFactory struct{}
DialURL(addr string, opts ...ldap.DialOpt) (LDAPConnection, error)
}
// LDAPConnectionFactoryImpl the production implementation of an ldap connection factory. // NewProductionLDAPConnectionFactory create a concrete ldap connection factory.
type LDAPConnectionFactoryImpl struct{} func NewProductionLDAPConnectionFactory() *ProductionLDAPConnectionFactory {
return &ProductionLDAPConnectionFactory{}
// NewLDAPConnectionFactoryImpl create a concrete ldap connection factory.
func NewLDAPConnectionFactoryImpl() *LDAPConnectionFactoryImpl {
return &LDAPConnectionFactoryImpl{}
} }
// DialURL creates a connection from an LDAP URL when successful. // DialURL creates a connection from an LDAP URL when successful.
func (lcf *LDAPConnectionFactoryImpl) DialURL(addr string, opts ...ldap.DialOpt) (LDAPConnection, error) { func (f *ProductionLDAPConnectionFactory) DialURL(addr string, opts ...ldap.DialOpt) (conn LDAPConnection, err error) {
conn, err := ldap.DialURL(addr, opts...) return ldap.DialURL(addr, opts...)
if err != nil {
return nil, err
}
return conn, nil
} }

View File

@ -9,7 +9,6 @@ import (
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/text/encoding/unicode"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/logging" "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. // LDAPUserProvider is a UserProvider that connects to LDAP servers like ActiveDirectory, OpenLDAP, OpenDJ, FreeIPA, etc.
type LDAPUserProvider struct { type LDAPUserProvider struct {
configuration schema.LDAPAuthenticationBackendConfiguration config schema.LDAPAuthenticationBackendConfiguration
tlsConfig *tls.Config tlsConfig *tls.Config
dialOpts []ldap.DialOpt dialOpts []ldap.DialOpt
log *logrus.Logger log *logrus.Logger
connectionFactory LDAPConnectionFactory factory LDAPConnectionFactory
disableResetPassword bool disableResetPassword bool
@ -43,21 +42,21 @@ type LDAPUserProvider struct {
} }
// NewLDAPUserProvider creates a new instance of LDAPUserProvider. // NewLDAPUserProvider creates a new instance of LDAPUserProvider.
func NewLDAPUserProvider(configuration schema.AuthenticationBackendConfiguration, certPool *x509.CertPool) (provider *LDAPUserProvider) { func NewLDAPUserProvider(config schema.AuthenticationBackendConfiguration, certPool *x509.CertPool) (provider *LDAPUserProvider) {
provider = newLDAPUserProvider(*configuration.LDAP, configuration.DisableResetPassword, certPool, nil) provider = newLDAPUserProvider(*config.LDAP, config.DisableResetPassword, certPool, nil)
return provider return provider
} }
func newLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfiguration, disableResetPassword bool, certPool *x509.CertPool, factory LDAPConnectionFactory) (provider *LDAPUserProvider) { func newLDAPUserProvider(config schema.LDAPAuthenticationBackendConfiguration, disableResetPassword bool, certPool *x509.CertPool, factory LDAPConnectionFactory) (provider *LDAPUserProvider) {
if configuration.TLS == nil { if config.TLS == nil {
configuration.TLS = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS 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{ var dialOpts = []ldap.DialOpt{
ldap.DialWithDialer(&net.Dialer{Timeout: configuration.Timeout}), ldap.DialWithDialer(&net.Dialer{Timeout: config.Timeout}),
} }
if tlsConfig != nil { if tlsConfig != nil {
@ -65,15 +64,15 @@ func newLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfigura
} }
if factory == nil { if factory == nil {
factory = NewLDAPConnectionFactoryImpl() factory = NewProductionLDAPConnectionFactory()
} }
provider = &LDAPUserProvider{ provider = &LDAPUserProvider{
configuration: configuration, config: config,
tlsConfig: tlsConfig, tlsConfig: tlsConfig,
dialOpts: dialOpts, dialOpts: dialOpts,
log: logging.Logger(), log: logging.Logger(),
connectionFactory: factory, factory: factory,
disableResetPassword: disableResetPassword, disableResetPassword: disableResetPassword,
} }
@ -83,77 +82,224 @@ func newLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfigura
return provider return provider
} }
func (p *LDAPUserProvider) connect(userDN string, password string) (LDAPConnection, error) { // CheckUserPassword checks if provided password matches for the given user.
conn, err := p.connectionFactory.DialURL(p.configuration.URL, p.dialOpts...) 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
}
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 { if err != nil {
return nil, err return fmt.Errorf("unable to update password. Cause: %w", err)
} }
if p.configuration.StartTLS { return nil
if err := conn.StartTLS(p.tlsConfig); err != nil { }
return nil, err
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 { if err = conn.Bind(userDN, password); err != nil {
return nil, err return nil, fmt.Errorf("bind failed with error: %w", err)
} }
return conn, nil return conn, nil
} }
// CheckUserPassword checks if provided password matches for the given user. func (p *LDAPUserProvider) search(conn LDAPConnection, searchRequest *ldap.SearchRequest) (searchResult *ldap.SearchResult, err error) {
func (p *LDAPUserProvider) CheckUserPassword(inputUsername string, password string) (bool, error) { searchResult, err = conn.Search(searchRequest)
conn, err := p.connect(p.configuration.User, p.configuration.Password)
if err != nil { 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() defer conn.Close()
profile, err := p.getUserProfile(conn, inputUsername) if result, err = conn.Search(searchRequest); err != nil {
if err != nil { p.log.Errorf("Failed to perform search operation during referred search request (referred to %s): %v", referral, err)
return false, err
return err
} }
userConn, err := p.connect(profile.DN, password) if len(result.Entries) == 0 {
if err != nil { return err
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))
} }
return inputUsername for i := 0; i < len(result.Entries); i++ {
} if !ldapEntriesContainsEntry(result.Entries[i], searchResult.Entries) {
searchResult.Entries = append(searchResult.Entries, result.Entries[i])
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))
} }
p.log.Tracef("Computed user filter is %s", filter) return nil
return filter
} }
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) userFilter := p.resolveUsersFilter(inputUsername)
// Search for the given username. // Search for the given username.
@ -162,36 +308,37 @@ func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, inputUsername str
1, 0, false, userFilter, p.usersAttributes, nil, 1, 0, false, userFilter, p.usersAttributes, nil,
) )
sr, err := conn.Search(searchRequest) var searchResult *ldap.SearchResult
if err != nil {
if searchResult, err = p.search(conn, searchRequest); err != nil {
return nil, fmt.Errorf("cannot find user DN of user '%s'. Cause: %w", inputUsername, err) 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 return nil, ErrUserNotFound
} }
if len(sr.Entries) > 1 { if len(searchResult.Entries) > 1 {
return nil, fmt.Errorf("multiple users %s found", inputUsername) return nil, fmt.Errorf("multiple users %s found", inputUsername)
} }
userProfile := ldapUserProfile{ userProfile := ldapUserProfile{
DN: sr.Entries[0].DN, DN: searchResult.Entries[0].DN,
} }
for _, attr := range sr.Entries[0].Attributes { for _, attr := range searchResult.Entries[0].Attributes {
if attr.Name == p.configuration.DisplayNameAttribute { if attr.Name == p.config.DisplayNameAttribute {
userProfile.DisplayName = attr.Values[0] userProfile.DisplayName = attr.Values[0]
} }
if attr.Name == p.configuration.MailAttribute { if attr.Name == p.config.MailAttribute {
userProfile.Emails = attr.Values userProfile.Emails = attr.Values
} }
if attr.Name == p.configuration.UsernameAttribute { if attr.Name == p.config.UsernameAttribute {
if len(attr.Values) != 1 { if len(attr.Values) != 1 {
return nil, fmt.Errorf("user '%s' cannot have multiple value for attribute '%s'", 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] userProfile.Username = attr.Values[0]
@ -205,12 +352,25 @@ func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, inputUsername str
return &userProfile, nil 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 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 { if p.groupsFilterReplacementInput {
// The {input} placeholder is replaced by the users username input. // 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 { if profile != nil {
@ -228,98 +388,78 @@ func (p *LDAPUserProvider) resolveGroupsFilter(inputUsername string, profile *ld
return filter, nil return filter, nil
} }
// GetDetails retrieve the groups a user belongs to. func (p *LDAPUserProvider) modify(conn LDAPConnection, modifyRequest *ldap.ModifyRequest) (err error) {
func (p *LDAPUserProvider) GetDetails(inputUsername string) (*UserDetails, error) { if err = conn.Modify(modifyRequest); err != nil {
conn, err := p.connect(p.configuration.User, p.configuration.Password) var (
if err != nil { referral string
return nil, err ok bool
}
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 referral, ok = p.getReferral(err); !ok {
return err
if err != nil {
return nil, fmt.Errorf("unable to retrieve groups of user '%s'. Cause: %w", inputUsername, err)
} }
groups := make([]string, 0) p.log.Debugf("Attempting Modify on referred URL %s", referral)
for _, res := range sr.Entries { var (
if len(res.Attributes) == 0 { connReferral LDAPConnection
p.log.Warningf("No groups retrieved from LDAP for user %s", inputUsername) errReferral error
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,
) )
_, err = conn.PasswordModify(modifyRequest) if connReferral, errReferral = p.connectCustom(referral, p.config.User, p.config.Password, p.config.StartTLS, p.dialOpts...); errReferral != nil {
case p.configuration.Implementation == schema.LDAPImplementationActiveDirectory: p.log.Errorf("Failed to connect during referred modify request (referred to %s): %v", referral, errReferral)
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})
err = conn.Modify(modifyRequest) return err
default:
modifyRequest := ldap.NewModifyRequest(profile.DN, nil)
modifyRequest.Replace("userPassword", []string{newPassword})
err = conn.Modify(modifyRequest)
} }
if err != nil { defer connReferral.Close()
return fmt.Errorf("unable to update password. Cause: %w", err)
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)
}
} }
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)
} }

View File

@ -10,8 +10,12 @@ import (
// StartupCheck implements the startup check provider interface. // StartupCheck implements the startup check provider interface.
func (p *LDAPUserProvider) StartupCheck() (err error) { func (p *LDAPUserProvider) StartupCheck() (err error) {
conn, err := p.connect(p.configuration.User, p.configuration.Password) var (
if err != nil { conn LDAPConnection
searchResult *ldap.SearchResult
)
if conn, err = p.connect(); err != nil {
return err return err
} }
@ -20,17 +24,16 @@ func (p *LDAPUserProvider) StartupCheck() (err error) {
searchRequest := ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, searchRequest := ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases,
1, 0, false, "(objectClass=*)", []string{ldapSupportedExtensionAttribute}, nil) 1, 0, false, "(objectClass=*)", []string{ldapSupportedExtensionAttribute}, nil)
sr, err := conn.Search(searchRequest) if searchResult, err = conn.Search(searchRequest); err != nil {
if err != nil {
return err return err
} }
if len(sr.Entries) != 1 { if len(searchResult.Entries) != 1 {
return nil return nil
} }
// Iterate the attribute values to see what the server supports. // 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 { if attr.Name == ldapSupportedExtensionAttribute {
p.log.Tracef("LDAP Supported Extension OIDs: %s", strings.Join(attr.Values, ", ")) p.log.Tracef("LDAP Supported Extension OIDs: %s", strings.Join(attr.Values, ", "))
@ -40,13 +43,11 @@ func (p *LDAPUserProvider) StartupCheck() (err error) {
break break
} }
} }
break
} }
} }
if !p.supportExtensionPasswdModify && !p.disableResetPassword && 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 " + 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 " + "known to Authelia, it's strongly recommended you ensure your directory server hashes the password " +
"attribute when users reset their password via Authelia.") "attribute when users reset their password via Authelia.")
@ -56,27 +57,27 @@ func (p *LDAPUserProvider) StartupCheck() (err error) {
} }
func (p *LDAPUserProvider) parseDynamicUsersConfiguration() { func (p *LDAPUserProvider) parseDynamicUsersConfiguration() {
p.configuration.UsersFilter = strings.ReplaceAll(p.configuration.UsersFilter, "{username_attribute}", p.configuration.UsernameAttribute) p.config.UsersFilter = strings.ReplaceAll(p.config.UsersFilter, "{username_attribute}", p.config.UsernameAttribute)
p.configuration.UsersFilter = strings.ReplaceAll(p.configuration.UsersFilter, "{mail_attribute}", p.configuration.MailAttribute) p.config.UsersFilter = strings.ReplaceAll(p.config.UsersFilter, "{mail_attribute}", p.config.MailAttribute)
p.configuration.UsersFilter = strings.ReplaceAll(p.configuration.UsersFilter, "{display_name_attribute}", p.configuration.DisplayNameAttribute) 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.usersAttributes = []string{
p.configuration.DisplayNameAttribute, p.config.DisplayNameAttribute,
p.configuration.MailAttribute, p.config.MailAttribute,
p.configuration.UsernameAttribute, p.config.UsernameAttribute,
} }
if p.configuration.AdditionalUsersDN != "" { if p.config.AdditionalUsersDN != "" {
p.usersBaseDN = p.configuration.AdditionalUsersDN + "," + p.configuration.BaseDN p.usersBaseDN = p.config.AdditionalUsersDN + "," + p.config.BaseDN
} else { } else {
p.usersBaseDN = p.configuration.BaseDN p.usersBaseDN = p.config.BaseDN
} }
p.log.Tracef("Dynamically generated users BaseDN is %s", p.usersBaseDN) 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 p.usersFilterReplacementInput = true
} }
@ -86,26 +87,26 @@ func (p *LDAPUserProvider) parseDynamicUsersConfiguration() {
func (p *LDAPUserProvider) parseDynamicGroupsConfiguration() { func (p *LDAPUserProvider) parseDynamicGroupsConfiguration() {
p.groupsAttributes = []string{ p.groupsAttributes = []string{
p.configuration.GroupNameAttribute, p.config.GroupNameAttribute,
} }
if p.configuration.AdditionalGroupsDN != "" { if p.config.AdditionalGroupsDN != "" {
p.groupsBaseDN = ldap.EscapeFilter(p.configuration.AdditionalGroupsDN + "," + p.configuration.BaseDN) p.groupsBaseDN = ldap.EscapeFilter(p.config.AdditionalGroupsDN + "," + p.config.BaseDN)
} else { } else {
p.groupsBaseDN = p.configuration.BaseDN p.groupsBaseDN = p.config.BaseDN
} }
p.log.Tracef("Dynamically generated groups BaseDN is %s", p.groupsBaseDN) 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 p.groupsFilterReplacementInput = true
} }
if strings.Contains(p.configuration.GroupsFilter, ldapPlaceholderUsername) { if strings.Contains(p.config.GroupsFilter, ldapPlaceholderUsername) {
p.groupsFilterReplacementUsername = true p.groupsFilterReplacementUsername = true
} }
if strings.Contains(p.configuration.GroupsFilter, ldapPlaceholderDistinguishedName) { if strings.Contains(p.config.GroupsFilter, ldapPlaceholderDistinguishedName) {
p.groupsFilterReplacementDN = true p.groupsFilterReplacementDN = true
} }

View File

@ -25,6 +25,8 @@ func TestShouldCreateRawConnectionWhenSchemeIsLDAP(t *testing.T) {
ldapClient := newLDAPUserProvider( ldapClient := newLDAPUserProvider(
schema.LDAPAuthenticationBackendConfiguration{ 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, false,
nil, nil,
@ -40,7 +42,7 @@ func TestShouldCreateRawConnectionWhenSchemeIsLDAP(t *testing.T) {
gomock.InOrder(dialURL, connBind) gomock.InOrder(dialURL, connBind)
_, err := ldapClient.connect("cn=admin,dc=example,dc=com", "password") _, err := ldapClient.connect()
require.NoError(t, err) require.NoError(t, err)
} }
@ -55,6 +57,8 @@ func TestShouldCreateTLSConnectionWhenSchemeIsLDAPS(t *testing.T) {
ldapClient := newLDAPUserProvider( ldapClient := newLDAPUserProvider(
schema.LDAPAuthenticationBackendConfiguration{ 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, false,
nil, nil,
@ -70,41 +74,28 @@ func TestShouldCreateTLSConnectionWhenSchemeIsLDAPS(t *testing.T) {
gomock.InOrder(dialURL, connBind) gomock.InOrder(dialURL, connBind)
_, err := ldapClient.connect("cn=admin,dc=example,dc=com", "password") _, err := ldapClient.connect()
require.NoError(t, err) require.NoError(t, err)
} }
func TestEscapeSpecialCharsFromUserInput(t *testing.T) { 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. // No escape.
assert.Equal(t, "xyz", ldapClient.ldapEscape("xyz")) assert.Equal(t, "xyz", ldapEscape("xyz"))
// Escape. // Escape.
assert.Equal(t, "test\\,abc", ldapClient.ldapEscape("test,abc")) assert.Equal(t, "test\\,abc", ldapEscape("test,abc"))
assert.Equal(t, "test\\5cabc", ldapClient.ldapEscape("test\\abc")) assert.Equal(t, "test\\5cabc", ldapEscape("test\\abc"))
assert.Equal(t, "test\\2aabc", ldapClient.ldapEscape("test*abc")) assert.Equal(t, "test\\2aabc", ldapEscape("test*abc"))
assert.Equal(t, "test \\28abc\\29", ldapClient.ldapEscape("test (abc)")) assert.Equal(t, "test \\28abc\\29", ldapEscape("test (abc)"))
assert.Equal(t, "test\\#abc", ldapClient.ldapEscape("test#abc")) assert.Equal(t, "test\\#abc", ldapEscape("test#abc"))
assert.Equal(t, "test\\+abc", ldapClient.ldapEscape("test+abc")) assert.Equal(t, "test\\+abc", ldapEscape("test+abc"))
assert.Equal(t, "test\\<abc", ldapClient.ldapEscape("test<abc")) assert.Equal(t, "test\\<abc", ldapEscape("test<abc"))
assert.Equal(t, "test\\>abc", ldapClient.ldapEscape("test>abc")) assert.Equal(t, "test\\>abc", ldapEscape("test>abc"))
assert.Equal(t, "test\\;abc", ldapClient.ldapEscape("test;abc")) assert.Equal(t, "test\\;abc", ldapEscape("test;abc"))
assert.Equal(t, "test\\\"abc", ldapClient.ldapEscape("test\"abc")) assert.Equal(t, "test\\\"abc", ldapEscape("test\"abc"))
assert.Equal(t, "test\\=abc", ldapClient.ldapEscape("test=abc")) assert.Equal(t, "test\\=abc", ldapEscape("test=abc"))
assert.Equal(t, "test\\,\\5c\\28abc\\29", ldapClient.ldapEscape("test,\\(abc)")) assert.Equal(t, "test\\,\\5c\\28abc\\29", ldapEscape("test,\\(abc)"))
} }
func TestEscapeSpecialCharsInGroupsFilter(t *testing.T) { func TestEscapeSpecialCharsInGroupsFilter(t *testing.T) {
@ -306,7 +297,7 @@ func TestShouldReturnCheckServerConnectError(t *testing.T) {
Return(mockConn, errors.New("could not connect")) Return(mockConn, errors.New("could not connect"))
err := ldapClient.StartupCheck() 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) assert.False(t, ldapClient.supportExtensionPasswdModify)
} }
@ -1105,7 +1096,7 @@ func TestShouldCheckInvalidUserPassword(t *testing.T) {
valid, err := ldapClient.CheckUserPassword("john", "password") valid, err := ldapClient.CheckUserPassword("john", "password")
assert.False(t, valid) 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) { func TestShouldCallStartTLSWhenEnabled(t *testing.T) {
@ -1215,8 +1206,8 @@ func TestShouldParseDynamicConfiguration(t *testing.T) {
assert.True(t, ldapClient.usersFilterReplacementInput) 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, "(&(|(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.configuration.GroupsFilter) 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=users,dc=example,dc=com", ldapClient.usersBaseDN)
assert.Equal(t, "ou=groups,dc=example,dc=com", ldapClient.groupsBaseDN) assert.Equal(t, "ou=groups,dc=example,dc=com", ldapClient.groupsBaseDN)
} }
@ -1342,5 +1333,5 @@ func TestShouldReturnLDAPSAlreadySecuredWhenStartTLSAttempted(t *testing.T) {
gomock.InOrder(dialURL, connStartTLS) gomock.InOrder(dialURL, connStartTLS)
_, err := ldapClient.GetDetails("john") _, 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")
} }

View File

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

View File

@ -1,5 +1,29 @@
package authentication 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. // UserDetails represent the details retrieved for a given user.
type UserDetails struct { type UserDetails struct {
Username string Username string
@ -7,3 +31,12 @@ type UserDetails struct {
Emails []string Emails []string
Groups []string Groups []string
} }
type ldapUserProfile struct {
DN string
Emails []string
DisplayName string
Username string
}
var utf16LittleEndian = unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)

View File

@ -313,6 +313,10 @@ authentication_backend:
## The attribute holding the display name of the user. This will be used to greet an authenticated user. ## The attribute holding the display name of the user. This will be used to greet an authenticated user.
# display_name_attribute: displayName # 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. ## The username and password of the admin user.
user: cn=admin,dc=example,dc=com user: cn=admin,dc=example,dc=com
## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html ## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html

View File

@ -26,6 +26,8 @@ type LDAPAuthenticationBackendConfiguration struct {
MailAttribute string `koanf:"mail_attribute"` MailAttribute string `koanf:"mail_attribute"`
DisplayNameAttribute string `koanf:"display_name_attribute"` DisplayNameAttribute string `koanf:"display_name_attribute"`
PermitReferrals bool `koanf:"permit_referrals"`
User string `koanf:"user"` User string `koanf:"user"`
Password string `koanf:"password"` Password string `koanf:"password"`
} }

View File

@ -61,6 +61,7 @@ var Keys = []string{
"authentication_backend.ldap.username_attribute", "authentication_backend.ldap.username_attribute",
"authentication_backend.ldap.mail_attribute", "authentication_backend.ldap.mail_attribute",
"authentication_backend.ldap.display_name_attribute", "authentication_backend.ldap.display_name_attribute",
"authentication_backend.ldap.permit_referrals",
"authentication_backend.ldap.user", "authentication_backend.ldap.user",
"authentication_backend.ldap.password", "authentication_backend.ldap.password",
"authentication_backend.file.path", "authentication_backend.file.path",

View File

@ -124,9 +124,7 @@ func validateLDAPAuthenticationBackend(config *schema.LDAPAuthenticationBackendC
if config.TLS == nil { if config.TLS == nil {
config.TLS = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS config.TLS = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS
} } else if config.TLS.MinimumVersion == "" {
if config.TLS.MinimumVersion == "" {
config.TLS.MinimumVersion = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS.MinimumVersion config.TLS.MinimumVersion = schema.DefaultLDAPAuthenticationBackendConfiguration.TLS.MinimumVersion
} }