check and update the password hashing algorithm on user login

also add ldap md5 variant as per-user request

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-04-02 22:20:21 +02:00
parent 77f3400161
commit 5a40f998ae
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
10 changed files with 172 additions and 19 deletions

View file

@ -653,6 +653,30 @@ func (p *BoltProvider) deleteUser(user *User) error {
})
}
func (p *BoltProvider) updateUserPassword(username, password string) error {
return p.dbHandle.Update(func(tx *bolt.Tx) error {
bucket, err := getUsersBucket(tx)
if err != nil {
return err
}
var u []byte
if u = bucket.Get([]byte(username)); u == nil {
return util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist", username))
}
var user User
err = json.Unmarshal(u, &user)
if err != nil {
return err
}
user.Password = password
buf, err := json.Marshal(user)
if err != nil {
return err
}
return bucket.Put([]byte(username), buf)
})
}
func (p *BoltProvider) dumpUsers() ([]User, error) {
users := make([]User, 0, 100)
err := p.dbHandle.View(func(tx *bolt.Tx) error {

View file

@ -6,6 +6,7 @@ import (
"bufio"
"bytes"
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
@ -81,6 +82,7 @@ const (
md5cryptPwdPrefix = "$1$"
md5cryptApr1PwdPrefix = "$apr1$"
sha512cryptPwdPrefix = "$6$"
md5LDAPPwdPrefix = "{MD5}"
trackQuotaDisabledError = "please enable track_quota in your configuration to use this method"
operationAdd = "add"
operationUpdate = "update"
@ -144,7 +146,8 @@ var (
sqlPlaceholders []string
internalHashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix}
hashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix,
pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix}
pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, md5LDAPPwdPrefix,
sha512cryptPwdPrefix}
pbkdfPwdPrefixes = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix}
pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix}
unixPwdPrefixes = []string{md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix}
@ -624,6 +627,7 @@ type Provider interface {
addUser(user *User) error
updateUser(user *User) error
deleteUser(user *User) error
updateUserPassword(username, password string) error
getUsers(limit int, offset int, order string) ([]User, error)
dumpUsers() ([]User, error)
getRecentlyUpdatedUsers(after int64) ([]User, error)
@ -2166,6 +2170,21 @@ func validateBaseParams(user *User) error {
return nil
}
func hashPlainPassword(plainPwd string) (string, error) {
if config.PasswordHashing.Algo == HashingAlgoBcrypt {
pwd, err := bcrypt.GenerateFromPassword([]byte(plainPwd), config.PasswordHashing.BcryptOptions.Cost)
if err != nil {
return "", fmt.Errorf("bcrypt hashing error: %w", err)
}
return string(pwd), nil
}
pwd, err := argon2id.CreateHash(plainPwd, argon2Params)
if err != nil {
return "", fmt.Errorf("argon2ID hashing error: %w", err)
}
return pwd, nil
}
func createUserPasswordHash(user *User) error {
if user.Password != "" && !user.IsPasswordHashed() {
if config.PasswordValidation.Users.MinEntropy > 0 {
@ -2173,19 +2192,11 @@ func createUserPasswordHash(user *User) error {
return util.NewValidationError(err.Error())
}
}
if config.PasswordHashing.Algo == 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
hashedPwd, err := hashPlainPassword(user.Password)
if err != nil {
return err
}
user.Password = hashedPwd
}
return nil
}
@ -2271,18 +2282,21 @@ func isPasswordOK(user *User, password string) (bool, error) {
}
match := false
updatePwd := true
var err error
if strings.HasPrefix(user.Password, bcryptPwdPrefix) {
if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return match, ErrInvalidCredentials
}
match = true
updatePwd = config.PasswordHashing.Algo != HashingAlgoBcrypt
} else if strings.HasPrefix(user.Password, argonPwdPrefix) {
match, err = argon2id.ComparePasswordAndHash(password, user.Password)
if err != nil {
providerLog(logger.LevelError, "error comparing password with argon hash: %v", err)
return match, err
}
updatePwd = config.PasswordHashing.Algo != HashingAlgoArgon2ID
} else if util.IsStringPrefixInSlice(user.Password, pbkdfPwdPrefixes) {
match, err = comparePbkdf2PasswordAndHash(password, user.Password)
if err != nil {
@ -2293,13 +2307,32 @@ func isPasswordOK(user *User, password string) (bool, error) {
if err != nil {
return match, err
}
} else if strings.HasPrefix(user.Password, md5LDAPPwdPrefix) {
h := md5.New()
h.Write([]byte(password))
match = fmt.Sprintf("%s%x", md5LDAPPwdPrefix, h.Sum(nil)) == user.Password
}
if err == nil && match {
cachedPasswords.Add(user.Username, password)
if updatePwd {
convertUserPassword(user.Username, password)
}
}
return match, err
}
func convertUserPassword(username, plainPwd string) {
hashedPwd, err := hashPlainPassword(plainPwd)
if err == nil {
err = provider.updateUserPassword(username, hashedPwd)
}
if err != nil {
providerLog(logger.LevelWarn, "unable to convert password for user %s: %v", username, err)
} else {
providerLog(logger.LevelDebug, "password converted for user %s", username)
}
}
func checkUserAndTLSCertificate(user *User, protocol string, tlsCert *x509.Certificate) (User, error) {
err := user.CheckLoginConditions()
if err != nil {

View file

@ -367,6 +367,22 @@ func (p *MemoryProvider) deleteUser(user *User) error {
return nil
}
func (p *MemoryProvider) updateUserPassword(username, password string) error {
p.dbHandle.Lock()
defer p.dbHandle.Unlock()
if p.dbHandle.isClosed {
return errMemoryProviderClosed
}
user, err := p.userExistsInternal(username)
if err != nil {
return err
}
user.Password = password
p.dbHandle.users[username] = user
return nil
}
func (p *MemoryProvider) dumpUsers() ([]User, error) {
p.dbHandle.Lock()
defer p.dbHandle.Unlock()

View file

@ -247,6 +247,10 @@ func (p *MySQLProvider) deleteUser(user *User) error {
return sqlCommonDeleteUser(user, p.dbHandle)
}
func (p *MySQLProvider) updateUserPassword(username, password string) error {
return sqlCommonUpdateUserPassword(username, password, p.dbHandle)
}
func (p *MySQLProvider) dumpUsers() ([]User, error) {
return sqlCommonDumpUsers(p.dbHandle)
}

View file

@ -223,6 +223,10 @@ func (p *PGSQLProvider) deleteUser(user *User) error {
return sqlCommonDeleteUser(user, p.dbHandle)
}
func (p *PGSQLProvider) updateUserPassword(username, password string) error {
return sqlCommonUpdateUserPassword(username, password, p.dbHandle)
}
func (p *PGSQLProvider) dumpUsers() ([]User, error) {
return sqlCommonDumpUsers(p.dbHandle)
}

View file

@ -838,6 +838,21 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
})
}
func sqlCommonUpdateUserPassword(username, password string, dbHandle *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
q := getUpdateUserPasswordQuery()
stmt, err := dbHandle.PrepareContext(ctx, q)
if err != nil {
providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err)
return err
}
defer stmt.Close()
_, err = stmt.ExecContext(ctx, password, username)
return err
}
func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
err := ValidateUser(user)
if err != nil {

View file

@ -197,6 +197,10 @@ func (p *SQLiteProvider) deleteUser(user *User) error {
return sqlCommonDeleteUser(user, p.dbHandle)
}
func (p *SQLiteProvider) updateUserPassword(username, password string) error {
return sqlCommonUpdateUserPassword(username, password, p.dbHandle)
}
func (p *SQLiteProvider) dumpUsers() ([]User, error) {
return sqlCommonDumpUsers(p.dbHandle)
}

View file

@ -365,6 +365,10 @@ func getUpdateUserQuery() string {
sqlPlaceholders[20], sqlPlaceholders[21], sqlPlaceholders[22])
}
func getUpdateUserPasswordQuery() string {
return fmt.Sprintf(`UPDATE %v SET password=%v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
}
func getDeleteUserQuery() string {
return fmt.Sprintf(`DELETE FROM %v WHERE id = %v`, sqlTableUsers, sqlPlaceholders[0])
}

View file

@ -405,7 +405,7 @@ You can select `sha256-simd` setting the environment variable `SFTPGO_MINIO_SHA2
</details>
<details><summary><font size=5> Binding to privileged ports</font></summary>
<details><summary><font size=5>Binding to privileged ports</font></summary>
On Linux, if you want to use Internet domain privileged ports (port numbers less than 1024) instead of running the SFTPGo service as root user you can set the `cap_net_bind_service` capability on the `sftpgo` binary. To set the capability you can use the following command:
@ -429,6 +429,27 @@ sudo iptables -t nat -A OUTPUT -d <ip> -p tcp --dport 22 -m addrtype --dst-t
</details>
<details><summary><font size=5>Supported Password Hashing Algorithms</font></summary>
SFTPGo can verify passwords in several formats and uses, by default, the `bcrypt` algorithm to hash passwords in plain-text before storing them inside the data provider. Each hashing algorithm is identified by a prefix.
Supported hash algorithms:
- bcrypt, prefix `$2a$`
- argon2id, prefix `$argon2id$`
- PBKDF2 sha1, prefix `$pbkdf2-sha1$`
- PBKDF2 sha256, prefix `$pbkdf2-sha256$`
- PBKDF2 sha512, prefix `$pbkdf2-sha512$`
- PBKDF2 sha256 with base64 salt, prefix `$pbkdf2-b64salt-sha256$`
- MD5 crypt, prefix `$1$`
- MD5 crypt APR1, prefix `$apr1$`
- SHA512 crypt, prefix `$6$`
- LDAP md5, prefix `{MD5}`
If you set a password with one of these prefixes it will not be encrypted.
When users log in, if their passwords are stored with anything other than the preferred algorithm, SFTPGo will automatically upgrade the algorithm to the preferred one.
</details>
## Telemetry Server
The telemetry server exposes the following endpoints:

View file

@ -6919,20 +6919,29 @@ func TestOverwriteDirWithFile(t *testing.T) {
func TestHashedPasswords(t *testing.T) {
usePubKey := false
plainPwd := "password"
pwdMapping := make(map[string]string)
pwdMapping["$pbkdf2-sha1$150000$DveVjgYUD05R$X6ydQZdyMeOvpgND2nqGR/0GGic="] = "password" //nolint:goconst
pwdMapping["$pbkdf2-sha256$150000$E86a9YMX3zC7$R5J62hsSq+pYw00hLLPKBbcGXmq7fj5+/M0IFoYtZbo="] = "password"
pwdMapping["$pbkdf2-sha512$150000$dsu7T5R3IaVQ$1hFXPO1ntRBcoWkSLKw+s4sAP09Xtu4Ya7CyxFq64jM9zdUg8eRJVr3NcR2vQgb0W9HHvZaILHsL4Q/Vr6arCg=="] = "password"
pwdMapping["$1$b5caebda$VODr/nyhGWgZaY8sJ4x05."] = "password"
pwdMapping["$argon2id$v=19$m=65536,t=3,p=2$xtcO/oRkC8O2Tn+mryl2mw$O7bn24f2kuSGRMi9s5Cm61Wqd810px1jDsAasrGWkzQ"] = plainPwd
pwdMapping["$pbkdf2-sha1$150000$DveVjgYUD05R$X6ydQZdyMeOvpgND2nqGR/0GGic="] = plainPwd
pwdMapping["$pbkdf2-sha256$150000$E86a9YMX3zC7$R5J62hsSq+pYw00hLLPKBbcGXmq7fj5+/M0IFoYtZbo="] = plainPwd
pwdMapping["$pbkdf2-sha512$150000$dsu7T5R3IaVQ$1hFXPO1ntRBcoWkSLKw+s4sAP09Xtu4Ya7CyxFq64jM9zdUg8eRJVr3NcR2vQgb0W9HHvZaILHsL4Q/Vr6arCg=="] = plainPwd
pwdMapping["$1$b5caebda$VODr/nyhGWgZaY8sJ4x05."] = plainPwd
pwdMapping["$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK"] = "secret"
pwdMapping["$6$459ead56b72e44bc$uog86fUxscjt28BZxqFBE2pp2QD8P/1e98MNF75Z9xJfQvOckZnQ/1YJqiq1XeytPuDieHZvDAMoP7352ELkO1"] = "secret"
pwdMapping["$apr1$OBWLeSme$WoJbB736e7kKxMBIAqilb1"] = "password"
pwdMapping["$apr1$OBWLeSme$WoJbB736e7kKxMBIAqilb1"] = plainPwd
pwdMapping["{MD5}5f4dcc3b5aa765d61d8327deb882cf99"] = plainPwd
for pwd, clearPwd := range pwdMapping {
u := getTestUser(usePubKey)
u.Password = pwd
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
user.Password = ""
userGetInitial, _, err := httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
user, err = dataprovider.UserExists(user.Username)
assert.NoError(t, err)
assert.Equal(t, pwd, user.Password)
user.Password = clearPwd
conn, client, err := getSftpClient(user, usePubKey)
if assert.NoError(t, err, "unable to login with password %#v", pwd) {
@ -6946,6 +6955,25 @@ func TestHashedPasswords(t *testing.T) {
client.Close()
conn.Close()
}
// the password must converted to bcrypt and we should still be able to login
user, err = dataprovider.UserExists(user.Username)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(user.Password, "$2a$"))
// update the user to invalidate the cached password and force a new check
user.Password = ""
userGet, _, err := httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
userGetInitial.LastLogin = userGet.LastLogin
userGetInitial.UpdatedAt = userGet.UpdatedAt
assert.Equal(t, userGetInitial, userGet)
// login should still work
user.Password = clearPwd
conn, client, err = getSftpClient(user, usePubKey)
if assert.NoError(t, err, "unable to login with password %#v", pwd) {
defer conn.Close()
defer client.Close()
assert.NoError(t, checkBasicSFTP(client))
}
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())