feat(configuration): automatically map old keys (#3199)

This performs automatic remapping of deprecated configuration keys in most situations.
This commit is contained in:
James Elliott 2022-06-28 13:15:50 +10:00 committed by GitHub
parent ab1d0c51d3
commit d2f1e5d36d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 670 additions and 46 deletions

View File

@ -216,11 +216,10 @@ ntp:
## ##
## The available providers are: `file`, `ldap`. You must use only one of these providers. ## The available providers are: `file`, `ldap`. You must use only one of these providers.
authentication_backend: authentication_backend:
## Disable both the HTML element and the API for reset password functionality.
disable_reset_password: false
## Password Reset Options. ## Password Reset Options.
password_reset: 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 ## External reset password url that redirects the user to an external reset portal. This disables the internal reset
## functionality. ## functionality.

View File

@ -18,7 +18,6 @@ aliases:
```yaml ```yaml
authentication_backend: authentication_backend:
disable_reset_password: false
file: file:
path: /config/users.yml path: /config/users.yml
password: password:

View File

@ -26,8 +26,8 @@ There are two ways to integrate *Authelia* with an authentication backend:
```yaml ```yaml
authentication_backend: authentication_backend:
refresh_interval: 5m refresh_interval: 5m
disable_reset_password: false
password_reset: password_reset:
disable: false
custom_url: "" custom_url: ""
``` ```
@ -40,14 +40,14 @@ authentication_backend:
This setting controls the interval at which details are refreshed from the backend. Particularly useful for This setting controls the interval at which details are refreshed from the backend. Particularly useful for
[LDAP](#ldap). [LDAP](#ldap).
### disable_reset_password ### password_reset
#### disable
{{< confkey type="boolean" default="false" required="no" >}} {{< confkey type="boolean" default="false" required="no" >}}
This setting controls if users can reset their password from the web frontend or not. This setting controls if users can reset their password from the web frontend or not.
### password_reset
#### custom_url #### custom_url
{{< confkey type="string" required="no" >}} {{< confkey type="string" required="no" >}}

View File

@ -196,8 +196,7 @@ referrals to be followed when performing write operations.
server and utilizing a service account.* server and utilizing a service account.*
Permits binding to the server without a password. For this option to be enabled both the [password](#password) 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 configuration option must be blank and the [password_reset disable](introduction.md#disable) option must be `true`.
disabled.
### user ### user

View File

@ -14,8 +14,12 @@ aliases:
- /docs/configuration/migration.html - /docs/configuration/migration.html
--- ---
This section documents changes in the configuration which may require manual migration by the administrator. Typically This section discusses the change to the configuration over time. Since v4.36.0 the migration process is automatically
this only occurs when a configuration key is renamed or moved to a more appropriate location. 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 ## Format
@ -29,14 +33,18 @@ server:
host: 0.0.0.0 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 ## 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 ### 4.33.0
The options deprecated in version [4.30.0](#4300) have been fully removed as per our deprecation policy and warnings The options deprecated in version [4.30.0](#4300) have been fully removed as per our deprecation policy and warnings

View File

@ -224,7 +224,7 @@ To configure mutual TLS, please refer to [this document](../../configuration/mis
### Reset Password ### Reset Password
It's possible to disable the reset password functionality and is an optional adjustment to consider for anyone wanting 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. for more information.
### Session security ### Session security

View File

@ -43,7 +43,7 @@ type LDAPUserProvider struct {
// NewLDAPUserProvider creates a new instance of LDAPUserProvider. // NewLDAPUserProvider creates a new instance of LDAPUserProvider.
func NewLDAPUserProvider(config schema.AuthenticationBackendConfiguration, certPool *x509.CertPool) (provider *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 return provider
} }

View File

@ -216,11 +216,10 @@ ntp:
## ##
## The available providers are: `file`, `ldap`. You must use only one of these providers. ## The available providers are: `file`, `ldap`. You must use only one of these providers.
authentication_backend: authentication_backend:
## Disable both the HTML element and the API for reset password functionality.
disable_reset_password: false
## Password Reset Options. ## Password Reset Options.
password_reset: 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 ## External reset password url that redirects the user to an external reset portal. This disables the internal reset
## functionality. ## functionality.

View File

@ -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,
},
}

View File

@ -2,13 +2,16 @@ package configuration
import ( import (
"fmt" "fmt"
"strings"
"github.com/knadh/koanf" "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" "github.com/authelia/authelia/v4/internal/utils"
) )
func getAllKoanfKeys(ko *koanf.Koanf) (keys []string) { func koanfGetKeys(ko *koanf.Koanf) (keys []string) {
keys = ko.Keys() keys = ko.Keys()
for key, value := range ko.All() { for key, value := range ko.All() {
@ -34,3 +37,128 @@ func getAllKoanfKeys(ko *koanf.Koanf) (keys []string) {
return keys 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
}

View File

@ -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)
}

View File

@ -29,14 +29,27 @@ func LoadAdvanced(val *schema.StructValidator, path string, result interface{},
StrictMerge: false, StrictMerge: false,
}) })
err = loadSources(ko, val, sources...) if err = loadSources(ko, val, sources...); err != nil {
if err != nil {
return ko.Keys(), err 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{}) { func unmarshal(ko *koanf.Koanf, val *schema.StructValidator, path string, o interface{}) {

View File

@ -228,17 +228,19 @@ func TestShouldValidateAndRaiseErrorsOnBadConfiguration(t *testing.T) {
testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc") testSetEnv(t, "AUTHENTICATION_BACKEND_LDAP_PASSWORD", "abc")
val := schema.NewStructValidator() 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) assert.NoError(t, err)
validator.ValidateKeys(keys, DefaultEnvPrefix, val) validator.ValidateKeys(keys, DefaultEnvPrefix, val)
require.Len(t, val.Errors(), 2) require.Len(t, val.Errors(), 1)
assert.Len(t, val.Warnings(), 0) require.Len(t, val.Warnings(), 1)
assert.EqualError(t, val.Errors()[0], "configuration key not expected: loggy_file") 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) { func TestShouldRaiseErrOnInvalidNotifierSMTPSender(t *testing.T) {

View File

@ -56,12 +56,12 @@ type AuthenticationBackendConfiguration struct {
PasswordReset PasswordResetAuthenticationBackendConfiguration `koanf:"password_reset"` 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. // PasswordResetAuthenticationBackendConfiguration represents the configuration related to password reset functionality.
type PasswordResetAuthenticationBackendConfiguration struct { type PasswordResetAuthenticationBackendConfiguration struct {
Disable bool `koanf:"disable"`
CustomURL url.URL `koanf:"custom_url"` CustomURL url.URL `koanf:"custom_url"`
} }

View File

@ -15,8 +15,7 @@ func EnsureConfigurationExists(path string) (created bool, err error) {
_, err = os.Stat(path) _, err = os.Stat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
err := os.WriteFile(path, template, 0600) if err = os.WriteFile(path, template, 0600); err != nil {
if err != nil {
return false, fmt.Errorf(errFmtGenerateConfiguration, err) return false, fmt.Errorf(errFmtGenerateConfiguration, err)
} }

View File

@ -37,7 +37,7 @@ func ValidateAuthenticationBackend(config *schema.AuthenticationBackendConfigura
if config.PasswordReset.CustomURL.String() != "" { if config.PasswordReset.CustomURL.String() != "" {
switch config.PasswordReset.CustomURL.Scheme { switch config.PasswordReset.CustomURL.Scheme {
case schemeHTTP, schemeHTTPS: case schemeHTTP, schemeHTTPS:
config.DisableResetPassword = false config.PasswordReset.Disable = false
default: default:
validator.Push(fmt.Errorf(errFmtAuthBackendPasswordResetCustomURLScheme, config.PasswordReset.CustomURL.String(), config.PasswordReset.CustomURL.Scheme)) 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)) validator.Push(fmt.Errorf(errFmtLDAPAuthBackendUnauthenticatedBindWithPassword))
} }
if !config.DisableResetPassword { if !config.PasswordReset.Disable {
validator.Push(fmt.Errorf(errFmtLDAPAuthBackendUnauthenticatedBindWithResetEnabled)) validator.Push(fmt.Errorf(errFmtLDAPAuthBackendUnauthenticatedBindWithResetEnabled))
} }
} else { } else {

View File

@ -233,9 +233,9 @@ func (suite *LDAPAuthenticationBackendSuite) TestShouldValidateCompleteConfigura
func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenResetURLIsInvalid() { func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenResetURLIsInvalid() {
suite.config.PasswordReset.CustomURL = url.URL{Scheme: "ldap", Host: "google.com"} 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) 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().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() { func (suite *FileBasedAuthenticationBackend) TestShouldNotRaiseErrorWhenResetURLIsValid() {
@ -258,16 +258,16 @@ func (suite *FileBasedAuthenticationBackend) TestShouldNotRaiseErrorWhenResetURL
func (suite *FileBasedAuthenticationBackend) TestShouldConfigureDisableResetPasswordWhenCustomURL() { func (suite *FileBasedAuthenticationBackend) TestShouldConfigureDisableResetPasswordWhenCustomURL() {
suite.config.PasswordReset.CustomURL = url.URL{Scheme: "https", Host: "google.com"} 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) ValidateAuthenticationBackend(&suite.config, suite.validator)
suite.Assert().Len(suite.validator.Warnings(), 0) suite.Assert().Len(suite.validator.Warnings(), 0)
suite.Assert().Len(suite.validator.Errors(), 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() { func (suite *LDAPAuthenticationBackendSuite) TestShouldValidateDefaultImplementationAndUsernameAttribute() {

View File

@ -1,5 +1,9 @@
package model package model
import (
"regexp"
)
const ( const (
errFmtValueNil = "cannot value model type '%T' with value nil to driver.Value" 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" 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 method using Duo application to receive push notifications.
SecondFactorMethodDuo = "mobile_push" SecondFactorMethodDuo = "mobile_push"
) )
var reSemanticVersion = regexp.MustCompile(`^v?(?P<Major>\d+)\.(?P<Minor>\d+)\.(?P<Patch>\d+)(\-(?P<PreRelease>[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*))?(\+(?P<Metadata>[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*))?$`)
const (
semverRegexpGroupPreRelease = "PreRelease"
)

120
internal/model/semver.go Normal file
View File

@ -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)
}

View File

@ -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))
})
}
}

View File

@ -93,7 +93,7 @@ func handleNotFound(next fasthttp.RequestHandler) fasthttp.RequestHandler {
func handleRouter(config schema.Configuration, providers middlewares.Providers) fasthttp.RequestHandler { func handleRouter(config schema.Configuration, providers middlewares.Providers) fasthttp.RequestHandler {
rememberMe := strconv.FormatBool(config.Session.RememberMeDuration != schema.RememberMeDisabled) 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() 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)) r.POST("/api/logout", middlewareAPI(handlers.LogoutPOST))
// Only register endpoints if forgot password is not disabled. // Only register endpoints if forgot password is not disabled.
if !config.AuthenticationBackend.DisableResetPassword && if !config.AuthenticationBackend.PasswordReset.Disable &&
config.AuthenticationBackend.PasswordReset.CustomURL.String() == "" { config.AuthenticationBackend.PasswordReset.CustomURL.String() == "" {
// Password reset related endpoints. // Password reset related endpoints.
r.POST("/api/reset-password/identity/start", middlewareAPI(handlers.ResetPasswordIdentityStart)) r.POST("/api/reset-password/identity/start", middlewareAPI(handlers.ResetPasswordIdentityStart))