diff --git a/cmd/authelia/main.go b/cmd/authelia/main.go index 2a0bde5c..5dd84945 100644 --- a/cmd/authelia/main.go +++ b/cmd/authelia/main.go @@ -59,7 +59,7 @@ func startServer() { var userProvider authentication.UserProvider if config.AuthenticationBackend.File != nil { - userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File.Path) + userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File) } else if config.AuthenticationBackend.Ldap != nil { userProvider = authentication.NewLDAPUserProvider(*config.AuthenticationBackend.Ldap) } else { @@ -120,7 +120,7 @@ func main() { }, } - rootCmd.AddCommand(versionCmd, commands.HashPasswordCmd, commands.MigrateCmd) + rootCmd.AddCommand(versionCmd, commands.MigrateCmd, commands.HashPasswordCmd) rootCmd.AddCommand(commands.CertificatesCmd) if err := rootCmd.Execute(); err != nil { log.Fatal(err) diff --git a/config.template.yml b/config.template.yml index b5a8b238..fd4dfd29 100644 --- a/config.template.yml +++ b/config.template.yml @@ -97,11 +97,21 @@ authentication_backend: # which is updated when users reset their passwords. # Therefore, this backend is meant to be used in a dev environment # and not in production since it prevents Authelia to be scaled to - # more than one instance. + # more than one instance. The options under password_options have sane + # defaults, and as it has security implications it is highly recommended + # you leave the default values. Before considering changing these settings + # please read the docs page below: + # https://docs.authelia.com/configuration/authentication/file.html#password-hash-algorithm-tuning # ## file: ## path: ./users_database.yml - + ## password_options: + ## algorithm: argon2id + ## iterations: 1 + ## key_length: 32 + ## salt_length: 16 + ## memory: ‭1048576‬ + ## parallelism: 8 # Access Control # # Access control is a list of rules defining the authorizations applied for one diff --git a/docs/configuration/authentication/file.md b/docs/configuration/authentication/file.md index af1be484..8424b297 100644 --- a/docs/configuration/authentication/file.md +++ b/docs/configuration/authentication/file.md @@ -18,59 +18,168 @@ file in the configuration file. authentication_backend: file: path: /var/lib/authelia/users.yml + password_hashing: + algorithm: argon2id + iterations: 1 + salt_length: 16 + parallelism: 8 + memory: 1024 + + +### Password hashing configuration settings + + #### algorithm + - Value Type: String + - Possible Value: `argon2id` and `sha512` + - Recommended: `argon2id` + - What it Does: Changes the hashing algorithm + + #### iterations + - Value Type: Int + - Possible Value: `1` or higher for argon2id and `1000` or higher for sha512 + (will automatically be set to `1000` on lower settings) + - Recommended: `1` for the `argon2id` algorithm and `50000` for `sha512` + - What it Does: Adjusts the number of times we run the password through the hashing algorithm + + #### key_length + - Value Type: Int + - Possible Value: `16` or higher. + - Recommended: `32` or higher. + - What it Does: Adjusts the length of the actual hash + + #### salt_length + - Value Type: Int + - Possible Value: between `2` and `16` + - Recommended: `16` + - What it Does: Adjusts the length of the random salt we add to the password, there + is no reason not to set this to 16 + + #### parallelism + - Value Type: Int + - Possible Value: `1` or higher + - Recommended: `8` or twice your CPU cores + - What it Does: Sets the number of threads used for hashing + + #### memory + - Value Type: Int + - Possible Value: at least `8` times the value of `parallelism` + - Recommended: `1024‬‬` (1GB) or as much RAM as you can afford to give to hashing + - What it Does: Sets the amount of RAM used in MB for hashing + + +#### Examples for specific systems + +These examples have been tested against a single system to make sure they roughly take +0.5 seconds each. Your results may vary depending on individual specification and +utilization, but they are a good guide to get started. You should however read +[How to choose the right parameters for Argon2]. + +| System |Iterations|Parallelism|Memory | +|:------------: |:--------:|:---------:|:-----:| +|Raspberry Pi 2 | 1 | 8 | 64 | +|Raspberry Pi 3 | 1 | 8 | 128 | +|Raspberry Pi 4 | 1 | 8 | 128 | +|Intel G5 i5 NUC| 1 | 8 | 1024 | ## Format - -The format of the file is as follows. +The format of the users file is as follows. users: john: - password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: john.doe@authelia.com groups: - admins - dev harry: - password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: harry.potter@authelia.com groups: [] bob: - password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: bob.dylan@authelia.com groups: - dev james: - password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: james.dean@authelia.com + This file should be set with read/write permissions as it could be updated by users resetting their passwords. - + ## Passwords -The file contains hash of passwords instead of plain text passwords for security reasons. +The file contains hashed passwords instead of plain text passwords for security reasons. -You can use authelia binary or docker image to generate the hash of any password. +You can use Authelia binary or docker image to generate the hash of any password. The +hash-password command has many tunable options, you can view them with the +`authelia hash-password --help` command. For example if you wanted to improve the entropy +you could generate a 16 byte salt and provide it with the `--salt` flag. +Example: `authelia hash-password --salt abcdefghijklhijl`. For argon2id the salt must +always be valid for base64 decoding (characters a through z, A through Z, 0 through 9, and +/). -For instance, with the docker image, just run +For instance to generate a hash with the docker image just run: $ docker run authelia/authelia:latest authelia hash-password yourpassword - $6$rounds=50000$BpLnfgDsc2WD8F2q$be7OyobnQ8K09dyDiGjY.cULh4yDePMh6CUMpLwF4WHTJmLcPE2ijM2ZsqZL.hVAANojEfDu3sU9u9uD7AeBJ/ + $ Password hash: $argon2id$v=19$m=65536$3oc26byQuSkQqksq$zM1QiTvVPrMfV6BVLs2t4gM+af5IN7euO0VB6+Q8ZFs +Full CLI Help Documentation: -## Password Hash Function +``` +Hash a password to be used in file-based users database. Default algorithm is argon2id. -The only supported hash function is salted sha512 determined by the prefix `$6$` as described -in this [wiki](https://en.wikipedia.org/wiki/Crypt_(C)) page. +Usage: + authelia hash-password [password] [flags] -Although not the best hash function, Salted SHA512 is a decent algorithm given the number of rounds is big -enough. It's not the best because the difficulty to crack the hash does not on the performance of the machine. -The best algorithm, [Argon2](https://en.wikipedia.org/wiki/Argon2) does though. It won the -[Password Hashing Competition](https://en.wikipedia.org/wiki/Password_Hashing_Competition) in 2015 and is now -considered the best hashing function. There is an open [issue](https://github.com/authelia/authelia/issues/577) -to add support for this hashing function. +Flags: + -h, --help help for hash-password + -i, --iterations int set the number of hashing iterations (default 1) + -k, --key-length int [argon2id] set the key length param (default 32) + -m, --memory int [argon2id] set the amount of memory param (in MB) (default 1024) + -p, --parallelism int [argon2id] set the parallelism param (default 8) + -s, --salt string set the salt string + -l, --salt-length int set the auto-generated salt length (default 16) + -z, --sha512 use sha512 as the algorithm (defaults iterations to 50000, change with -i) +``` + +## Password hash algorithm +The default hash algorithm is salted Argon2id version 19. Argon2id is currently considered +the best hashing algorithm, and in 2015 won the +[Password Hashing Competition](https://en.wikipedia.org/wiki/Password_Hashing_Competition). +It benefits from customizable parameters allowing the cost of computing a hash to scale +into the future which makes it harder to brute-force. Argon2id was implemented due to community +feedback as you can see in this closed [issue](https://github.com/authelia/authelia/issues/577). + +Additionally SHA512 is supported for backwards compatibility and user choice. While it's a reasonable +hash function given high enough iterations, as hardware gets better it has a higher chance of being +brute-forced. + +Hashes are identifiable as argon2id or SHA512 by their prefix of either `$argon2id$` and `$6$` +respectively, as described in this [wiki page](https://en.wikipedia.org/wiki/Crypt_(C)). + + ### Password hash algorithm tuning + + All algorithm tuning is supported for Argon2id. The only configuration variables that affect + SHA512 are iterations and salt length. The configuration variables are unique to the file + authentication provider, thus they all exist in a key under the file authentication configuration + key called `password_hashing`. We have set what are considered as sane and recommended defaults + to cater for a reasonable system, if you're unsure about which settings to tune, please see the + parameters above, or for a more in depth understanding see the referenced documentation. + + #### Argon2 Links + [How to choose the right parameters for Argon2] + + [How to choose the right parameters for Argon2](https://www.twelve21.io/how-to-choose-the-right-parameters-for-argon2/) + + [Go Documentation](https://godoc.org/golang.org/x/crypto/argon2) + + [IETF Draft](https://tools.ietf.org/id/draft-irtf-cfrg-argon2-09.html) + + +[How to choose the right parameters for Argon2]: https://www.twelve21.io/how-to-choose-the-right-parameters-for-argon2/ \ No newline at end of file diff --git a/docs/deployment/supported-proxies/index.md b/docs/deployment/supported-proxies/index.md index 41790709..00fdca5b 100644 --- a/docs/deployment/supported-proxies/index.md +++ b/docs/deployment/supported-proxies/index.md @@ -13,7 +13,7 @@ can find the documentation of the configuration required for every supported proxy. If you are not aware of the workflow of an authentication request, reading this -[documentation](./home/architecture) first is highly recommended. +[documentation](../../home/architecture.md) first is highly recommended. ## How Authelia integrates with proxies? @@ -22,8 +22,8 @@ Authelia takes authentication requests coming from the proxy and targeting the `/api/verify` endpoint exposed by Authelia. Two pieces of information are required for Authelia to be able to authenticate the user request: -* The session cookie or a `Proxy-Authorization` header (see [single factor authentication](./features/single-factor)). -* The target URL of the user request (used primarily for [access control](./features/access-control)). +* The session cookie or a `Proxy-Authorization` header (see [single factor authentication](../../features/single-factor.md)). +* The target URL of the user request (used primarily for [access control](../../features/access-control.md)). The target URL can be provided using one of the following ways: @@ -45,4 +45,4 @@ login portal if not authenticated yet. If no redirection parameter is provided, the response code is either 200 or 401. The redirection must then be handled by the proxy when an error is detected -(see [nginx](./deployment/supported-proxies/nginx) example). +(see [nginx](./nginx.md) example). diff --git a/docs/security/measures.md b/docs/security/measures.md index baa31182..a2f65028 100644 --- a/docs/security/measures.md +++ b/docs/security/measures.md @@ -28,6 +28,23 @@ that the attacker must also require the certificate to retrieve the cookies. Note that using [HSTS] has consequences. That's why you should read the blog post nginx has written on [HSTS]. +## Protections against password cracking (File authentication provider) + +Authelia implements a variety of measures to prevent an attacker cracking passwords if they +somehow obtain the file used by the file authentication provider, this is unrelated to LDAP auth. + +First and foremost Authelia only uses very secure hashing algorithms with sane and secure defaults. +The first and default hashing algorithm we use is Argon2id which is currently considered +the most secure hashing algorithm. We also support SHA512, which previously was the default. + +Secondly Authelia uses salting with all hashing algorithms. These salts are generated with a random +string generator, which is seeded every time it's used by a cryptographically secure 1024bit prime number. +This ensures that even if an attacker obtains the file, each password has to be brute forced individually. + +Lastly Authelia's implementation of Argon2id is highly tunable. You can tune the key length, salt +used, iterations (time), paralellism, and memory usage. To read more about this please read how to +[configure](../configuration/authentication/file.md) file authentication. + ## Notifier security measures (SMTP) By default the SMTP Notifier implementation does not allow connections that are not secure. @@ -100,4 +117,4 @@ add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "1; mode=block"; ``` -[HSTS]: https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/ +[HSTS]: https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/ \ No newline at end of file diff --git a/go.mod b/go.mod index 7e6c6093..01eacc94 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/onsi/gomega v1.7.1 // indirect github.com/otiai10/copy v1.0.2 github.com/pquerna/otp v1.2.0 - github.com/simia-tech/crypt v0.2.0 + github.com/simia-tech/crypt v0.4.2 github.com/sirupsen/logrus v1.4.2 github.com/spf13/cobra v0.0.5 github.com/spf13/viper v1.6.2 diff --git a/go.sum b/go.sum index d1922223..d6aea958 100644 --- a/go.sum +++ b/go.sum @@ -187,6 +187,8 @@ github.com/savsgio/gotils v0.0.0-20190925070755-524bc4f47500 h1:9Pi10H7E8E79/x2H github.com/savsgio/gotils v0.0.0-20190925070755-524bc4f47500/go.mod h1:lHhJedqxCoHN+zMtwGNTXWmF0u9Jt363FYRhV6g0CdY= github.com/simia-tech/crypt v0.2.0 h1:cU8qdqUYNuEFKSMq15yaB2aI1aC5vrn6dFOonT6Kg6o= github.com/simia-tech/crypt v0.2.0/go.mod h1:DMwvjPTzsiHrjqHVW5HvIbF4vUUzMCYDKVLsPWmLdTo= +github.com/simia-tech/crypt v0.4.2 h1:ZQFyCxgImhXpyxWNXEtBfAmV6T8dT1w481fpm8blQww= +github.com/simia-tech/crypt v0.4.2/go.mod h1:DMwvjPTzsiHrjqHVW5HvIbF4vUUzMCYDKVLsPWmLdTo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/internal/authentication/const.go b/internal/authentication/const.go index 3a6b67ec..1931b434 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -23,3 +23,22 @@ const ( // PossibleMethods is the set of all possible 2FA methods. var PossibleMethods = []string{TOTP, U2F, Push} + +const ( + //Argon2id Hash Identifier + HashingAlgorithmArgon2id = "argon2id" + //SHA512 Hash Identifier + HashingAlgorithmSHA512 = "6" +) + +// These are the default values from the upstream crypt module, we use them to for GetInt, and they need to be checked when updating github.com/simia-tech/crypt +const ( + HashingDefaultArgon2idTime = 1 + HashingDefaultArgon2idMemory = 32 * 1024 + HashingDefaultArgon2idParallelism = 4 + HashingDefaultArgon2idKeyLength = 32 + HashingDefaultSHA512Iterations = 5000 +) + +// Valid Hashing runes +var HashingPossibleSaltCharacters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/") diff --git a/internal/authentication/file_user_provider.go b/internal/authentication/file_user_provider.go index e8969e90..1c6c4709 100644 --- a/internal/authentication/file_user_provider.go +++ b/internal/authentication/file_user_provider.go @@ -1,21 +1,22 @@ package authentication import ( + "errors" "fmt" "io/ioutil" "strings" "sync" "github.com/asaskevich/govalidator" - + "github.com/authelia/authelia/internal/configuration/schema" "gopkg.in/yaml.v2" ) // FileUserProvider is a provider reading details from a file. type FileUserProvider struct { - path *string - database *DatabaseModel - lock *sync.Mutex + configuration *schema.FileAuthenticationBackendConfiguration + database *DatabaseModel + lock *sync.Mutex } // UserDetailsModel is the model of user details in the file database. @@ -31,8 +32,8 @@ type DatabaseModel struct { } // NewFileUserProvider creates a new instance of FileUserProvider. -func NewFileUserProvider(filepath string) *FileUserProvider { - database, err := readDatabase(filepath) +func NewFileUserProvider(configuration *schema.FileAuthenticationBackendConfiguration) *FileUserProvider { + database, err := readDatabase(configuration.Path) if err != nil { // Panic since the file does not exist when Authelia is starting. panic(err.Error()) @@ -45,9 +46,9 @@ func NewFileUserProvider(filepath string) *FileUserProvider { } return &FileUserProvider{ - path: &filepath, - database: database, - lock: &sync.Mutex{}, + configuration: configuration, + database: database, + lock: &sync.Mutex{}, } } @@ -114,9 +115,24 @@ func (p *FileUserProvider) UpdatePassword(username string, newPassword string) e return fmt.Errorf("User '%s' does not exist in database", username) } - hash := HashPassword(newPassword, "") - details.HashedPassword = fmt.Sprintf("{CRYPT}%s", hash) + var algorithm string + if p.configuration.PasswordHashing.Algorithm == "argon2id" { + algorithm = HashingAlgorithmArgon2id + } else if p.configuration.PasswordHashing.Algorithm == "sha512" { + algorithm = HashingAlgorithmSHA512 + } else { + return errors.New("Invalid algorithm in configuration. It should be `argon2id` or `sha512`") + } + hash, err := HashPassword( + newPassword, "", algorithm, p.configuration.PasswordHashing.Iterations, + p.configuration.PasswordHashing.Memory*1024, p.configuration.PasswordHashing.Parallelism, + p.configuration.PasswordHashing.KeyLength, p.configuration.PasswordHashing.SaltLength) + + if err != nil { + return err + } + details.HashedPassword = hash p.lock.Lock() p.database.Users[username] = details @@ -125,7 +141,7 @@ func (p *FileUserProvider) UpdatePassword(username string, newPassword string) e p.lock.Unlock() return err } - err = ioutil.WriteFile(*p.path, b, 0644) + err = ioutil.WriteFile(p.configuration.Path, b, 0644) p.lock.Unlock() return err } diff --git a/internal/authentication/file_user_provider_test.go b/internal/authentication/file_user_provider_test.go index 2e3d03a2..09fef413 100644 --- a/internal/authentication/file_user_provider_test.go +++ b/internal/authentication/file_user_provider_test.go @@ -4,8 +4,10 @@ import ( "io/ioutil" "log" "os" + "strings" "testing" + "github.com/authelia/authelia/internal/configuration/schema" "github.com/stretchr/testify/assert" ) @@ -29,9 +31,11 @@ func WithDatabase(content []byte, f func(path string)) { } } -func TestShouldCheckUserPasswordIsCorrect(t *testing.T) { +func TestShouldCheckUserArgon2idPasswordIsCorrect(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) ok, err := provider.CheckUserPassword("john", "password") assert.NoError(t, err) @@ -39,9 +43,23 @@ func TestShouldCheckUserPasswordIsCorrect(t *testing.T) { }) } +func TestShouldCheckUserSHA512PasswordIsCorrect(t *testing.T) { + WithDatabase(UserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) + ok, err := provider.CheckUserPassword("harry", "password") + + assert.NoError(t, err) + assert.True(t, ok) + }) +} + func TestShouldCheckUserPasswordIsWrong(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) ok, err := provider.CheckUserPassword("john", "wrong_password") assert.NoError(t, err) @@ -51,7 +69,9 @@ func TestShouldCheckUserPasswordIsWrong(t *testing.T) { func TestShouldCheckUserPasswordOfUnexistingUser(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) _, err := provider.CheckUserPassword("fake", "password") assert.Error(t, err) assert.Equal(t, "User 'fake' does not exist in database", err.Error()) @@ -60,7 +80,9 @@ func TestShouldCheckUserPasswordOfUnexistingUser(t *testing.T) { func TestShouldRetrieveUserDetails(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) details, err := provider.GetDetails("john") assert.NoError(t, err) assert.Equal(t, details.Emails, []string{"john.doe@authelia.com"}) @@ -70,45 +92,125 @@ func TestShouldRetrieveUserDetails(t *testing.T) { func TestShouldUpdatePassword(t *testing.T) { WithDatabase(UserDatabaseContent, func(path string) { - provider := NewFileUserProvider(path) - err := provider.UpdatePassword("john", "newpassword") + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) + err := provider.UpdatePassword("harry", "newpassword") assert.NoError(t, err) // Reset the provider to force a read from disk. - provider = NewFileUserProvider(path) - ok, err := provider.CheckUserPassword("john", "newpassword") + provider = NewFileUserProvider(&config) + ok, err := provider.CheckUserPassword("harry", "newpassword") assert.NoError(t, err) assert.True(t, ok) }) } +// Checks both that the hashing algo changes and that it removes {CRYPT} from the start. +func TestShouldUpdatePasswordHashingAlgorithmToArgon2id(t *testing.T) { + WithDatabase(UserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) + assert.True(t, strings.HasPrefix(provider.database.Users["harry"].HashedPassword, "{CRYPT}$6$")) + err := provider.UpdatePassword("harry", "newpassword") + assert.NoError(t, err) + + // Reset the provider to force a read from disk. + provider = NewFileUserProvider(&config) + ok, err := provider.CheckUserPassword("harry", "newpassword") + assert.NoError(t, err) + assert.True(t, ok) + assert.True(t, strings.HasPrefix(provider.database.Users["harry"].HashedPassword, "$argon2id$")) + }) +} + +func TestShouldUpdatePasswordHashingAlgorithmToSHA512(t *testing.T) { + WithDatabase(UserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + config.PasswordHashing.Algorithm = "sha512" + config.PasswordHashing.Iterations = 50000 + + provider := NewFileUserProvider(&config) + assert.True(t, strings.HasPrefix(provider.database.Users["john"].HashedPassword, "{CRYPT}$argon2id$")) + err := provider.UpdatePassword("john", "newpassword") + assert.NoError(t, err) + + // Reset the provider to force a read from disk. + provider = NewFileUserProvider(&config) + ok, err := provider.CheckUserPassword("john", "newpassword") + assert.NoError(t, err) + assert.True(t, ok) + assert.True(t, strings.HasPrefix(provider.database.Users["john"].HashedPassword, "$6$")) + }) +} + func TestShouldRaiseWhenLoadingMalformedDatabaseForFirstTime(t *testing.T) { WithDatabase(MalformedUserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path assert.PanicsWithValue(t, "Unable to parse database: yaml: line 4: mapping values are not allowed in this context", func() { - NewFileUserProvider(path) + NewFileUserProvider(&config) }) }) } func TestShouldRaiseWhenLoadingDatabaseWithBadSchemaForFirstTime(t *testing.T) { WithDatabase(BadSchemaUserDatabaseContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path assert.PanicsWithValue(t, "Invalid schema of database: Users: non zero value required", func() { - NewFileUserProvider(path) + NewFileUserProvider(&config) }) }) } -func TestShouldRaiseWhenLoadingDatabaseWithBadHashesForTheFirstTime(t *testing.T) { - WithDatabase(BadHashContent, func(path string) { - assert.PanicsWithValue(t, "Unable to parse hash of user john: Cannot match pattern 'rounds=' to find the number of rounds", func() { - NewFileUserProvider(path) +func TestShouldRaiseWhenLoadingDatabaseWithBadSHA512HashesForTheFirstTime(t *testing.T) { + WithDatabase(BadSHA512HashContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + assert.PanicsWithValue(t, "Unable to parse hash of user john: Hash key is not the last parameter, the hash is likely malformed ($6$rounds00000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/).", func() { + NewFileUserProvider(&config) + }) + }) +} + +func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashSettingsForTheFirstTime(t *testing.T) { + WithDatabase(BadArgon2idHashSettingsContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + assert.PanicsWithValue(t, "Unable to parse hash of user john: Hash key is not the last parameter, the hash is likely malformed ($argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM).", func() { + NewFileUserProvider(&config) + }) + }) +} + +func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashKeyForTheFirstTime(t *testing.T) { + WithDatabase(BadArgon2idHashKeyContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + assert.PanicsWithValue(t, "Unable to parse hash of user john: Hash key contains invalid base64 characters.", func() { + NewFileUserProvider(&config) + }) + }) +} + +func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashSaltForTheFirstTime(t *testing.T) { + WithDatabase(BadArgon2idHashSaltContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + assert.PanicsWithValue(t, "Unable to parse hash of user john: Salt contains invalid base64 characters.", func() { + NewFileUserProvider(&config) }) }) } func TestShouldSupportHashPasswordWithoutCRYPT(t *testing.T) { - WithDatabase(UserDatabaseWithouCryptContent, func(path string) { - provider := NewFileUserProvider(path) + WithDatabase(UserDatabaseWithoutCryptContent, func(path string) { + config := DefaultFileAuthenticationBackendConfiguration + config.Path = path + provider := NewFileUserProvider(&config) ok, err := provider.CheckUserPassword("john", "password") assert.NoError(t, err) @@ -116,10 +218,24 @@ func TestShouldSupportHashPasswordWithoutCRYPT(t *testing.T) { }) } +var ( + DefaultFileAuthenticationBackendConfiguration = schema.FileAuthenticationBackendConfiguration{ + Path: "", + PasswordHashing: &schema.PasswordHashingConfiguration{ + Iterations: schema.DefaultCIPasswordOptionsConfiguration.Iterations, + KeyLength: schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + SaltLength: schema.DefaultCIPasswordOptionsConfiguration.SaltLength, + Algorithm: schema.DefaultCIPasswordOptionsConfiguration.Algorithm, + Memory: schema.DefaultCIPasswordOptionsConfiguration.Memory, + Parallelism: schema.DefaultCIPasswordOptionsConfiguration.Parallelism, + }, + } +) + var UserDatabaseContent = []byte(` users: john: - password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" + password: "{CRYPT}$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" email: john.doe@authelia.com groups: - admins @@ -161,7 +277,7 @@ user: - dev `) -var UserDatabaseWithouCryptContent = []byte(` +var UserDatabaseWithoutCryptContent = []byte(` users: john: password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" @@ -174,7 +290,7 @@ users: email: james.dean@authelia.com `) -var BadHashContent = []byte(` +var BadSHA512HashContent = []byte(` users: john: password: "$6$rounds00000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" @@ -186,3 +302,35 @@ users: password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" email: james.dean@authelia.com `) + +var BadArgon2idHashSettingsContent = []byte(` +users: + john: + password: "$argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: john.doe@authelia.com + groups: + - admins + - dev + james: + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: james.dean@authelia.com +`) + +var BadArgon2idHashKeyContent = []byte(` +users: + john: + password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$^^vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: john.doe@authelia.com + groups: + - admins + - dev +`) +var BadArgon2idHashSaltContent = []byte(` +users: + john: + password: "$argon2id$v=19$m=65536,t=3,p=2$^^LnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + email: john.doe@authelia.com + groups: + - admins + - dev +`) diff --git a/internal/authentication/password_hash.go b/internal/authentication/password_hash.go index 4c3fc682..688f471f 100644 --- a/internal/authentication/password_hash.go +++ b/internal/authentication/password_hash.go @@ -3,87 +3,149 @@ package authentication import ( "errors" "fmt" - "log" - "math/rand" "strconv" "strings" + "github.com/authelia/authelia/internal/utils" "github.com/simia-tech/crypt" ) // PasswordHash represents all characteristics of a password hash. -// Authelia only supports salted SHA512 method, i.e., $6$ mode. +// Authelia only supports salted SHA512 or salted argon2id method, i.e., $6$ mode or $argon2id$ mode. type PasswordHash struct { - // The number of rounds. - Rounds int - // The salt with a max size of 16 characters for SHA512. - Salt string - // The password hash. - Hash string + Algorithm string + Iterations int + Salt string + Key string + KeyLength int + Memory int + Parallelism int } // ParseHash extracts all characteristics of a hash given its string representation. -func ParseHash(hash string) (*PasswordHash, error) { +func ParseHash(hash string) (passwordHash *PasswordHash, err error) { parts := strings.Split(hash, "$") - if len(parts) != 5 { - return nil, fmt.Errorf("Cannot parse the hash %s", hash) + // This error can be ignored as it's always nil + code, parameters, salt, key, _ := crypt.DecodeSettings(hash) + h := &PasswordHash{} + + h.Salt = salt + h.Key = key + + if h.Key != parts[len(parts)-1] { + return nil, fmt.Errorf("Hash key is not the last parameter, the hash is likely malformed (%s).", hash) + } + if h.Key == "" { + return nil, fmt.Errorf("Hash key contains no characters or the field length is invalid (%s)", hash) } - // Only supports salted sha 512. - if parts[1] != "6" { - return nil, fmt.Errorf("Authelia only supports salted SHA512 hashing ($6$), not $%s$", parts[1]) - } - - roundsKV := strings.Split(parts[2], "=") - if len(roundsKV) != 2 { - return nil, errors.New("Cannot match pattern 'rounds=' to find the number of rounds") - } - - rounds, err := strconv.ParseInt(roundsKV[1], 10, 0) + _, err = crypt.Base64Encoding.DecodeString(h.Salt) if err != nil { - return nil, fmt.Errorf("Cannot find the number of rounds from %s using pattern 'rounds='. Cause: %s", roundsKV[1], err.Error()) + return nil, errors.New("Salt contains invalid base64 characters.") } - return &PasswordHash{ - Rounds: int(rounds), - Salt: parts[3], - Hash: parts[4], - }, nil -} + if code == HashingAlgorithmSHA512 { + h.Iterations = parameters.GetInt("rounds", HashingDefaultSHA512Iterations) + h.Algorithm = HashingAlgorithmSHA512 + if parameters["rounds"] != "" && parameters["rounds"] != strconv.Itoa(h.Iterations) { + return nil, fmt.Errorf("SHA512 iterations is not numeric (%s).", parameters["rounds"]) + } + } else if code == HashingAlgorithmArgon2id { + version := parameters.GetInt("v", 0) + if version < 19 { + if version == 0 { + return nil, fmt.Errorf("Argon2id version parameter not found (%s)", hash) + } + return nil, fmt.Errorf("Argon2id versions less than v19 are not supported (hash is version %d).", version) + } else if version > 19 { + return nil, fmt.Errorf("Argon2id versions greater than v19 are not supported (hash is version %d).", version) + } + h.Algorithm = HashingAlgorithmArgon2id + h.Memory = parameters.GetInt("m", HashingDefaultArgon2idMemory) + h.Iterations = parameters.GetInt("t", HashingDefaultArgon2idTime) + h.Parallelism = parameters.GetInt("p", HashingDefaultArgon2idParallelism) + h.KeyLength = parameters.GetInt("k", HashingDefaultArgon2idKeyLength) -// The set of letters RandomString can pick in. -var possibleLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -// RandomString generate a random string of n characters. -func RandomString(n int) string { - b := make([]rune, n) - for i := range b { - b[i] = possibleLetters[rand.Intn(len(possibleLetters))] + decodedKey, err := crypt.Base64Encoding.DecodeString(h.Key) + if err != nil { + return nil, errors.New("Hash key contains invalid base64 characters.") + } + if len(decodedKey) != h.KeyLength { + return nil, fmt.Errorf("Argon2id key length parameter (%d) does not match the actual key length (%d).", h.KeyLength, len(decodedKey)) + } + } else { + return nil, fmt.Errorf("Authelia only supports salted SHA512 hashing ($6$) and salted argon2id ($argon2id$), not $%s$", code) } - return string(b) + return h, nil } // HashPassword generate a salt and hash the password with the salt and a constant // number of rounds. -func HashPassword(password string, salt string) string { +func HashPassword(password, salt, algorithm string, iterations, memory, parallelism, keyLength, saltLength int) (hash string, err error) { + var settings string + + if algorithm != HashingAlgorithmArgon2id && algorithm != HashingAlgorithmSHA512 { + return "", fmt.Errorf("Hashing algorithm input of '%s' is invalid, only values of %s and %s are supported.", algorithm, HashingAlgorithmArgon2id, HashingAlgorithmSHA512) + } + if salt == "" { - salt = fmt.Sprintf("$6$rounds=50000$%s", RandomString(16)) + if saltLength < 2 { + return "", fmt.Errorf("Salt length input of %d is invalid, it must be 2 or higher.", saltLength) + } else if saltLength > 16 { + return "", fmt.Errorf("Salt length input of %d is invalid, it must be 16 or lower.", saltLength) + } + } else if len(salt) > 16 { + return "", fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 16 or fewer characters.", salt, len(salt)) + } else if len(salt) < 2 { + return "", fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 2 or more characters.", salt, len(salt)) + } else if _, err = crypt.Base64Encoding.DecodeString(salt); err != nil { + return "", fmt.Errorf("Salt input of %s is invalid, only characters [a-zA-Z0-9+/] are valid for input.", salt) } - hash, err := crypt.Crypt(password, salt) - if err != nil { - log.Fatal(err) + + if algorithm == HashingAlgorithmArgon2id { + // Caution: Increasing any of the values in the below block has a high chance in old passwords that cannot be verified. + if memory < 8 { + return "", fmt.Errorf("Memory (argon2id) input of %d is invalid, it must be 8 or higher.", memory) + } + if parallelism < 1 { + return "", fmt.Errorf("Parallelism (argon2id) input of %d is invalid, it must be 1 or higher.", parallelism) + } + if memory < parallelism*8 { + return "", fmt.Errorf("Memory (argon2id) input of %d is invalid with a paraellelism input of %d, it must be %d (parallelism * 8) or higher.", memory, parallelism, parallelism*8) + } + if keyLength < 16 { + return "", fmt.Errorf("Key length (argon2id) input of %d is invalid, it must be 16 or higher.", keyLength) + } + if iterations < 1 { + return "", fmt.Errorf("Iterations (argon2id) input of %d is invalid, it must be 1 or more.", iterations) + } + // Caution: Increasing any of the values in the above block has a high chance in old passwords that cannot be verified. } - return hash + + if salt == "" { + salt = utils.RandomString(saltLength, HashingPossibleSaltCharacters) + } + if algorithm == HashingAlgorithmArgon2id { + settings, _ = crypt.Argon2idSettings(memory, iterations, parallelism, keyLength, salt) + } else if algorithm == HashingAlgorithmSHA512 { + settings = fmt.Sprintf("$6$rounds=%d$%s", iterations, salt) + } + + // This error can be ignored because we check for it before a user gets here + hash, _ = crypt.Crypt(password, settings) + return hash, nil } // CheckPassword check a password against a hash. -func CheckPassword(password string, hash string) (bool, error) { +func CheckPassword(password, hash string) (ok bool, err error) { passwordHash, err := ParseHash(hash) if err != nil { return false, err } - salt := fmt.Sprintf("$6$rounds=%d$%s$", passwordHash.Rounds, passwordHash.Salt) - pHash := HashPassword(password, salt) - return pHash == hash, nil + expectedHash, err := HashPassword(password, passwordHash.Salt, passwordHash.Algorithm, passwordHash.Iterations, passwordHash.Memory, passwordHash.Parallelism, passwordHash.KeyLength, len(passwordHash.Salt)) + if err != nil { + return false, err + } + return hash == expectedHash, nil } diff --git a/internal/authentication/password_hash_test.go b/internal/authentication/password_hash_test.go index 422b9c25..4b08c883 100644 --- a/internal/authentication/password_hash_test.go +++ b/internal/authentication/password_hash_test.go @@ -1,55 +1,323 @@ package authentication import ( + "fmt" "testing" + "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/utils" + "github.com/simia-tech/crypt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestShouldHashPassword(t *testing.T) { - hash := HashPassword("password", "$6$rounds=50000$aFr56HjK3DrB8t3S") - assert.Equal(t, "$6$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1", hash) +func TestShouldHashSHA512Password(t *testing.T) { + hash, err := HashPassword("password", "aFr56HjK3DrB8t3S", HashingAlgorithmSHA512, 50000, 0, 0, 0, 16) + + assert.NoError(t, err) + + code, parameters, salt, hash, err := crypt.DecodeSettings(hash) + + assert.Equal(t, "6", code) + assert.Equal(t, "aFr56HjK3DrB8t3S", salt) + assert.Equal(t, "zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1", hash) + assert.Equal(t, schema.DefaultPasswordOptionsSHA512Configuration.Iterations, parameters.GetInt("rounds", HashingDefaultSHA512Iterations)) } -func TestShouldCheckPassword(t *testing.T) { - ok, err := CheckPassword("password", "$6$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") +func TestShouldHashArgon2idPassword(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) assert.NoError(t, err) + + code, parameters, salt, key, err := crypt.DecodeSettings(hash) + + assert.NoError(t, err) + assert.Equal(t, "argon2id", code) + assert.Equal(t, "BpLnfgDsc2WD8F2q", salt) + assert.Equal(t, "O126GHPeZ5fwj7OLSs7PndXsTbje76R+QW9/EGfhkJg", key) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Iterations, parameters.GetInt("t", HashingDefaultArgon2idTime)) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, parameters.GetInt("m", HashingDefaultArgon2idMemory)) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, parameters.GetInt("p", HashingDefaultArgon2idParallelism)) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, parameters.GetInt("k", HashingDefaultArgon2idKeyLength)) +} + +// This checks the method of hashing (for argon2id) supports all the characters we allow in Authelia's hash function +func TestArgon2idHashSaltValidValues(t *testing.T) { + data := string(HashingPossibleSaltCharacters) + datas := utils.SliceString(data, 16) + var hash string + var err error + for _, salt := range datas { + hash, err = HashPassword("password", salt, HashingAlgorithmArgon2id, 1, 8, 1, 32, 16) + assert.NoError(t, err) + assert.Equal(t, fmt.Sprintf("$argon2id$v=19$m=8,p=1$%s$", salt), hash[0:40]) + } +} + +// This checks the method of hashing (for sha512) supports all the characters we allow in Authelia's hash function +func TestSHA512HashSaltValidValues(t *testing.T) { + data := string(HashingPossibleSaltCharacters) + datas := utils.SliceString(data, 16) + var hash string + var err error + for _, salt := range datas { + hash, err = HashPassword("password", salt, HashingAlgorithmSHA512, 1000, 0, 0, 0, 16) + assert.NoError(t, err) + assert.Equal(t, fmt.Sprintf("$6$rounds=1000$%s$", salt), hash[0:32]) + } +} + +func TestShouldNotHashPasswordWithNonExistentAlgorithm(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", "bogus", + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Hashing algorithm input of 'bogus' is invalid, only values of argon2id and 6 are supported.") +} + +func TestShouldNotHashArgon2idPasswordDueToMemoryParallelismMismatch(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, 8, 2, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Memory (argon2id) input of 8 is invalid with a paraellelism input of 2, it must be 16 (parallelism * 8) or higher.") +} + +func TestShouldNotHashArgon2idPasswordDueToMemoryLessThanEight(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, 1, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Memory (argon2id) input of 1 is invalid, it must be 8 or higher.") +} + +func TestShouldNotHashArgon2idPasswordDueToKeyLengthLessThanSixteen(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, 5, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Key length (argon2id) input of 5 is invalid, it must be 16 or higher.") +} + +func TestShouldNotHashArgon2idPasswordDueParallelismLessThanOne(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, -1, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Parallelism (argon2id) input of -1 is invalid, it must be 1 or higher.") +} + +func TestShouldNotHashArgon2idPasswordDueIterationsLessThanOne(t *testing.T) { + hash, err := HashPassword("password", "BpLnfgDsc2WD8F2q", HashingAlgorithmArgon2id, + 0, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Iterations (argon2id) input of 0 is invalid, it must be 1 or more.") +} + +func TestShouldNotHashPasswordDueToSaltLength(t *testing.T) { + hash, err := HashPassword("password", "", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, 0) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt length input of 0 is invalid, it must be 2 or higher.") + + hash, err = HashPassword("password", "", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, 20) + + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt length input of 20 is invalid, it must be 16 or lower.") +} + +func TestShouldNotHashPasswordDueToSaltCharLengthTooLong(t *testing.T) { + hash, err := HashPassword("password", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt input of abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 is invalid (62 characters), it must be 16 or fewer characters.") +} + +func TestShouldNotHashPasswordDueToSaltCharLengthTooShort(t *testing.T) { + hash, err := HashPassword("password", "a", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt input of a is invalid (1 characters), it must be 2 or more characters.") +} + +func TestShouldNotHashPasswordWithNonBase64CharsInSalt(t *testing.T) { + hash, err := HashPassword("password", "abc&123", HashingAlgorithmArgon2id, + schema.DefaultCIPasswordOptionsConfiguration.Iterations, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, + schema.DefaultCIPasswordOptionsConfiguration.Parallelism, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, + schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + assert.Equal(t, "", hash) + assert.EqualError(t, err, "Salt input of abc&123 is invalid, only characters [a-zA-Z0-9+/] are valid for input.") +} + +func TestShouldNotParseHashWithNoneBase64CharsInKey(t *testing.T) { + passwordHash, err := ParseHash("$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$^^vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + assert.EqualError(t, err, "Hash key contains invalid base64 characters.") + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseHashWithNoneBase64CharsInSalt(t *testing.T) { + passwordHash, err := ParseHash("$argon2id$v=19$m=65536$^^wTFoFjITudo57a$Z4NH/EKkdv6PJ01Ye1twJ61fsmRJujZZn1IXdUOyrJY") + assert.EqualError(t, err, "Salt contains invalid base64 characters.") + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseWithMalformedHash(t *testing.T) { + hashExtraField := "$argon2id$v=19$m=65536,t=3,p=2$abc$BpLnfgDsc2WD8F2q$^^vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM" + hashMissingSaltAndParams := "$argon2id$v=1$2t9X8nNCN2n3/kFYJ3xWNBg5k/rO782Qr7JJoJIK7G4" + hashMissingSalt := "$argon2id$v=1$m=65536,t=3,p=2$2t9X8nNCN2n3/kFYJ3xWNBg5k/rO782Qr7JJoJIK7G4" + + passwordHash, err := ParseHash(hashExtraField) + assert.EqualError(t, err, fmt.Sprintf("Hash key is not the last parameter, the hash is likely malformed (%s).", hashExtraField)) + assert.Nil(t, passwordHash) + + passwordHash, err = ParseHash(hashMissingSaltAndParams) + assert.EqualError(t, err, fmt.Sprintf("Hash key is not the last parameter, the hash is likely malformed (%s).", hashMissingSaltAndParams)) + assert.Nil(t, passwordHash) + + passwordHash, err = ParseHash(hashMissingSalt) + assert.EqualError(t, err, fmt.Sprintf("Hash key is not the last parameter, the hash is likely malformed (%s).", hashMissingSalt)) + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseHashWithEmptyKey(t *testing.T) { + hash := "$argon2id$v=19$m=65536$fvwTFoFjITudo57a$" + passwordHash, err := ParseHash(hash) + assert.EqualError(t, err, fmt.Sprintf("Hash key contains no characters or the field length is invalid (%s)", hash)) + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseArgon2idHashWithEmptyVersion(t *testing.T) { + hash := "$argon2id$m=65536$fvwTFoFjITudo57a$Z4NH/EKkdv6PJ01Ye1twJ61fsmRJujZZn1IXdUOyrJY" + passwordHash, err := ParseHash(hash) + assert.EqualError(t, err, fmt.Sprintf("Argon2id version parameter not found (%s)", hash)) + assert.Nil(t, passwordHash) +} + +func TestShouldNotParseArgon2idHashWithWrongKeyLength(t *testing.T) { + hash := "$argon2id$v=19$m=65536,k=50$fvwTFoFjITudo57a$Z4NH/EKkdv6PJ01Ye1twJ61fsmRJujZZn1IXdUOyrJY" + passwordHash, err := ParseHash(hash) + assert.EqualError(t, err, "Argon2id key length parameter (50) does not match the actual key length (32).") + assert.Nil(t, passwordHash) +} + +func TestShouldParseArgon2idHash(t *testing.T) { + passwordHash, err := ParseHash("$argon2id$v=19$m=131072,t=1,p=8$BpLnfgDsc2WD8F2q$G4fD5nJwXHDMS+u0eEMKvU0LF23jxbSmJSxhSLTteHE") + assert.NoError(t, err) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Iterations, passwordHash.Iterations) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, passwordHash.Parallelism) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.KeyLength, passwordHash.KeyLength) + assert.Equal(t, schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, passwordHash.Memory) +} + +func TestShouldCheckSHA512Password(t *testing.T) { + ok, err := CheckPassword("password", "$6$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") + assert.NoError(t, err) assert.True(t, ok) } -func TestCannotParseHash(t *testing.T) { +func TestShouldCheckArgon2idPassword(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + assert.NoError(t, err) + assert.True(t, ok) +} + +func TestCannotParseSHA512Hash(t *testing.T) { ok, err := CheckPassword("password", "$6$roSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") - assert.EqualError(t, err, "Cannot parse the hash $6$roSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") + assert.EqualError(t, err, "Hash key is not the last parameter, the hash is likely malformed ($6$roSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1).") assert.False(t, ok) } -func TestOnlySupportSHA512(t *testing.T) { +func TestCannotParseArgon2idHash(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + + assert.EqualError(t, err, "Hash key is not the last parameter, the hash is likely malformed ($argon2id$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM).") + assert.False(t, ok) +} + +func TestOnlySupportSHA512AndArgon2id(t *testing.T) { ok, err := CheckPassword("password", "$8$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") - assert.EqualError(t, err, "Authelia only supports salted SHA512 hashing ($6$), not $8$") + assert.EqualError(t, err, "Authelia only supports salted SHA512 hashing ($6$) and salted argon2id ($argon2id$), not $8$") assert.False(t, ok) } func TestCannotFindNumberOfRounds(t *testing.T) { - ok, err := CheckPassword("password", "$6$rounds50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") + hash := "$6$rounds50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1" + ok, err := CheckPassword("password", hash) - assert.EqualError(t, err, "Cannot match pattern 'rounds=' to find the number of rounds") + assert.EqualError(t, err, fmt.Sprintf("Hash key is not the last parameter, the hash is likely malformed (%s).", hash)) + assert.False(t, ok) +} + +func TestCannotMatchArgon2idParamPattern(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + + assert.EqualError(t, err, "Hash key is not the last parameter, the hash is likely malformed ($argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM).") + assert.False(t, ok) +} + +func TestArgon2idVersionLessThanSupported(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$v=18$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + + assert.EqualError(t, err, "Argon2id versions less than v19 are not supported (hash is version 18).") + assert.False(t, ok) +} + +func TestArgon2idVersionGreaterThanSupported(t *testing.T) { + ok, err := CheckPassword("password", "$argon2id$v=20$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM") + + assert.EqualError(t, err, "Argon2id versions greater than v19 are not supported (hash is version 20).") assert.False(t, ok) } func TestNumberOfRoundsNotInt(t *testing.T) { ok, err := CheckPassword("password", "$6$rounds=abc$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1") - assert.EqualError(t, err, "Cannot find the number of rounds from abc using pattern 'rounds='. Cause: strconv.ParseInt: parsing \"abc\": invalid syntax") + assert.EqualError(t, err, "SHA512 iterations is not numeric (abc).") assert.False(t, ok) } -func TestShouldCheckPasswordHashedWithAuthelia(t *testing.T) { +func TestShouldCheckPasswordArgon2idHashedWithAuthelia(t *testing.T) { password := "my;secure*password" - hash := HashPassword(password, "") + hash, err := HashPassword(password, "", HashingAlgorithmArgon2id, schema.DefaultCIPasswordOptionsConfiguration.Iterations, + schema.DefaultCIPasswordOptionsConfiguration.Memory*1024, schema.DefaultCIPasswordOptionsConfiguration.Parallelism, + schema.DefaultCIPasswordOptionsConfiguration.KeyLength, schema.DefaultCIPasswordOptionsConfiguration.SaltLength) + + assert.NoError(t, err) + + equal, err := CheckPassword(password, hash) + + require.NoError(t, err) + assert.True(t, equal) +} + +func TestShouldCheckPasswordSHA512HashedWithAuthelia(t *testing.T) { + password := "my;secure*password" + hash, err := HashPassword(password, "", HashingAlgorithmSHA512, schema.DefaultPasswordOptionsSHA512Configuration.Iterations, + 0, 0, 0, schema.DefaultPasswordOptionsSHA512Configuration.SaltLength) + + assert.NoError(t, err) + equal, err := CheckPassword(password, hash) require.NoError(t, err) diff --git a/internal/commands/hash.go b/internal/commands/hash.go index 32be714d..0c27cbb6 100644 --- a/internal/commands/hash.go +++ b/internal/commands/hash.go @@ -4,14 +4,51 @@ import ( "fmt" "github.com/authelia/authelia/internal/authentication" + "github.com/authelia/authelia/internal/configuration/schema" "github.com/spf13/cobra" ) +func init() { + HashPasswordCmd.Flags().BoolP("sha512", "z", false, fmt.Sprintf("use sha512 as the algorithm (changes iterations to %d, change with -i)", schema.DefaultPasswordOptionsSHA512Configuration.Iterations)) + HashPasswordCmd.Flags().IntP("iterations", "i", schema.DefaultPasswordOptionsConfiguration.Iterations, "set the number of hashing iterations") + HashPasswordCmd.Flags().StringP("salt", "s", "", "set the salt string") + HashPasswordCmd.Flags().IntP("memory", "m", schema.DefaultPasswordOptionsConfiguration.Memory, "[argon2id] set the amount of memory param (in MB)") + HashPasswordCmd.Flags().IntP("parallelism", "p", schema.DefaultPasswordOptionsConfiguration.Parallelism, "[argon2id] set the parallelism param") + HashPasswordCmd.Flags().IntP("key-length", "k", schema.DefaultPasswordOptionsConfiguration.KeyLength, "[argon2id] set the key length param") + HashPasswordCmd.Flags().IntP("salt-length", "l", schema.DefaultPasswordOptionsConfiguration.SaltLength, "set the auto-generated salt length") +} + var HashPasswordCmd = &cobra.Command{ Use: "hash-password [password]", - Short: "Hash a password to be used in file-based users database", + Short: "Hash a password to be used in file-based users database. Default algorithm is argon2id.", Run: func(cobraCmd *cobra.Command, args []string) { - fmt.Println(authentication.HashPassword(args[0], "")) + sha512, _ := cobraCmd.Flags().GetBool("sha512") + iterations, _ := cobraCmd.Flags().GetInt("iterations") + salt, _ := cobraCmd.Flags().GetString("salt") + keyLength, _ := cobraCmd.Flags().GetInt("key-length") + saltLength, _ := cobraCmd.Flags().GetInt("salt-length") + memory, _ := cobraCmd.Flags().GetInt("memory") + parallelism, _ := cobraCmd.Flags().GetInt("parallelism") + + var err error + var hash string + var algorithm string + + if sha512 { + if iterations == schema.DefaultPasswordOptionsConfiguration.Iterations { + iterations = schema.DefaultPasswordOptionsSHA512Configuration.Iterations + } + algorithm = authentication.HashingAlgorithmSHA512 + } else { + algorithm = authentication.HashingAlgorithmArgon2id + } + + hash, err = authentication.HashPassword(args[0], salt, algorithm, iterations, memory*1024, parallelism, keyLength, saltLength) + if err != nil { + fmt.Println(fmt.Sprintf("Error occured during hashing: %s", err)) + } else { + fmt.Println(fmt.Sprintf("Password hash: %s", hash)) + } }, Args: cobra.MinimumNArgs(1), } diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index b7f2f1d0..6b6a85e2 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -17,7 +17,44 @@ type LDAPAuthenticationBackendConfiguration struct { // FileAuthenticationBackendConfiguration represents the configuration related to file-based backend type FileAuthenticationBackendConfiguration struct { - Path string `mapstructure:"path"` + Path string `mapstructure:"path"` + PasswordHashing *PasswordHashingConfiguration `mapstructure:"password"` +} + +type PasswordHashingConfiguration struct { + Iterations int `mapstructure:"iterations"` + KeyLength int `mapstructure:"key_length"` + SaltLength int `mapstructure:"salt_length"` + Algorithm string `mapstrucutre:"algorithm"` + Memory int `mapstructure:"memory"` + Parallelism int `mapstructure:"parallelism"` +} + +// Default Argon2id Configuration +var DefaultPasswordOptionsConfiguration = PasswordHashingConfiguration{ + Iterations: 1, + KeyLength: 32, + SaltLength: 16, + Algorithm: "argon2id", + Memory: 1024, + Parallelism: 8, +} + +// Default Argon2id Configuration for CI testing when calling HashPassword() +var DefaultCIPasswordOptionsConfiguration = PasswordHashingConfiguration{ + Iterations: 1, + KeyLength: 32, + SaltLength: 16, + Algorithm: "argon2id", + Memory: 128, + Parallelism: 8, +} + +// Default SHA512 Cofniguration +var DefaultPasswordOptionsSHA512Configuration = PasswordHashingConfiguration{ + Iterations: 50000, + SaltLength: 16, + Algorithm: "sha512", } // AuthenticationBackendConfiguration represents the configuration related to the authentication backend. diff --git a/internal/configuration/validator/authentication.go b/internal/configuration/validator/authentication.go index f9968e91..7bc06e9c 100644 --- a/internal/configuration/validator/authentication.go +++ b/internal/configuration/validator/authentication.go @@ -16,6 +16,63 @@ func validateFileAuthenticationBackend(configuration *schema.FileAuthenticationB if configuration.Path == "" { validator.Push(errors.New("Please provide a `path` for the users database in `authentication_backend`")) } + + if configuration.PasswordHashing == nil { + configuration.PasswordHashing = &schema.DefaultPasswordOptionsConfiguration + } else { + if configuration.PasswordHashing.Algorithm == "" { + configuration.PasswordHashing.Algorithm = schema.DefaultPasswordOptionsConfiguration.Algorithm + } else { + configuration.PasswordHashing.Algorithm = strings.ToLower(configuration.PasswordHashing.Algorithm) + if configuration.PasswordHashing.Algorithm != "argon2id" && configuration.PasswordHashing.Algorithm != "sha512" { + validator.Push(fmt.Errorf("Unknown hashing algorithm supplied, valid values are argon2id and sha512, you configured '%s'", configuration.PasswordHashing.Algorithm)) + } + } + + // Iterations (time) + if configuration.PasswordHashing.Iterations == 0 { + if configuration.PasswordHashing.Algorithm == "argon2id" { + configuration.PasswordHashing.Iterations = schema.DefaultPasswordOptionsConfiguration.Iterations + } else { + configuration.PasswordHashing.Iterations = schema.DefaultPasswordOptionsSHA512Configuration.Iterations + } + } else if configuration.PasswordHashing.Iterations < 1 { + validator.Push(fmt.Errorf("The number of iterations specified is invalid, must be 1 or more, you configured %d", configuration.PasswordHashing.Iterations)) + } + + //Salt Length + if configuration.PasswordHashing.SaltLength == 0 { + configuration.PasswordHashing.SaltLength = schema.DefaultPasswordOptionsConfiguration.SaltLength + } else if configuration.PasswordHashing.SaltLength < 2 { + validator.Push(fmt.Errorf("The salt length must be 2 or more, you configured %d", configuration.PasswordHashing.SaltLength)) + } else if configuration.PasswordHashing.SaltLength > 16 { + validator.Push(fmt.Errorf("The salt length must be 16 or less, you configured %d", configuration.PasswordHashing.SaltLength)) + } + + if configuration.PasswordHashing.Algorithm == "argon2id" { + + // Parallelism + if configuration.PasswordHashing.Parallelism == 0 { + configuration.PasswordHashing.Parallelism = schema.DefaultPasswordOptionsConfiguration.Parallelism + } else if configuration.PasswordHashing.Parallelism < 1 { + validator.Push(fmt.Errorf("Parallelism for argon2id must be 1 or more, you configured %d", configuration.PasswordHashing.Parallelism)) + } + + // Memory + if configuration.PasswordHashing.Memory == 0 { + configuration.PasswordHashing.Memory = schema.DefaultPasswordOptionsConfiguration.Memory + } else if configuration.PasswordHashing.Memory < configuration.PasswordHashing.Parallelism*8 { + validator.Push(fmt.Errorf("Memory for argon2id must be %d or more (parallelism * 8), you configured memory as %d and parallelism as %d", configuration.PasswordHashing.Parallelism*8, configuration.PasswordHashing.Memory, configuration.PasswordHashing.Parallelism)) + } + + // Key Length + if configuration.PasswordHashing.KeyLength == 0 { + configuration.PasswordHashing.KeyLength = schema.DefaultPasswordOptionsConfiguration.KeyLength + } else if configuration.PasswordHashing.KeyLength < 16 { + validator.Push(fmt.Errorf("Key length for argon2id must be 16, you configured %d", configuration.PasswordHashing.KeyLength)) + } + } + } } func validateLdapURL(ldapURL string, validator *schema.StructValidator) string { diff --git a/internal/configuration/validator/authentication_test.go b/internal/configuration/validator/authentication_test.go index f61dbebd..a3333f17 100644 --- a/internal/configuration/validator/authentication_test.go +++ b/internal/configuration/validator/authentication_test.go @@ -28,9 +28,16 @@ type FileBasedAuthenticationBackend struct { func (suite *FileBasedAuthenticationBackend) SetupTest() { suite.validator = schema.NewStructValidator() suite.configuration = schema.AuthenticationBackendConfiguration{} - suite.configuration.File = &schema.FileAuthenticationBackendConfiguration{Path: "/a/path"} + suite.configuration.File = &schema.FileAuthenticationBackendConfiguration{Path: "/a/path", PasswordHashing: &schema.PasswordHashingConfiguration{ + Algorithm: schema.DefaultPasswordOptionsConfiguration.Algorithm, + Iterations: schema.DefaultPasswordOptionsConfiguration.Iterations, + Parallelism: schema.DefaultPasswordOptionsConfiguration.Parallelism, + Memory: schema.DefaultPasswordOptionsConfiguration.Memory, + KeyLength: schema.DefaultPasswordOptionsConfiguration.KeyLength, + SaltLength: schema.DefaultPasswordOptionsConfiguration.SaltLength, + }} + suite.configuration.File.PasswordHashing.Algorithm = schema.DefaultPasswordOptionsConfiguration.Algorithm } - func (suite *FileBasedAuthenticationBackend) TestShouldValidateCompleteConfiguration() { ValidateAuthenticationBackend(&suite.configuration, suite.validator) assert.Len(suite.T(), suite.validator.Errors(), 0) @@ -43,6 +50,107 @@ func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenNoPathProvi assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a `path` for the users database in `authentication_backend`") } +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenMemoryNotMoreThanEightTimesParallelism() { + suite.configuration.File.PasswordHashing.Memory = 8 + suite.configuration.File.PasswordHashing.Parallelism = 2 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Memory for argon2id must be 16 or more (parallelism * 8), you configured memory as 8 and parallelism as 2") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultConfigurationWhenBlank() { + suite.configuration.File.PasswordHashing = &schema.PasswordHashingConfiguration{} + + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.KeyLength) + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.Iterations) + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.SaltLength) + assert.Equal(suite.T(), "", suite.configuration.File.PasswordHashing.Algorithm) + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.Memory) + assert.Equal(suite.T(), 0, suite.configuration.File.PasswordHashing.Parallelism) + + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + + assert.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.KeyLength, suite.configuration.File.PasswordHashing.KeyLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Iterations, suite.configuration.File.PasswordHashing.Iterations) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.SaltLength, suite.configuration.File.PasswordHashing.SaltLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Algorithm, suite.configuration.File.PasswordHashing.Algorithm) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Memory, suite.configuration.File.PasswordHashing.Memory) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Parallelism, suite.configuration.File.PasswordHashing.Parallelism) +} + +func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultConfigurationWhenOnlySHA512Set() { + suite.configuration.File.PasswordHashing = &schema.PasswordHashingConfiguration{} + assert.Equal(suite.T(), "", suite.configuration.File.PasswordHashing.Algorithm) + suite.configuration.File.PasswordHashing.Algorithm = "sha512" + + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + + assert.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.KeyLength, suite.configuration.File.PasswordHashing.KeyLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.Iterations, suite.configuration.File.PasswordHashing.Iterations) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.SaltLength, suite.configuration.File.PasswordHashing.SaltLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.Algorithm, suite.configuration.File.PasswordHashing.Algorithm) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.Memory, suite.configuration.File.PasswordHashing.Memory) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsSHA512Configuration.Parallelism, suite.configuration.File.PasswordHashing.Parallelism) +} +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenKeyLengthTooLow() { + suite.configuration.File.PasswordHashing.KeyLength = 1 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Key length for argon2id must be 16, you configured 1") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenSaltLengthTooLow() { + suite.configuration.File.PasswordHashing.SaltLength = -1 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "The salt length must be 2 or more, you configured -1") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenSaltLengthTooHigh() { + suite.configuration.File.PasswordHashing.SaltLength = 20 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "The salt length must be 16 or less, you configured 20") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenBadAlgorithmDefined() { + suite.configuration.File.PasswordHashing.Algorithm = "bogus" + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Unknown hashing algorithm supplied, valid values are argon2id and sha512, you configured 'bogus'") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenIterationsTooLow() { + suite.configuration.File.PasswordHashing.Iterations = -1 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "The number of iterations specified is invalid, must be 1 or more, you configured -1") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldRaiseErrorWhenParallelismTooLow() { + suite.configuration.File.PasswordHashing.Parallelism = -1 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Parallelism for argon2id must be 1 or more, you configured -1") +} + +func (suite *FileBasedAuthenticationBackend) TestShouldSetDefaultValues() { + suite.configuration.File.PasswordHashing.Algorithm = "" + suite.configuration.File.PasswordHashing.Iterations = 0 + suite.configuration.File.PasswordHashing.SaltLength = 0 + suite.configuration.File.PasswordHashing.Memory = 0 + suite.configuration.File.PasswordHashing.Parallelism = 0 + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Algorithm, suite.configuration.File.PasswordHashing.Algorithm) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Iterations, suite.configuration.File.PasswordHashing.Iterations) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.SaltLength, suite.configuration.File.PasswordHashing.SaltLength) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Memory, suite.configuration.File.PasswordHashing.Memory) + assert.Equal(suite.T(), schema.DefaultPasswordOptionsConfiguration.Parallelism, suite.configuration.File.PasswordHashing.Parallelism) +} + func TestFileBasedAuthenticationBackend(t *testing.T) { suite.Run(t, new(FileBasedAuthenticationBackend)) } diff --git a/internal/utils/strings.go b/internal/utils/strings.go index 805d0288..32cdd865 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -1,6 +1,12 @@ package utils -func IsStringInSlice(a string, list []string) bool { +import ( + "math/rand" + "time" +) + +// Checks if a single string is in an array of strings +func IsStringInSlice(a string, list []string) (inSlice bool) { for _, b := range list { if b == a { return true @@ -8,3 +14,28 @@ func IsStringInSlice(a string, list []string) bool { } return false } + +// Splits a string s into an array with each item being a max of int d +// d = denominator, n = numerator, q = quotient, r = remainder +func SliceString(s string, d int) (array []string) { + n := len(s) + q := n / d + r := n % d + for i := 0; i < q; i++ { + array = append(array, s[i*d:i*d+d]) + if i+1 == q && r != 0 { + array = append(array, s[i*d+d:]) + } + } + return +} + +// RandomString generate a random string of n characters +func RandomString(n int, characters []rune) (randomString string) { + rand.Seed(time.Now().UnixNano()) + b := make([]rune, n) + for i := range b { + b[i] = characters[rand.Intn(len(characters))] + } + return string(b) +} diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go new file mode 100644 index 00000000..71523836 --- /dev/null +++ b/internal/utils/strings_test.go @@ -0,0 +1,37 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldSplitIntoEvenStringsOfFour(t *testing.T) { + input := "abcdefghijkl" + arrayOfStrings := SliceString(input, 4) + assert.Equal(t, len(arrayOfStrings), 3) + assert.Equal(t, "abcd", arrayOfStrings[0]) + assert.Equal(t, "efgh", arrayOfStrings[1]) + assert.Equal(t, "ijkl", arrayOfStrings[2]) +} + +func TestShouldSplitIntoEvenStringsOfOne(t *testing.T) { + input := "abcdefghijkl" + arrayOfStrings := SliceString(input, 1) + assert.Equal(t, 12, len(arrayOfStrings)) + assert.Equal(t, "a", arrayOfStrings[0]) + assert.Equal(t, "b", arrayOfStrings[1]) + assert.Equal(t, "c", arrayOfStrings[2]) + assert.Equal(t, "d", arrayOfStrings[3]) + assert.Equal(t, "l", arrayOfStrings[11]) +} + +func TestShouldSplitIntoUnevenStringsOfFour(t *testing.T) { + input := "abcdefghijklm" + arrayOfStrings := SliceString(input, 4) + assert.Equal(t, len(arrayOfStrings), 4) + assert.Equal(t, "abcd", arrayOfStrings[0]) + assert.Equal(t, "efgh", arrayOfStrings[1]) + assert.Equal(t, "ijkl", arrayOfStrings[2]) + assert.Equal(t, "m", arrayOfStrings[3]) +}