[FEATURE][BREAKING] Allow users to sign in with email. (#792)

* [FEATURE][BREAKING] Allow users to sign in with email.

The users_filter purpose evolved with the introduction of username_attribute
but is reverted here to allow the most flexibility. users_filter is now the
actual filter used for searching the user and not a sub-filter based on the
username_attribute anymore.

* {input} placeholder has been introduced to later deprecate {0} which has been
kept for backward compatibility.
* {username_attribute} and {mail_attribute} are new placeholders used to back
reference other configuration options.

Fix #735

* [MISC] Introduce new placeholders for groups_filter too.

* [MISC] Update BREAKING.md to mention the change regarding users_filter.

* [MISC] Fix unit and integration tests.

* Log an error message in console when U2F is not supported.

* Apply suggestions from code review

* Update BREAKING.md

Co-authored-by: James Elliott <james-d-elliott@users.noreply.github.com>
Co-authored-by: Amir Zarrinkafsh <nightah@me.com>
pull/798/head^2
Clément Michaud 2020-03-31 00:36:04 +02:00 committed by GitHub
parent 95f6c1a893
commit 7a3e782dc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 264 additions and 107 deletions

View File

@ -6,6 +6,12 @@ recommended not to use the 'latest' Docker image tag blindly but pick a version
and read this documentation before upgrading. This is where you will get information about and read this documentation before upgrading. This is where you will get information about
breaking changes and about what you should do to overcome those changes. breaking changes and about what you should do to overcome those changes.
## Breaking in v4.10.0
* Revert of `users_filter` purpose. This option now represents the complete search filter again, meaning
there is no more automatic filter computation based on username. This gives the most flexibility.
For instance, this allows administrators to choose whether they want the users to be able to sign in with
username or email.
## Breaking in v4.7.0 ## Breaking in v4.7.0
* `logs_level` configuration key has been renamed to `log_level`. * `logs_level` configuration key has been renamed to `log_level`.
* `users_filter` was a search pattern for a given user with the `{0}` matcher replaced with the * `users_filter` was a search pattern for a given user with the `{0}` matcher replaced with the

View File

@ -82,33 +82,42 @@ authentication_backend:
# The base dn for every entries # The base dn for every entries
base_dn: dc=example,dc=com base_dn: dc=example,dc=com
# The attribute holding the username of the user (introduced to handle # The attribute holding the username of the user. This attribute is used to populate
# case insensitive search queries: #561). # the username in the session information. It was introduced due to #561 to handle case
# Microsoft Active Directory usually uses 'sAMAccountName' # insensitive search queries.
# OpenLDAP usually uses 'uid' # For you information, Microsoft Active Directory usually uses 'sAMAccountName' and OpenLDAP
# usually uses 'uid'
username_attribute: uid username_attribute: uid
# An additional dn to define the scope to all users # An additional dn to define the scope to all users
additional_users_dn: ou=users additional_users_dn: ou=users
# This attribute is optional. The user filter used in the LDAP search queries # The users filter used in search queries to find the user profile based on input filled in login form.
# is a combination of this filter and the username attribute. # Various placeholders are available to represent the user input and back reference other options of the configuration:
# This filter is used to reduce the scope of users targeted by the LDAP search query. # - {input} is a placeholder replaced by what the user inputs in the login form.
# For instance, if the username attribute is set to 'uid', the computed filter is # - {username_attribute} is a placeholder replaced by what is configured in `username_attribute`.
# (&(uid=<username>)(objectClass=person)) # - {mail_attribute} is a placeholder replaced by what is configured in `mail_attribute`.
# - DON'T USE - {0} is an alias for {input} supported for backward compatibility but it will be deprecated in later versions, so please don't use it.
#
# Recommended settings are as follows: # Recommended settings are as follows:
# Microsoft Active Directory '(&(objectCategory=person)(objectClass=user))' # - Microsoft Active Directory: (&({username_attribute}={input})(objectCategory=person)(objectClass=user))
# OpenLDAP '(objectClass=person)' or '(objectClass=inetOrgPerson)' # - OpenLDAP: (&({username_attribute}={input})(objectClass=person))' or '(&({username_attribute}={input})(objectClass=inetOrgPerson))
users_filter: (objectClass=person) #
# To allow sign in both with username and email, one can use a filter like
# (&(|({username_attribute}={input})({mail_attribute={input}))(objectClass=person))
users_filter: (&({username_attribute}={input})(objectClass=person))
# An additional dn to define the scope of groups # An additional dn to define the scope of groups
additional_groups_dn: ou=groups additional_groups_dn: ou=groups
# The groups filter used for retrieving groups of a given user. # The groups filter used in search queries to find the groups of the user.
# {0} is a matcher replaced by username (as provided in login portal). # - {input} is a placeholder replaced by what the user inputs in the login form.
# {1} is a matcher replaced by username (as stored in LDAP). # - {username} is a placeholder replace by the username stored in LDAP (based on `username_attribute`).
# {dn} is a matcher replaced by user DN. # - {dn} is a matcher replaced by the user distinguished name, aka, user DN.
# 'member={dn}' by default. # - {username_attribute} is a placeholder replaced by what is configured in `username_attribute`.
# - {mail_attribute} is a placeholder replaced by what is configured in `mail_attribute`.
# - DON'T USE - {0} is an alias for {input} supported for backward compatibility but it will be deprecated in later versions, so please don't use it.
# - DON'T USE - {1} is an alias for {username} supported for backward compatibility but it will be deprecated in later version, so please don't use it.
groups_filter: (&(member={dn})(objectclass=groupOfNames)) groups_filter: (&(member={dn})(objectclass=groupOfNames))
# The attribute holding the name of the group # The attribute holding the name of the group

View File

@ -26,33 +26,42 @@ authentication_backend:
# The base dn for every entries # The base dn for every entries
base_dn: dc=example,dc=com base_dn: dc=example,dc=com
# The attribute holding the username of the user (introduced to handle # The attribute holding the username of the user. This attribute is used to populate
# case insensitive search queries: #561). # the username in the session information. It was introduced due to #561 to handle case
# Microsoft Active Directory usually uses 'sAMAccountName' # insensitive search queries.
# OpenLDAP usually uses 'uid' # For you information, Microsoft Active Directory usually uses 'sAMAccountName' and OpenLDAP
# usually uses 'uid'
username_attribute: uid username_attribute: uid
# An additional dn to define the scope to all users # An additional dn to define the scope to all users
additional_users_dn: ou=users additional_users_dn: ou=users
# This attribute is optional. The user filter used in the LDAP search queries # The users filter used in search queries to find the user profile based on input filled in login form.
# is a combination of this filter and the username attribute. # Various placeholders are available to represent the user input and back reference other options of the configuration:
# This filter is used to reduce the scope of users targeted by the LDAP search query. # - {input} is a placeholder replaced by what the user inputs in the login form.
# For instance, if the username attribute is set to 'uid', the computed filter is # - {username_attribute} is a placeholder replaced by what is configured in `username_attribute`.
# (&(uid=<username>)(objectClass=person)) # - {mail_attribute} is a placeholder replaced by what is configured in `mail_attribute`.
# - DON'T USE - {0} is an alias for {input} supported for backward compatibility but it will be deprecated in later versions, so please don't use it.
#
# Recommended settings are as follows: # Recommended settings are as follows:
# Microsoft Active Directory '(&(objectCategory=person)(objectClass=user))' # - Microsoft Active Directory: (&({username_attribute}={input})(objectCategory=person)(objectClass=user))
# OpenLDAP '(objectClass=person)' or '(objectClass=inetOrgPerson)' # - OpenLDAP: (&({username_attribute}={input})(objectClass=person))' or '(&({username_attribute}={input})(objectClass=inetOrgPerson))
users_filter: (objectClass=person) #
# To allow sign in both with username and email, one can use a filter like
# (&(|({username_attribute}={input})({mail_attribute={input}))(objectClass=person))
users_filter: (&({username_attribute}={input})(objectClass=person))
# An additional dn to define the scope of groups # An additional dn to define the scope of groups
additional_groups_dn: ou=groups additional_groups_dn: ou=groups
# The groups filter used for retrieving groups of a given user. # The groups filter used in search queries to find the groups of the user.
# {0} is a matcher replaced by username (as provided in login portal). # - {input} is a placeholder replaced by what the user inputs in the login form.
# {1} is a matcher replaced by username (as stored in LDAP). # - {username} is a placeholder replace by the username stored in LDAP (based on `username_attribute`).
# {dn} is a matcher replaced by user DN. # - {dn} is a matcher replaced by the user distinguished name, aka, user DN.
# 'member={dn}' by default. # - {username_attribute} is a placeholder replaced by what is configured in `username_attribute`.
# - {mail_attribute} is a placeholder replaced by what is configured in `mail_attribute`.
# - DON'T USE - {0} is an alias for {input} supported for backward compatibility but it will be deprecated in later versions, so please don't use it.
# - DON'T USE - {1} is an alias for {username} supported for backward compatibility but it will be deprecated in later version, so please don't use it.
groups_filter: (&(member={dn})(objectclass=groupOfNames)) groups_filter: (&(member={dn})(objectclass=groupOfNames))
# The attribute holding the name of the group # The attribute holding the name of the group

View File

@ -68,21 +68,21 @@ func (p *LDAPUserProvider) connect(userDN string, password string) (LDAPConnecti
} }
// CheckUserPassword checks if provided password matches for the given user. // CheckUserPassword checks if provided password matches for the given user.
func (p *LDAPUserProvider) CheckUserPassword(username string, password string) (bool, error) { func (p *LDAPUserProvider) CheckUserPassword(inputUsername string, password string) (bool, error) {
adminClient, err := p.connect(p.configuration.User, p.configuration.Password) adminClient, err := p.connect(p.configuration.User, p.configuration.Password)
if err != nil { if err != nil {
return false, err return false, err
} }
defer adminClient.Close() defer adminClient.Close()
profile, err := p.getUserProfile(adminClient, username) profile, err := p.getUserProfile(adminClient, inputUsername)
if err != nil { if err != nil {
return false, err return false, err
} }
conn, err := p.connect(profile.DN, password) conn, err := p.connect(profile.DN, password)
if err != nil { if err != nil {
return false, fmt.Errorf("Authentication of user %s failed. Cause: %s", username, err) return false, fmt.Errorf("Authentication of user %s failed. Cause: %s", inputUsername, err)
} }
defer conn.Close() defer conn.Close()
@ -91,13 +91,14 @@ func (p *LDAPUserProvider) CheckUserPassword(username string, password string) (
// OWASP recommends to escape some special characters // OWASP recommends to escape some special characters
// https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/LDAP_Injection_Prevention_Cheat_Sheet.md // https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/LDAP_Injection_Prevention_Cheat_Sheet.md
const SpecialLDAPRunes = "\\,#+<>;\"=" const specialLDAPRunes = ",#+<>;\"="
func (p *LDAPUserProvider) ldapEscape(input string) string { func (p *LDAPUserProvider) ldapEscape(inputUsername string) string {
for _, c := range SpecialLDAPRunes { inputUsername = ldap.EscapeFilter(inputUsername)
input = strings.ReplaceAll(input, string(c), fmt.Sprintf("\\%c", c)) for _, c := range specialLDAPRunes {
inputUsername = strings.ReplaceAll(inputUsername, string(c), fmt.Sprintf("\\%c", c))
} }
return input return inputUsername
} }
type ldapUserProfile struct { type ldapUserProfile struct {
@ -106,12 +107,26 @@ type ldapUserProfile struct {
Username string Username string
} }
func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, username string) (*ldapUserProfile, error) { func (p *LDAPUserProvider) resolveUsersFilter(userFilter string, inputUsername string) string {
username = p.ldapEscape(username) inputUsername = p.ldapEscape(inputUsername)
userFilter := fmt.Sprintf("(%s=%s)", p.configuration.UsernameAttribute, username)
if p.configuration.UsersFilter != "" { // We temporarily keep placeholder {0} for backward compatibility
userFilter = fmt.Sprintf("(&%s%s)", userFilter, p.configuration.UsersFilter) userFilter = strings.ReplaceAll(userFilter, "{0}", inputUsername)
// The {username} placeholder is equivalent to {0}, it's the new way, a named placeholder.
userFilter = strings.ReplaceAll(userFilter, "{input}", inputUsername)
// {username_attribute} and {mail_attribute} are replaced by the content of the attribute defined
// in configuration.
userFilter = strings.ReplaceAll(userFilter, "{username_attribute}", p.configuration.UsernameAttribute)
userFilter = strings.ReplaceAll(userFilter, "{mail_attribute}", p.configuration.MailAttribute)
return userFilter
} }
func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, inputUsername string) (*ldapUserProfile, error) {
userFilter := p.resolveUsersFilter(p.configuration.UsersFilter, inputUsername)
logging.Logger().Tracef("Computed user filter is %s", userFilter)
baseDN := p.configuration.BaseDN baseDN := p.configuration.BaseDN
if p.configuration.AdditionalUsersDN != "" { if p.configuration.AdditionalUsersDN != "" {
baseDN = p.configuration.AdditionalUsersDN + "," + baseDN baseDN = p.configuration.AdditionalUsersDN + "," + baseDN
@ -129,15 +144,15 @@ func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, username string)
sr, err := conn.Search(searchRequest) sr, err := conn.Search(searchRequest)
if err != nil { if err != nil {
return nil, fmt.Errorf("Cannot find user DN of user %s. Cause: %s", username, err) return nil, fmt.Errorf("Cannot find user DN of user %s. Cause: %s", inputUsername, err)
} }
if len(sr.Entries) == 0 { if len(sr.Entries) == 0 {
return nil, fmt.Errorf("No user %s found", username) return nil, fmt.Errorf("No user %s found", inputUsername)
} }
if len(sr.Entries) > 1 { if len(sr.Entries) > 1 {
return nil, fmt.Errorf("Multiple users %s found", username) return nil, fmt.Errorf("Multiple users %s found", inputUsername)
} }
userProfile := ldapUserProfile{ userProfile := ldapUserProfile{
@ -148,51 +163,55 @@ func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, username string)
userProfile.Emails = attr.Values userProfile.Emails = attr.Values
} else if attr.Name == p.configuration.UsernameAttribute { } else if attr.Name == p.configuration.UsernameAttribute {
if len(attr.Values) != 1 { if len(attr.Values) != 1 {
return nil, fmt.Errorf("User %s cannot have multiple value for attribute %s", username, p.configuration.UsernameAttribute) return nil, fmt.Errorf("User %s cannot have multiple value for attribute %s",
inputUsername, p.configuration.UsernameAttribute)
} }
userProfile.Username = attr.Values[0] userProfile.Username = attr.Values[0]
} }
} }
if userProfile.DN == "" { if userProfile.DN == "" {
return nil, fmt.Errorf("No DN has been found for user %s", username) return nil, fmt.Errorf("No DN has been found for user %s", inputUsername)
} }
return &userProfile, nil return &userProfile, nil
} }
func (p *LDAPUserProvider) createGroupsFilter(conn LDAPConnection, username string) (string, error) { func (p *LDAPUserProvider) resolveGroupsFilter(inputUsername string, profile *ldapUserProfile) (string, error) {
if strings.Contains(p.configuration.GroupsFilter, "{0}") { inputUsername = p.ldapEscape(inputUsername)
return strings.Replace(p.configuration.GroupsFilter, "{0}", username, -1), nil
} else if strings.Contains(p.configuration.GroupsFilter, "{dn}") { // We temporarily keep placeholder {0} for backward compatibility
profile, err := p.getUserProfile(conn, username) groupFilter := strings.ReplaceAll(p.configuration.GroupsFilter, "{0}", inputUsername)
if err != nil { groupFilter = strings.ReplaceAll(groupFilter, "{input}", inputUsername)
return "", err if profile != nil {
// We temporarily keep placeholder {1} for backward compatibility
groupFilter = strings.ReplaceAll(groupFilter, "{1}", ldap.EscapeFilter(profile.Username))
groupFilter = strings.ReplaceAll(groupFilter, "{username}", ldap.EscapeFilter(profile.Username))
groupFilter = strings.ReplaceAll(groupFilter, "{dn}", ldap.EscapeFilter(profile.DN))
} }
return strings.Replace(p.configuration.GroupsFilter, "{dn}", ldap.EscapeFilter(profile.DN), -1), nil
} else if strings.Contains(p.configuration.GroupsFilter, "{1}") { return groupFilter, nil
profile, err := p.getUserProfile(conn, username)
if err != nil {
return "", err
}
return strings.Replace(p.configuration.GroupsFilter, "{1}", profile.Username, -1), nil
}
return p.configuration.GroupsFilter, nil
} }
// GetDetails retrieve the groups a user belongs to. // GetDetails retrieve the groups a user belongs to.
func (p *LDAPUserProvider) GetDetails(username string) (*UserDetails, error) { func (p *LDAPUserProvider) GetDetails(inputUsername string) (*UserDetails, error) {
conn, err := p.connect(p.configuration.User, p.configuration.Password) conn, err := p.connect(p.configuration.User, p.configuration.Password)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer conn.Close() defer conn.Close()
groupsFilter, err := p.createGroupsFilter(conn, username) profile, err := p.getUserProfile(conn, inputUsername)
if err != nil { if err != nil {
return nil, fmt.Errorf("Unable to create group filter for user %s. Cause: %s", username, err) 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: %s", inputUsername, err)
}
logging.Logger().Tracef("Computed groups filter is %s", groupsFilter)
groupBaseDN := p.configuration.BaseDN groupBaseDN := p.configuration.BaseDN
if p.configuration.AdditionalGroupsDN != "" { if p.configuration.AdditionalGroupsDN != "" {
groupBaseDN = p.configuration.AdditionalGroupsDN + "," + groupBaseDN groupBaseDN = p.configuration.AdditionalGroupsDN + "," + groupBaseDN
@ -207,24 +226,19 @@ func (p *LDAPUserProvider) GetDetails(username string) (*UserDetails, error) {
sr, err := conn.Search(searchGroupRequest) sr, err := conn.Search(searchGroupRequest)
if err != nil { if err != nil {
return nil, fmt.Errorf("Unable to retrieve groups of user %s. Cause: %s", username, err) return nil, fmt.Errorf("Unable to retrieve groups of user %s. Cause: %s", inputUsername, err)
} }
groups := make([]string, 0) groups := make([]string, 0)
for _, res := range sr.Entries { for _, res := range sr.Entries {
if len(res.Attributes) == 0 { if len(res.Attributes) == 0 {
logging.Logger().Warningf("No groups retrieved from LDAP for user %s", username) logging.Logger().Warningf("No groups retrieved from LDAP for user %s", inputUsername)
break break
} }
// append all values of the document. Normally there should be only one per document. // append all values of the document. Normally there should be only one per document.
groups = append(groups, res.Attributes[0].Values...) groups = append(groups, res.Attributes[0].Values...)
} }
profile, err := p.getUserProfile(conn, username)
if err != nil {
return nil, err
}
return &UserDetails{ return &UserDetails{
Username: profile.Username, Username: profile.Username,
Emails: profile.Emails, Emails: profile.Emails,
@ -233,14 +247,14 @@ func (p *LDAPUserProvider) GetDetails(username string) (*UserDetails, error) {
} }
// UpdatePassword update the password of the given user. // UpdatePassword update the password of the given user.
func (p *LDAPUserProvider) UpdatePassword(username string, newPassword string) error { func (p *LDAPUserProvider) UpdatePassword(inputUsername string, newPassword string) error {
client, err := p.connect(p.configuration.User, p.configuration.Password) client, err := p.connect(p.configuration.User, p.configuration.Password)
if err != nil { if err != nil {
return fmt.Errorf("Unable to update password. Cause: %s", err) return fmt.Errorf("Unable to update password. Cause: %s", err)
} }
profile, err := p.getUserProfile(client, username) profile, err := p.getUserProfile(client, inputUsername)
if err != nil { if err != nil {
return fmt.Errorf("Unable to update password. Cause: %s", err) return fmt.Errorf("Unable to update password. Cause: %s", err)

View File

@ -58,7 +58,7 @@ func TestShouldCreateTLSConnectionWhenSchemeIsLDAPS(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestEscapeSpecialChars(t *testing.T) { func TestEscapeSpecialCharsFromUserInput(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@ -72,7 +72,9 @@ func TestEscapeSpecialChars(t *testing.T) {
// Escape // Escape
assert.Equal(t, "test\\,abc", ldap.ldapEscape("test,abc")) assert.Equal(t, "test\\,abc", ldap.ldapEscape("test,abc"))
assert.Equal(t, "test\\\\abc", ldap.ldapEscape("test\\abc")) assert.Equal(t, "test\\5cabc", ldap.ldapEscape("test\\abc"))
assert.Equal(t, "test\\2aabc", ldap.ldapEscape("test*abc"))
assert.Equal(t, "test \\28abc\\29", ldap.ldapEscape("test (abc)"))
assert.Equal(t, "test\\#abc", ldap.ldapEscape("test#abc")) assert.Equal(t, "test\\#abc", ldap.ldapEscape("test#abc"))
assert.Equal(t, "test\\+abc", ldap.ldapEscape("test+abc")) assert.Equal(t, "test\\+abc", ldap.ldapEscape("test+abc"))
assert.Equal(t, "test\\<abc", ldap.ldapEscape("test<abc")) assert.Equal(t, "test\\<abc", ldap.ldapEscape("test<abc"))
@ -80,7 +82,30 @@ func TestEscapeSpecialChars(t *testing.T) {
assert.Equal(t, "test\\;abc", ldap.ldapEscape("test;abc")) assert.Equal(t, "test\\;abc", ldap.ldapEscape("test;abc"))
assert.Equal(t, "test\\\"abc", ldap.ldapEscape("test\"abc")) assert.Equal(t, "test\\\"abc", ldap.ldapEscape("test\"abc"))
assert.Equal(t, "test\\=abc", ldap.ldapEscape("test=abc")) assert.Equal(t, "test\\=abc", ldap.ldapEscape("test=abc"))
assert.Equal(t, "test\\,\\5c\\28abc\\29", ldap.ldapEscape("test,\\(abc)"))
}
func TestEscapeSpecialCharsInGroupsFilter(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockFactory := NewMockLDAPConnectionFactory(ctrl)
ldap := NewLDAPUserProviderWithFactory(schema.LDAPAuthenticationBackendConfiguration{
URL: "ldaps://127.0.0.1:389",
GroupsFilter: "(|(member={dn})(uid={username})(uid={input}))",
}, mockFactory)
profile := ldapUserProfile{
DN: "cn=john (external),dc=example,dc=com",
Username: "john",
Emails: []string{"john.doe@authelia.com"},
}
filter, _ := ldap.resolveGroupsFilter("john", &profile)
assert.Equal(t, "(|(member=cn=john \\28external\\29,dc=example,dc=com)(uid=john)(uid=john))", filter)
filter, _ = ldap.resolveGroupsFilter("john#=(abc,def)", &profile)
assert.Equal(t, "(|(member=cn=john \\28external\\29,dc=example,dc=com)(uid=john)(uid=john\\#\\=\\28abc\\,def\\29))", filter)
} }
type SearchRequestMatcher struct { type SearchRequestMatcher struct {
@ -110,7 +135,9 @@ func TestShouldEscapeUserInput(t *testing.T) {
ldapClient := NewLDAPUserProviderWithFactory(schema.LDAPAuthenticationBackendConfiguration{ ldapClient := NewLDAPUserProviderWithFactory(schema.LDAPAuthenticationBackendConfiguration{
URL: "ldap://127.0.0.1:389", URL: "ldap://127.0.0.1:389",
User: "cn=admin,dc=example,dc=com", User: "cn=admin,dc=example,dc=com",
UsersFilter: "(|({username_attribute}={input})({mail_attribute}={input}))",
UsernameAttribute: "uid", UsernameAttribute: "uid",
MailAttribute: "mail",
Password: "password", Password: "password",
AdditionalUsersDN: "ou=users", AdditionalUsersDN: "ou=users",
BaseDN: "dc=example,dc=com", BaseDN: "dc=example,dc=com",
@ -118,7 +145,7 @@ func TestShouldEscapeUserInput(t *testing.T) {
mockConn.EXPECT(). mockConn.EXPECT().
// Here we ensure that the input has been correctly escaped. // Here we ensure that the input has been correctly escaped.
Search(NewSearchRequestMatcher("(uid=john\\=abc)")). Search(NewSearchRequestMatcher("(|(uid=john\\=abc)(mail=john\\=abc))")).
Return(&ldap.SearchResult{}, nil) Return(&ldap.SearchResult{}, nil)
ldapClient.getUserProfile(mockConn, "john=abc") ldapClient.getUserProfile(mockConn, "john=abc")
@ -135,10 +162,11 @@ func TestShouldCombineUsernameFilterAndUsersFilter(t *testing.T) {
URL: "ldap://127.0.0.1:389", URL: "ldap://127.0.0.1:389",
User: "cn=admin,dc=example,dc=com", User: "cn=admin,dc=example,dc=com",
UsernameAttribute: "uid", UsernameAttribute: "uid",
UsersFilter: "(&(objectCategory=person)(objectClass=user))", UsersFilter: "(&({username_attribute}={input})(&(objectCategory=person)(objectClass=user)))",
Password: "password", Password: "password",
AdditionalUsersDN: "ou=users", AdditionalUsersDN: "ou=users",
BaseDN: "dc=example,dc=com", BaseDN: "dc=example,dc=com",
MailAttribute: "mail",
}, mockFactory) }, mockFactory)
mockConn.EXPECT(). mockConn.EXPECT().
@ -175,7 +203,7 @@ func TestShouldNotCrashWhenGroupsAreNotRetrievedFromLDAP(t *testing.T) {
Password: "password", Password: "password",
UsernameAttribute: "uid", UsernameAttribute: "uid",
MailAttribute: "mail", MailAttribute: "mail",
UsersFilter: "uid={0}", UsersFilter: "uid={input}",
AdditionalUsersDN: "ou=users", AdditionalUsersDN: "ou=users",
BaseDN: "dc=example,dc=com", BaseDN: "dc=example,dc=com",
}, mockFactory) }, mockFactory)
@ -214,7 +242,7 @@ func TestShouldNotCrashWhenGroupsAreNotRetrievedFromLDAP(t *testing.T) {
}, },
}, nil) }, nil)
gomock.InOrder(searchGroups, searchProfile) gomock.InOrder(searchProfile, searchGroups)
details, err := ldapClient.GetDetails("john") details, err := ldapClient.GetDetails("john")
require.NoError(t, err) require.NoError(t, err)
@ -236,7 +264,7 @@ func TestShouldNotCrashWhenEmailsAreNotRetrievedFromLDAP(t *testing.T) {
User: "cn=admin,dc=example,dc=com", User: "cn=admin,dc=example,dc=com",
Password: "password", Password: "password",
UsernameAttribute: "uid", UsernameAttribute: "uid",
UsersFilter: "uid={0}", UsersFilter: "uid={input}",
AdditionalUsersDN: "ou=users", AdditionalUsersDN: "ou=users",
BaseDN: "dc=example,dc=com", BaseDN: "dc=example,dc=com",
}, mockFactory) }, mockFactory)
@ -271,7 +299,7 @@ func TestShouldNotCrashWhenEmailsAreNotRetrievedFromLDAP(t *testing.T) {
}, },
}, nil) }, nil)
gomock.InOrder(searchGroups, searchProfile) gomock.InOrder(searchProfile, searchGroups)
details, err := ldapClient.GetDetails("john") details, err := ldapClient.GetDetails("john")
require.NoError(t, err) require.NoError(t, err)
@ -294,7 +322,7 @@ func TestShouldReturnUsernameFromLDAP(t *testing.T) {
Password: "password", Password: "password",
UsernameAttribute: "uid", UsernameAttribute: "uid",
MailAttribute: "mail", MailAttribute: "mail",
UsersFilter: "uid={0}", UsersFilter: "uid={input}",
AdditionalUsersDN: "ou=users", AdditionalUsersDN: "ou=users",
BaseDN: "dc=example,dc=com", BaseDN: "dc=example,dc=com",
}, mockFactory) }, mockFactory)
@ -333,7 +361,7 @@ func TestShouldReturnUsernameFromLDAP(t *testing.T) {
}, },
}, nil) }, nil)
gomock.InOrder(searchGroups, searchProfile) gomock.InOrder(searchProfile, searchGroups)
details, err := ldapClient.GetDetails("john") details, err := ldapClient.GetDetails("john")
require.NoError(t, err) require.NoError(t, err)

View File

@ -21,7 +21,7 @@ authentication_backend:
base_dn: dc=example,dc=com base_dn: dc=example,dc=com
username_attribute: uid username_attribute: uid
additional_users_dn: ou=users additional_users_dn: ou=users
users_filter: (&(objectCategory=person)(objectClass=user)) users_filter: (&({username_attribute}={input})(objectCategory=person)(objectClass=user))
additional_groups_dn: ou=groups additional_groups_dn: ou=groups
groups_filter: (&(member={dn})(objectclass=groupOfNames)) groups_filter: (&(member={dn})(objectclass=groupOfNames))
group_name_attribute: cn group_name_attribute: cn

View File

@ -120,9 +120,17 @@ func validateLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationB
validator.Push(errors.New("Please provide a base DN to connect to the LDAP server")) validator.Push(errors.New("Please provide a base DN to connect to the LDAP server"))
} }
if configuration.UsersFilter != "" { if configuration.UsersFilter == "" {
validator.Push(errors.New("Please provide a users filter with `users_filter` attribute"))
} else {
if !strings.HasPrefix(configuration.UsersFilter, "(") || !strings.HasSuffix(configuration.UsersFilter, ")") { if !strings.HasPrefix(configuration.UsersFilter, "(") || !strings.HasSuffix(configuration.UsersFilter, ")") {
validator.Push(errors.New("The users filter should contain enclosing parenthesis. For instance uid={0} should be (uid={0})")) validator.Push(errors.New("The users filter should contain enclosing parenthesis. For instance uid={input} should be (uid={input})"))
}
// This test helps the user know that users_filter is broken after the breaking change induced by this commit.
if !strings.Contains(configuration.UsersFilter, "{0}") && !strings.Contains(configuration.UsersFilter, "{input}") {
validator.Push(errors.New("Unable to detect {input} placeholder in users_filter, your configuration might be broken. " +
"Please review configuration options listed at https://docs.authelia.com/configuration/authentication/ldap.html"))
} }
} }
@ -130,7 +138,7 @@ func validateLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationB
validator.Push(errors.New("Please provide a groups filter with `groups_filter` attribute")) validator.Push(errors.New("Please provide a groups filter with `groups_filter` attribute"))
} else { } else {
if !strings.HasPrefix(configuration.GroupsFilter, "(") || !strings.HasSuffix(configuration.GroupsFilter, ")") { if !strings.HasPrefix(configuration.GroupsFilter, "(") || !strings.HasSuffix(configuration.GroupsFilter, ")") {
validator.Push(errors.New("The groups filter should contain enclosing parenthesis. For instance cn={0} should be (cn={0})")) validator.Push(errors.New("The groups filter should contain enclosing parenthesis. For instance cn={input} should be (cn={input})"))
} }
} }

View File

@ -170,8 +170,8 @@ func (suite *LdapAuthenticationBackendSuite) SetupTest() {
suite.configuration.Ldap.Password = "password" suite.configuration.Ldap.Password = "password"
suite.configuration.Ldap.BaseDN = "base_dn" suite.configuration.Ldap.BaseDN = "base_dn"
suite.configuration.Ldap.UsernameAttribute = "uid" suite.configuration.Ldap.UsernameAttribute = "uid"
suite.configuration.Ldap.UsersFilter = "(uid={0})" suite.configuration.Ldap.UsersFilter = "(uid={input})"
suite.configuration.Ldap.GroupsFilter = "(cn={0})" suite.configuration.Ldap.GroupsFilter = "(cn={input})"
} }
func (suite *LdapAuthenticationBackendSuite) TestShouldValidateCompleteConfiguration() { func (suite *LdapAuthenticationBackendSuite) TestShouldValidateCompleteConfiguration() {
@ -214,10 +214,11 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnEmptyGroupsFilter(
assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a groups filter with `groups_filter` attribute") assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a groups filter with `groups_filter` attribute")
} }
func (suite *LdapAuthenticationBackendSuite) TestShouldAllowEmptyUsersGroupsFilter() { func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnEmptyUsersFilter() {
suite.configuration.Ldap.UsersFilter = "" suite.configuration.Ldap.UsersFilter = ""
ValidateAuthenticationBackend(&suite.configuration, suite.validator) ValidateAuthenticationBackend(&suite.configuration, suite.validator)
require.Len(suite.T(), suite.validator.Errors(), 0) require.Len(suite.T(), suite.validator.Errors(), 1)
assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a users filter with `users_filter` attribute")
} }
func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnEmptyUsernameAttribute() { func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnEmptyUsernameAttribute() {
@ -240,17 +241,24 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultMailAttribute()
} }
func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseWhenUsersFilterDoesNotContainEnclosingParenthesis() { func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseWhenUsersFilterDoesNotContainEnclosingParenthesis() {
suite.configuration.Ldap.UsersFilter = "uid={0}" suite.configuration.Ldap.UsersFilter = "uid={input}"
ValidateAuthenticationBackend(&suite.configuration, suite.validator) ValidateAuthenticationBackend(&suite.configuration, suite.validator)
assert.Len(suite.T(), suite.validator.Errors(), 1) assert.Len(suite.T(), suite.validator.Errors(), 1)
assert.EqualError(suite.T(), suite.validator.Errors()[0], "The users filter should contain enclosing parenthesis. For instance uid={0} should be (uid={0})") assert.EqualError(suite.T(), suite.validator.Errors()[0], "The users filter should contain enclosing parenthesis. For instance uid={input} should be (uid={input})")
} }
func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseWhenGroupsFilterDoesNotContainEnclosingParenthesis() { func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseWhenGroupsFilterDoesNotContainEnclosingParenthesis() {
suite.configuration.Ldap.GroupsFilter = "cn={0}" suite.configuration.Ldap.GroupsFilter = "cn={input}"
ValidateAuthenticationBackend(&suite.configuration, suite.validator) ValidateAuthenticationBackend(&suite.configuration, suite.validator)
assert.Len(suite.T(), suite.validator.Errors(), 1) assert.Len(suite.T(), suite.validator.Errors(), 1)
assert.EqualError(suite.T(), suite.validator.Errors()[0], "The groups filter should contain enclosing parenthesis. For instance cn={0} should be (cn={0})") assert.EqualError(suite.T(), suite.validator.Errors()[0], "The groups filter should contain enclosing parenthesis. For instance cn={input} should be (cn={input})")
}
func (suite *LdapAuthenticationBackendSuite) TestShouldHelpDetectNoInputPlaceholder() {
suite.configuration.Ldap.UsersFilter = "(objectClass=person)"
ValidateAuthenticationBackend(&suite.configuration, suite.validator)
assert.Len(suite.T(), suite.validator.Errors(), 1)
assert.EqualError(suite.T(), suite.validator.Errors()[0], "Unable to detect {input} placeholder in users_filter, your configuration might be broken. Please review configuration options listed at https://docs.authelia.com/configuration/authentication/ldap.html")
} }
func (suite *LdapAuthenticationBackendSuite) TestShouldAdaptLDAPURL() { func (suite *LdapAuthenticationBackendSuite) TestShouldAdaptLDAPURL() {

View File

@ -17,7 +17,7 @@ authentication_backend:
base_dn: dc=example,dc=com base_dn: dc=example,dc=com
username_attribute: uid username_attribute: uid
additional_users_dn: ou=users additional_users_dn: ou=users
users_filter: (objectClass=person) users_filter: (&({username_attribute}={input})(objectClass=person))
additional_groups_dn: ou=groups additional_groups_dn: ou=groups
groups_filter: (&(member={dn})(objectclass=groupOfNames)) groups_filter: (&(member={dn})(objectclass=groupOfNames))
group_name_attribute: cn group_name_attribute: cn

View File

@ -17,7 +17,7 @@ authentication_backend:
base_dn: dc=example,dc=com base_dn: dc=example,dc=com
username_attribute: uid username_attribute: uid
additional_users_dn: ou=users additional_users_dn: ou=users
users_filter: (objectClass=person) users_filter: (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person)(objectClass=inetOrgPerson))
additional_groups_dn: ou=groups additional_groups_dn: ou=groups
groups_filter: (&(member={dn})(objectclass=groupOfNames)) groups_filter: (&(member={dn})(objectclass=groupOfNames))
group_name_attribute: cn group_name_attribute: cn

View File

@ -14,7 +14,7 @@ authentication_backend:
base_dn: dc=example,dc=com base_dn: dc=example,dc=com
username_attribute: uid username_attribute: uid
additional_users_dn: ou=users additional_users_dn: ou=users
users_filter: (objectClass=person) users_filter: (&({username_attribute}={input})(objectClass=person))
additional_groups_dn: ou=groups additional_groups_dn: ou=groups
groups_filter: (&(member={dn})(objectclass=groupOfNames)) groups_filter: (&(member={dn})(objectclass=groupOfNames))
group_name_attribute: cn group_name_attribute: cn

View File

@ -0,0 +1,63 @@
package suites
// This scenario is used to test sign in using the user email address.
import (
"context"
"fmt"
"log"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
type SigninEmailScenario struct {
*SeleniumSuite
}
func NewSigninEmailScenario() *SigninEmailScenario {
return &SigninEmailScenario{
SeleniumSuite: new(SeleniumSuite),
}
}
func (s *SigninEmailScenario) SetupSuite() {
wds, err := StartWebDriver()
if err != nil {
log.Fatal(err)
}
s.WebDriverSession = wds
}
func (s *SigninEmailScenario) TearDownSuite() {
err := s.WebDriverSession.Stop()
if err != nil {
log.Fatal(err)
}
}
func (s *SigninEmailScenario) SetupTest() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.doLogout(ctx, s.T())
s.doVisit(s.T(), HomeBaseURL)
s.verifyIsHome(ctx, s.T())
}
func (s *SigninEmailScenario) TestShouldSignInWithUserEmail() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
targetURL := fmt.Sprintf("%s/secret.html", SingleFactorBaseURL)
s.doLoginOneFactor(ctx, s.T(), "john.doe@authelia.com", "password", false, targetURL)
s.verifySecretAuthorized(ctx, s.T())
}
func TestSigninEmailScenario(t *testing.T) {
suite.Run(t, NewSigninEmailScenario())
}

View File

@ -22,6 +22,14 @@ func (s *LDAPSuite) TestTwoFactorScenario() {
suite.Run(s.T(), NewTwoFactorScenario()) suite.Run(s.T(), NewTwoFactorScenario())
} }
func (s *LDAPSuite) TestResetPassword() {
suite.Run(s.T(), NewResetPasswordScenario())
}
func (s *LDAPSuite) TestSigninEmailScenario() {
suite.Run(s.T(), NewSigninEmailScenario())
}
func TestLDAPSuite(t *testing.T) { func TestLDAPSuite(t *testing.T) {
suite.Run(t, NewLDAPSuite()) suite.Run(t, NewLDAPSuite())
} }

View File

@ -44,7 +44,11 @@ export default function (props: Props) {
const [u2fSupported, setU2fSupported] = useState(false); const [u2fSupported, setU2fSupported] = useState(false);
// Check that U2F is supported. // Check that U2F is supported.
useEffect(() => { u2fApi.ensureSupport().then(() => setU2fSupported(true)) }, [setU2fSupported]); useEffect(() => {
u2fApi.ensureSupport().then(
() => setU2fSupported(true),
() => console.error("U2F not supported"));
}, [setU2fSupported]);
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => { const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => {
return async () => { return async () => {