[MISC] Refactor Authentication (#987)
* only do salt validation in validate salt * fix tests * remove panic(err.Error()) * use file mode const * do hash cleanup on file read instead of check password * design ConfigAlgoToCryptoAlgo and implement it * split HashPassword func into functional chunks that could theoretically be reusedpull/994/head
parent
aa242142c0
commit
332a68541c
|
@ -58,3 +58,5 @@ const argon2id = "argon2id"
|
||||||
const sha512 = "sha512"
|
const sha512 = "sha512"
|
||||||
|
|
||||||
const testPassword = "my;secure*password"
|
const testPassword = "my;secure*password"
|
||||||
|
|
||||||
|
const fileAuthenticationMode = 0600
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package authentication
|
package authentication
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -42,13 +41,13 @@ func NewFileUserProvider(configuration *schema.FileAuthenticationBackendConfigur
|
||||||
database, err := readDatabase(configuration.Path)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Early check whether hashed passwords are correct for all users
|
// Early check whether hashed passwords are correct for all users
|
||||||
err = checkPasswordHashes(database)
|
err = checkPasswordHashes(database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err.Error())
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cryptAlgo CryptAlgo = HashingAlgorithmArgon2id
|
var cryptAlgo CryptAlgo = HashingAlgorithmArgon2id
|
||||||
|
@ -74,10 +73,14 @@ func NewFileUserProvider(configuration *schema.FileAuthenticationBackendConfigur
|
||||||
|
|
||||||
func checkPasswordHashes(database *DatabaseModel) error {
|
func checkPasswordHashes(database *DatabaseModel) error {
|
||||||
for u, v := range database.Users {
|
for u, v := range database.Users {
|
||||||
|
v.HashedPassword = strings.ReplaceAll(v.HashedPassword, "{CRYPT}", "")
|
||||||
_, err := ParseHash(v.HashedPassword)
|
_, err := ParseHash(v.HashedPassword)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Unable to parse hash of user %s: %s", u, err)
|
return fmt.Errorf("Unable to parse hash of user %s: %s", u, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
database.Users[u] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -111,9 +114,7 @@ func readDatabase(path string) (*DatabaseModel, error) {
|
||||||
// CheckUserPassword checks if provided password matches for the given user.
|
// CheckUserPassword checks if provided password matches for the given user.
|
||||||
func (p *FileUserProvider) CheckUserPassword(username string, password string) (bool, error) {
|
func (p *FileUserProvider) CheckUserPassword(username string, password string) (bool, error) {
|
||||||
if details, ok := p.database.Users[username]; ok {
|
if details, ok := p.database.Users[username]; ok {
|
||||||
hashedPassword := strings.ReplaceAll(details.HashedPassword, "{CRYPT}", "")
|
ok, err := CheckPassword(password, details.HashedPassword)
|
||||||
|
|
||||||
ok, err := CheckPassword(password, hashedPassword)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
@ -122,10 +123,9 @@ func (p *FileUserProvider) CheckUserPassword(username string, password string) (
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove this. This is only here to temporarily fix the username enumeration security flaw in #949.
|
// TODO: Remove this. This is only here to temporarily fix the username enumeration security flaw in #949.
|
||||||
hashedPassword := strings.ReplaceAll(p.fakeHash, "{CRYPT}", "")
|
_, _ = CheckPassword(password, p.fakeHash)
|
||||||
_, err := CheckPassword(password, hashedPassword)
|
|
||||||
|
|
||||||
return false, err
|
return false, ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDetails retrieve the groups a user belongs to.
|
// GetDetails retrieve the groups a user belongs to.
|
||||||
|
@ -145,24 +145,19 @@ func (p *FileUserProvider) GetDetails(username string) (*UserDetails, error) {
|
||||||
func (p *FileUserProvider) UpdatePassword(username string, newPassword string) error {
|
func (p *FileUserProvider) UpdatePassword(username string, newPassword string) error {
|
||||||
details, ok := p.database.Users[username]
|
details, ok := p.database.Users[username]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("User '%s' does not exist in database", username)
|
return ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
var algorithm CryptAlgo
|
algorithm, err := ConfigAlgoToCryptoAlgo(p.configuration.Password.Algorithm)
|
||||||
|
if err != nil {
|
||||||
switch p.configuration.Password.Algorithm {
|
return err
|
||||||
case argon2id:
|
|
||||||
algorithm = HashingAlgorithmArgon2id
|
|
||||||
case sha512:
|
|
||||||
algorithm = HashingAlgorithmSHA512
|
|
||||||
default:
|
|
||||||
return errors.New("Invalid algorithm in configuration. It should be `argon2id` or `sha512`")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := HashPassword(
|
hash, err := HashPassword(
|
||||||
newPassword, "", algorithm, p.configuration.Password.Iterations,
|
newPassword, "", algorithm, p.configuration.Password.Iterations,
|
||||||
p.configuration.Password.Memory*1024, p.configuration.Password.Parallelism,
|
p.configuration.Password.Memory*1024, p.configuration.Password.Parallelism,
|
||||||
p.configuration.Password.KeyLength, p.configuration.Password.SaltLength)
|
p.configuration.Password.KeyLength, p.configuration.Password.SaltLength)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -178,7 +173,7 @@ func (p *FileUserProvider) UpdatePassword(username string, newPassword string) e
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ioutil.WriteFile(p.configuration.Path, b, 0644) //nolint:gosec // Fixed in future PR.
|
err = ioutil.WriteFile(p.configuration.Path, b, fileAuthenticationMode)
|
||||||
p.lock.Unlock()
|
p.lock.Unlock()
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -86,8 +86,9 @@ func TestShouldCheckUserPasswordOfUserThatDoesNotExist(t *testing.T) {
|
||||||
config.Path = path
|
config.Path = path
|
||||||
provider := NewFileUserProvider(&config)
|
provider := NewFileUserProvider(&config)
|
||||||
ok, err := provider.CheckUserPassword("fake", "password")
|
ok, err := provider.CheckUserPassword("fake", "password")
|
||||||
assert.NoError(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, false, ok)
|
assert.Equal(t, false, ok)
|
||||||
|
assert.EqualError(t, err, "user not found")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,7 +127,7 @@ func TestShouldUpdatePasswordHashingAlgorithmToArgon2id(t *testing.T) {
|
||||||
config := DefaultFileAuthenticationBackendConfiguration
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
config.Path = path
|
config.Path = path
|
||||||
provider := NewFileUserProvider(&config)
|
provider := NewFileUserProvider(&config)
|
||||||
assert.True(t, strings.HasPrefix(provider.database.Users["harry"].HashedPassword, "{CRYPT}$6$"))
|
assert.True(t, strings.HasPrefix(provider.database.Users["harry"].HashedPassword, "$6$"))
|
||||||
err := provider.UpdatePassword("harry", "newpassword")
|
err := provider.UpdatePassword("harry", "newpassword")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
@ -147,7 +148,7 @@ func TestShouldUpdatePasswordHashingAlgorithmToSHA512(t *testing.T) {
|
||||||
config.Password.Iterations = 50000
|
config.Password.Iterations = 50000
|
||||||
|
|
||||||
provider := NewFileUserProvider(&config)
|
provider := NewFileUserProvider(&config)
|
||||||
assert.True(t, strings.HasPrefix(provider.database.Users["john"].HashedPassword, "{CRYPT}$argon2id$"))
|
assert.True(t, strings.HasPrefix(provider.database.Users["john"].HashedPassword, "$argon2id$"))
|
||||||
err := provider.UpdatePassword("john", "newpassword")
|
err := provider.UpdatePassword("john", "newpassword")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
@ -164,7 +165,7 @@ func TestShouldRaiseWhenLoadingMalformedDatabaseForFirstTime(t *testing.T) {
|
||||||
WithDatabase(MalformedUserDatabaseContent, func(path string) {
|
WithDatabase(MalformedUserDatabaseContent, func(path string) {
|
||||||
config := DefaultFileAuthenticationBackendConfiguration
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
config.Path = path
|
config.Path = path
|
||||||
assert.PanicsWithValue(t, "Unable to parse database: yaml: line 4: mapping values are not allowed in this context", func() {
|
assert.PanicsWithError(t, "Unable to parse database: yaml: line 4: mapping values are not allowed in this context", func() {
|
||||||
NewFileUserProvider(&config)
|
NewFileUserProvider(&config)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -174,7 +175,7 @@ func TestShouldRaiseWhenLoadingDatabaseWithBadSchemaForFirstTime(t *testing.T) {
|
||||||
WithDatabase(BadSchemaUserDatabaseContent, func(path string) {
|
WithDatabase(BadSchemaUserDatabaseContent, func(path string) {
|
||||||
config := DefaultFileAuthenticationBackendConfiguration
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
config.Path = path
|
config.Path = path
|
||||||
assert.PanicsWithValue(t, "Invalid schema of database: Users: non zero value required", func() {
|
assert.PanicsWithError(t, "Invalid schema of database: Users: non zero value required", func() {
|
||||||
NewFileUserProvider(&config)
|
NewFileUserProvider(&config)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -184,7 +185,7 @@ func TestShouldRaiseWhenLoadingDatabaseWithBadSHA512HashesForTheFirstTime(t *tes
|
||||||
WithDatabase(BadSHA512HashContent, func(path string) {
|
WithDatabase(BadSHA512HashContent, func(path string) {
|
||||||
config := DefaultFileAuthenticationBackendConfiguration
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
config.Path = 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() {
|
assert.PanicsWithError(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)
|
NewFileUserProvider(&config)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -194,7 +195,7 @@ func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashSettingsForTheFirstTim
|
||||||
WithDatabase(BadArgon2idHashSettingsContent, func(path string) {
|
WithDatabase(BadArgon2idHashSettingsContent, func(path string) {
|
||||||
config := DefaultFileAuthenticationBackendConfiguration
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
config.Path = 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 ($argon2id$v=19$m65536,t3,p2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM)", func() {
|
assert.PanicsWithError(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)
|
NewFileUserProvider(&config)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -204,7 +205,7 @@ func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashKeyForTheFirstTime(t *
|
||||||
WithDatabase(BadArgon2idHashKeyContent, func(path string) {
|
WithDatabase(BadArgon2idHashKeyContent, func(path string) {
|
||||||
config := DefaultFileAuthenticationBackendConfiguration
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
config.Path = path
|
config.Path = path
|
||||||
assert.PanicsWithValue(t, "Unable to parse hash of user john: Hash key contains invalid base64 characters", func() {
|
assert.PanicsWithError(t, "Unable to parse hash of user john: Hash key contains invalid base64 characters", func() {
|
||||||
NewFileUserProvider(&config)
|
NewFileUserProvider(&config)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -214,7 +215,7 @@ func TestShouldRaiseWhenLoadingDatabaseWithBadArgon2idHashSaltForTheFirstTime(t
|
||||||
WithDatabase(BadArgon2idHashSaltContent, func(path string) {
|
WithDatabase(BadArgon2idHashSaltContent, func(path string) {
|
||||||
config := DefaultFileAuthenticationBackendConfiguration
|
config := DefaultFileAuthenticationBackendConfiguration
|
||||||
config.Path = path
|
config.Path = path
|
||||||
assert.PanicsWithValue(t, "Unable to parse hash of user john: Salt contains invalid base64 characters", func() {
|
assert.PanicsWithError(t, "Unable to parse hash of user john: Salt contains invalid base64 characters", func() {
|
||||||
NewFileUserProvider(&config)
|
NewFileUserProvider(&config)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -23,6 +23,18 @@ type PasswordHash struct {
|
||||||
Parallelism int
|
Parallelism int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigAlgoToCryptoAlgo returns a CryptAlgo and nil error if valid, otherwise it returns argon2id and an error.
|
||||||
|
func ConfigAlgoToCryptoAlgo(fromConfig string) (CryptAlgo, error) {
|
||||||
|
switch fromConfig {
|
||||||
|
case argon2id:
|
||||||
|
return HashingAlgorithmArgon2id, nil
|
||||||
|
case sha512:
|
||||||
|
return HashingAlgorithmSHA512, nil
|
||||||
|
default:
|
||||||
|
return HashingAlgorithmArgon2id, errors.New("Invalid algorithm in configuration. It should be `argon2id` or `sha512`")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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 *PasswordHash, err error) {
|
func ParseHash(hash string) (passwordHash *PasswordHash, err error) {
|
||||||
parts := strings.Split(hash, "$")
|
parts := strings.Split(hash, "$")
|
||||||
|
@ -91,7 +103,6 @@ func ParseHash(hash string) (passwordHash *PasswordHash, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword generate a salt and hash the password with the salt and a constant number of rounds.
|
// HashPassword generate a salt and hash the password with the salt and a constant number of rounds.
|
||||||
//nolint:gocyclo // TODO: Consider refactoring/simplifying, time permitting.
|
|
||||||
func HashPassword(password, salt string, algorithm CryptAlgo, iterations, memory, parallelism, keyLength, saltLength int) (hash string, err error) {
|
func HashPassword(password, salt string, algorithm CryptAlgo, iterations, memory, parallelism, keyLength, saltLength int) (hash string, err error) {
|
||||||
var settings string
|
var settings string
|
||||||
|
|
||||||
|
@ -99,41 +110,16 @@ func HashPassword(password, salt string, algorithm CryptAlgo, iterations, memory
|
||||||
return "", fmt.Errorf("Hashing algorithm input of '%s' is invalid, only values of %s and %s are supported", algorithm, HashingAlgorithmArgon2id, 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 saltLength < 2 {
|
|
||||||
return "", fmt.Errorf("Salt length input of %d is invalid, it must be 2 or higher", saltLength)
|
|
||||||
} else if saltLength > 16 {
|
|
||||||
return "", fmt.Errorf("Salt length input of %d is invalid, it must be 16 or lower", saltLength)
|
|
||||||
}
|
|
||||||
} else if len(salt) > 16 {
|
|
||||||
return "", fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 16 or fewer characters", salt, len(salt))
|
|
||||||
} else if len(salt) < 2 {
|
|
||||||
return "", fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 2 or more characters", salt, len(salt))
|
|
||||||
} else if _, err = crypt.Base64Encoding.DecodeString(salt); err != nil {
|
|
||||||
return "", fmt.Errorf("Salt input of %s is invalid, only characters [a-zA-Z0-9+/] are valid for input", salt)
|
|
||||||
}
|
|
||||||
|
|
||||||
if algorithm == HashingAlgorithmArgon2id {
|
if algorithm == HashingAlgorithmArgon2id {
|
||||||
// Caution: Increasing any of the values in the below block has a high chance in old passwords that cannot be verified.
|
err := validateArgon2idSettings(memory, parallelism, iterations, keyLength)
|
||||||
if memory < 8 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Memory (argon2id) input of %d is invalid, it must be 8 or higher", memory)
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if parallelism < 1 {
|
err = validateSalt(salt, saltLength)
|
||||||
return "", fmt.Errorf("Parallelism (argon2id) input of %d is invalid, it must be 1 or higher", parallelism)
|
if err != nil {
|
||||||
}
|
return "", err
|
||||||
|
|
||||||
if memory < parallelism*8 {
|
|
||||||
return "", fmt.Errorf("Memory (argon2id) input of %d is invalid with a parallelism 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if salt == "" {
|
if salt == "" {
|
||||||
|
@ -175,3 +161,49 @@ func getCryptSettings(salt string, algorithm CryptAlgo, iterations, memory, para
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateSalt checks the salt input and settings are valid and returns it and a nil error if they are, otherwise returns an error.
|
||||||
|
func validateSalt(salt string, saltLength int) error {
|
||||||
|
if salt == "" {
|
||||||
|
if saltLength < 2 {
|
||||||
|
return fmt.Errorf("Salt length input of %d is invalid, it must be 2 or higher", saltLength)
|
||||||
|
} else if saltLength > 16 {
|
||||||
|
return fmt.Errorf("Salt length input of %d is invalid, it must be 16 or lower", saltLength)
|
||||||
|
}
|
||||||
|
} else if len(salt) > 16 {
|
||||||
|
return fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 16 or fewer characters", salt, len(salt))
|
||||||
|
} else if len(salt) < 2 {
|
||||||
|
return fmt.Errorf("Salt input of %s is invalid (%d characters), it must be 2 or more characters", salt, len(salt))
|
||||||
|
} else if _, err := crypt.Base64Encoding.DecodeString(salt); err != nil {
|
||||||
|
return fmt.Errorf("Salt input of %s is invalid, only characters [a-zA-Z0-9+/] are valid for input", salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateArgon2idSettings checks the argon2id settings are valid.
|
||||||
|
func validateArgon2idSettings(memory, parallelism, iterations, keyLength int) error {
|
||||||
|
// 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 parallelism input of %d, it must be %d (parallelism * 8) or higher", memory, parallelism, parallelism*8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyLength < 16 {
|
||||||
|
return fmt.Errorf("Key length (argon2id) input of %d is invalid, it must be 16 or higher", keyLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if iterations < 1 {
|
||||||
|
return fmt.Errorf("Iterations (argon2id) input of %d is invalid, it must be 1 or more", iterations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caution: Increasing any of the values in the above block has a high chance in old passwords that cannot be verified.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue