From aa64d0c4e542f0504d4388a6f3ac0fed83d3b696 Mon Sep 17 00:00:00 2001 From: Amir Zarrinkafsh Date: Fri, 27 Nov 2020 20:59:22 +1100 Subject: [PATCH] [FEATURE] Support MSAD password reset via unicodePwd attribute (#1460) * Added `ActiveDirectory` suite for integration tests with Samba AD * Updated documentation * Minor styling refactor to suites * Clean up LDAP user provisioning * Fix Authelia home splash to reference correct link for webmail * Add notification message for password complexity errors * Add password complexity integration test * Rename implementation default from rfc to custom * add specific defaults for LDAP (activedirectory implementation) * add docs to show the new defaults * add docs explaining the importance of users filter * add tests * update instances of LDAP implementation names to use the new consts where applicable * made the 'custom' case in the UpdatePassword method for the implementation switch the default case instead * update config examples due to the new defaults * apply changes from code review * replace schema default name from MSAD to ActiveDirectory for consistency * fix missing default for username_attribute * replace test raising on empty username attribute with not raising on empty Co-authored-by: James Elliott --- .buildkite/steps/e2etests.sh | 11 +- config.template.yml | 20 ++- docs/configuration/authentication/ldap.md | 89 +++++++++--- internal/authentication/ldap_user_provider.go | 12 +- .../configuration/schema/authentication.go | 13 ++ internal/configuration/schema/const.go | 6 + .../configuration/validator/authentication.go | 45 ++++++ .../validator/authentication_test.go | 135 +++++++++++++++--- internal/configuration/validator/const.go | 1 + internal/handlers/const.go | 2 + .../handlers/handler_reset_password_step2.go | 9 +- .../suites/ActiveDirectory/configuration.yml | 68 +++++++++ .../suites/ActiveDirectory/docker-compose.yml | 6 + internal/suites/action_reset_password.go | 13 +- internal/suites/environment.go | 17 ++- .../example/compose/ldap/ldif/base.ldif | 10 -- .../nginx/backend/html/home/index.html | 2 +- .../suites/example/compose/samba/Dockerfile | 12 ++ .../example/compose/samba/docker-compose.yml | 14 ++ internal/suites/example/compose/samba/init.sh | 103 +++++++++++++ .../scenario_password_complexity_test.go | 61 ++++++++ .../suites/scenario_reset_password_test.go | 4 +- internal/suites/suite_activedirectory.go | 62 ++++++++ internal/suites/suite_activedirectory_test.go | 39 +++++ internal/suites/suite_bypass_all.go | 2 +- internal/suites/suite_docker.go | 6 +- internal/suites/suite_duo_push.go | 2 +- internal/suites/suite_haproxy.go | 6 +- internal/suites/suite_high_availability.go | 2 +- internal/suites/suite_ldap.go | 6 +- internal/suites/suite_mariadb.go | 2 +- internal/suites/suite_mysql.go | 2 +- internal/suites/suite_network_acl.go | 2 +- internal/suites/suite_one_factor_only.go | 2 +- internal/suites/suite_pathprefix.go | 6 +- internal/suites/suite_postgres.go | 2 +- internal/suites/suite_short_timeouts.go | 2 +- internal/suites/suite_standalone.go | 6 +- internal/suites/suite_traefik.go | 6 +- internal/suites/suite_traefik2.go | 6 +- .../ResetPassword/ResetPasswordStep2.tsx | 6 +- 41 files changed, 721 insertions(+), 99 deletions(-) create mode 100644 internal/suites/ActiveDirectory/configuration.yml create mode 100644 internal/suites/ActiveDirectory/docker-compose.yml create mode 100644 internal/suites/example/compose/samba/Dockerfile create mode 100644 internal/suites/example/compose/samba/docker-compose.yml create mode 100755 internal/suites/example/compose/samba/init.sh create mode 100644 internal/suites/scenario_password_complexity_test.go create mode 100644 internal/suites/suite_activedirectory.go create mode 100644 internal/suites/suite_activedirectory_test.go diff --git a/.buildkite/steps/e2etests.sh b/.buildkite/steps/e2etests.sh index 1c3d5cc5..6c7ee297 100755 --- a/.buildkite/steps/e2etests.sh +++ b/.buildkite/steps/e2etests.sh @@ -8,15 +8,20 @@ cat << EOF retry: automatic: true EOF -if [[ "${SUITE_NAME}" != "Kubernetes" ]]; then +if [[ "${SUITE_NAME}" = "ActiveDirectory" ]]; then cat << EOF agents: - suite: "all" + suite: "activedirectory" EOF -else +elif [[ "${SUITE_NAME}" = "Kubernetes" ]]; then cat << EOF agents: suite: "kubernetes" EOF +else +cat << EOF + agents: + suite: "all" +EOF fi done \ No newline at end of file diff --git a/config.template.yml b/config.template.yml index 305317c5..d1dedcd2 100644 --- a/config.template.yml +++ b/config.template.yml @@ -93,6 +93,18 @@ authentication_backend: # than one instance and therefore is recommended for # production. ldap: + # The LDAP implementation, this affects elements like the attribute utilised for resetting a password. + # Acceptable options are as follows: + # - 'activedirectory' - For Microsoft Active Directory. + # - 'custom' - For custom specifications of attributes and filters. + # This currently defaults to 'custom' to maintain existing behaviour. + # + # Depending on the option here certain other values in this section have a default value, notably all + # of the attribute mappings have a default value that this config overrides, you can read more + # about these default values at https://docs.authelia.com/configuration/authentication/ldap.html#defaults + + implementation: custom + # The url to the ldap server. Scheme can be ldap:// or ldaps:// url: ldap://127.0.0.1 @@ -113,7 +125,7 @@ authentication_backend: # for that user. Technically, non-unique attributes like 'mail' can also be used but we don't recommend using # them, we instead advise to use the attributes mentioned above (sAMAccountName and uid) to follow # https://www.ietf.org/rfc/rfc2307.txt. - username_attribute: uid + # username_attribute: uid # An additional dn to define the scope to all users additional_users_dn: ou=users @@ -147,14 +159,14 @@ authentication_backend: groups_filter: (&(member={dn})(objectclass=groupOfNames)) # The attribute holding the name of the group - group_name_attribute: cn + # group_name_attribute: cn # The attribute holding the mail address of the user. If multiple email addresses are defined for a user, only the first # one returned by the LDAP server is used. - mail_attribute: mail + # mail_attribute: mail # The attribute holding the display name of the user. This will be used to greet an authenticated user. - display_name_attribute: displayname + # display_name_attribute: displayname # The username and password of the admin user. user: cn=admin,dc=example,dc=com diff --git a/docs/configuration/authentication/ldap.md b/docs/configuration/authentication/ldap.md index ef45523d..738d1ed1 100644 --- a/docs/configuration/authentication/ldap.md +++ b/docs/configuration/authentication/ldap.md @@ -15,6 +15,11 @@ nav_order: 2 Configuration of the LDAP backend is done as follows ```yaml +# The authentication backend to use for verifying user passwords +# and retrieve information such as email address and groups +# users belong to. +# +# There are two supported backends: 'ldap' and 'file'. authentication_backend: # Disable both the HTML element and the API for reset password functionality disable_reset_password: false @@ -28,16 +33,32 @@ authentication_backend: # Refresh Interval docs: https://docs.authelia.com/configuration/authentication/ldap.html#refresh-interval refresh_interval: 5m + # LDAP backend configuration. + # + # This backend allows Authelia to be scaled to more + # than one instance and therefore is recommended for + # production. ldap: + # The LDAP implementation, this affects elements like the attribute utilised for resetting a password. + # Acceptable options are as follows: + # - 'activedirectory' - For Microsoft Active Directory. + # - 'custom' - For custom specifications of attributes and filters. + # This currently defaults to 'custom' to maintain existing behaviour. + # + # Depending on the option here certain other values in this section have a default value, notably all + # of the attribute mappings have a default value that this config overrides, you can read more + # about these default values at https://docs.authelia.com/configuration/authentication/ldap.html#defaults + implementation: custom + # The url to the ldap server. Scheme can be ldap:// or ldaps:// url: ldap://127.0.0.1 - + # Skip verifying the server certificate (to allow self-signed certificate). skip_verify: false - + # The base dn for every entries base_dn: dc=example,dc=com - + # The attribute holding the username of the user. This attribute is used to populate # the username in the session information. It was introduced due to #561 to handle case # insensitive search queries. @@ -49,11 +70,11 @@ authentication_backend: # for that user. Technically, non-unique attributes like 'mail' can also be used but we don't recommend using # them, we instead advise to use the attributes mentioned above (sAMAccountName and uid) to follow # https://www.ietf.org/rfc/rfc2307.txt. - username_attribute: uid + # username_attribute: uid # An additional dn to define the scope to all users additional_users_dn: ou=users - + # The users filter used in search queries to find the user profile based on input filled in login form. # Various placeholders are available to represent the user input and back reference other options of the configuration: # - {input} is a placeholder replaced by what the user inputs in the login form. @@ -68,7 +89,7 @@ authentication_backend: # 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 additional_groups_dn: ou=groups @@ -81,20 +102,19 @@ authentication_backend: # - 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)) - - # The attribute holding the name of the group - group_name_attribute: cn - - # The attribute holding the mail address of the user - mail_attribute: mail - - # The attribute holding the display name of the user. This will be used to greet an authenticated user. - display_name_attribute: displayname - # The username and password of the admin user. If multiple email addresses are defined for a user, only the first + # The attribute holding the name of the group + # group_name_attribute: cn + + # The attribute holding the mail address of the user. If multiple email addresses are defined for a user, only the first # one returned by the LDAP server is used. + # mail_attribute: mail + + # The attribute holding the display name of the user. This will be used to greet an authenticated user. + # display_name_attribute: displayname + + # The username and password of the admin user. user: cn=admin,dc=example,dc=com - # Password can also be set using a secret: https://docs.authelia.com/configuration/secrets.html password: password ``` @@ -103,6 +123,39 @@ 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. +## Implementation + +There are currently two implementations, `custom` and `activedirectory`. The `activedirectory` implementation +must be used if you wish to allow users to change or reset their password as Active Directory +uses a custom attribute for this, and an input format other implementations do not use. The long term +intention of this is to have logical defaults for various RFC implementations of LDAP. + +### Defaults + +The below tables describes the current attribute defaults for each implementation. + +#### Attributes +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 | + +#### Filters + +The filters are probably the most important part to get correct when setting up LDAP. +You want to exclude disabled accounts. The active directory example has two attribute +filters that accomplish this as an example (more examples would be appreciated). The +userAccountControl filter checks that the account is not disabled and the pwdLastSet +makes sure that value is not 0 which means the password requires changing at the next login. + +|Implementation |Users Filter |Groups Filter| +|:-------------:|:------------:|:-----------:| +|custom |n/a |n/a | +|activedirectory|(&(|({username_attribute}={input})({mail_attribute}={input}))(objectCategory=person)(objectClass=user)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!pwdLastSet=0))|(&(member={dn})(objectClass=group)(objectCategory=group))| + ## Refresh Interval @@ -129,7 +182,7 @@ be guaranteed by the administrator to be unique. If multiple users have the same fail authenticating the user and display an error message in the logs. In order to avoid such problems, we highly recommended you follow https://www.ietf.org/rfc/rfc2307.txt by using -`sAMAccountName` for Microsoft Active Directory and `uid` for other implementations as the attribute holding the +`sAMAccountName` for Active Directory and `uid` for other implementations as the attribute holding the unique identifier for your users. ## Loading a password from a secret instead of inside the configuration diff --git a/internal/authentication/ldap_user_provider.go b/internal/authentication/ldap_user_provider.go index 3b6c0888..190ed497 100644 --- a/internal/authentication/ldap_user_provider.go +++ b/internal/authentication/ldap_user_provider.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/go-ldap/ldap/v3" + "golang.org/x/text/encoding/unicode" "github.com/authelia/authelia/internal/configuration/schema" "github.com/authelia/authelia/internal/logging" @@ -284,7 +285,16 @@ func (p *LDAPUserProvider) UpdatePassword(inputUsername string, newPassword stri modifyRequest := ldap.NewModifyRequest(profile.DN, nil) - modifyRequest.Replace("userPassword", []string{newPassword}) + switch p.configuration.Implementation { + case schema.LDAPImplementationActiveDirectory: + 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}) + default: + modifyRequest.Replace("userPassword", []string{newPassword}) + } err = client.Modify(modifyRequest) diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index fb2da365..3b836ba0 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -2,6 +2,7 @@ package schema // LDAPAuthenticationBackendConfiguration represents the configuration related to LDAP server. type LDAPAuthenticationBackendConfiguration struct { + Implementation string `mapstructure:"implementation"` URL string `mapstructure:"url"` SkipVerify bool `mapstructure:"skip_verify"` BaseDN string `mapstructure:"base_dn"` @@ -70,7 +71,19 @@ var DefaultPasswordSHA512Configuration = PasswordConfiguration{ // DefaultLDAPAuthenticationBackendConfiguration represents the default LDAP config. var DefaultLDAPAuthenticationBackendConfiguration = LDAPAuthenticationBackendConfiguration{ + Implementation: LDAPImplementationCustom, + UsernameAttribute: "uid", MailAttribute: "mail", DisplayNameAttribute: "displayname", GroupNameAttribute: "cn", } + +// DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration represents the default LDAP config for the MSAD Implementation. +var DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration = LDAPAuthenticationBackendConfiguration{ + UsersFilter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectCategory=person)(objectClass=user)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!pwdLastSet=0))", + UsernameAttribute: "sAMAccountName", + MailAttribute: "mail", + DisplayNameAttribute: "displayName", + GroupsFilter: "(&(member={dn})(objectClass=group))", + GroupNameAttribute: "cn", +} diff --git a/internal/configuration/schema/const.go b/internal/configuration/schema/const.go index 715862aa..013390c0 100644 --- a/internal/configuration/schema/const.go +++ b/internal/configuration/schema/const.go @@ -19,3 +19,9 @@ const RefreshIntervalDefault = "5m" // RefreshIntervalAlways represents the duration value refresh interval should have if set to always. const RefreshIntervalAlways = 0 * time.Millisecond + +// LDAPImplementationCustom is the string for the custom LDAP implementation. +const LDAPImplementationCustom = "custom" + +// LDAPImplementationActiveDirectory is the string for the Active Directory LDAP implementation. +const LDAPImplementationActiveDirectory = "activedirectory" diff --git a/internal/configuration/validator/authentication.go b/internal/configuration/validator/authentication.go index b11354f1..5082b0f1 100644 --- a/internal/configuration/validator/authentication.go +++ b/internal/configuration/validator/authentication.go @@ -100,6 +100,19 @@ func validateLdapURL(ldapURL string, validator *schema.StructValidator) string { //nolint:gocyclo // TODO: Consider refactoring/simplifying, time permitting. func validateLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationBackendConfiguration, validator *schema.StructValidator) { + if configuration.Implementation == "" { + configuration.Implementation = schema.DefaultLDAPAuthenticationBackendConfiguration.Implementation + } + + switch configuration.Implementation { + case schema.LDAPImplementationCustom: + setDefaultImplementationCustomLdapAuthenticationBackend(configuration) + case schema.LDAPImplementationActiveDirectory: + setDefaultImplementationActiveDirectoryLdapAuthenticationBackend(configuration) + default: + validator.Push(fmt.Errorf("authentication backend ldap implementation must be blank or one of the following values `%s`, `%s`", schema.LDAPImplementationCustom, schema.LDAPImplementationActiveDirectory)) + } + if configuration.URL == "" { validator.Push(errors.New("Please provide a URL to the LDAP server")) } else { @@ -143,6 +156,38 @@ func validateLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationB if configuration.UsernameAttribute == "" { validator.Push(errors.New("Please provide a username attribute with `username_attribute`")) } +} + +func setDefaultImplementationActiveDirectoryLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationBackendConfiguration) { + if configuration.UsersFilter == "" { + configuration.UsersFilter = schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.UsersFilter + } + + if configuration.UsernameAttribute == "" { + configuration.UsernameAttribute = schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.UsernameAttribute + } + + if configuration.DisplayNameAttribute == "" { + configuration.DisplayNameAttribute = schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.DisplayNameAttribute + } + + if configuration.MailAttribute == "" { + configuration.MailAttribute = schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.MailAttribute + } + + if configuration.GroupsFilter == "" { + configuration.GroupsFilter = schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.GroupsFilter + } + + if configuration.GroupNameAttribute == "" { + configuration.GroupNameAttribute = schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.GroupNameAttribute + } +} + +func setDefaultImplementationCustomLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationBackendConfiguration) { + if configuration.UsernameAttribute == "" { + configuration.UsernameAttribute = schema.DefaultLDAPAuthenticationBackendConfiguration.UsernameAttribute + } if configuration.GroupNameAttribute == "" { configuration.GroupNameAttribute = schema.DefaultLDAPAuthenticationBackendConfiguration.GroupNameAttribute diff --git a/internal/configuration/validator/authentication_test.go b/internal/configuration/validator/authentication_test.go index a6b91658..338b0d8b 100644 --- a/internal/configuration/validator/authentication_test.go +++ b/internal/configuration/validator/authentication_test.go @@ -16,7 +16,7 @@ func TestShouldRaiseErrorsWhenNoBackendProvided(t *testing.T) { ValidateAuthenticationBackend(&backendConfig, validator) - assert.Len(t, validator.Errors(), 1) + require.Len(t, validator.Errors(), 1) assert.EqualError(t, validator.Errors()[0], "Please provide `ldap` or `file` object in `authentication_backend`") } @@ -47,7 +47,7 @@ func (suite *FileBasedAuthenticationBackend) TestShouldValidateCompleteConfigura func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenNoPathProvided() { suite.configuration.File.Path = "" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a `path` for the users database in `authentication_backend`") } @@ -55,7 +55,7 @@ func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenMemoryNotMo suite.configuration.File.Password.Memory = 8 suite.configuration.File.Password.Parallelism = 2 ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Memory for argon2id must be 16 or more (parallelism * 8), you configured memory as 8 and parallelism as 2") } @@ -98,35 +98,35 @@ func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultConfigurationWh func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenKeyLengthTooLow() { suite.configuration.File.Password.KeyLength = 1 ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Key length for argon2id must be 16, you configured 1") } func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenSaltLengthTooLow() { suite.configuration.File.Password.SaltLength = -1 ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "The salt length must be 2 or more, you configured -1") } func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenBadAlgorithmDefined() { suite.configuration.File.Password.Algorithm = "bogus" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Unknown hashing algorithm supplied, valid values are argon2id and sha512, you configured 'bogus'") } func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenIterationsTooLow() { suite.configuration.File.Password.Iterations = -1 ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "The number of iterations specified is invalid, must be 1 or more, you configured -1") } func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenParallelismTooLow() { suite.configuration.File.Password.Parallelism = -1 ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Parallelism for argon2id must be 1 or more, you configured -1") } @@ -159,6 +159,7 @@ func (suite *LdapAuthenticationBackendSuite) SetupTest() { suite.validator = schema.NewStructValidator() suite.configuration = schema.AuthenticationBackendConfiguration{} suite.configuration.Ldap = &schema.LDAPAuthenticationBackendConfiguration{} + suite.configuration.Ldap.Implementation = schema.LDAPImplementationCustom suite.configuration.Ldap.URL = "ldap://ldap" suite.configuration.Ldap.User = "user" suite.configuration.Ldap.Password = "password" @@ -173,31 +174,38 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldValidateCompleteConfigura assert.Len(suite.T(), suite.validator.Errors(), 0) } +func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseErrorWhenImplementationIsInvalidMSAD() { + suite.configuration.Ldap.Implementation = "masd" + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + require.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "authentication backend ldap implementation must be blank or one of the following values `custom`, `activedirectory`") +} + func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseErrorWhenURLNotProvided() { suite.configuration.Ldap.URL = "" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a URL to the LDAP server") } func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseErrorWhenUserNotProvided() { suite.configuration.Ldap.User = "" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a user name to connect to the LDAP server") } func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseErrorWhenPasswordNotProvided() { suite.configuration.Ldap.Password = "" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a password to connect to the LDAP server") } func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseErrorWhenBaseDNNotProvided() { suite.configuration.Ldap.BaseDN = "" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.Len(suite.T(), suite.validator.Errors(), 1) assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a base DN to connect to the LDAP server") } @@ -215,11 +223,10 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnEmptyUsersFilter() assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a users filter with `users_filter` attribute") } -func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnEmptyUsernameAttribute() { +func (suite *LdapAuthenticationBackendSuite) TestShouldNotRaiseOnEmptyUsernameAttribute() { suite.configuration.Ldap.UsernameAttribute = "" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - require.Len(suite.T(), suite.validator.Errors(), 1) - assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a username attribute with `username_attribute`") + assert.Len(suite.T(), suite.validator.Errors(), 0) } func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnBadRefreshInterval() { @@ -229,6 +236,12 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnBadRefreshInterval assert.EqualError(suite.T(), suite.validator.Errors()[0], "Auth Backend `refresh_interval` is configured to 'blah' but it must be either a duration notation or one of 'disable', or 'always'. Error from parser: Could not convert the input string of blah into a duration") } +func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultImplementation() { + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), schema.LDAPImplementationCustom, suite.configuration.Ldap.Implementation) +} + func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultGroupNameAttribute() { ValidateAuthenticationBackend(&suite.configuration, suite.validator) assert.Len(suite.T(), suite.validator.Errors(), 0) @@ -237,34 +250,40 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultGroupNameAttrib func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultMailAttribute() { ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 0) + require.Len(suite.T(), suite.validator.Errors(), 0) assert.Equal(suite.T(), "mail", suite.configuration.Ldap.MailAttribute) } +func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultDisplayNameAttribute() { + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + require.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), "displayname", suite.configuration.Ldap.DisplayNameAttribute) +} + func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultRefreshInterval() { ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 0) + require.Len(suite.T(), suite.validator.Errors(), 0) assert.Equal(suite.T(), "5m", suite.configuration.RefreshInterval) } func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseWhenUsersFilterDoesNotContainEnclosingParenthesis() { suite.configuration.Ldap.UsersFilter = "uid={input}" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.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={input} should be (uid={input})") } func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseWhenGroupsFilterDoesNotContainEnclosingParenthesis() { suite.configuration.Ldap.GroupsFilter = "cn={input}" ValidateAuthenticationBackend(&suite.configuration, suite.validator) - assert.Len(suite.T(), suite.validator.Errors(), 1) + require.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={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) + require.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") } @@ -289,3 +308,79 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldAdaptLDAPURL() { func TestLdapAuthenticationBackend(t *testing.T) { suite.Run(t, new(LdapAuthenticationBackendSuite)) } + +type ActiveDirectoryAuthenticationBackendSuite struct { + suite.Suite + configuration schema.AuthenticationBackendConfiguration + validator *schema.StructValidator +} + +func (suite *ActiveDirectoryAuthenticationBackendSuite) SetupTest() { + suite.validator = schema.NewStructValidator() + suite.configuration = schema.AuthenticationBackendConfiguration{} + suite.configuration.Ldap = &schema.LDAPAuthenticationBackendConfiguration{} + suite.configuration.Ldap.Implementation = schema.LDAPImplementationActiveDirectory + suite.configuration.Ldap.URL = "ldap://ldap" + suite.configuration.Ldap.User = "user" + suite.configuration.Ldap.Password = "password" + suite.configuration.Ldap.BaseDN = "base_dn" +} + +func (suite *ActiveDirectoryAuthenticationBackendSuite) TestShouldSetActiveDirectoryDefaults() { + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + + assert.Len(suite.T(), suite.validator.Errors(), 0) + + assert.Equal(suite.T(), + suite.configuration.Ldap.UsersFilter, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.UsersFilter) + assert.Equal(suite.T(), + suite.configuration.Ldap.UsernameAttribute, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.UsernameAttribute) + assert.Equal(suite.T(), + suite.configuration.Ldap.DisplayNameAttribute, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.DisplayNameAttribute) + assert.Equal(suite.T(), + suite.configuration.Ldap.MailAttribute, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.MailAttribute) + assert.Equal(suite.T(), + suite.configuration.Ldap.GroupsFilter, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.GroupsFilter) + assert.Equal(suite.T(), + suite.configuration.Ldap.GroupNameAttribute, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.GroupNameAttribute) +} + +func (suite *ActiveDirectoryAuthenticationBackendSuite) TestShouldOnlySetDefaultsIfNotManuallyConfigured() { + suite.configuration.Ldap.UsersFilter = "(&({username_attribute}={input})(objectCategory=person)(objectClass=user)(!userAccountControl:1.2.840.113556.1.4.803:=2))" + suite.configuration.Ldap.UsernameAttribute = "cn" + suite.configuration.Ldap.MailAttribute = "userPrincipalName" + suite.configuration.Ldap.DisplayNameAttribute = "name" + suite.configuration.Ldap.GroupsFilter = "(&(member={dn})(objectClass=group)(objectCategory=group))" + suite.configuration.Ldap.GroupNameAttribute = "distinguishedName" + + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + + assert.NotEqual(suite.T(), + suite.configuration.Ldap.UsersFilter, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.UsersFilter) + assert.NotEqual(suite.T(), + suite.configuration.Ldap.UsernameAttribute, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.UsernameAttribute) + assert.NotEqual(suite.T(), + suite.configuration.Ldap.DisplayNameAttribute, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.DisplayNameAttribute) + assert.NotEqual(suite.T(), + suite.configuration.Ldap.MailAttribute, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.MailAttribute) + assert.NotEqual(suite.T(), + suite.configuration.Ldap.GroupsFilter, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.GroupsFilter) + assert.NotEqual(suite.T(), + suite.configuration.Ldap.GroupNameAttribute, + schema.DefaultLDAPAuthenticationBackendImplementationActiveDirectoryConfiguration.GroupNameAttribute) +} + +func TestActiveDirectoryAuthenticationBackend(t *testing.T) { + suite.Run(t, new(ActiveDirectoryAuthenticationBackendSuite)) +} diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 461e0c71..45a67074 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -91,6 +91,7 @@ var validKeys = []string{ "authentication_backend.refresh_interval", // LDAP Authentication Backend Keys. + "authentication_backend.ldap.implementation", "authentication_backend.ldap.url", "authentication_backend.ldap.skip_verify", "authentication_backend.ldap.base_dn", diff --git a/internal/handlers/const.go b/internal/handlers/const.go index 6a8654b9..370de9f6 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -37,6 +37,8 @@ const unableToRegisterSecurityKeyMessage = "Unable to register your security key const unableToResetPasswordMessage = "Unable to reset your password." const mfaValidationFailedMessage = "Authentication failed, please retry later." +const ldapPasswordComplexityCode = "0000052D" + const testInactivity = "10" const testRedirectionURL = "http://redirection.local" const testResultAllow = "allow" diff --git a/internal/handlers/handler_reset_password_step2.go b/internal/handlers/handler_reset_password_step2.go index 9e615cf4..30487538 100644 --- a/internal/handlers/handler_reset_password_step2.go +++ b/internal/handlers/handler_reset_password_step2.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "strings" "github.com/authelia/authelia/internal/middlewares" ) @@ -29,7 +30,13 @@ func ResetPasswordPost(ctx *middlewares.AutheliaCtx) { err = ctx.Providers.UserProvider.UpdatePassword(*userSession.PasswordResetUsername, requestBody.Password) if err != nil { - ctx.Error(fmt.Errorf("Unable to update password: %s", err), unableToResetPasswordMessage) + switch { + case strings.Contains(err.Error(), ldapPasswordComplexityCode): + ctx.Error(fmt.Errorf("%s", err), ldapPasswordComplexityCode) + default: + ctx.Error(fmt.Errorf("%s", err), unableToResetPasswordMessage) + } + return } diff --git a/internal/suites/ActiveDirectory/configuration.yml b/internal/suites/ActiveDirectory/configuration.yml new file mode 100644 index 00000000..363a2b37 --- /dev/null +++ b/internal/suites/ActiveDirectory/configuration.yml @@ -0,0 +1,68 @@ +############################################################### +# Authelia minimal configuration # +############################################################### + +port: 9091 +tls_cert: /config/ssl/cert.pem +tls_key: /config/ssl/key.pem + +log_level: debug + +default_redirection_url: https://home.example.com:8080/ + +jwt_secret: very_important_secret + +authentication_backend: + ldap: + implementation: activedirectory + url: ldaps://sambaldap + skip_verify: true + base_dn: DC=example,DC=com + username_attribute: sAMAccountName + additional_users_dn: OU=Users + users_filter: (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person)(objectClass=user)) + additional_groups_dn: OU=Groups + groups_filter: (&(member={dn})(objectClass=group)) + group_name_attribute: cn + mail_attribute: mail + display_name_attribute: displayName + user: CN=Administrator,CN=Users,DC=example,DC=com + password: password + +session: + secret: unsecure_session_secret + domain: example.com + expiration: 3600 # 1 hour + inactivity: 300 # 5 minutes + remember_me_duration: 1y + +storage: + local: + path: /config/db.sqlite3 + +totp: + issuer: example.com + +access_control: + default_policy: deny + rules: + - domain: "public.example.com" + policy: bypass + - domain: "admin.example.com" + policy: two_factor + - domain: "secure.example.com" + policy: two_factor + - domain: "singlefactor.example.com" + policy: one_factor + +regulation: + max_retries: 3 + find_time: 300 + ban_time: 900 + +notifier: + smtp: + host: smtp + port: 1025 + sender: admin@example.com + disable_require_tls: true \ No newline at end of file diff --git a/internal/suites/ActiveDirectory/docker-compose.yml b/internal/suites/ActiveDirectory/docker-compose.yml new file mode 100644 index 00000000..b9937345 --- /dev/null +++ b/internal/suites/ActiveDirectory/docker-compose.yml @@ -0,0 +1,6 @@ +version: '3' +services: + authelia-backend: + volumes: + - './ActiveDirectory/configuration.yml:/config/configuration.yml:ro' + - './common/ssl:/config/ssl:ro' \ No newline at end of file diff --git a/internal/suites/action_reset_password.go b/internal/suites/action_reset_password.go index ae6d9184..4d20bdeb 100644 --- a/internal/suites/action_reset_password.go +++ b/internal/suites/action_reset_password.go @@ -27,9 +27,18 @@ func (wds *WebDriverSession) doSuccessfullyCompletePasswordReset(ctx context.Con wds.verifyIsFirstFactorPage(ctx, t) } -func (wds *WebDriverSession) doResetPassword(ctx context.Context, t *testing.T, username, newPassword1, newPassword2 string) { +func (wds *WebDriverSession) doUnsuccessfulPasswordReset(ctx context.Context, t *testing.T, newPassword1, newPassword2 string) { + wds.doCompletePasswordReset(ctx, t, newPassword1, newPassword2) +} + +func (wds *WebDriverSession) doResetPassword(ctx context.Context, t *testing.T, username, newPassword1, newPassword2 string, unsuccessful bool) { wds.doInitiatePasswordReset(ctx, t, username) // then wait for the "email sent notification" wds.verifyMailNotificationDisplayed(ctx, t) - wds.doSuccessfullyCompletePasswordReset(ctx, t, newPassword1, newPassword2) + + if unsuccessful { + wds.doUnsuccessfulPasswordReset(ctx, t, newPassword1, newPassword2) + } else { + wds.doSuccessfullyCompletePasswordReset(ctx, t, newPassword1, newPassword2) + } } diff --git a/internal/suites/environment.go b/internal/suites/environment.go index 70498327..5649c1aa 100644 --- a/internal/suites/environment.go +++ b/internal/suites/environment.go @@ -58,7 +58,16 @@ func waitUntilAutheliaFrontendIsReady(dockerEnvironment *DockerEnvironment) erro []string{"You can now view web in the browser.", "Compiled with warnings", "Compiled successfully!"}) } -func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment) error { +func waitUntilSambaIsReady(dockerEnvironment *DockerEnvironment) error { + return waitUntilServiceLogDetected( + 5*time.Second, + 90*time.Second, + dockerEnvironment, + "sambaldap", + []string{"samba entered RUNNING state"}) +} + +func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment, suite string) error { log.Info("Waiting for Authelia to be ready...") if err := waitUntilAutheliaBackendIsReady(dockerEnvironment); err != nil { @@ -71,6 +80,12 @@ func waitUntilAutheliaIsReady(dockerEnvironment *DockerEnvironment) error { } } + if suite == "ActiveDirectory" { + if err := waitUntilSambaIsReady(dockerEnvironment); err != nil { + return err + } + } + log.Info("Authelia is now ready!") return nil diff --git a/internal/suites/example/compose/ldap/ldif/base.ldif b/internal/suites/example/compose/ldap/ldif/base.ldif index 5e267486..f3cc5822 100644 --- a/internal/suites/example/compose/ldap/ldif/base.ldif +++ b/internal/suites/example/compose/ldap/ldif/base.ldif @@ -65,13 +65,3 @@ sn: Dean uid: james userpassword: {CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/ -dn: cn=Billy Blackhat,ou=users,dc=example,dc=com -cn: Billy Blackhat -displayname: Billy Blackhat -givenName: Billy -objectclass: inetOrgPerson -objectclass: top -mail: billy.blackhat@authelia.com -sn: BlackHat -uid: blackhat -userpassword: {CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/ diff --git a/internal/suites/example/compose/nginx/backend/html/home/index.html b/internal/suites/example/compose/nginx/backend/html/home/index.html index ec4b95e2..1178229a 100644 --- a/internal/suites/example/compose/nginx/backend/html/home/index.html +++ b/internal/suites/example/compose/nginx/backend/html/home/index.html @@ -68,7 +68,7 @@
Once first factor is passed, you will need to follow the links to register a secret for the second factor.
Authelia will send you a fictitious email in a fake webmail at http://localhost:8085.
+ href="https://mail.example.com:8080/">https://mail.example.com:8080/.
It will provide you with the link to complete the registration allowing you to authenticate with 2-factor.