From d2f1e5d36de9e271d0c668a7323b9e876812e6a2 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Tue, 28 Jun 2022 13:15:50 +1000 Subject: [PATCH] feat(configuration): automatically map old keys (#3199) This performs automatic remapping of deprecated configuration keys in most situations. --- config.template.yml | 5 +- .../en/configuration/first-factor/file.md | 1 - .../first-factor/introduction.md | 8 +- .../en/configuration/first-factor/ldap.md | 3 +- .../en/configuration/prologue/migration.md | 24 ++- docs/content/en/overview/security/measures.md | 2 +- internal/authentication/ldap_user_provider.go | 2 +- internal/configuration/config.template.yml | 5 +- internal/configuration/deprecation.go | 116 +++++++++++++ internal/configuration/koanf_util.go | 130 ++++++++++++++- internal/configuration/koanf_util_test.go | 75 +++++++++ internal/configuration/provider.go | 21 ++- internal/configuration/provider_test.go | 10 +- .../configuration/schema/authentication.go | 4 +- internal/configuration/template.go | 3 +- .../configuration/validator/authentication.go | 4 +- .../validator/authentication_test.go | 12 +- internal/model/const.go | 10 ++ internal/model/semver.go | 120 +++++++++++++ internal/model/semver_test.go | 157 ++++++++++++++++++ internal/server/handlers.go | 4 +- 21 files changed, 670 insertions(+), 46 deletions(-) create mode 100644 internal/configuration/deprecation.go create mode 100644 internal/configuration/koanf_util_test.go create mode 100644 internal/model/semver.go create mode 100644 internal/model/semver_test.go diff --git a/config.template.yml b/config.template.yml index 9f9c28a08..e865f05ed 100644 --- a/config.template.yml +++ b/config.template.yml @@ -216,11 +216,10 @@ ntp: ## ## The available providers are: `file`, `ldap`. You must use only one of these providers. authentication_backend: - ## Disable both the HTML element and the API for reset password functionality. - disable_reset_password: false - ## Password Reset Options. password_reset: + ## Disable both the HTML element and the API for reset password functionality. + disable: false ## External reset password url that redirects the user to an external reset portal. This disables the internal reset ## functionality. diff --git a/docs/content/en/configuration/first-factor/file.md b/docs/content/en/configuration/first-factor/file.md index c419fdd29..72a5f8278 100644 --- a/docs/content/en/configuration/first-factor/file.md +++ b/docs/content/en/configuration/first-factor/file.md @@ -18,7 +18,6 @@ aliases: ```yaml authentication_backend: - disable_reset_password: false file: path: /config/users.yml password: diff --git a/docs/content/en/configuration/first-factor/introduction.md b/docs/content/en/configuration/first-factor/introduction.md index 2800d3c9c..b77975628 100644 --- a/docs/content/en/configuration/first-factor/introduction.md +++ b/docs/content/en/configuration/first-factor/introduction.md @@ -26,8 +26,8 @@ There are two ways to integrate *Authelia* with an authentication backend: ```yaml authentication_backend: refresh_interval: 5m - disable_reset_password: false password_reset: + disable: false custom_url: "" ``` @@ -40,14 +40,14 @@ authentication_backend: This setting controls the interval at which details are refreshed from the backend. Particularly useful for [LDAP](#ldap). -### disable_reset_password +### password_reset + +#### disable {{< confkey type="boolean" default="false" required="no" >}} This setting controls if users can reset their password from the web frontend or not. -### password_reset - #### custom_url {{< confkey type="string" required="no" >}} diff --git a/docs/content/en/configuration/first-factor/ldap.md b/docs/content/en/configuration/first-factor/ldap.md index bca733623..3c06b5200 100644 --- a/docs/content/en/configuration/first-factor/ldap.md +++ b/docs/content/en/configuration/first-factor/ldap.md @@ -196,8 +196,7 @@ referrals to be followed when performing write operations. server and utilizing a service account.* Permits binding to the server without a password. For this option to be enabled both the [password](#password) -configuration option must be blank and [disable_reset_password](introduction.md#disable_reset_password) must be -disabled. +configuration option must be blank and the [password_reset disable](introduction.md#disable) option must be `true`. ### user diff --git a/docs/content/en/configuration/prologue/migration.md b/docs/content/en/configuration/prologue/migration.md index 242a01f51..80b14cb97 100644 --- a/docs/content/en/configuration/prologue/migration.md +++ b/docs/content/en/configuration/prologue/migration.md @@ -14,8 +14,12 @@ aliases: - /docs/configuration/migration.html --- -This section documents changes in the configuration which may require manual migration by the administrator. Typically -this only occurs when a configuration key is renamed or moved to a more appropriate location. +This section discusses the change to the configuration over time. Since v4.36.0 the migration process is automatically +performed where possible in memory (the file is unchanged). The automatic process generates warnings and the automatic +migrations are disabled in major version bumps. + +If you're running a version prior to v4.36.0 this it may require manual migration by the administrator. Typically this +only occurs when a configuration key is renamed or moved to a more appropriate location. ## Format @@ -29,14 +33,18 @@ server: host: 0.0.0.0 ``` -## Policy - -Our deprecation policy for configuration keys is 3 minor versions. For example if a configuration option is deprecated -in version 4.30.0, it will remain as a warning for 4.30.x, 4.31.x, and 4.32.x; then it will become a fatal error in -4.33.0+. - ## Migrations +### 4.36.0 + +Automatic mapping was introduced in this version. + +The following changes occurred in 4.30.0: + +| Previous Key | New Key | +|:---------------------------------------------:|:---------------------------------------------:| +| authentication_backend.disable_reset_password | authentication_backend.password_reset.disable | + ### 4.33.0 The options deprecated in version [4.30.0](#4300) have been fully removed as per our deprecation policy and warnings diff --git a/docs/content/en/overview/security/measures.md b/docs/content/en/overview/security/measures.md index 45af7a3a8..368bdfeb6 100644 --- a/docs/content/en/overview/security/measures.md +++ b/docs/content/en/overview/security/measures.md @@ -224,7 +224,7 @@ To configure mutual TLS, please refer to [this document](../../configuration/mis ### Reset Password It's possible to disable the reset password functionality and is an optional adjustment to consider for anyone wanting -to increase security. See the [configuration](../../configuration/first-factor/introduction.md#disable_reset_password) +to increase security. See the [configuration](../../configuration/first-factor/introduction.md#disable) for more information. ### Session security diff --git a/internal/authentication/ldap_user_provider.go b/internal/authentication/ldap_user_provider.go index dd84461be..0c4b2ed9f 100644 --- a/internal/authentication/ldap_user_provider.go +++ b/internal/authentication/ldap_user_provider.go @@ -43,7 +43,7 @@ type LDAPUserProvider struct { // NewLDAPUserProvider creates a new instance of LDAPUserProvider. func NewLDAPUserProvider(config schema.AuthenticationBackendConfiguration, certPool *x509.CertPool) (provider *LDAPUserProvider) { - provider = newLDAPUserProvider(*config.LDAP, config.DisableResetPassword, certPool, nil) + provider = newLDAPUserProvider(*config.LDAP, config.PasswordReset.Disable, certPool, nil) return provider } diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 9f9c28a08..e865f05ed 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -216,11 +216,10 @@ ntp: ## ## The available providers are: `file`, `ldap`. You must use only one of these providers. authentication_backend: - ## Disable both the HTML element and the API for reset password functionality. - disable_reset_password: false - ## Password Reset Options. password_reset: + ## Disable both the HTML element and the API for reset password functionality. + disable: false ## External reset password url that redirects the user to an external reset portal. This disables the internal reset ## functionality. diff --git a/internal/configuration/deprecation.go b/internal/configuration/deprecation.go new file mode 100644 index 000000000..f0850bf74 --- /dev/null +++ b/internal/configuration/deprecation.go @@ -0,0 +1,116 @@ +package configuration + +import ( + "github.com/authelia/authelia/v4/internal/model" +) + +// Deprecation represents a deprecated configuration key. +type Deprecation struct { + Version model.SemanticVersion + Key string + NewKey string + AutoMap bool + MapFunc func(value interface{}) interface{} + ErrText string +} + +var deprecations = map[string]Deprecation{ + "logs_level": { + Version: model.SemanticVersion{Major: 4, Minor: 7}, + Key: "logs_level", + NewKey: "log.level", + AutoMap: true, + MapFunc: nil, + }, + "logs_file": { + Version: model.SemanticVersion{Major: 4, Minor: 7}, + Key: "logs_file", + NewKey: "log.file_path", + AutoMap: true, + MapFunc: nil, + }, + "authentication_backend.ldap.skip_verify": { + Version: model.SemanticVersion{Major: 4, Minor: 25}, + Key: "authentication_backend.ldap.skip_verify", + NewKey: "authentication_backend.ldap.tls.skip_verify", + AutoMap: true, + MapFunc: nil, + }, + "authentication_backend.ldap.minimum_tls_version": { + Version: model.SemanticVersion{Major: 4, Minor: 25}, + Key: "authentication_backend.ldap.minimum_tls_version", + NewKey: "authentication_backend.ldap.tls.minimum_version", + AutoMap: true, + MapFunc: nil, + }, + "notifier.smtp.disable_verify_cert": { + Version: model.SemanticVersion{Major: 4, Minor: 25}, + Key: "notifier.smtp.disable_verify_cert", + NewKey: "notifier.smtp.tls.skip_verify", + AutoMap: true, + MapFunc: nil, + }, + "notifier.smtp.trusted_cert": { + Version: model.SemanticVersion{Major: 4, Minor: 25}, + Key: "notifier.smtp.trusted_cert", + NewKey: "certificates_directory", + AutoMap: false, + MapFunc: nil, + }, + "host": { + Version: model.SemanticVersion{Major: 4, Minor: 30}, + Key: "logs_file", + NewKey: "server.host", + AutoMap: true, + MapFunc: nil, + }, + "port": { + Version: model.SemanticVersion{Major: 4, Minor: 30}, + Key: "port", + NewKey: "server.port", + AutoMap: true, + MapFunc: nil, + }, + "tls_key": { + Version: model.SemanticVersion{Major: 4, Minor: 30}, + Key: "tls_key", + NewKey: "server.tls.key", + AutoMap: true, + MapFunc: nil, + }, + "tls_cert": { + Version: model.SemanticVersion{Major: 4, Minor: 30}, + Key: "tls_cert", + NewKey: "server.tls.certificate", + AutoMap: true, + MapFunc: nil, + }, + "log_level": { + Version: model.SemanticVersion{Major: 4, Minor: 30}, + Key: "log_level", + NewKey: "log.level", + AutoMap: true, + MapFunc: nil, + }, + "log_file_path": { + Version: model.SemanticVersion{Major: 4, Minor: 30}, + Key: "log_file_path", + NewKey: "log.file_path", + AutoMap: true, + MapFunc: nil, + }, + "log_format": { + Version: model.SemanticVersion{Major: 4, Minor: 30}, + Key: "log_format", + NewKey: "log.format", + AutoMap: true, + MapFunc: nil, + }, + "authentication_backend.disable_reset_password": { + Version: model.SemanticVersion{Major: 4, Minor: 36}, + Key: "authentication_backend.disable_reset_password", + NewKey: "authentication_backend.password_reset.disable", + AutoMap: true, + MapFunc: nil, + }, +} diff --git a/internal/configuration/koanf_util.go b/internal/configuration/koanf_util.go index d9e816f0e..589b4b05e 100644 --- a/internal/configuration/koanf_util.go +++ b/internal/configuration/koanf_util.go @@ -2,13 +2,16 @@ package configuration import ( "fmt" + "strings" "github.com/knadh/koanf" + "github.com/knadh/koanf/providers/confmap" + "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/utils" ) -func getAllKoanfKeys(ko *koanf.Koanf) (keys []string) { +func koanfGetKeys(ko *koanf.Koanf) (keys []string) { keys = ko.Keys() for key, value := range ko.All() { @@ -34,3 +37,128 @@ func getAllKoanfKeys(ko *koanf.Koanf) (keys []string) { return keys } + +func koanfRemapKeys(val *schema.StructValidator, ko *koanf.Koanf, ds map[string]Deprecation) (final *koanf.Koanf, err error) { + keys := ko.All() + + keys = koanfRemapKeysStandard(keys, val, ds) + keys = koanfRemapKeysMapped(keys, val, ds) + + final = koanf.New(".") + + if err = final.Load(confmap.Provider(keys, "."), nil); err != nil { + return nil, err + } + + return final, nil +} + +func koanfRemapKeysStandard(keys map[string]interface{}, val *schema.StructValidator, ds map[string]Deprecation) (keysFinal map[string]interface{}) { + var ( + ok bool + d Deprecation + key string + value interface{} + ) + + keysFinal = make(map[string]interface{}) + + for key, value = range keys { + if d, ok = ds[key]; ok { + if !d.AutoMap { + val.Push(fmt.Errorf("invalid configuration key '%s' was replaced by '%s'", d.Key, d.NewKey)) + + keysFinal[key] = value + + continue + } else { + val.PushWarning(fmt.Errorf("configuration key '%s' is deprecated in %s and has been replaced by '%s': "+ + "this has been automatically mapped for you but you will need to adjust your configuration to remove this message", d.Key, d.Version.String(), d.NewKey)) + } + + if !mapHasKey(d.NewKey, keys) && !mapHasKey(d.NewKey, keysFinal) { + if d.MapFunc != nil { + keysFinal[d.NewKey] = d.MapFunc(value) + } else { + keysFinal[d.NewKey] = value + } + } + + continue + } + + keysFinal[key] = value + } + + return keysFinal +} + +func koanfRemapKeysMapped(keys map[string]interface{}, val *schema.StructValidator, ds map[string]Deprecation) (keysFinal map[string]interface{}) { + var ( + key string + value interface{} + slc, slcFinal []interface{} + ok bool + m map[string]interface{} + d Deprecation + ) + + keysFinal = make(map[string]interface{}) + + for key, value = range keys { + if slc, ok = value.([]interface{}); !ok { + keysFinal[key] = value + + continue + } + + slcFinal = make([]interface{}, len(slc)) + + for i, item := range slc { + if m, ok = item.(map[string]interface{}); !ok { + slcFinal[i] = item + + continue + } + + itemFinal := make(map[string]interface{}) + + for subkey, element := range m { + prefix := fmt.Sprintf("%s[].", key) + + fullKey := prefix + subkey + + if d, ok = ds[fullKey]; ok { + if !d.AutoMap { + val.Push(fmt.Errorf("invalid configuration key '%s' was replaced by '%s'", d.Key, d.NewKey)) + + itemFinal[subkey] = element + + continue + } else { + val.PushWarning(fmt.Errorf("configuration key '%s' is deprecated in %s and has been replaced by '%s': "+ + "this has been automatically mapped for you but you will need to adjust your configuration to remove this message", d.Key, d.Version.String(), d.NewKey)) + } + + newkey := strings.Replace(d.NewKey, prefix, "", 1) + + if !mapHasKey(newkey, m) && !mapHasKey(newkey, itemFinal) { + if d.MapFunc != nil { + itemFinal[newkey] = d.MapFunc(element) + } else { + itemFinal[newkey] = element + } + } + } else { + itemFinal[subkey] = element + } + } + + slcFinal[i] = itemFinal + } + + keysFinal[key] = slcFinal + } + + return keysFinal +} diff --git a/internal/configuration/koanf_util_test.go b/internal/configuration/koanf_util_test.go new file mode 100644 index 000000000..fb7038818 --- /dev/null +++ b/internal/configuration/koanf_util_test.go @@ -0,0 +1,75 @@ +package configuration + +import ( + "testing" + + "github.com/knadh/koanf" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/model" +) + +type testDeprecationsConf struct { + SubItems []testDeprecationsConfSubItem `koanf:"subitems"` + + ANonSubItemString string `koanf:"a_non_subitem_string"` + ANonSubItemInt int `koanf:"a_non_subitem_int"` + ANonSubItemBool bool `koanf:"a_non_subitem_bool"` +} + +type testDeprecationsConfSubItem struct { + AString string `koanf:"a_string"` + AnInt int `koanf:"an_int"` + ABool bool `koanf:"a_bool"` +} + +func TestSubItemRemap(t *testing.T) { + ds := map[string]Deprecation{ + "astring": { + Key: "astring", + NewKey: "a_non_subitem_string", + Version: model.SemanticVersion{Major: 4, Minor: 30}, + AutoMap: true, + }, + "subitems[].astring": { + Key: "subitems[].astring", + NewKey: "subitems[].a_string", + Version: model.SemanticVersion{Major: 4, Minor: 30}, + AutoMap: true, + }, + } + + val := schema.NewStructValidator() + + ko := koanf.New(".") + + configYAML := []byte(` +astring: test +subitems: +- astring: example +- an_int: 1 +`) + + require.NoError(t, ko.Load(rawbytes.Provider(configYAML), yaml.Parser())) + + final, err := koanfRemapKeys(val, ko, ds) + require.NoError(t, err) + + conf := &testDeprecationsConf{} + + require.NoError(t, final.Unmarshal("", conf)) + + assert.Equal(t, "test", conf.ANonSubItemString) + assert.Equal(t, 0, conf.ANonSubItemInt) + assert.False(t, conf.ANonSubItemBool) + + require.Len(t, conf.SubItems, 2) + assert.Equal(t, "example", conf.SubItems[0].AString) + assert.Equal(t, 0, conf.SubItems[0].AnInt) + assert.Equal(t, "", conf.SubItems[1].AString) + assert.Equal(t, 1, conf.SubItems[1].AnInt) +} diff --git a/internal/configuration/provider.go b/internal/configuration/provider.go index b64bb8bc1..111e5c041 100644 --- a/internal/configuration/provider.go +++ b/internal/configuration/provider.go @@ -29,14 +29,27 @@ func LoadAdvanced(val *schema.StructValidator, path string, result interface{}, StrictMerge: false, }) - err = loadSources(ko, val, sources...) - if err != nil { + if err = loadSources(ko, val, sources...); err != nil { return ko.Keys(), err } - unmarshal(ko, val, path, result) + var final *koanf.Koanf - return getAllKoanfKeys(ko), nil + if final, err = koanfRemapKeys(val, ko, deprecations); err != nil { + return koanfGetKeys(ko), err + } + + unmarshal(final, val, path, result) + + return koanfGetKeys(final), nil +} + +func mapHasKey(k string, m map[string]interface{}) bool { + if _, ok := m[k]; ok { + return true + } + + return false } func unmarshal(ko *koanf.Koanf, val *schema.StructValidator, path string, o interface{}) { diff --git a/internal/configuration/provider_test.go b/internal/configuration/provider_test.go index d3fd9cb5a..3f7aa7b45 100644 --- a/internal/configuration/provider_test.go +++ b/internal/configuration/provider_test.go @@ -228,17 +228,19 @@ func TestShouldValidateAndRaiseErrorsOnBadConfiguration(t *testing.T) { testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc") val := schema.NewStructValidator() - keys, _, err := Load(val, NewDefaultSources([]string{"./test_resources/config_bad_keys.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + keys, c, err := Load(val, NewDefaultSources([]string{"./test_resources/config_bad_keys.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) assert.NoError(t, err) validator.ValidateKeys(keys, DefaultEnvPrefix, val) - require.Len(t, val.Errors(), 2) - assert.Len(t, val.Warnings(), 0) + require.Len(t, val.Errors(), 1) + require.Len(t, val.Warnings(), 1) assert.EqualError(t, val.Errors()[0], "configuration key not expected: loggy_file") - assert.EqualError(t, val.Errors()[1], "invalid configuration key 'logs_level' was replaced by 'log.level'") + assert.EqualError(t, val.Warnings()[0], "configuration key 'logs_level' is deprecated in 4.7.0 and has been replaced by 'log.level': this has been automatically mapped for you but you will need to adjust your configuration to remove this message") + + assert.Equal(t, "debug", c.Log.Level) } func TestShouldRaiseErrOnInvalidNotifierSMTPSender(t *testing.T) { diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index a87536e39..895cc7711 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -56,12 +56,12 @@ type AuthenticationBackendConfiguration struct { PasswordReset PasswordResetAuthenticationBackendConfiguration `koanf:"password_reset"` - DisableResetPassword bool `koanf:"disable_reset_password"` - RefreshInterval string `koanf:"refresh_interval"` + RefreshInterval string `koanf:"refresh_interval"` } // PasswordResetAuthenticationBackendConfiguration represents the configuration related to password reset functionality. type PasswordResetAuthenticationBackendConfiguration struct { + Disable bool `koanf:"disable"` CustomURL url.URL `koanf:"custom_url"` } diff --git a/internal/configuration/template.go b/internal/configuration/template.go index e777f4d33..667b1cf0a 100644 --- a/internal/configuration/template.go +++ b/internal/configuration/template.go @@ -15,8 +15,7 @@ func EnsureConfigurationExists(path string) (created bool, err error) { _, err = os.Stat(path) if err != nil { if os.IsNotExist(err) { - err := os.WriteFile(path, template, 0600) - if err != nil { + if err = os.WriteFile(path, template, 0600); err != nil { return false, fmt.Errorf(errFmtGenerateConfiguration, err) } diff --git a/internal/configuration/validator/authentication.go b/internal/configuration/validator/authentication.go index f65f8f04e..59ea0518a 100644 --- a/internal/configuration/validator/authentication.go +++ b/internal/configuration/validator/authentication.go @@ -37,7 +37,7 @@ func ValidateAuthenticationBackend(config *schema.AuthenticationBackendConfigura if config.PasswordReset.CustomURL.String() != "" { switch config.PasswordReset.CustomURL.Scheme { case schemeHTTP, schemeHTTPS: - config.DisableResetPassword = false + config.PasswordReset.Disable = false default: validator.Push(fmt.Errorf(errFmtAuthBackendPasswordResetCustomURLScheme, config.PasswordReset.CustomURL.String(), config.PasswordReset.CustomURL.Scheme)) } @@ -197,7 +197,7 @@ func validateLDAPRequiredParameters(config *schema.AuthenticationBackendConfigur validator.Push(fmt.Errorf(errFmtLDAPAuthBackendUnauthenticatedBindWithPassword)) } - if !config.DisableResetPassword { + if !config.PasswordReset.Disable { validator.Push(fmt.Errorf(errFmtLDAPAuthBackendUnauthenticatedBindWithResetEnabled)) } } else { diff --git a/internal/configuration/validator/authentication_test.go b/internal/configuration/validator/authentication_test.go index a575a231c..9df81a38b 100644 --- a/internal/configuration/validator/authentication_test.go +++ b/internal/configuration/validator/authentication_test.go @@ -233,9 +233,9 @@ func (suite *LDAPAuthenticationBackendSuite) TestShouldValidateCompleteConfigura func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenResetURLIsInvalid() { suite.config.PasswordReset.CustomURL = url.URL{Scheme: "ldap", Host: "google.com"} - suite.config.DisableResetPassword = true + suite.config.PasswordReset.Disable = true - suite.Assert().True(suite.config.DisableResetPassword) + suite.Assert().True(suite.config.PasswordReset.Disable) ValidateAuthenticationBackend(&suite.config, suite.validator) @@ -244,7 +244,7 @@ func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenResetURLIsI suite.Assert().EqualError(suite.validator.Errors()[0], "authentication_backend: password_reset: option 'custom_url' is configured to 'ldap://google.com' which has the scheme 'ldap' but the scheme must be either 'http' or 'https'") - suite.Assert().True(suite.config.DisableResetPassword) + suite.Assert().True(suite.config.PasswordReset.Disable) } func (suite *FileBasedAuthenticationBackend) TestShouldNotRaiseErrorWhenResetURLIsValid() { @@ -258,16 +258,16 @@ func (suite *FileBasedAuthenticationBackend) TestShouldNotRaiseErrorWhenResetURL func (suite *FileBasedAuthenticationBackend) TestShouldConfigureDisableResetPasswordWhenCustomURL() { suite.config.PasswordReset.CustomURL = url.URL{Scheme: "https", Host: "google.com"} - suite.config.DisableResetPassword = true + suite.config.PasswordReset.Disable = true - suite.Assert().True(suite.config.DisableResetPassword) + suite.Assert().True(suite.config.PasswordReset.Disable) ValidateAuthenticationBackend(&suite.config, suite.validator) suite.Assert().Len(suite.validator.Warnings(), 0) suite.Assert().Len(suite.validator.Errors(), 0) - suite.Assert().False(suite.config.DisableResetPassword) + suite.Assert().False(suite.config.PasswordReset.Disable) } func (suite *LDAPAuthenticationBackendSuite) TestShouldValidateDefaultImplementationAndUsernameAttribute() { diff --git a/internal/model/const.go b/internal/model/const.go index efdf6ef38..3c0b13a64 100644 --- a/internal/model/const.go +++ b/internal/model/const.go @@ -1,5 +1,9 @@ package model +import ( + "regexp" +) + const ( errFmtValueNil = "cannot value model type '%T' with value nil to driver.Value" errFmtScanNil = "cannot scan model type '%T' from value nil: type doesn't support nil values" @@ -17,3 +21,9 @@ const ( // SecondFactorMethodDuo method using Duo application to receive push notifications. SecondFactorMethodDuo = "mobile_push" ) + +var reSemanticVersion = regexp.MustCompile(`^v?(?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*))?(\+(?P[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*))?$`) + +const ( + semverRegexpGroupPreRelease = "PreRelease" +) diff --git a/internal/model/semver.go b/internal/model/semver.go new file mode 100644 index 000000000..4e724c837 --- /dev/null +++ b/internal/model/semver.go @@ -0,0 +1,120 @@ +package model + +import ( + "fmt" + "strconv" + "strings" +) + +// NewSemanticVersion creates a SemanticVersion from a string. +func NewSemanticVersion(input string) (version *SemanticVersion, err error) { + if !reSemanticVersion.MatchString(input) { + return nil, fmt.Errorf("the input '%s' failed to match the semantic version pattern", input) + } + + version = &SemanticVersion{} + + submatch := reSemanticVersion.FindStringSubmatch(input) + + for i, name := range reSemanticVersion.SubexpNames() { + switch name { + case "Major": + version.Major, _ = strconv.Atoi(submatch[i]) + case "Minor": + version.Minor, _ = strconv.Atoi(submatch[i]) + case "Patch": + version.Patch, _ = strconv.Atoi(submatch[i]) + case semverRegexpGroupPreRelease, "Metadata": + if submatch[i] == "" { + continue + } + + val := strings.Split(submatch[i], ".") + + if name == semverRegexpGroupPreRelease { + version.PreRelease = val + } else { + version.Metadata = val + } + } + } + + return version, nil +} + +// SemanticVersion represents a semantic 2.0 version. +type SemanticVersion struct { + Major int + Minor int + Patch int + PreRelease []string + Metadata []string +} + +// String is a function to provide a nice representation of a SemanticVersion. +func (v SemanticVersion) String() (value string) { + builder := strings.Builder{} + + builder.WriteString(fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)) + + if len(v.PreRelease) != 0 { + builder.WriteString("-") + builder.WriteString(strings.Join(v.PreRelease, ".")) + } + + if len(v.Metadata) != 0 { + builder.WriteString("+") + builder.WriteString(strings.Join(v.Metadata, ".")) + } + + return builder.String() +} + +// Equal returns true if this SemanticVersion is equal to the provided SemanticVersion. +func (v SemanticVersion) Equal(version SemanticVersion) (equals bool) { + return v.Major == version.Major && v.Minor == version.Minor && v.Patch == version.Patch +} + +// GreaterThan returns true if this SemanticVersion is greater than the provided SemanticVersion. +func (v SemanticVersion) GreaterThan(version SemanticVersion) (gt bool) { + if v.Major > version.Major { + return true + } + + if v.Major == version.Major && v.Minor > version.Minor { + return true + } + + if v.Major == version.Major && v.Minor == version.Minor && v.Patch > version.Patch { + return true + } + + return false +} + +// LessThan returns true if this SemanticVersion is less than the provided SemanticVersion. +func (v SemanticVersion) LessThan(version SemanticVersion) (gt bool) { + if v.Major < version.Major { + return true + } + + if v.Major == version.Major && v.Minor < version.Minor { + return true + } + + if v.Major == version.Major && v.Minor == version.Minor && v.Patch < version.Patch { + return true + } + + return false +} + +// GreaterThanOrEqual returns true if this SemanticVersion is greater than or equal to the provided SemanticVersion. +func (v SemanticVersion) GreaterThanOrEqual(version SemanticVersion) (ge bool) { + return v.Equal(version) || v.GreaterThan(version) +} + +// LessThanOrEqual returns true if this SemanticVersion is less than or equal to the provided SemanticVersion. +func (v SemanticVersion) LessThanOrEqual(version SemanticVersion) (ge bool) { + return v.Equal(version) || v.LessThan(version) +} diff --git a/internal/model/semver_test.go b/internal/model/semver_test.go new file mode 100644 index 000000000..e2548329b --- /dev/null +++ b/internal/model/semver_test.go @@ -0,0 +1,157 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSemanticVersion(t *testing.T) { + testCases := []struct { + desc string + have string + expected *SemanticVersion + err string + }{ + { + desc: "ShouldParseStandardSemVer", + have: "4.30.0", + expected: &SemanticVersion{Major: 4, Minor: 30, Patch: 0}, + }, + { + desc: "ShouldParseSemVerWithPre", + have: "4.30.0-alpha1", + expected: &SemanticVersion{Major: 4, Minor: 30, Patch: 0, PreRelease: []string{"alpha1"}}, + }, + { + desc: "ShouldParseSemVerWithMeta", + have: "4.30.0+build4", + expected: &SemanticVersion{Major: 4, Minor: 30, Patch: 0, Metadata: []string{"build4"}}, + }, + { + desc: "ShouldParseSemVerWithPreAndMeta", + have: "4.30.0-alpha1+build4", + expected: &SemanticVersion{Major: 4, Minor: 30, Patch: 0, PreRelease: []string{"alpha1"}, Metadata: []string{"build4"}}, + }, + { + desc: "ShouldParseSemVerWithPreAndMetaMulti", + have: "4.30.0-alpha1.test+build4.new", + expected: &SemanticVersion{Major: 4, Minor: 30, Patch: 0, PreRelease: []string{"alpha1", "test"}, Metadata: []string{"build4", "new"}}, + }, + { + desc: "ShouldNotParseInvalidVersion", + have: "1.2", + expected: nil, + err: "the input '1.2' failed to match the semantic version pattern", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + version, err := NewSemanticVersion(tc.have) + + if tc.err == "" { + assert.Nil(t, err) + require.NotNil(t, version) + assert.Equal(t, tc.expected, version) + assert.Equal(t, tc.have, version.String()) + } else { + assert.Nil(t, version) + require.NotNil(t, err) + assert.EqualError(t, err, tc.err) + } + }) + } +} + +func TestSemanticVersionComparisons(t *testing.T) { + testCases := []struct { + desc string + + haveFirst, haveSecond SemanticVersion + + expectedEQ, expectedGT, expectedGE, expectedLT, expectedLE bool + }{ + { + desc: "ShouldCompareVersionLessThanMajor", + haveFirst: SemanticVersion{Major: 4, Minor: 30, Patch: 0}, + haveSecond: SemanticVersion{Major: 5, Minor: 3, Patch: 0}, + expectedEQ: false, + expectedGT: false, + expectedGE: false, + expectedLT: true, + expectedLE: true, + }, + { + desc: "ShouldCompareVersionLessThanMinor", + haveFirst: SemanticVersion{Major: 4, Minor: 30, Patch: 0}, + haveSecond: SemanticVersion{Major: 4, Minor: 31, Patch: 0}, + expectedEQ: false, + expectedGT: false, + expectedGE: false, + expectedLT: true, + expectedLE: true, + }, + { + desc: "ShouldCompareVersionLessThanPatch", + haveFirst: SemanticVersion{Major: 4, Minor: 31, Patch: 0}, + haveSecond: SemanticVersion{Major: 4, Minor: 31, Patch: 9}, + expectedEQ: false, + expectedGT: false, + expectedGE: false, + expectedLT: true, + expectedLE: true, + }, + { + desc: "ShouldCompareVersionEqual", + haveFirst: SemanticVersion{Major: 4, Minor: 31, Patch: 0}, + haveSecond: SemanticVersion{Major: 4, Minor: 31, Patch: 0}, + expectedEQ: true, + expectedGT: false, + expectedGE: true, + expectedLT: false, + expectedLE: true, + }, + { + desc: "ShouldCompareVersionGreaterThanMajor", + haveFirst: SemanticVersion{Major: 5, Minor: 0, Patch: 0}, + haveSecond: SemanticVersion{Major: 4, Minor: 30, Patch: 0}, + expectedEQ: false, + expectedGT: true, + expectedGE: true, + expectedLT: false, + expectedLE: false, + }, + { + desc: "ShouldCompareVersionGreaterThanMinor", + haveFirst: SemanticVersion{Major: 4, Minor: 31, Patch: 0}, + haveSecond: SemanticVersion{Major: 4, Minor: 30, Patch: 0}, + expectedEQ: false, + expectedGT: true, + expectedGE: true, + expectedLT: false, + expectedLE: false, + }, + { + desc: "ShouldCompareVersionGreaterThanPatch", + haveFirst: SemanticVersion{Major: 4, Minor: 31, Patch: 5}, + haveSecond: SemanticVersion{Major: 4, Minor: 31, Patch: 0}, + expectedEQ: false, + expectedGT: true, + expectedGE: true, + expectedLT: false, + expectedLE: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + assert.Equal(t, tc.expectedEQ, tc.haveFirst.Equal(tc.haveSecond)) + assert.Equal(t, tc.expectedGT, tc.haveFirst.GreaterThan(tc.haveSecond)) + assert.Equal(t, tc.expectedGE, tc.haveFirst.GreaterThanOrEqual(tc.haveSecond)) + assert.Equal(t, tc.expectedLT, tc.haveFirst.LessThan(tc.haveSecond)) + assert.Equal(t, tc.expectedLE, tc.haveFirst.LessThanOrEqual(tc.haveSecond)) + }) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index a7f5798c9..389743d39 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -93,7 +93,7 @@ func handleNotFound(next fasthttp.RequestHandler) fasthttp.RequestHandler { func handleRouter(config schema.Configuration, providers middlewares.Providers) fasthttp.RequestHandler { rememberMe := strconv.FormatBool(config.Session.RememberMeDuration != schema.RememberMeDisabled) - resetPassword := strconv.FormatBool(!config.AuthenticationBackend.DisableResetPassword) + resetPassword := strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable) resetPasswordCustomURL := config.AuthenticationBackend.PasswordReset.CustomURL.String() @@ -175,7 +175,7 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers) r.POST("/api/logout", middlewareAPI(handlers.LogoutPOST)) // Only register endpoints if forgot password is not disabled. - if !config.AuthenticationBackend.DisableResetPassword && + if !config.AuthenticationBackend.PasswordReset.Disable && config.AuthenticationBackend.PasswordReset.CustomURL.String() == "" { // Password reset related endpoints. r.POST("/api/reset-password/identity/start", middlewareAPI(handlers.ResetPasswordIdentityStart))