Repository: authelia/authelia (go)

Issue:
Allow setting of "default" 2fa method for new users
## Feature Request

### Description

I would like to be able to set the "default" 2fa method for new users for the registration flow. Being able to set it to duo push for example

We have left the totp settings option blank in configuration.yml (we dont want any auth method other than duo)
This works as expected.
However.
When an ldap user logs in to authelia for the first time, they get a prompt to register their device. As soon as they hit register it will return an error `There was a problem initiating the registration process`. 
this seems to be because as soon as you login you are redirected to the totp registration process, the user doesn't get a choice initially (they can hit methods and then choose push, which works, but isn't a great user experience)

### Use Case

In my usecase we have an existing ldap & duo setup. All users exist in both. 
We wish users to only use duo as an option. we want the onboard flow to go from login to authelia  > follow duo push setup flow.
right now they have to login to authelia > press methods (assuming they've read the documentation email they've been given) > press push > follow duo push setup flow

### Additional info

I brought this up in discord. @james-d-elliott pointed out that https://github.com/authelia/authelia/blob/v4.33.2/internal/storage/sql_provider.go#L212 would probably need to be adjusted to the below as a temporary workaround (assuming i can get a local build working well)
```
         if _, err = p.db.ExecContext(ctx, p.sqlUpsertPreferred2FAMethod, username, authentication.Push); err != nil {
```


Pull request description:
feat(configuration): configurable default second factor method
This allows configuring the default second factor method.

Closes #2838

Patch:
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
+<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
+```
\ 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/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/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
+	}
+}


Test patch:
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_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/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)