add support for hashing password using bcrypt

argon2id remains the default
This commit is contained in:
Nicola Murino 2021-04-20 13:55:09 +02:00
parent 6ef85d6026
commit 92638ce93d
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
9 changed files with 188 additions and 15 deletions

View file

@ -271,8 +271,6 @@ I'd like to make SFTPGo into a sustainable long term project and your [sponsorsh
Thank you to our sponsors!
[<img src="https://images.squarespace-cdn.com/content/5e5db7f1ded5fc06a4e9628b/1583608099266-T5NW2WNQL7PC15LPRB16/logo+black.png?format=1500w&content-type=image%2Fpng" width="33%" alt="segmed logo">](https://www.segmed.ai/)
## License
GNU AGPLv3

View file

@ -12,6 +12,7 @@ import (
"path"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@ -1956,6 +1957,68 @@ func TestResolvePathError(t *testing.T) {
assert.NoError(t, err)
}
func TestUserPasswordHashing(t *testing.T) {
if config.GetProviderConf().Driver == dataprovider.MemoryDataProviderName {
t.Skip("this test is not supported with the memory provider")
}
u := getTestUser()
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
err = dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
assert.NoError(t, err)
providerConf := config.GetProviderConf()
providerConf.PasswordHashingAlgo = dataprovider.HashingAlgoBcrypt
err = dataprovider.Initialize(providerConf, configDir, true)
assert.NoError(t, err)
currentUser, err := dataprovider.UserExists(user.Username)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(currentUser.Password, "$argon2id$"))
client, err := getSftpClient(user)
if assert.NoError(t, err) {
defer client.Close()
err = checkBasicSFTP(client)
assert.NoError(t, err)
}
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
u = getTestUser()
user, _, err = httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
currentUser, err = dataprovider.UserExists(user.Username)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(currentUser.Password, "$2a$"))
client, err = getSftpClient(user)
if assert.NoError(t, err) {
defer client.Close()
err = checkBasicSFTP(client)
assert.NoError(t, err)
}
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
err = dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
assert.NoError(t, err)
providerConf = config.GetProviderConf()
err = dataprovider.Initialize(providerConf, configDir, true)
assert.NoError(t, err)
}
func TestDelayedQuotaUpdater(t *testing.T) {
err := dataprovider.Close()
assert.NoError(t, err)

View file

@ -220,7 +220,11 @@ func Init() {
Iterations: 1,
Parallelism: 2,
},
BcryptOptions: dataprovider.BcryptOptions{
Cost: 10,
},
},
PasswordHashingAlgo: dataprovider.HashingAlgoArgon2ID,
PasswordCaching: true,
UpdateMode: 0,
PreferDatabaseCredentials: false,
@ -939,6 +943,8 @@ func setViperDefaults() {
viper.SetDefault("data_provider.password_hashing.argon2_options.memory", globalConf.ProviderConf.PasswordHashing.Argon2Options.Memory)
viper.SetDefault("data_provider.password_hashing.argon2_options.iterations", globalConf.ProviderConf.PasswordHashing.Argon2Options.Iterations)
viper.SetDefault("data_provider.password_hashing.argon2_options.parallelism", globalConf.ProviderConf.PasswordHashing.Argon2Options.Parallelism)
viper.SetDefault("data_provider.password_hashing.bcrypt_options.cost", globalConf.ProviderConf.PasswordHashing.BcryptOptions.Cost)
viper.SetDefault("data_provider.password_hashing_algo", globalConf.ProviderConf.PasswordHashingAlgo)
viper.SetDefault("data_provider.update_mode", globalConf.ProviderConf.UpdateMode)
viper.SetDefault("data_provider.skip_natural_keys_validation", globalConf.ProviderConf.SkipNaturalKeysValidation)
viper.SetDefault("data_provider.delayed_quota_update", globalConf.ProviderConf.DelayedQuotaUpdate)

View file

@ -10,6 +10,7 @@ import (
"strings"
"github.com/alexedwards/argon2id"
"golang.org/x/crypto/bcrypt"
"github.com/drakkan/sftpgo/utils"
)
@ -65,11 +66,19 @@ type Admin struct {
func (a *Admin) checkPassword() error {
if a.Password != "" && !strings.HasPrefix(a.Password, argonPwdPrefix) {
pwd, err := argon2id.CreateHash(a.Password, argon2Params)
if err != nil {
return err
if config.PasswordHashingAlgo == HashingAlgoBcrypt {
pwd, err := bcrypt.GenerateFromPassword([]byte(a.Password), config.PasswordHashing.BcryptOptions.Cost)
if err != nil {
return err
}
a.Password = string(pwd)
} else {
pwd, err := argon2id.CreateHash(a.Password, argon2Params)
if err != nil {
return err
}
a.Password = pwd
}
a.Password = pwd
}
return nil
}
@ -114,6 +123,12 @@ func (a *Admin) validate() error {
// CheckPassword verifies the admin password
func (a *Admin) CheckPassword(password string) (bool, error) {
if strings.HasPrefix(a.Password, bcryptPwdPrefix) {
if err := bcrypt.CompareHashAndPassword([]byte(a.Password), []byte(password)); err != nil {
return false, err
}
return true, nil
}
return argon2id.ComparePasswordAndHash(password, a.Password)
}

View file

@ -83,6 +83,13 @@ const (
sqlPrefixValidChars = "abcdefghijklmnopqrstuvwxyz_0123456789"
)
// Supported algorithms for hashing passwords.
// These algorithms can be used when SFTPGo hashes a plain text password
const (
HashingAlgoBcrypt = "bcrypt"
HashingAlgoArgon2ID = "argon2id"
)
// ordering constants
const (
OrderASC = "ASC"
@ -137,6 +144,11 @@ type schemaVersion struct {
Version int
}
// BcryptOptions defines the options for bcrypt password hashing
type BcryptOptions struct {
Cost int `json:"cost" mapstructure:"cost"`
}
// Argon2Options defines the options for argon2 password hashing
type Argon2Options struct {
Memory uint32 `json:"memory" mapstructure:"memory"`
@ -147,6 +159,7 @@ type Argon2Options struct {
// PasswordHashing defines the configuration for password hashing
type PasswordHashing struct {
Argon2Options Argon2Options `json:"argon2_options" mapstructure:"argon2_options"`
BcryptOptions BcryptOptions `json:"bcrypt_options" mapstructure:"bcrypt_options"`
}
// UserActions defines the action to execute on user create, update, delete.
@ -274,7 +287,8 @@ type Config struct {
// PreferDatabaseCredentials indicates whether credential files (currently used for Google
// Cloud Storage) should be stored in the database instead of in the directory specified by
// CredentialsPath.
PreferDatabaseCredentials bool `json:"prefer_database_credentials" mapstructure:"prefer_database_credentials"`
PasswordHashingAlgo string `json:"password_hashing_algo" mapstructure:"password_hashing_algo"`
PreferDatabaseCredentials bool `json:"prefer_database_credentials" mapstructure:"prefer_database_credentials"`
// SkipNaturalKeysValidation allows to use any UTF-8 character for natural keys as username, admin name,
// folder name. These keys are used in URIs for REST API and Web admin. By default only unreserved URI
// characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~".
@ -447,6 +461,15 @@ func Initialize(cnf Config, basePath string, checkAdmins bool) error {
KeyLength: 32,
}
if config.PasswordHashingAlgo == HashingAlgoBcrypt {
if config.PasswordHashing.BcryptOptions.Cost > bcrypt.MaxCost {
err = fmt.Errorf("invalid bcrypt cost %v, max allowed %v", config.PasswordHashing.BcryptOptions.Cost, bcrypt.MaxCost)
logger.WarnToConsole("Unable to initialize data provider: %v", err)
providerLog(logger.LevelWarn, "Unable to initialize data provider: %v", err)
return err
}
}
if err = validateHooks(); err != nil {
return err
}
@ -1497,11 +1520,19 @@ func validateBaseParams(user *User) error {
func createUserPasswordHash(user *User) error {
if user.Password != "" && !user.IsPasswordHashed() {
pwd, err := argon2id.CreateHash(user.Password, argon2Params)
if err != nil {
return err
if config.PasswordHashingAlgo == HashingAlgoBcrypt {
pwd, err := bcrypt.GenerateFromPassword([]byte(user.Password), config.PasswordHashing.BcryptOptions.Cost)
if err != nil {
return err
}
user.Password = string(pwd)
} else {
pwd, err := argon2id.CreateHash(user.Password, argon2Params)
if err != nil {
return err
}
user.Password = pwd
}
user.Password = pwd
}
return nil
}

View file

@ -194,12 +194,15 @@ The configuration file contains the following sections:
- `post_login_scope`, defines the scope for the post-login hook. 0 means notify both failed and successful logins. 1 means notify failed logins. 2 means notify successful logins.
- `check_password_hook`, string. Absolute path to an external program or an HTTP URL to invoke to check the user provided password. See [Check password hook](./check-password-hook.md) for more details. Leave empty to disable.
- `check_password_scope`, defines the scope for the check password hook. 0 means all protocols, 1 means SSH, 2 means FTP, 4 means WebDAV. You can combine the scopes, for example 6 means FTP and WebDAV.
- `password_hashing`, struct. It contains the configuration parameters to be used to generate the password hash. SFTPGo can verify passwords in several formats and uses the `argon2id` algorithm to hash passwords in plain-text before storing them inside the data provider. These options allow you to customize how the hash is generated.
- `argon2_options` struct containing the options for argon2id hashing algorithm. The `memory` and `iterations` parameters control the computational cost of hashing the password. The higher these figures are, the greater the cost of generating the hash and the longer the runtime. It also follows that the greater the cost will be for any attacker trying to guess the password. If the code is running on a machine with multiple cores, then you can decrease the runtime without reducing the cost by increasing the `parallelism` parameter. This controls the number of threads that the work is spread across.
- `password_hashing`, struct. It contains the configuration parameters to be used to generate the password hash. SFTPGo can verify passwords in several formats and uses, by default, the `argon2id` algorithm to hash passwords in plain-text before storing them inside the data provider. These options allow you to customize how the hash is generated.
- `argon2_options`, struct containing the options for argon2id hashing algorithm. The `memory` and `iterations` parameters control the computational cost of hashing the password. The higher these figures are, the greater the cost of generating the hash and the longer the runtime. It also follows that the greater the cost will be for any attacker trying to guess the password. If the code is running on a machine with multiple cores, then you can decrease the runtime without reducing the cost by increasing the `parallelism` parameter. This controls the number of threads that the work is spread across.
- `memory`, unsigned integer. The amount of memory used by the algorithm (in kibibytes). Default: 65536.
- `iterations`, unsigned integer. The number of iterations over the memory. Default: 1.
- `parallelism`. unsigned 8 bit integer. The number of threads (or lanes) used by the algorithm. Default: 2.
- `password_caching`, boolean. Verifying argon2 passwords has a high memory and computational cost, by enabling, in memory, password caching you reduce this cost. Default: `true`
- `bcrypt_options`, struct containing the options for bcrypt hashing algorithm
- `cost`, integer between 4 and 31. Default: 10
- `password_hashing_algo`, string. Algorithm to use for hashing passwords. Available algorithms: `argon2id`, `bcrypt`. Default: `argon2id`
- `password_caching`, boolean. Verifying argon2id passwords has a high memory and computational cost, verifying bcrypt passwords has a high CPU cost, by enabling, in memory, password caching you reduce these costs. Default: `true`
- `update_mode`, integer. Defines how the database will be initialized/updated. 0 means automatically. 1 means manually using the initprovider sub-command.
- `skip_natural_keys_validation`, boolean. If `true` you can use any UTF-8 character for natural keys as username, admin name, folder name. These keys are used in URIs for REST API and Web admin. If `false` only unreserved URI characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". Default: `false`.
- `delayed_quota_update`, integer. This configuration parameter defines the number of seconds to accumulate quota updates. If there are a lot of close uploads, accumulating quota updates can save you many queries to the data provider. If you want to track quotas, a scheduled quota update is recommended in any case, the stored quota size may be incorrect for several reasons, such as an unexpected shutdown, temporary provider failures, file copied outside of SFTPGo, and so on. 0 means immediate quota update.

View file

@ -374,6 +374,59 @@ func TestChangeAdminPassword(t *testing.T) {
assert.NoError(t, err)
}
func TestAdminPasswordHashing(t *testing.T) {
if config.GetProviderConf().Driver == dataprovider.MemoryDataProviderName {
t.Skip("this test is not supported with the memory provider")
}
err := dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
providerConf := config.GetProviderConf()
assert.NoError(t, err)
providerConf.PasswordHashingAlgo = dataprovider.HashingAlgoBcrypt
err = dataprovider.Initialize(providerConf, configDir, true)
assert.NoError(t, err)
currentAdmin, err := dataprovider.AdminExists(defaultTokenAuthUser)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(currentAdmin.Password, "$argon2id$"))
a := getTestAdmin()
a.Username = altAdminUsername
a.Password = altAdminPassword
admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
assert.NoError(t, err)
newAdmin, err := dataprovider.AdminExists(altAdminUsername)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(newAdmin.Password, "$2a$"))
token, _, err := httpdtest.GetToken(altAdminUsername, altAdminPassword)
assert.NoError(t, err)
httpdtest.SetJWTToken(token)
_, _, err = httpdtest.GetStatus(http.StatusOK)
assert.NoError(t, err)
httpdtest.SetJWTToken("")
_, _, err = httpdtest.GetStatus(http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
assert.NoError(t, err)
err = dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
assert.NoError(t, err)
providerConf = config.GetProviderConf()
providerConf.CredentialsPath = credentialsPath
err = os.RemoveAll(credentialsPath)
assert.NoError(t, err)
err = dataprovider.Initialize(providerConf, configDir, true)
assert.NoError(t, err)
}
func TestAdminAllowList(t *testing.T) {
a := getTestAdmin()
a.Username = altAdminUsername

View file

@ -1753,7 +1753,7 @@ components:
password:
type: string
format: password
description: password or public key/SSH user certificate are mandatory. If the password has no known hashing algo prefix it will be stored using argon2id. You can send a password hashed as bcrypt or pbkdf2 and it will be stored as is. For security reasons this field is omitted when you search/get users
description: password or public key/SSH user certificate are mandatory. If the password has no known hashing algo prefix it will be stored, by default, using argon2id. You can send a password hashed as bcrypt or pbkdf2 and it will be stored as is. For security reasons this field is omitted when you search/get users
public_keys:
type: array
items:

View file

@ -165,8 +165,12 @@
"memory": 65536,
"iterations": 1,
"parallelism": 2
},
"bcrypt_options": {
"cost": 10
}
},
"password_hashing_algo": "argon2id",
"password_caching": true,
"update_mode": 0,
"skip_natural_keys_validation": false