diff --git a/config.template.yml b/config.template.yml index c4eaabfa..f97662b8 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 98e18c1f..137ebaf3 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 71f483a5..d673e1bd 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 5a0b4cb1..d4510fe2 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 678f9385..d8ac50e1 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 c4eaabfa..f97662b8 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 54cfeadd..47a318d3 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 6c3124e7..108ccfc1 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 13990c80..00000000 --- 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 ef8b0508..e58ccca4 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 00dc7e51..a93fd4aa 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 47484abb..dd7cc40e 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 a51c820e..67574545 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 4b95b3e6..28489b6e 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 50fc9294..62f87f28 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 cefe9ac7..c4f7f21e 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)