mirror of
https://github.com/0rangebananaspy/authelia.git
synced 2024-09-14 22:47:21 +07:00
[FEATURE] Support Argon2id password hasing and improved entropy (#679)
* [FEATURE] Support Argon2id Passwords - Updated go module github.com/simia-tech/crypt - Added Argon2id support for file based authentication backend - Made it the default method - Made it so backwards compatibility with SHA512 exists - Force seeding of the random string generator used for salts to ensure they are all different - Added command params to the authelia hash-password command - Automatically remove {CRYPT} from hashes as they are updated - Automatically change hashes when they are updated to the configured algorithm - Made the hashing algorithm parameters completely configurable - Added reasonably comprehensive test suites - Updated docs - Updated config template * Adjust error output * Fix unit test * Add unit tests and argon2 version check * Fix new unit tests * Update docs, added tests * Implement configurable values and more comprehensive testing * Added cmd params to hash_password, updated docs, misc fixes * More detailed error for cmd, fixed a typo * Fixed cmd flag error, minor refactoring * Requested Changes and Minor refactoring * Increase entropy * Update docs for entropy changes * Refactor to reduce nesting and easier code maintenance * Cleanup Errors (uniformity for the function call) * Check salt length, fix docs * Add Base64 string validation for argon2id * Cleanup and Finalization - Moved RandomString function from ./internal/authentication/password_hash.go to ./internal/utils/strings.go - Added SplitStringToArrayOfStrings func that splits strings into an array with a fixed max string len - Fixed an error in validator that would allow a zero salt length - Added a test to verify the upstream crypt module supports our defined random salt chars - Updated docs - Removed unused "HashingAlgorithm" string type * Update crypt go mod, support argon2id key length and major refactor * Config Template Update, Final Tests * Use schema defaults for hash-password cmd * Iterations check * Docs requested changes * Test Coverage, suggested edits * Wording edit * Doc changes * Default sanity changes * Default sanity changes - docs * CI Sanity changes * Memory in MB
This commit is contained in:
parent
72a3f1e0d7
commit
26369fff3d
|
@ -59,7 +59,7 @@ func startServer() {
|
||||||
var userProvider authentication.UserProvider
|
var userProvider authentication.UserProvider
|
||||||
|
|
||||||
if config.AuthenticationBackend.File != nil {
|
if config.AuthenticationBackend.File != nil {
|
||||||
userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File.Path)
|
userProvider = authentication.NewFileUserProvider(config.AuthenticationBackend.File)
|
||||||
} else if config.AuthenticationBackend.Ldap != nil {
|
} else if config.AuthenticationBackend.Ldap != nil {
|
||||||
userProvider = authentication.NewLDAPUserProvider(*config.AuthenticationBackend.Ldap)
|
userProvider = authentication.NewLDAPUserProvider(*config.AuthenticationBackend.Ldap)
|
||||||
} else {
|
} 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)
|
rootCmd.AddCommand(commands.CertificatesCmd)
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
|
@ -97,11 +97,21 @@ authentication_backend:
|
||||||
# which is updated when users reset their passwords.
|
# which is updated when users reset their passwords.
|
||||||
# Therefore, this backend is meant to be used in a dev environment
|
# Therefore, this backend is meant to be used in a dev environment
|
||||||
# and not in production since it prevents Authelia to be scaled to
|
# 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:
|
## file:
|
||||||
## path: ./users_database.yml
|
## path: ./users_database.yml
|
||||||
|
## password_options:
|
||||||
|
## algorithm: argon2id
|
||||||
|
## iterations: 1
|
||||||
|
## key_length: 32
|
||||||
|
## salt_length: 16
|
||||||
|
## memory: 1048576
|
||||||
|
## parallelism: 8
|
||||||
# Access Control
|
# Access Control
|
||||||
#
|
#
|
||||||
# Access control is a list of rules defining the authorizations applied for one
|
# Access control is a list of rules defining the authorizations applied for one
|
||||||
|
|
|
@ -18,59 +18,168 @@ file in the configuration file.
|
||||||
authentication_backend:
|
authentication_backend:
|
||||||
file:
|
file:
|
||||||
path: /var/lib/authelia/users.yml
|
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
|
## Format
|
||||||
|
|
||||||
|
The format of the users file is as follows.
|
||||||
The format of the file is as follows.
|
|
||||||
|
|
||||||
users:
|
users:
|
||||||
john:
|
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
|
email: john.doe@authelia.com
|
||||||
groups:
|
groups:
|
||||||
- admins
|
- admins
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
harry:
|
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
|
email: harry.potter@authelia.com
|
||||||
groups: []
|
groups: []
|
||||||
|
|
||||||
bob:
|
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
|
email: bob.dylan@authelia.com
|
||||||
groups:
|
groups:
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
james:
|
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
|
email: james.dean@authelia.com
|
||||||
|
|
||||||
|
|
||||||
This file should be set with read/write permissions as it could be updated by users
|
This file should be set with read/write permissions as it could be updated by users
|
||||||
resetting their passwords.
|
resetting their passwords.
|
||||||
|
|
||||||
## 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
|
$ 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hash a password to be used in file-based users database. Default algorithm is argon2id.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
authelia hash-password [password] [flags]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
## Password Hash Function
|
[How to choose the right parameters for Argon2]: https://www.twelve21.io/how-to-choose-the-right-parameters-for-argon2/
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
|
@ -13,7 +13,7 @@ can find the documentation of the configuration required for every supported
|
||||||
proxy.
|
proxy.
|
||||||
|
|
||||||
If you are not aware of the workflow of an authentication request, reading this
|
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?
|
## 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
|
`/api/verify` endpoint exposed by Authelia. Two pieces of information are required for
|
||||||
Authelia to be able to authenticate the user request:
|
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 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)).
|
* 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:
|
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
|
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
|
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).
|
||||||
|
|
|
@ -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
|
Note that using [HSTS] has consequences. That's why you should read the blog
|
||||||
post nginx has written on [HSTS].
|
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)
|
## Notifier security measures (SMTP)
|
||||||
|
|
||||||
By default the SMTP Notifier implementation does not allow connections that are not secure.
|
By default the SMTP Notifier implementation does not allow connections that are not secure.
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -25,7 +25,7 @@ require (
|
||||||
github.com/onsi/gomega v1.7.1 // indirect
|
github.com/onsi/gomega v1.7.1 // indirect
|
||||||
github.com/otiai10/copy v1.0.2
|
github.com/otiai10/copy v1.0.2
|
||||||
github.com/pquerna/otp v1.2.0
|
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/sirupsen/logrus v1.4.2
|
||||||
github.com/spf13/cobra v0.0.5
|
github.com/spf13/cobra v0.0.5
|
||||||
github.com/spf13/viper v1.6.2
|
github.com/spf13/viper v1.6.2
|
||||||
|
|
2
go.sum
2
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/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 h1:cU8qdqUYNuEFKSMq15yaB2aI1aC5vrn6dFOonT6Kg6o=
|
||||||
github.com/simia-tech/crypt v0.2.0/go.mod h1:DMwvjPTzsiHrjqHVW5HvIbF4vUUzMCYDKVLsPWmLdTo=
|
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.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
|
|
@ -23,3 +23,22 @@ const (
|
||||||
|
|
||||||
// PossibleMethods is the set of all possible 2FA methods.
|
// PossibleMethods is the set of all possible 2FA methods.
|
||||||
var PossibleMethods = []string{TOTP, U2F, Push}
|
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+/")
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
package authentication
|
package authentication
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
"github.com/asaskevich/govalidator"
|
||||||
|
"github.com/authelia/authelia/internal/configuration/schema"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileUserProvider is a provider reading details from a file.
|
// FileUserProvider is a provider reading details from a file.
|
||||||
type FileUserProvider struct {
|
type FileUserProvider struct {
|
||||||
path *string
|
configuration *schema.FileAuthenticationBackendConfiguration
|
||||||
database *DatabaseModel
|
database *DatabaseModel
|
||||||
lock *sync.Mutex
|
lock *sync.Mutex
|
||||||
}
|
}
|
||||||
|
@ -31,8 +32,8 @@ type DatabaseModel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFileUserProvider creates a new instance of FileUserProvider.
|
// NewFileUserProvider creates a new instance of FileUserProvider.
|
||||||
func NewFileUserProvider(filepath string) *FileUserProvider {
|
func NewFileUserProvider(configuration *schema.FileAuthenticationBackendConfiguration) *FileUserProvider {
|
||||||
database, err := readDatabase(filepath)
|
database, err := readDatabase(configuration.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Panic since the file does not exist when Authelia is starting.
|
// Panic since the file does not exist when Authelia is starting.
|
||||||
panic(err.Error())
|
panic(err.Error())
|
||||||
|
@ -45,7 +46,7 @@ func NewFileUserProvider(filepath string) *FileUserProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &FileUserProvider{
|
return &FileUserProvider{
|
||||||
path: &filepath,
|
configuration: configuration,
|
||||||
database: database,
|
database: database,
|
||||||
lock: &sync.Mutex{},
|
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)
|
return fmt.Errorf("User '%s' does not exist in database", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := HashPassword(newPassword, "")
|
var algorithm string
|
||||||
details.HashedPassword = fmt.Sprintf("{CRYPT}%s", hash)
|
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.lock.Lock()
|
||||||
p.database.Users[username] = details
|
p.database.Users[username] = details
|
||||||
|
|
||||||
|
@ -125,7 +141,7 @@ func (p *FileUserProvider) UpdatePassword(username string, newPassword string) e
|
||||||
p.lock.Unlock()
|
p.lock.Unlock()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = ioutil.WriteFile(*p.path, b, 0644)
|
err = ioutil.WriteFile(p.configuration.Path, b, 0644)
|
||||||
p.lock.Unlock()
|
p.lock.Unlock()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,10 @@ import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/authelia/authelia/internal/configuration/schema"
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
WithDatabase(UserDatabaseContent, func(path string) {
|
||||||
provider := NewFileUserProvider(path)
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
|
config.Path = path
|
||||||
|
provider := NewFileUserProvider(&config)
|
||||||
ok, err := provider.CheckUserPassword("john", "password")
|
ok, err := provider.CheckUserPassword("john", "password")
|
||||||
|
|
||||||
assert.NoError(t, err)
|
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) {
|
func TestShouldCheckUserPasswordIsWrong(t *testing.T) {
|
||||||
WithDatabase(UserDatabaseContent, func(path string) {
|
WithDatabase(UserDatabaseContent, func(path string) {
|
||||||
provider := NewFileUserProvider(path)
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
|
config.Path = path
|
||||||
|
provider := NewFileUserProvider(&config)
|
||||||
ok, err := provider.CheckUserPassword("john", "wrong_password")
|
ok, err := provider.CheckUserPassword("john", "wrong_password")
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
@ -51,7 +69,9 @@ func TestShouldCheckUserPasswordIsWrong(t *testing.T) {
|
||||||
|
|
||||||
func TestShouldCheckUserPasswordOfUnexistingUser(t *testing.T) {
|
func TestShouldCheckUserPasswordOfUnexistingUser(t *testing.T) {
|
||||||
WithDatabase(UserDatabaseContent, func(path string) {
|
WithDatabase(UserDatabaseContent, func(path string) {
|
||||||
provider := NewFileUserProvider(path)
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
|
config.Path = path
|
||||||
|
provider := NewFileUserProvider(&config)
|
||||||
_, err := provider.CheckUserPassword("fake", "password")
|
_, err := provider.CheckUserPassword("fake", "password")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, "User 'fake' does not exist in database", err.Error())
|
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) {
|
func TestShouldRetrieveUserDetails(t *testing.T) {
|
||||||
WithDatabase(UserDatabaseContent, func(path string) {
|
WithDatabase(UserDatabaseContent, func(path string) {
|
||||||
provider := NewFileUserProvider(path)
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
|
config.Path = path
|
||||||
|
provider := NewFileUserProvider(&config)
|
||||||
details, err := provider.GetDetails("john")
|
details, err := provider.GetDetails("john")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, details.Emails, []string{"john.doe@authelia.com"})
|
assert.Equal(t, details.Emails, []string{"john.doe@authelia.com"})
|
||||||
|
@ -70,45 +92,125 @@ func TestShouldRetrieveUserDetails(t *testing.T) {
|
||||||
|
|
||||||
func TestShouldUpdatePassword(t *testing.T) {
|
func TestShouldUpdatePassword(t *testing.T) {
|
||||||
WithDatabase(UserDatabaseContent, func(path string) {
|
WithDatabase(UserDatabaseContent, func(path string) {
|
||||||
provider := NewFileUserProvider(path)
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
err := provider.UpdatePassword("john", "newpassword")
|
config.Path = path
|
||||||
|
provider := NewFileUserProvider(&config)
|
||||||
|
err := provider.UpdatePassword("harry", "newpassword")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Reset the provider to force a read from disk.
|
// Reset the provider to force a read from disk.
|
||||||
provider = NewFileUserProvider(path)
|
provider = NewFileUserProvider(&config)
|
||||||
ok, err := provider.CheckUserPassword("john", "newpassword")
|
ok, err := provider.CheckUserPassword("harry", "newpassword")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, ok)
|
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) {
|
func TestShouldRaiseWhenLoadingMalformedDatabaseForFirstTime(t *testing.T) {
|
||||||
WithDatabase(MalformedUserDatabaseContent, func(path string) {
|
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() {
|
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) {
|
func TestShouldRaiseWhenLoadingDatabaseWithBadSchemaForFirstTime(t *testing.T) {
|
||||||
WithDatabase(BadSchemaUserDatabaseContent, func(path string) {
|
WithDatabase(BadSchemaUserDatabaseContent, func(path string) {
|
||||||
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
|
config.Path = path
|
||||||
assert.PanicsWithValue(t, "Invalid schema of database: Users: non zero value required", func() {
|
assert.PanicsWithValue(t, "Invalid schema of database: Users: non zero value required", func() {
|
||||||
NewFileUserProvider(path)
|
NewFileUserProvider(&config)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldRaiseWhenLoadingDatabaseWithBadHashesForTheFirstTime(t *testing.T) {
|
func TestShouldRaiseWhenLoadingDatabaseWithBadSHA512HashesForTheFirstTime(t *testing.T) {
|
||||||
WithDatabase(BadHashContent, func(path string) {
|
WithDatabase(BadSHA512HashContent, func(path string) {
|
||||||
assert.PanicsWithValue(t, "Unable to parse hash of user john: Cannot match pattern 'rounds=<int>' to find the number of rounds", func() {
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
NewFileUserProvider(path)
|
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) {
|
func TestShouldSupportHashPasswordWithoutCRYPT(t *testing.T) {
|
||||||
WithDatabase(UserDatabaseWithouCryptContent, func(path string) {
|
WithDatabase(UserDatabaseWithoutCryptContent, func(path string) {
|
||||||
provider := NewFileUserProvider(path)
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
|
config.Path = path
|
||||||
|
provider := NewFileUserProvider(&config)
|
||||||
ok, err := provider.CheckUserPassword("john", "password")
|
ok, err := provider.CheckUserPassword("john", "password")
|
||||||
|
|
||||||
assert.NoError(t, err)
|
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(`
|
var UserDatabaseContent = []byte(`
|
||||||
users:
|
users:
|
||||||
john:
|
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
|
email: john.doe@authelia.com
|
||||||
groups:
|
groups:
|
||||||
- admins
|
- admins
|
||||||
|
@ -161,7 +277,7 @@ user:
|
||||||
- dev
|
- dev
|
||||||
`)
|
`)
|
||||||
|
|
||||||
var UserDatabaseWithouCryptContent = []byte(`
|
var UserDatabaseWithoutCryptContent = []byte(`
|
||||||
users:
|
users:
|
||||||
john:
|
john:
|
||||||
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
||||||
|
@ -174,7 +290,7 @@ users:
|
||||||
email: james.dean@authelia.com
|
email: james.dean@authelia.com
|
||||||
`)
|
`)
|
||||||
|
|
||||||
var BadHashContent = []byte(`
|
var BadSHA512HashContent = []byte(`
|
||||||
users:
|
users:
|
||||||
john:
|
john:
|
||||||
password: "$6$rounds00000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
password: "$6$rounds00000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
||||||
|
@ -186,3 +302,35 @@ users:
|
||||||
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
||||||
email: james.dean@authelia.com
|
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
|
||||||
|
`)
|
||||||
|
|
|
@ -3,87 +3,149 @@ package authentication
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/authelia/authelia/internal/utils"
|
||||||
"github.com/simia-tech/crypt"
|
"github.com/simia-tech/crypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PasswordHash represents all characteristics of a password hash.
|
// 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 {
|
type PasswordHash struct {
|
||||||
// The number of rounds.
|
Algorithm string
|
||||||
Rounds int
|
Iterations int
|
||||||
// The salt with a max size of 16 characters for SHA512.
|
|
||||||
Salt string
|
Salt string
|
||||||
// The password hash.
|
Key string
|
||||||
Hash string
|
KeyLength int
|
||||||
|
Memory int
|
||||||
|
Parallelism int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseHash extracts all characteristics of a hash given its string representation.
|
// 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, "$")
|
parts := strings.Split(hash, "$")
|
||||||
|
|
||||||
if len(parts) != 5 {
|
// This error can be ignored as it's always nil
|
||||||
return nil, fmt.Errorf("Cannot parse the hash %s", hash)
|
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.
|
_, err = crypt.Base64Encoding.DecodeString(h.Salt)
|
||||||
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=<int>' to find the number of rounds")
|
|
||||||
}
|
|
||||||
|
|
||||||
rounds, err := strconv.ParseInt(roundsKV[1], 10, 0)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot find the number of rounds from %s using pattern 'rounds=<int>'. Cause: %s", roundsKV[1], err.Error())
|
return nil, errors.New("Salt contains invalid base64 characters.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &PasswordHash{
|
if code == HashingAlgorithmSHA512 {
|
||||||
Rounds: int(rounds),
|
h.Iterations = parameters.GetInt("rounds", HashingDefaultSHA512Iterations)
|
||||||
Salt: parts[3],
|
h.Algorithm = HashingAlgorithmSHA512
|
||||||
Hash: parts[4],
|
if parameters["rounds"] != "" && parameters["rounds"] != strconv.Itoa(h.Iterations) {
|
||||||
}, nil
|
return nil, fmt.Errorf("SHA512 iterations is not numeric (%s).", parameters["rounds"])
|
||||||
}
|
}
|
||||||
|
} else if code == HashingAlgorithmArgon2id {
|
||||||
// The set of letters RandomString can pick in.
|
version := parameters.GetInt("v", 0)
|
||||||
var possibleLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
if version < 19 {
|
||||||
|
if version == 0 {
|
||||||
// RandomString generate a random string of n characters.
|
return nil, fmt.Errorf("Argon2id version parameter not found (%s)", hash)
|
||||||
func RandomString(n int) string {
|
|
||||||
b := make([]rune, n)
|
|
||||||
for i := range b {
|
|
||||||
b[i] = possibleLetters[rand.Intn(len(possibleLetters))]
|
|
||||||
}
|
}
|
||||||
return string(b)
|
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)
|
||||||
|
|
||||||
|
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 h, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword generate a salt and hash the password with the salt and a constant
|
// HashPassword generate a salt and hash the password with the salt and a constant
|
||||||
// number of rounds.
|
// 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 == "" {
|
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)
|
||||||
}
|
}
|
||||||
hash, err := crypt.Crypt(password, salt)
|
} else if len(salt) > 16 {
|
||||||
if err != nil {
|
return "", fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 16 or fewer characters.", salt, len(salt))
|
||||||
log.Fatal(err)
|
} 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)
|
||||||
}
|
}
|
||||||
return hash
|
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// 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)
|
passwordHash, err := ParseHash(hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
salt := fmt.Sprintf("$6$rounds=%d$%s$", passwordHash.Rounds, passwordHash.Salt)
|
expectedHash, err := HashPassword(password, passwordHash.Salt, passwordHash.Algorithm, passwordHash.Iterations, passwordHash.Memory, passwordHash.Parallelism, passwordHash.KeyLength, len(passwordHash.Salt))
|
||||||
pHash := HashPassword(password, salt)
|
if err != nil {
|
||||||
return pHash == hash, nil
|
return false, err
|
||||||
|
}
|
||||||
|
return hash == expectedHash, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,55 +1,323 @@
|
||||||
package authentication
|
package authentication
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"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/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShouldHashPassword(t *testing.T) {
|
func TestShouldHashSHA512Password(t *testing.T) {
|
||||||
hash := HashPassword("password", "$6$rounds=50000$aFr56HjK3DrB8t3S")
|
hash, err := HashPassword("password", "aFr56HjK3DrB8t3S", HashingAlgorithmSHA512, 50000, 0, 0, 0, 16)
|
||||||
assert.Equal(t, "$6$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1", hash)
|
|
||||||
|
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) {
|
func TestShouldHashArgon2idPassword(t *testing.T) {
|
||||||
ok, err := CheckPassword("password", "$6$rounds=50000$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1")
|
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)
|
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)
|
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")
|
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)
|
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")
|
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)
|
assert.False(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCannotFindNumberOfRounds(t *testing.T) {
|
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=<int>' 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)
|
assert.False(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNumberOfRoundsNotInt(t *testing.T) {
|
func TestNumberOfRoundsNotInt(t *testing.T) {
|
||||||
ok, err := CheckPassword("password", "$6$rounds=abc$aFr56HjK3DrB8t3S$zhPQiS85cgBlNhUKKE6n/AHMlpqrvYSnSL3fEVkK0yHFQ.oFFAd8D4OhPAy18K5U61Z2eBhxQXExGU/eknXlY1")
|
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=<int>'. Cause: strconv.ParseInt: parsing \"abc\": invalid syntax")
|
assert.EqualError(t, err, "SHA512 iterations is not numeric (abc).")
|
||||||
assert.False(t, ok)
|
assert.False(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldCheckPasswordHashedWithAuthelia(t *testing.T) {
|
func TestShouldCheckPasswordArgon2idHashedWithAuthelia(t *testing.T) {
|
||||||
password := "my;secure*password"
|
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)
|
equal, err := CheckPassword(password, hash)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
@ -4,14 +4,51 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/authelia/authelia/internal/authentication"
|
"github.com/authelia/authelia/internal/authentication"
|
||||||
|
"github.com/authelia/authelia/internal/configuration/schema"
|
||||||
"github.com/spf13/cobra"
|
"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{
|
var HashPasswordCmd = &cobra.Command{
|
||||||
Use: "hash-password [password]",
|
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) {
|
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),
|
Args: cobra.MinimumNArgs(1),
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,43 @@ type LDAPAuthenticationBackendConfiguration struct {
|
||||||
// FileAuthenticationBackendConfiguration represents the configuration related to file-based backend
|
// FileAuthenticationBackendConfiguration represents the configuration related to file-based backend
|
||||||
type FileAuthenticationBackendConfiguration struct {
|
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.
|
// AuthenticationBackendConfiguration represents the configuration related to the authentication backend.
|
||||||
|
|
|
@ -16,6 +16,63 @@ func validateFileAuthenticationBackend(configuration *schema.FileAuthenticationB
|
||||||
if configuration.Path == "" {
|
if configuration.Path == "" {
|
||||||
validator.Push(errors.New("Please provide a `path` for the users database in `authentication_backend`"))
|
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 {
|
func validateLdapURL(ldapURL string, validator *schema.StructValidator) string {
|
||||||
|
|
|
@ -28,9 +28,16 @@ type FileBasedAuthenticationBackend struct {
|
||||||
func (suite *FileBasedAuthenticationBackend) SetupTest() {
|
func (suite *FileBasedAuthenticationBackend) SetupTest() {
|
||||||
suite.validator = schema.NewStructValidator()
|
suite.validator = schema.NewStructValidator()
|
||||||
suite.configuration = schema.AuthenticationBackendConfiguration{}
|
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() {
|
func (suite *FileBasedAuthenticationBackend) TestShouldValidateCompleteConfiguration() {
|
||||||
ValidateAuthenticationBackend(&suite.configuration, suite.validator)
|
ValidateAuthenticationBackend(&suite.configuration, suite.validator)
|
||||||
assert.Len(suite.T(), suite.validator.Errors(), 0)
|
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`")
|
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) {
|
func TestFileBasedAuthenticationBackend(t *testing.T) {
|
||||||
suite.Run(t, new(FileBasedAuthenticationBackend))
|
suite.Run(t, new(FileBasedAuthenticationBackend))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
package utils
|
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 {
|
for _, b := range list {
|
||||||
if b == a {
|
if b == a {
|
||||||
return true
|
return true
|
||||||
|
@ -8,3 +14,28 @@ func IsStringInSlice(a string, list []string) bool {
|
||||||
}
|
}
|
||||||
return false
|
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)
|
||||||
|
}
|
||||||
|
|
37
internal/utils/strings_test.go
Normal file
37
internal/utils/strings_test.go
Normal file
|
@ -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])
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user