feat(authentication): ldap users reset filter

This allows setting a specific users filter for password resets.

Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
feat-ldap-reset-filter
James Elliott 2023-05-11 21:26:14 +10:00
parent 92cf5a186d
commit 099f4fa5e0
No known key found for this signature in database
GPG Key ID: 0F1C4A096E857E49
10 changed files with 97 additions and 42 deletions

View File

@ -421,6 +421,11 @@ authentication_backend:
## (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))
# users_filter: '(&({username_attribute}={input})(objectClass=person))'
## Defaults to the users_filter. This filter is used exclusively for password resets. So you can leverage it to
## prevent users who must change their password from logging in, but if you set a separate filter here they could
## theoretically still reset their password.
# users_reset_filter: '(&({username_attribute}={input})(objectClass=person))'
## The additional_groups_dn is prefixed to base_dn and delimited by a comma when searching for groups.
## i.e. with this set to OU=Groups and base_dn set to DC=a,DC=com; OU=Groups,DC=a,DC=com is searched for groups.
# additional_groups_dn: 'ou=groups'

View File

@ -209,10 +209,18 @@ exactly which OU to get users from for either security or performance reasons. F
default negating this requirement. Refer to the [filter defaults](../../reference/guides/ldap.md#filter-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.
The default value is dependent on the [implementation](#implementation), refer to the
The LDAP filter to determine 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](../../reference/guides/ldap.md#attribute-defaults) for more information.
### users_reset_filter
{{< confkey type="string" required="no" >}}
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 the same as [users_filter](#usersfilter). This can be leveraged to allow users who cannot login
due to restrictions in the [users_filter](#usersfilter) to still be able to reset their password.
### additional_groups_dn
{{< confkey type="string" required="no" >}}

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ import (
"strconv"
"strings"
ldap "github.com/go-ldap/ldap/v3"
"github.com/go-ldap/ldap/v3"
"github.com/sirupsen/logrus"
"github.com/authelia/authelia/v4/internal/configuration/schema"
@ -106,7 +106,7 @@ func (p *LDAPUserProvider) CheckUserPassword(username string, password string) (
defer client.Close()
if profile, err = p.getUserProfile(client, username); err != nil {
if profile, err = p.getUserProfile(client, username, false); err != nil {
return false, err
}
@ -132,7 +132,7 @@ func (p *LDAPUserProvider) GetDetails(username string) (details *UserDetails, er
defer client.Close()
if profile, err = p.getUserProfile(client, username); err != nil {
if profile, err = p.getUserProfile(client, username, false); err != nil {
return nil, err
}
@ -165,7 +165,7 @@ func (p *LDAPUserProvider) UpdatePassword(username, password string) (err error)
defer client.Close()
if profile, err = p.getUserProfile(client, username); err != nil {
if profile, err = p.getUserProfile(client, username, true); err != nil {
return fmt.Errorf("unable to update password. Cause: %w", err)
}
@ -306,11 +306,11 @@ func (p *LDAPUserProvider) searchReferrals(request *ldap.SearchRequest, result *
return nil
}
func (p *LDAPUserProvider) getUserProfile(client LDAPClient, username string) (profile *ldapUserProfile, err error) {
func (p *LDAPUserProvider) getUserProfile(client LDAPClient, username string, reset bool) (profile *ldapUserProfile, err error) {
// Search for the given username.
request := ldap.NewSearchRequest(
p.usersBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 0, false, p.resolveUsersFilter(username), p.usersAttributes, nil,
1, 0, false, p.resolveUsersFilter(username, reset), p.usersAttributes, nil,
)
p.log.
@ -518,8 +518,12 @@ func (p *LDAPUserProvider) getUserGroupsRequestMemberOf(client LDAPClient, usern
return groups, nil
}
func (p *LDAPUserProvider) resolveUsersFilter(input string) (filter string) {
filter = p.config.UsersFilter
func (p *LDAPUserProvider) resolveUsersFilter(input string, reset bool) (filter string) {
if reset {
filter = p.config.UsersResetFilter
} else {
filter = p.config.UsersFilter
}
if p.usersFilterReplacementInput {
// The {input} placeholder is replaced by the username input.

View File

@ -91,6 +91,16 @@ func (p *LDAPUserProvider) parseDynamicUsersConfiguration() {
p.config.UsersFilter = strings.ReplaceAll(p.config.UsersFilter, ldapPlaceholderMailAttribute, p.config.Attributes.Mail)
p.config.UsersFilter = strings.ReplaceAll(p.config.UsersFilter, ldapPlaceholderMemberOfAttribute, p.config.Attributes.MemberOf)
if p.config.UsersResetFilter == "" {
p.config.UsersResetFilter = p.config.UsersFilter
} else {
p.config.UsersResetFilter = strings.ReplaceAll(p.config.UsersResetFilter, ldapPlaceholderDistinguishedNameAttribute, p.config.Attributes.DistinguishedName)
p.config.UsersResetFilter = strings.ReplaceAll(p.config.UsersResetFilter, ldapPlaceholderUsernameAttribute, p.config.Attributes.Username)
p.config.UsersResetFilter = strings.ReplaceAll(p.config.UsersResetFilter, ldapPlaceholderDisplayNameAttribute, p.config.Attributes.DisplayName)
p.config.UsersResetFilter = strings.ReplaceAll(p.config.UsersResetFilter, ldapPlaceholderMailAttribute, p.config.Attributes.Mail)
p.config.UsersResetFilter = strings.ReplaceAll(p.config.UsersResetFilter, ldapPlaceholderMemberOfAttribute, p.config.Attributes.MemberOf)
}
p.log.Tracef("Dynamically generated users filter is %s", p.config.UsersFilter)
if len(p.config.Attributes.Username) != 0 && !utils.IsStringInSlice(p.config.Attributes.Username, p.usersAttributes) {

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/go-ldap/ldap/v3"
ldap "github.com/go-ldap/ldap/v3"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -757,7 +757,7 @@ func TestShouldEscapeUserInput(t *testing.T) {
Search(NewSearchRequestMatcher("(|(uid=john\\=abc)(mail=john\\=abc))")).
Return(&ldap.SearchResult{}, nil)
_, err := provider.getUserProfile(mockClient, "john=abc")
_, err := provider.getUserProfile(mockClient, "john=abc", false)
require.Error(t, err)
assert.EqualError(t, err, "user not found")
}
@ -823,7 +823,7 @@ func TestShouldReturnEmailWhenAttributeSameAsUsername(t *testing.T) {
client, err := provider.connect()
assert.NoError(t, err)
profile, err := provider.getUserProfile(client, "john@example.com")
profile, err := provider.getUserProfile(client, "john@example.com", false)
assert.NoError(t, err)
require.NotNil(t, profile)
@ -897,7 +897,7 @@ func TestShouldReturnUsernameAndBlankDisplayNameWhenAttributesTheSame(t *testing
client, err := provider.connect()
assert.NoError(t, err)
profile, err := provider.getUserProfile(client, "john@example.com")
profile, err := provider.getUserProfile(client, "john@example.com", false)
assert.NoError(t, err)
require.NotNil(t, profile)
@ -975,7 +975,7 @@ func TestShouldReturnBlankEmailAndDisplayNameWhenAttrsLenZero(t *testing.T) {
client, err := provider.connect()
assert.NoError(t, err)
profile, err := provider.getUserProfile(client, "john@example.com")
profile, err := provider.getUserProfile(client, "john@example.com", false)
assert.NoError(t, err)
require.NotNil(t, profile)
@ -1022,7 +1022,7 @@ func TestShouldCombineUsernameFilterAndUsersFilter(t *testing.T) {
Search(NewSearchRequestMatcher("(&(uid=john)(&(objectCategory=person)(objectClass=user)))")).
Return(&ldap.SearchResult{}, nil)
_, err := provider.getUserProfile(mockClient, "john")
_, err := provider.getUserProfile(mockClient, "john", false)
require.Error(t, err)
assert.EqualError(t, err, "user not found")
}
@ -3975,7 +3975,7 @@ func TestShouldReturnErrorWhenMultipleUsernameAttributes(t *testing.T) {
client, err := provider.connect()
assert.NoError(t, err)
profile, err := provider.getUserProfile(client, "john")
profile, err := provider.getUserProfile(client, "john", false)
assert.Nil(t, profile)
assert.EqualError(t, err, "user 'john' has 2 values for for attribute 'uid' but the attribute must be a single value attribute")
@ -4044,7 +4044,7 @@ func TestShouldReturnErrorWhenZeroUsernameAttributes(t *testing.T) {
client, err := provider.connect()
assert.NoError(t, err)
profile, err := provider.getUserProfile(client, "john")
profile, err := provider.getUserProfile(client, "john", false)
assert.Nil(t, profile)
assert.EqualError(t, err, "user 'john' must have value for attribute 'uid'")
@ -4109,7 +4109,7 @@ func TestShouldReturnErrorWhenUsernameAttributeNotReturned(t *testing.T) {
client, err := provider.connect()
assert.NoError(t, err)
profile, err := provider.getUserProfile(client, "john")
profile, err := provider.getUserProfile(client, "john", false)
assert.Nil(t, profile)
assert.EqualError(t, err, "user 'john' must have value for attribute 'uid'")
@ -4195,7 +4195,7 @@ func TestShouldReturnErrorWhenMultipleUsersFound(t *testing.T) {
client, err := provider.connect()
assert.NoError(t, err)
profile, err := provider.getUserProfile(client, "john")
profile, err := provider.getUserProfile(client, "john", false)
assert.Nil(t, profile)
assert.EqualError(t, err, "there were 2 users found when searching for 'john' but there should only be 1")
@ -4264,7 +4264,7 @@ func TestShouldReturnErrorWhenNoDN(t *testing.T) {
client, err := provider.connect()
assert.NoError(t, err)
profile, err := provider.getUserProfile(client, "john")
profile, err := provider.getUserProfile(client, "john", false)
assert.Nil(t, profile)
assert.EqualError(t, err, "user 'john' must have a distinguished name but the result returned an empty distinguished name")
@ -4581,7 +4581,7 @@ func TestShouldParseDynamicConfiguration(t *testing.T) {
assert.Equal(t, "ou=users,dc=example,dc=com", provider.usersBaseDN)
assert.Equal(t, "ou=groups,dc=example,dc=com", provider.groupsBaseDN)
assert.Equal(t, "(&(|(uid=test@example.com)(mail=test@example.com))(sAMAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(pwdLastSet=0))(|(!(accountExpires=*))(accountExpires=0)(accountExpires>=133147241190000000)(accountExpires>=20221205142839.0Z)))", provider.resolveUsersFilter("test@example.com"))
assert.Equal(t, "(&(|(uid=test@example.com)(mail=test@example.com))(sAMAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(pwdLastSet=0))(|(!(accountExpires=*))(accountExpires=0)(accountExpires>=133147241190000000)(accountExpires>=20221205142839.0Z)))", provider.resolveUsersFilter("test@example.com", false))
assert.Equal(t, "(&(|(member=cn=admin,dc=example,dc=com)(member=test@example.com)(member=test))(objectClass=group))", provider.resolveGroupsFilter("test@example.com", &ldapUserProfile{Username: "test", DN: "cn=admin,dc=example,dc=com"}))
}

View File

@ -421,6 +421,11 @@ authentication_backend:
## (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))
# users_filter: '(&({username_attribute}={input})(objectClass=person))'
## Defaults to the users_filter. This filter is used exclusively for password resets. So you can leverage it to
## prevent users who must change their password from logging in, but if you set a separate filter here they could
## theoretically still reset their password.
# users_reset_filter: '(&({username_attribute}={input})(objectClass=person))'
## The additional_groups_dn is prefixed to base_dn and delimited by a comma when searching for groups.
## i.e. with this set to OU=Groups and base_dn set to DC=a,DC=com; OU=Groups,DC=a,DC=com is searched for groups.
# additional_groups_dn: 'ou=groups'

View File

@ -105,6 +105,7 @@ type LDAPAuthenticationBackend struct {
AdditionalUsersDN string `koanf:"additional_users_dn"`
UsersFilter string `koanf:"users_filter"`
UsersResetFilter string `koanf:"users_reset_filter"`
AdditionalGroupsDN string `koanf:"additional_groups_dn"`
GroupsFilter string `koanf:"groups_filter"`

View File

@ -116,6 +116,7 @@ var Keys = []string{
"authentication_backend.ldap.base_dn",
"authentication_backend.ldap.additional_users_dn",
"authentication_backend.ldap.users_filter",
"authentication_backend.ldap.users_reset_filter",
"authentication_backend.ldap.additional_groups_dn",
"authentication_backend.ldap.groups_filter",
"authentication_backend.ldap.group_search_mode",

View File

@ -479,30 +479,45 @@ func validateLDAPRequiredParameters(config *schema.AuthenticationBackend, valida
if config.LDAP.UsersFilter == "" {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendMissingOption, "users_filter"))
} else {
if !strings.HasPrefix(config.LDAP.UsersFilter, "(") || !strings.HasSuffix(config.LDAP.UsersFilter, ")") {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterEnclosingParenthesis, "users_filter", config.LDAP.UsersFilter, config.LDAP.UsersFilter))
}
if !strings.Contains(config.LDAP.UsersFilter, "{username_attribute}") {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingPlaceholder, "users_filter", "username_attribute"))
}
// This test helps the user know that users_filter is broken after the breaking change induced by this commit.
if !strings.Contains(config.LDAP.UsersFilter, "{input}") {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingPlaceholder, "users_filter", "input"))
}
validateLDAPUsersFilter(config, "users_filter", config.LDAP.UsersFilter, validator)
}
if config.LDAP.UsersResetFilter != "" {
validateLDAPUsersFilter(config, "users_reset_filter", config.LDAP.UsersFilter, validator)
}
validateLDAPGroupFilter(config, validator)
}
func validateLDAPUsersFilter(config *schema.AuthenticationBackend, name, filter string, val *schema.StructValidator) {
if !strings.HasPrefix(filter, "(") || !strings.HasSuffix(filter, ")") {
val.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterEnclosingParenthesis, name, filter, filter))
}
if !strings.Contains(filter, "{username_attribute}") {
val.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingPlaceholder, name, "username_attribute"))
}
if !strings.Contains(filter, "{input}") {
val.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingPlaceholder, name, "input"))
}
if config.LDAP.Attributes.DistinguishedName == "" && strings.Contains(filter, "{distinguished_name_attribute}") {
val.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingAttribute, "distinguished_name", strJoinOr([]string{"{distinguished_name_attribute}"})))
}
if config.LDAP.Attributes.MemberOf == "" && strings.Contains(filter, "{member_of_attribute}") {
val.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingAttribute, "member_of", strJoinOr([]string{"{member_of_attribute}"})))
}
}
func validateLDAPGroupFilter(config *schema.AuthenticationBackend, validator *schema.StructValidator) {
if config.LDAP.GroupsFilter == "" {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendMissingOption, "groups_filter"))
} else if !strings.HasPrefix(config.LDAP.GroupsFilter, "(") || !strings.HasSuffix(config.LDAP.GroupsFilter, ")") {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterEnclosingParenthesis, "groups_filter", config.LDAP.GroupsFilter, config.LDAP.GroupsFilter))
}
validateLDAPGroupFilter(config, validator)
}
func validateLDAPGroupFilter(config *schema.AuthenticationBackend, validator *schema.StructValidator) {
if config.LDAP.GroupSearchMode == "" {
config.LDAP.GroupSearchMode = schema.LDAPGroupSearchModeFilter
}
@ -511,7 +526,7 @@ func validateLDAPGroupFilter(config *schema.AuthenticationBackend, validator *sc
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendOptionMustBeOneOf, "group_search_mode", strJoinOr(validLDAPGroupSearchModes), config.LDAP.GroupSearchMode))
}
pMemberOfDN, pMemberOfRDN := strings.Contains(config.LDAP.GroupsFilter, "{memberof:dn}"), strings.Contains(config.LDAP.GroupsFilter, "{memberof:rdn}")
pMemberOf, pMemberOfDN, pMemberOfRDN := strings.Contains(config.LDAP.GroupsFilter, "{member_of_attribute}"), strings.Contains(config.LDAP.GroupsFilter, "{memberof:dn}"), strings.Contains(config.LDAP.GroupsFilter, "{memberof:rdn}")
if config.LDAP.GroupSearchMode == schema.LDAPGroupSearchModeMemberOf {
if !pMemberOfDN && !pMemberOfRDN {
@ -523,7 +538,13 @@ func validateLDAPGroupFilter(config *schema.AuthenticationBackend, validator *sc
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingAttribute, "distinguished_name", strJoinOr([]string{"{memberof:dn}"})))
}
if (pMemberOfDN || pMemberOfRDN) && config.LDAP.Attributes.MemberOf == "" {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingAttribute, "member_of", strJoinOr([]string{"{memberof:rdn}", "{memberof:dn}"})))
if config.LDAP.Attributes.MemberOf == "" {
if pMemberOfDN || pMemberOfRDN {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingAttribute, "member_of", strJoinOr([]string{"{memberof:rdn}", "{memberof:dn}"})))
}
if pMemberOf {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendFilterMissingAttribute, "member_of", strJoinOr([]string{"{member_of_attribute}"})))
}
}
}