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>
This commit is contained in:
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.
# 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

View File

@ -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
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: no
{: .label .label-config .label-green }
required: yes
{: .label .label-config .label-red }
</div>
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
<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
to the [attribute defaults](#attribute-defaults) for more information.
### users_filter
<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 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
{: .label .label-config .label-green }
</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.
### 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
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
<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
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
<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.
### 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
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|(&(&#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))|
| Implementation | Users Filter | Groups Filter |
|:---------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------:|
| 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)) |
_**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

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
)
const (
ldapAttributeUnicodePwd = "unicodePwd"
ldapAttributeUserPassword = "userPassword"
)
const (
ldapPlaceholderInput = "{input}"
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"
)
// 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...)
}

View File

@ -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
config schema.LDAPAuthenticationBackendConfiguration
tlsConfig *tls.Config
dialOpts []ldap.DialOpt
log *logrus.Logger
connectionFactory LDAPConnectionFactory
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...)
// 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
}
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 nil, err
return fmt.Errorf("unable to update password. Cause: %w", err)
}
if p.configuration.StartTLS {
if err := conn.StartTLS(p.tlsConfig); err != nil {
return nil, 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,
func (p *LDAPUserProvider) modify(conn LDAPConnection, modifyRequest *ldap.ModifyRequest) (err error) {
if err = conn.Modify(modifyRequest); err != nil {
var (
referral string
ok bool
)
sr, err := conn.Search(searchGroupRequest)
if err != nil {
return nil, fmt.Errorf("unable to retrieve groups of user '%s'. Cause: %w", inputUsername, err)
if referral, ok = p.getReferral(err); !ok {
return err
}
groups := make([]string, 0)
p.log.Debugf("Attempting Modify on referred URL %s", referral)
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,
var (
connReferral LDAPConnection
errReferral error
)
_, 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 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)
err = conn.Modify(modifyRequest)
default:
modifyRequest := ldap.NewModifyRequest(profile.DN, nil)
modifyRequest.Replace("userPassword", []string{newPassword})
err = conn.Modify(modifyRequest)
return err
}
if err != nil {
return fmt.Errorf("unable to update password. Cause: %w", 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)
}
}
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.
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
}

View File

@ -25,6 +25,8 @@ func TestShouldCreateRawConnectionWhenSchemeIsLDAP(t *testing.T) {
ldapClient := newLDAPUserProvider(
schema.LDAPAuthenticationBackendConfiguration{
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)
}
@ -55,6 +57,8 @@ func TestShouldCreateTLSConnectionWhenSchemeIsLDAPS(t *testing.T) {
ldapClient := newLDAPUserProvider(
schema.LDAPAuthenticationBackendConfiguration{
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\\=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\\=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")
}

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

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.
# 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

View File

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

View File

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

View File

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