feat(configuration): configurable default second factor method (#3081)

This allows configuring the default second factor method.
pull/3145/head^2
James Elliott 2022-04-18 09:58:24 +10:00 committed by GitHub
parent 9bd10e6409
commit e99fb7a08f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 353 additions and 319 deletions

View File

@ -27,6 +27,11 @@ jwt_secret: a_very_important_secret
## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication. ## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication.
default_redirection_url: https://home.example.com/ default_redirection_url: https://home.example.com/
## Set the default 2FA method for new users and for when a user has a preferred method configured that has been
## disabled. This setting must be a method that is enabled.
## Options are totp, webauthn, mobile_push.
default_2fa_method: ""
## ##
## Server Configuration ## Server Configuration
## ##

View File

@ -59,3 +59,28 @@ redirected to that URL. If not defined, the user is not redirected after authent
```yaml ```yaml
default_redirection_url: https://home.example.com:8080/ default_redirection_url: https://home.example.com:8080/
``` ```
## default_2fa_method
<div markdown="1">
type: string
{: .label .label-config .label-purple }
default: ""
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
Sets the default second factor method for users. This must be blank or one of the enabled methods. New users will by
default have this method selected for them. In addition if this was configured to `webauthn` and a user had the `totp`
method, and the `totp` method was disabled in the configuration, the users' method would automatically update to the
`webauthn` method.
Options are:
- totp
- webauthn
- mobile_push
```yaml
default_2fa_method: totp
```

View File

@ -2,7 +2,7 @@
layout: default layout: default
title: Password Policy title: Password Policy
parent: Configuration parent: Configuration
nav_order: 17 nav_order: 18
--- ---
# Password Policy # Password Policy

View File

@ -28,21 +28,21 @@ Here is the list of the environment variables which are considered secrets and c
secrets can be loaded into the configuration if they end with one of the suffixes above, you can set the value of any secrets can be loaded into the configuration if they end with one of the suffixes above, you can set the value of any
other configuration using the environment but instead of loading a file the value of the environment variable is used. other configuration using the environment but instead of loading a file the value of the environment variable is used.
|Configuration Key |Environment Variable | | Configuration Key | Environment Variable |
|:-----------------------------------------------:|:------------------------------------------------------:| |:-------------------------------------------------:|:--------------------------------------------------------:|
|tls_key |AUTHELIA_TLS_KEY_FILE | | tls_key | AUTHELIA_TLS_KEY_FILE |
|jwt_secret |AUTHELIA_JWT_SECRET_FILE | | jwt_secret | AUTHELIA_JWT_SECRET_FILE |
|duo_api.secret_key |AUTHELIA_DUO_API_SECRET_KEY_FILE | | duo_api.secret_key | AUTHELIA_DUO_API_SECRET_KEY_FILE |
|session.secret |AUTHELIA_SESSION_SECRET_FILE | | session.secret | AUTHELIA_SESSION_SECRET_FILE |
|session.redis.password |AUTHELIA_SESSION_REDIS_PASSWORD_FILE | | session.redis.password | AUTHELIA_SESSION_REDIS_PASSWORD_FILE |
|session.redis.high_availability.sentinel_password|AUTHELIA_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE | | session.redis.high_availability.sentinel_password | AUTHELIA_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE |
|storage.encryption_key |AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE | | storage.encryption_key | AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE |
|storage.mysql.password |AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE | | storage.mysql.password | AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE |
|storage.postgres.password |AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE | | storage.postgres.password | AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE |
|notifier.smtp.password |AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE | | notifier.smtp.password | AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE |
|authentication_backend.ldap.password |AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE | | authentication_backend.ldap.password | AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE |
|identity_providers.oidc.issuer_private_key |AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE| | identity_providers.oidc.issuer_private_key | AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE |
|identity_providers.oidc.hmac_secret |AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE | | identity_providers.oidc.hmac_secret | AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE |
## Secrets in configuration file ## Secrets in configuration file

View File

@ -2,7 +2,7 @@
layout: default layout: default
title: Webauthn title: Webauthn
parent: Configuration parent: Configuration
nav_order: 16 nav_order: 17
--- ---
The Webauthn section has tunable options for the Webauthn implementation. The Webauthn section has tunable options for the Webauthn implementation.

View File

@ -27,6 +27,11 @@ jwt_secret: a_very_important_secret
## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication. ## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication.
default_redirection_url: https://home.example.com/ default_redirection_url: https://home.example.com/
## Set the default 2FA method for new users and for when a user has a preferred method configured that has been
## disabled. This setting must be a method that is enabled.
## Options are totp, webauthn, mobile_push.
default_2fa_method: ""
## ##
## Server Configuration ## Server Configuration
## ##

View File

@ -6,6 +6,7 @@ type Configuration struct {
CertificatesDirectory string `koanf:"certificates_directory"` CertificatesDirectory string `koanf:"certificates_directory"`
JWTSecret string `koanf:"jwt_secret"` JWTSecret string `koanf:"jwt_secret"`
DefaultRedirectionURL string `koanf:"default_redirection_url"` DefaultRedirectionURL string `koanf:"default_redirection_url"`
Default2FAMethod string `koanf:"default_2fa_method"`
Log LogConfiguration `koanf:"log"` Log LogConfiguration `koanf:"log"`
IdentityProviders IdentityProvidersConfiguration `koanf:"identity_providers"` IdentityProviders IdentityProvidersConfiguration `koanf:"identity_providers"`

View File

@ -12,6 +12,7 @@ var Keys = []string{
"certificates_directory", "certificates_directory",
"jwt_secret", "jwt_secret",
"default_redirection_url", "default_redirection_url",
"default_2fa_method",
"log.level", "log.level",
"log.format", "log.format",
"log.file_path", "log.file_path",

View File

@ -1,253 +0,0 @@
package schema
import (
"testing"
"github.com/stretchr/testify/assert"
)
var ValidKeys = []string{
// Root Keys.
"certificates_directory",
"theme",
"default_redirection_url",
"jwt_secret",
// Log keys.
"log.level",
"log.format",
"log.file_path",
"log.keep_stdout",
// Server Keys.
"server.host",
"server.port",
"server.read_buffer_size",
"server.write_buffer_size",
"server.path",
"server.asset_path",
"server.enable_pprof",
"server.enable_expvars",
"server.disable_healthcheck",
"server.tls.key",
"server.tls.certificate",
"server.tls.client_certificates",
"server.headers.csp_template",
// TOTP Keys.
"totp.disable",
"totp.issuer",
"totp.algorithm",
"totp.digits",
"totp.period",
"totp.skew",
"totp.secret_size",
// Webauthn Keys.
"webauthn.disable",
"webauthn.display_name",
"webauthn.attestation_conveyance_preference",
"webauthn.user_verification",
"webauthn.timeout",
// DUO API Keys.
"duo_api.disable",
"duo_api.hostname",
"duo_api.enable_self_enrollment",
"duo_api.secret_key",
"duo_api.integration_key",
// Access Control Keys.
"access_control.default_policy",
"access_control.networks",
"access_control.networks[].name",
"access_control.networks[].networks",
"access_control.rules",
"access_control.rules[].domain",
"access_control.rules[].domain_regex",
"access_control.rules[].methods",
"access_control.rules[].networks",
"access_control.rules[].subject",
"access_control.rules[].policy",
"access_control.rules[].resources",
// Session Keys.
"session.name",
"session.domain",
"session.secret",
"session.same_site",
"session.expiration",
"session.inactivity",
"session.remember_me_duration",
// Redis Session Keys.
"session.redis.host",
"session.redis.port",
"session.redis.username",
"session.redis.password",
"session.redis.database_index",
"session.redis.maximum_active_connections",
"session.redis.minimum_idle_connections",
"session.redis.tls.minimum_version",
"session.redis.tls.skip_verify",
"session.redis.tls.server_name",
"session.redis.high_availability.sentinel_name",
"session.redis.high_availability.sentinel_username",
"session.redis.high_availability.sentinel_password",
"session.redis.high_availability.nodes",
"session.redis.high_availability.nodes[].host",
"session.redis.high_availability.nodes[].port",
"session.redis.high_availability.route_by_latency",
"session.redis.high_availability.route_randomly",
// Storage Keys.
"storage.encryption_key",
// Local Storage Keys.
"storage.local.path",
// MySQL Storage Keys.
"storage.mysql.host",
"storage.mysql.port",
"storage.mysql.database",
"storage.mysql.username",
"storage.mysql.password",
"storage.mysql.timeout",
// PostgreSQL Storage Keys.
"storage.postgres.host",
"storage.postgres.port",
"storage.postgres.database",
"storage.postgres.username",
"storage.postgres.password",
"storage.postgres.timeout",
"storage.postgres.schema",
"storage.postgres.ssl.mode",
"storage.postgres.ssl.root_certificate",
"storage.postgres.ssl.certificate",
"storage.postgres.ssl.key",
"storage.postgres.sslmode", // Deprecated. TODO: Remove in v4.36.0.
// FileSystem Notifier Keys.
"notifier.filesystem.filename",
"notifier.disable_startup_check",
// SMTP Notifier Keys.
"notifier.smtp.host",
"notifier.smtp.port",
"notifier.smtp.timeout",
"notifier.smtp.username",
"notifier.smtp.password",
"notifier.smtp.identifier",
"notifier.smtp.sender",
"notifier.smtp.subject",
"notifier.smtp.startup_check_address",
"notifier.smtp.disable_require_tls",
"notifier.smtp.disable_html_emails",
"notifier.smtp.tls.minimum_version",
"notifier.smtp.tls.skip_verify",
"notifier.smtp.tls.server_name",
"notifier.template_path",
// Regulation Keys.
"regulation.max_retries",
"regulation.find_time",
"regulation.ban_time",
// Authentication Backend Keys.
"authentication_backend.disable_reset_password",
"authentication_backend.password_reset.custom_url",
"authentication_backend.refresh_interval",
// LDAP Authentication Backend Keys.
"authentication_backend.ldap.implementation",
"authentication_backend.ldap.url",
"authentication_backend.ldap.timeout",
"authentication_backend.ldap.base_dn",
"authentication_backend.ldap.username_attribute",
"authentication_backend.ldap.additional_users_dn",
"authentication_backend.ldap.users_filter",
"authentication_backend.ldap.additional_groups_dn",
"authentication_backend.ldap.groups_filter",
"authentication_backend.ldap.group_name_attribute",
"authentication_backend.ldap.mail_attribute",
"authentication_backend.ldap.display_name_attribute",
"authentication_backend.ldap.user",
"authentication_backend.ldap.password",
"authentication_backend.ldap.start_tls",
"authentication_backend.ldap.tls.minimum_version",
"authentication_backend.ldap.tls.skip_verify",
"authentication_backend.ldap.tls.server_name",
// File Authentication Backend Keys.
"authentication_backend.file.path",
"authentication_backend.file.password.algorithm",
"authentication_backend.file.password.iterations",
"authentication_backend.file.password.key_length",
"authentication_backend.file.password.salt_length",
"authentication_backend.file.password.memory",
"authentication_backend.file.password.parallelism",
// Identity Provider Keys.
"identity_providers.oidc.hmac_secret",
"identity_providers.oidc.issuer_private_key",
"identity_providers.oidc.id_token_lifespan",
"identity_providers.oidc.access_token_lifespan",
"identity_providers.oidc.refresh_token_lifespan",
"identity_providers.oidc.authorize_code_lifespan",
"identity_providers.oidc.enforce_pkce",
"identity_providers.oidc.enable_pkce_plain_challenge",
"identity_providers.oidc.enable_client_debug_messages",
"identity_providers.oidc.minimum_parameter_entropy",
"identity_providers.oidc.cors.endpoints",
"identity_providers.oidc.cors.allowed_origins",
"identity_providers.oidc.cors.allowed_origins_from_client_redirect_uris",
"identity_providers.oidc.clients",
"identity_providers.oidc.clients[].id",
"identity_providers.oidc.clients[].description",
"identity_providers.oidc.clients[].secret",
"identity_providers.oidc.clients[].sector_identifier",
"identity_providers.oidc.clients[].public",
"identity_providers.oidc.clients[].redirect_uris",
"identity_providers.oidc.clients[].authorization_policy",
"identity_providers.oidc.clients[].pre_configured_consent_duration",
"identity_providers.oidc.clients[].scopes",
"identity_providers.oidc.clients[].audience",
"identity_providers.oidc.clients[].grant_types",
"identity_providers.oidc.clients[].response_types",
"identity_providers.oidc.clients[].response_modes",
"identity_providers.oidc.clients[].userinfo_signing_algorithm",
// NTP keys.
"ntp.address",
"ntp.version",
"ntp.max_desync",
"ntp.disable_startup_check",
"ntp.disable_failure",
// Password Policy keys.
"password_policy.standard.enabled",
"password_policy.standard.min_length",
"password_policy.standard.max_length",
"password_policy.standard.require_uppercase",
"password_policy.standard.require_lowercase",
"password_policy.standard.require_number",
"password_policy.standard.require_special",
"password_policy.zxcvbn.enabled",
"password_policy.zxcvbn.min_score",
}
func TestOldKeys(t *testing.T) {
for _, key := range ValidKeys {
assert.Contains(t, Keys, key)
}
for _, key := range Keys {
assert.Contains(t, ValidKeys, key)
}
}
func TestDuplicates(t *testing.T) {
assert.Equal(t, len(Keys), len(ValidKeys))
}

View File

@ -33,6 +33,8 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc
} }
} }
validateDefault2FAMethod(config, validator)
ValidateTheme(config, validator) ValidateTheme(config, validator)
ValidateLog(config, validator) ValidateLog(config, validator)
@ -65,3 +67,33 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc
ValidatePasswordPolicy(&config.PasswordPolicy, validator) ValidatePasswordPolicy(&config.PasswordPolicy, validator)
} }
func validateDefault2FAMethod(config *schema.Configuration, validator *schema.StructValidator) {
if config.Default2FAMethod == "" {
return
}
if !utils.IsStringInSlice(config.Default2FAMethod, validDefault2FAMethods) {
validator.Push(fmt.Errorf(errFmtInvalidDefault2FAMethod, config.Default2FAMethod, strings.Join(validDefault2FAMethods, "', '")))
return
}
var enabledMethods []string
if !config.TOTP.Disable {
enabledMethods = append(enabledMethods, "totp")
}
if !config.Webauthn.Disable {
enabledMethods = append(enabledMethods, "webauthn")
}
if !config.DuoAPI.Disable {
enabledMethods = append(enabledMethods, "mobile_push")
}
if !utils.IsStringInSlice(config.Default2FAMethod, enabledMethods) {
validator.Push(fmt.Errorf(errFmtInvalidDefault2FAMethodDisabled, config.Default2FAMethod, strings.Join(enabledMethods, "', '")))
}
}

View File

@ -1,6 +1,7 @@
package validator package validator
import ( import (
"fmt"
"runtime" "runtime"
"testing" "testing"
@ -158,3 +159,114 @@ func TestShouldNotRaiseErrorOnValidCertificatesDirectory(t *testing.T) {
assert.EqualError(t, validator.Warnings()[0], "access control: no rules have been specified so the 'default_policy' of 'two_factor' is going to be applied to all requests") assert.EqualError(t, validator.Warnings()[0], "access control: no rules have been specified so the 'default_policy' of 'two_factor' is going to be applied to all requests")
} }
func TestValidateDefault2FAMethod(t *testing.T) {
testCases := []struct {
desc string
have *schema.Configuration
expectedErrs []string
}{
{
desc: "ShouldAllowConfiguredMethodTOTP",
have: &schema.Configuration{
Default2FAMethod: "totp",
DuoAPI: schema.DuoAPIConfiguration{
SecretKey: "a key",
IntegrationKey: "another key",
Hostname: "none",
},
},
},
{
desc: "ShouldAllowConfiguredMethodWebauthn",
have: &schema.Configuration{
Default2FAMethod: "webauthn",
DuoAPI: schema.DuoAPIConfiguration{
SecretKey: "a key",
IntegrationKey: "another key",
Hostname: "none",
},
},
},
{
desc: "ShouldAllowConfiguredMethodMobilePush",
have: &schema.Configuration{
Default2FAMethod: "mobile_push",
DuoAPI: schema.DuoAPIConfiguration{
SecretKey: "a key",
IntegrationKey: "another key",
Hostname: "none",
},
},
},
{
desc: "ShouldNotAllowDisabledMethodTOTP",
have: &schema.Configuration{
Default2FAMethod: "totp",
DuoAPI: schema.DuoAPIConfiguration{
SecretKey: "a key",
IntegrationKey: "another key",
Hostname: "none",
},
TOTP: schema.TOTPConfiguration{Disable: true},
},
expectedErrs: []string{
"option 'default_2fa_method' is configured as 'totp' but must be one of the following enabled method values: 'webauthn', 'mobile_push'",
},
},
{
desc: "ShouldNotAllowDisabledMethodWebauthn",
have: &schema.Configuration{
Default2FAMethod: "webauthn",
DuoAPI: schema.DuoAPIConfiguration{
SecretKey: "a key",
IntegrationKey: "another key",
Hostname: "none",
},
Webauthn: schema.WebauthnConfiguration{Disable: true},
},
expectedErrs: []string{
"option 'default_2fa_method' is configured as 'webauthn' but must be one of the following enabled method values: 'totp', 'mobile_push'",
},
},
{
desc: "ShouldNotAllowDisabledMethodMobilePush",
have: &schema.Configuration{
Default2FAMethod: "mobile_push",
DuoAPI: schema.DuoAPIConfiguration{Disable: true},
},
expectedErrs: []string{
"option 'default_2fa_method' is configured as 'mobile_push' but must be one of the following enabled method values: 'totp', 'webauthn'",
},
},
{
desc: "ShouldNotAllowInvalidMethodDuo",
have: &schema.Configuration{
Default2FAMethod: "duo",
},
expectedErrs: []string{
"option 'default_2fa_method' is configured as 'duo' but must be one of the following values: 'totp', 'webauthn', 'mobile_push'",
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
validator := schema.NewStructValidator()
validateDefault2FAMethod(tc.have, validator)
assert.Len(t, validator.Warnings(), 0)
errs := validator.Errors()
require.Len(t, errs, len(tc.expectedErrs))
for i, expected := range tc.expectedErrs {
t.Run(fmt.Sprintf("Err%d", i+1), func(t *testing.T) {
assert.EqualError(t, errs[i], expected)
})
}
})
}
}

View File

@ -265,6 +265,11 @@ const (
TODO (cont): The main consideration is making sure we do not overwrite the destination key name if it already exists. TODO (cont): The main consideration is making sure we do not overwrite the destination key name if it already exists.
*/ */
errFmtInvalidDefault2FAMethod = "option 'default_2fa_method' is configured as '%s' but must be one of " +
"the following values: '%s'"
errFmtInvalidDefault2FAMethodDisabled = "option 'default_2fa_method' is configured as '%s' " +
"but must be one of the following enabled method values: '%s'"
errFmtReplacedConfigurationKey = "invalid configuration key '%s' was replaced by '%s'" errFmtReplacedConfigurationKey = "invalid configuration key '%s' was replaced by '%s'"
errFmtLoggingLevelInvalid = "log: option 'level' must be one of '%s' but it is configured as '%s'" errFmtLoggingLevelInvalid = "log: option 'level' must be one of '%s' but it is configured as '%s'"
@ -292,6 +297,8 @@ var validACLHTTPMethodVerbs = append(validRFC7231HTTPMethodVerbs, validRFC4918HT
var validACLRulePolicies = []string{policyBypass, policyOneFactor, policyTwoFactor, policyDeny} var validACLRulePolicies = []string{policyBypass, policyOneFactor, policyTwoFactor, policyDeny}
var validDefault2FAMethods = []string{"totp", "webauthn", "mobile_push"}
var validOIDCScopes = []string{oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeProfile, oidc.ScopeGroups, "offline_access"} var validOIDCScopes = []string{oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeProfile, oidc.ScopeGroups, "offline_access"}
var validOIDCGrantTypes = []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"} var validOIDCGrantTypes = []string{"implicit", "refresh_token", "authorization_code", "password", "client_credentials"}
var validOIDCResponseModes = []string{"form_post", "query", "fragment"} var validOIDCResponseModes = []string{"form_post", "query", "fragment"}

View File

@ -10,7 +10,7 @@ import (
"github.com/authelia/authelia/v4/internal/utils" "github.com/authelia/authelia/v4/internal/utils"
) )
// ValidateIdentityProviders validates and update IdentityProviders configuration. // ValidateIdentityProviders validates and updates the IdentityProviders configuration.
func ValidateIdentityProviders(config *schema.IdentityProvidersConfiguration, validator *schema.StructValidator) { func ValidateIdentityProviders(config *schema.IdentityProvidersConfiguration, validator *schema.StructValidator) {
validateOIDC(config.OIDC, validator) validateOIDC(config.OIDC, validator)
} }

View File

@ -39,7 +39,7 @@ func UserInfoPOST(ctx *middlewares.AutheliaCtx) {
changed bool changed bool
) )
if changed = userInfo.SetDefaultPreferred2FAMethod(ctx.AvailableSecondFactorMethods()); changed { if changed = userInfo.SetDefaultPreferred2FAMethod(ctx.AvailableSecondFactorMethods(), ctx.Configuration.Default2FAMethod); changed {
if err = ctx.Providers.StorageProvider.SavePreferred2FAMethod(ctx, userSession.Username, userInfo.Method); err != nil { if err = ctx.Providers.StorageProvider.SavePreferred2FAMethod(ctx, userSession.Username, userInfo.Method); err != nil {
ctx.Error(fmt.Errorf("unable to save user two factor method: %v", err), messageOperationFailed) ctx.Error(fmt.Errorf("unable to save user two factor method: %v", err), messageOperationFailed)
return return

View File

@ -23,7 +23,7 @@ type UserInfo struct {
} }
// SetDefaultPreferred2FAMethod configures the default method based on what is configured as available and the users available methods. // SetDefaultPreferred2FAMethod configures the default method based on what is configured as available and the users available methods.
func (i *UserInfo) SetDefaultPreferred2FAMethod(methods []string) (changed bool) { func (i *UserInfo) SetDefaultPreferred2FAMethod(methods []string, fallback string) (changed bool) {
if len(methods) == 0 { if len(methods) == 0 {
// No point attempting to change the method if no methods are available. // No point attempting to change the method if no methods are available.
return false return false
@ -33,11 +33,20 @@ func (i *UserInfo) SetDefaultPreferred2FAMethod(methods []string) (changed bool)
totp, webauthn, duo := utils.IsStringInSlice(SecondFactorMethodTOTP, methods), utils.IsStringInSlice(SecondFactorMethodWebauthn, methods), utils.IsStringInSlice(SecondFactorMethodDuo, methods) totp, webauthn, duo := utils.IsStringInSlice(SecondFactorMethodTOTP, methods), utils.IsStringInSlice(SecondFactorMethodWebauthn, methods), utils.IsStringInSlice(SecondFactorMethodDuo, methods)
if i.Method != "" && !utils.IsStringInSlice(i.Method, methods) { if i.Method == "" && utils.IsStringInSlice(fallback, methods) {
i.Method = fallback
} else if i.Method != "" && !utils.IsStringInSlice(i.Method, methods) {
i.Method = "" i.Method = ""
} }
if i.Method == "" { if i.Method == "" {
i.setMethod(totp, webauthn, duo, methods, fallback)
}
return before != i.Method
}
func (i *UserInfo) setMethod(totp, webauthn, duo bool, methods []string, fallback string) {
switch { switch {
case i.HasTOTP && totp: case i.HasTOTP && totp:
i.Method = SecondFactorMethodTOTP i.Method = SecondFactorMethodTOTP
@ -45,6 +54,8 @@ func (i *UserInfo) SetDefaultPreferred2FAMethod(methods []string) (changed bool)
i.Method = SecondFactorMethodWebauthn i.Method = SecondFactorMethodWebauthn
case i.HasDuo && duo: case i.HasDuo && duo:
i.Method = SecondFactorMethodDuo i.Method = SecondFactorMethodDuo
case fallback != "" && utils.IsStringInSlice(fallback, methods):
i.Method = fallback
case totp: case totp:
i.Method = SecondFactorMethodTOTP i.Method = SecondFactorMethodTOTP
case webauthn: case webauthn:
@ -52,7 +63,4 @@ func (i *UserInfo) SetDefaultPreferred2FAMethod(methods []string) (changed bool)
case duo: case duo:
i.Method = SecondFactorMethodDuo i.Method = SecondFactorMethodDuo
} }
}
return before != i.Method
} }

View File

@ -8,10 +8,10 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { func TestUserInfo_SetDefaultMethod(t *testing.T) {
none := "none" none := "none"
testName := func(i int, have UserInfo, availableMethods []string) string { testName := func(i int, have UserInfo, methods []string, fallback string) string {
method := have.Method method := have.Method
if method == "" { if method == "" {
@ -37,18 +37,25 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
} }
available := none available := none
if len(availableMethods) != 0 { if len(methods) != 0 {
available = strings.Join(availableMethods, " ") available = strings.Join(methods, " ")
} }
return fmt.Sprintf("%d/method %s%s/available methods %s", i+1, method, has, available) if fallback != "" {
fallback = "/fallback " + fallback
}
return fmt.Sprintf("%d/method %s%s/available methods %s%s", i+1, method, has, available, fallback)
} }
testCases := []struct { testCases := []struct {
have UserInfo have UserInfo
availableMethods []string
changed bool
want UserInfo want UserInfo
methods []string
fallback string
changed bool
}{ }{
{ {
have: UserInfo{ have: UserInfo{
@ -57,14 +64,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
availableMethods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo},
changed: true,
want: UserInfo{ want: UserInfo{
Method: SecondFactorMethodWebauthn, Method: SecondFactorMethodWebauthn,
HasDuo: true, HasDuo: true,
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
methods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo},
changed: true,
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -72,14 +79,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
availableMethods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo},
changed: true,
want: UserInfo{ want: UserInfo{
Method: SecondFactorMethodTOTP, Method: SecondFactorMethodTOTP,
HasDuo: true, HasDuo: true,
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
methods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo},
changed: true,
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -88,14 +95,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: false, HasTOTP: false,
HasWebauthn: false, HasWebauthn: false,
}, },
availableMethods: []string{SecondFactorMethodTOTP},
changed: true,
want: UserInfo{ want: UserInfo{
Method: SecondFactorMethodTOTP, Method: SecondFactorMethodTOTP,
HasDuo: true, HasDuo: true,
HasTOTP: false, HasTOTP: false,
HasWebauthn: false, HasWebauthn: false,
}, },
methods: []string{SecondFactorMethodTOTP},
changed: true,
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -104,14 +111,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: false, HasTOTP: false,
HasWebauthn: false, HasWebauthn: false,
}, },
availableMethods: []string{SecondFactorMethodTOTP},
changed: true,
want: UserInfo{ want: UserInfo{
Method: SecondFactorMethodTOTP, Method: SecondFactorMethodTOTP,
HasDuo: false, HasDuo: false,
HasTOTP: false, HasTOTP: false,
HasWebauthn: false, HasWebauthn: false,
}, },
methods: []string{SecondFactorMethodTOTP},
changed: true,
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -120,14 +127,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: false, HasTOTP: false,
HasWebauthn: false, HasWebauthn: false,
}, },
availableMethods: []string{SecondFactorMethodWebauthn},
changed: true,
want: UserInfo{ want: UserInfo{
Method: SecondFactorMethodWebauthn, Method: SecondFactorMethodWebauthn,
HasDuo: false, HasDuo: false,
HasTOTP: false, HasTOTP: false,
HasWebauthn: false, HasWebauthn: false,
}, },
methods: []string{SecondFactorMethodWebauthn},
changed: true,
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -136,14 +143,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: false, HasTOTP: false,
HasWebauthn: false, HasWebauthn: false,
}, },
availableMethods: []string{SecondFactorMethodDuo},
changed: true,
want: UserInfo{ want: UserInfo{
Method: SecondFactorMethodDuo, Method: SecondFactorMethodDuo,
HasDuo: false, HasDuo: false,
HasTOTP: false, HasTOTP: false,
HasWebauthn: false, HasWebauthn: false,
}, },
methods: []string{SecondFactorMethodDuo},
changed: true,
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -152,14 +159,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
availableMethods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo}, want: UserInfo{
Method: SecondFactorMethodWebauthn,
HasDuo: false,
HasTOTP: true,
HasWebauthn: true,
},
methods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo},
changed: false, changed: false,
want: UserInfo{
Method: SecondFactorMethodWebauthn,
HasDuo: false,
HasTOTP: true,
HasWebauthn: true,
},
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -168,14 +175,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
availableMethods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo},
changed: true,
want: UserInfo{ want: UserInfo{
Method: SecondFactorMethodWebauthn, Method: SecondFactorMethodWebauthn,
HasDuo: false, HasDuo: false,
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
methods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo},
changed: true,
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -184,14 +191,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
availableMethods: []string{SecondFactorMethodDuo},
changed: true,
want: UserInfo{ want: UserInfo{
Method: SecondFactorMethodDuo, Method: SecondFactorMethodDuo,
HasDuo: false, HasDuo: false,
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
methods: []string{SecondFactorMethodDuo},
changed: true,
}, },
{ {
have: UserInfo{ have: UserInfo{
@ -200,20 +207,104 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) {
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
availableMethods: nil,
changed: false,
want: UserInfo{ want: UserInfo{
Method: "", Method: "",
HasDuo: false, HasDuo: false,
HasTOTP: true, HasTOTP: true,
HasWebauthn: true, HasWebauthn: true,
}, },
methods: nil,
changed: false,
},
{
have: UserInfo{
Method: "",
HasDuo: false,
HasTOTP: false,
HasWebauthn: false,
},
want: UserInfo{
Method: SecondFactorMethodDuo,
HasDuo: false,
HasTOTP: false,
HasWebauthn: false,
},
methods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo},
fallback: SecondFactorMethodDuo,
changed: true,
},
{
have: UserInfo{
Method: "",
HasDuo: false,
HasTOTP: false,
HasWebauthn: false,
},
want: UserInfo{
Method: SecondFactorMethodTOTP,
HasDuo: false,
HasTOTP: false,
HasWebauthn: false,
},
methods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn},
fallback: SecondFactorMethodDuo,
changed: true,
},
{
have: UserInfo{
Method: SecondFactorMethodTOTP,
HasDuo: true,
HasTOTP: false,
HasWebauthn: false,
},
want: UserInfo{
Method: SecondFactorMethodDuo,
HasDuo: true,
HasTOTP: false,
HasWebauthn: false,
},
methods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo},
changed: true,
},
{
have: UserInfo{
Method: SecondFactorMethodTOTP,
HasDuo: false,
HasTOTP: false,
HasWebauthn: false,
},
want: UserInfo{
Method: SecondFactorMethodWebauthn,
HasDuo: false,
HasTOTP: false,
HasWebauthn: false,
},
methods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo},
fallback: SecondFactorMethodWebauthn,
changed: true,
},
{
have: UserInfo{
Method: SecondFactorMethodWebauthn,
HasDuo: false,
HasTOTP: false,
HasWebauthn: false,
},
want: UserInfo{
Method: SecondFactorMethodDuo,
HasDuo: false,
HasTOTP: false,
HasWebauthn: false,
},
methods: []string{SecondFactorMethodTOTP, SecondFactorMethodDuo},
fallback: SecondFactorMethodDuo,
changed: true,
}, },
} }
for i, tc := range testCases { for i, tc := range testCases {
t.Run(testName(i, tc.have, tc.availableMethods), func(t *testing.T) { t.Run(testName(i, tc.have, tc.methods, tc.fallback), func(t *testing.T) {
changed := tc.have.SetDefaultPreferred2FAMethod(tc.availableMethods) changed := tc.have.SetDefaultPreferred2FAMethod(tc.methods, tc.fallback)
assert.Equal(t, tc.changed, changed) assert.Equal(t, tc.changed, changed)
assert.Equal(t, tc.want, tc.have) assert.Equal(t, tc.want, tc.have)