From e99fb7a08fa61676c6ea8ce7be24d9d3cf0f5665 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Mon, 18 Apr 2022 09:58:24 +1000 Subject: [PATCH] feat(configuration): configurable default second factor method (#3081) This allows configuring the default second factor method. --- config.template.yml | 5 + docs/configuration/miscellaneous.md | 25 ++ docs/configuration/password_policy.md | 2 +- docs/configuration/secrets.md | 30 +-- docs/configuration/webauthn.md | 2 +- internal/configuration/config.template.yml | 5 + .../configuration/schema/configuration.go | 1 + internal/configuration/schema/keys.go | 1 + internal/configuration/schema/keys_test.go | 253 ------------------ .../configuration/validator/configuration.go | 32 +++ .../validator/configuration_test.go | 112 ++++++++ internal/configuration/validator/const.go | 7 + .../validator/identity_providers.go | 2 +- internal/handlers/handler_user_info.go | 2 +- internal/model/user_info.go | 40 +-- internal/model/user_info_test.go | 153 ++++++++--- 16 files changed, 353 insertions(+), 319 deletions(-) delete mode 100644 internal/configuration/schema/keys_test.go diff --git a/config.template.yml b/config.template.yml index c4eaabfa1..f97662b87 100644 --- a/config.template.yml +++ b/config.template.yml @@ -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. 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 ## diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 98e18c1f2..137ebaf36 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -59,3 +59,28 @@ redirected to that URL. If not defined, the user is not redirected after authent ```yaml default_redirection_url: https://home.example.com:8080/ ``` + +## default_2fa_method +
+type: string +{: .label .label-config .label-purple } +default: "" +{: .label .label-config .label-blue } +required: no +{: .label .label-config .label-green } +
+ +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 +``` \ No newline at end of file diff --git a/docs/configuration/password_policy.md b/docs/configuration/password_policy.md index 71f483a5f..d673e1bd6 100644 --- a/docs/configuration/password_policy.md +++ b/docs/configuration/password_policy.md @@ -2,7 +2,7 @@ layout: default title: Password Policy parent: Configuration -nav_order: 17 +nav_order: 18 --- # Password Policy diff --git a/docs/configuration/secrets.md b/docs/configuration/secrets.md index 5a0b4cb17..d4510fe24 100644 --- a/docs/configuration/secrets.md +++ b/docs/configuration/secrets.md @@ -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 other configuration using the environment but instead of loading a file the value of the environment variable is used. -|Configuration Key |Environment Variable | -|:-----------------------------------------------:|:------------------------------------------------------:| -|tls_key |AUTHELIA_TLS_KEY_FILE | -|jwt_secret |AUTHELIA_JWT_SECRET_FILE | -|duo_api.secret_key |AUTHELIA_DUO_API_SECRET_KEY_FILE | -|session.secret |AUTHELIA_SESSION_SECRET_FILE | -|session.redis.password |AUTHELIA_SESSION_REDIS_PASSWORD_FILE | -|session.redis.high_availability.sentinel_password|AUTHELIA_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE | -|storage.encryption_key |AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE | -|storage.mysql.password |AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE | -|storage.postgres.password |AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE | -|notifier.smtp.password |AUTHELIA_NOTIFIER_SMTP_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.hmac_secret |AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE | +| Configuration Key | Environment Variable | +|:-------------------------------------------------:|:--------------------------------------------------------:| +| tls_key | AUTHELIA_TLS_KEY_FILE | +| jwt_secret | AUTHELIA_JWT_SECRET_FILE | +| duo_api.secret_key | AUTHELIA_DUO_API_SECRET_KEY_FILE | +| session.secret | AUTHELIA_SESSION_SECRET_FILE | +| session.redis.password | AUTHELIA_SESSION_REDIS_PASSWORD_FILE | +| session.redis.high_availability.sentinel_password | AUTHELIA_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE | +| storage.encryption_key | AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE | +| storage.mysql.password | AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE | +| storage.postgres.password | AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE | +| notifier.smtp.password | AUTHELIA_NOTIFIER_SMTP_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.hmac_secret | AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE | ## Secrets in configuration file diff --git a/docs/configuration/webauthn.md b/docs/configuration/webauthn.md index 678f9385e..d8ac50e19 100644 --- a/docs/configuration/webauthn.md +++ b/docs/configuration/webauthn.md @@ -2,7 +2,7 @@ layout: default title: Webauthn parent: Configuration -nav_order: 16 +nav_order: 17 --- The Webauthn section has tunable options for the Webauthn implementation. diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index c4eaabfa1..f97662b87 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -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. 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 ## diff --git a/internal/configuration/schema/configuration.go b/internal/configuration/schema/configuration.go index 54cfeadd6..47a318d30 100644 --- a/internal/configuration/schema/configuration.go +++ b/internal/configuration/schema/configuration.go @@ -6,6 +6,7 @@ type Configuration struct { CertificatesDirectory string `koanf:"certificates_directory"` JWTSecret string `koanf:"jwt_secret"` DefaultRedirectionURL string `koanf:"default_redirection_url"` + Default2FAMethod string `koanf:"default_2fa_method"` Log LogConfiguration `koanf:"log"` IdentityProviders IdentityProvidersConfiguration `koanf:"identity_providers"` diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index 6c3124e7d..108ccfc12 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -12,6 +12,7 @@ var Keys = []string{ "certificates_directory", "jwt_secret", "default_redirection_url", + "default_2fa_method", "log.level", "log.format", "log.file_path", diff --git a/internal/configuration/schema/keys_test.go b/internal/configuration/schema/keys_test.go deleted file mode 100644 index 13990c80e..000000000 --- a/internal/configuration/schema/keys_test.go +++ /dev/null @@ -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)) -} diff --git a/internal/configuration/validator/configuration.go b/internal/configuration/validator/configuration.go index ef8b05086..e58ccca41 100644 --- a/internal/configuration/validator/configuration.go +++ b/internal/configuration/validator/configuration.go @@ -33,6 +33,8 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc } } + validateDefault2FAMethod(config, validator) + ValidateTheme(config, validator) ValidateLog(config, validator) @@ -65,3 +67,33 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc 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, "', '"))) + } +} diff --git a/internal/configuration/validator/configuration_test.go b/internal/configuration/validator/configuration_test.go index 00dc7e519..a93fd4aa1 100644 --- a/internal/configuration/validator/configuration_test.go +++ b/internal/configuration/validator/configuration_test.go @@ -1,6 +1,7 @@ package validator import ( + "fmt" "runtime" "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") } + +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) + }) + } + }) + } +} diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 47484abb5..dd7cc40e6 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -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. */ + 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'" 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 validDefault2FAMethods = []string{"totp", "webauthn", "mobile_push"} + 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 validOIDCResponseModes = []string{"form_post", "query", "fragment"} diff --git a/internal/configuration/validator/identity_providers.go b/internal/configuration/validator/identity_providers.go index a51c820ed..675745450 100644 --- a/internal/configuration/validator/identity_providers.go +++ b/internal/configuration/validator/identity_providers.go @@ -10,7 +10,7 @@ import ( "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) { validateOIDC(config.OIDC, validator) } diff --git a/internal/handlers/handler_user_info.go b/internal/handlers/handler_user_info.go index 4b95b3e6f..28489b6e7 100644 --- a/internal/handlers/handler_user_info.go +++ b/internal/handlers/handler_user_info.go @@ -39,7 +39,7 @@ func UserInfoPOST(ctx *middlewares.AutheliaCtx) { 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 { ctx.Error(fmt.Errorf("unable to save user two factor method: %v", err), messageOperationFailed) return diff --git a/internal/model/user_info.go b/internal/model/user_info.go index 50fc92945..62f87f28d 100644 --- a/internal/model/user_info.go +++ b/internal/model/user_info.go @@ -23,7 +23,7 @@ type UserInfo struct { } // 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 { // No point attempting to change the method if no methods are available. return false @@ -33,26 +33,34 @@ func (i *UserInfo) SetDefaultPreferred2FAMethod(methods []string) (changed bool) 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 = "" } if i.Method == "" { - switch { - case i.HasTOTP && totp: - i.Method = SecondFactorMethodTOTP - case i.HasWebauthn && webauthn: - i.Method = SecondFactorMethodWebauthn - case i.HasDuo && duo: - i.Method = SecondFactorMethodDuo - case totp: - i.Method = SecondFactorMethodTOTP - case webauthn: - i.Method = SecondFactorMethodWebauthn - case duo: - i.Method = SecondFactorMethodDuo - } + i.setMethod(totp, webauthn, duo, methods, fallback) } return before != i.Method } + +func (i *UserInfo) setMethod(totp, webauthn, duo bool, methods []string, fallback string) { + switch { + case i.HasTOTP && totp: + i.Method = SecondFactorMethodTOTP + case i.HasWebauthn && webauthn: + i.Method = SecondFactorMethodWebauthn + case i.HasDuo && duo: + i.Method = SecondFactorMethodDuo + case fallback != "" && utils.IsStringInSlice(fallback, methods): + i.Method = fallback + case totp: + i.Method = SecondFactorMethodTOTP + case webauthn: + i.Method = SecondFactorMethodWebauthn + case duo: + i.Method = SecondFactorMethodDuo + } +} diff --git a/internal/model/user_info_test.go b/internal/model/user_info_test.go index cefe9ac7f..c4f7f21ea 100644 --- a/internal/model/user_info_test.go +++ b/internal/model/user_info_test.go @@ -8,10 +8,10 @@ import ( "github.com/stretchr/testify/assert" ) -func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { +func TestUserInfo_SetDefaultMethod(t *testing.T) { 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 if method == "" { @@ -37,18 +37,25 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { } available := none - if len(availableMethods) != 0 { - available = strings.Join(availableMethods, " ") + if len(methods) != 0 { + 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 { - have UserInfo - availableMethods []string - changed bool - want UserInfo + have UserInfo + want UserInfo + + methods []string + fallback string + + changed bool }{ { have: UserInfo{ @@ -57,14 +64,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: true, HasWebauthn: true, }, - availableMethods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo}, - changed: true, want: UserInfo{ Method: SecondFactorMethodWebauthn, HasDuo: true, HasTOTP: true, HasWebauthn: true, }, + methods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo}, + changed: true, }, { have: UserInfo{ @@ -72,14 +79,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: true, HasWebauthn: true, }, - availableMethods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo}, - changed: true, want: UserInfo{ Method: SecondFactorMethodTOTP, HasDuo: true, HasTOTP: true, HasWebauthn: true, }, + methods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo}, + changed: true, }, { have: UserInfo{ @@ -88,14 +95,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: false, HasWebauthn: false, }, - availableMethods: []string{SecondFactorMethodTOTP}, - changed: true, want: UserInfo{ Method: SecondFactorMethodTOTP, HasDuo: true, HasTOTP: false, HasWebauthn: false, }, + methods: []string{SecondFactorMethodTOTP}, + changed: true, }, { have: UserInfo{ @@ -104,14 +111,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: false, HasWebauthn: false, }, - availableMethods: []string{SecondFactorMethodTOTP}, - changed: true, want: UserInfo{ Method: SecondFactorMethodTOTP, HasDuo: false, HasTOTP: false, HasWebauthn: false, }, + methods: []string{SecondFactorMethodTOTP}, + changed: true, }, { have: UserInfo{ @@ -120,14 +127,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: false, HasWebauthn: false, }, - availableMethods: []string{SecondFactorMethodWebauthn}, - changed: true, want: UserInfo{ Method: SecondFactorMethodWebauthn, HasDuo: false, HasTOTP: false, HasWebauthn: false, }, + methods: []string{SecondFactorMethodWebauthn}, + changed: true, }, { have: UserInfo{ @@ -136,14 +143,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: false, HasWebauthn: false, }, - availableMethods: []string{SecondFactorMethodDuo}, - changed: true, want: UserInfo{ Method: SecondFactorMethodDuo, HasDuo: false, HasTOTP: false, HasWebauthn: false, }, + methods: []string{SecondFactorMethodDuo}, + changed: true, }, { have: UserInfo{ @@ -152,14 +159,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: true, HasWebauthn: true, }, - availableMethods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo}, - changed: false, want: UserInfo{ Method: SecondFactorMethodWebauthn, HasDuo: false, HasTOTP: true, HasWebauthn: true, }, + methods: []string{SecondFactorMethodTOTP, SecondFactorMethodWebauthn, SecondFactorMethodDuo}, + changed: false, }, { have: UserInfo{ @@ -168,14 +175,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: true, HasWebauthn: true, }, - availableMethods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo}, - changed: true, want: UserInfo{ Method: SecondFactorMethodWebauthn, HasDuo: false, HasTOTP: true, HasWebauthn: true, }, + methods: []string{SecondFactorMethodWebauthn, SecondFactorMethodDuo}, + changed: true, }, { have: UserInfo{ @@ -184,14 +191,14 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: true, HasWebauthn: true, }, - availableMethods: []string{SecondFactorMethodDuo}, - changed: true, want: UserInfo{ Method: SecondFactorMethodDuo, HasDuo: false, HasTOTP: true, HasWebauthn: true, }, + methods: []string{SecondFactorMethodDuo}, + changed: true, }, { have: UserInfo{ @@ -200,20 +207,104 @@ func TestUserInfo_SetDefaultMethod_ShouldConfigureConfigDefault(t *testing.T) { HasTOTP: true, HasWebauthn: true, }, - availableMethods: nil, - changed: false, want: UserInfo{ Method: "", HasDuo: false, HasTOTP: 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 { - t.Run(testName(i, tc.have, tc.availableMethods), func(t *testing.T) { - changed := tc.have.SetDefaultPreferred2FAMethod(tc.availableMethods) + t.Run(testName(i, tc.have, tc.methods, tc.fallback), func(t *testing.T) { + changed := tc.have.SetDefaultPreferred2FAMethod(tc.methods, tc.fallback) assert.Equal(t, tc.changed, changed) assert.Equal(t, tc.want, tc.have)