diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index ced30801..15987dcb 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -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 { diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 8fb6a553..6b1898ac 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -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 { diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 355c02ad..96438f63 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -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() diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index 3ab8c6a9..79d9b0cc 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -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) } diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 91d35d7e..3adb03c5 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -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) } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index bd40bc9b..b97e03dc 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -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 { diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 5ba82c1c..ae213bf3 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -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) } diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index c7957fe8..e2615ec1 100644 --- a/dataprovider/sqlqueries.go +++ b/dataprovider/sqlqueries.go @@ -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]) } diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 70ec340d..8cc835ee 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -405,7 +405,7 @@ You can select `sha256-simd` setting the environment variable `SFTPGO_MINIO_SHA2 -
Binding to privileged ports +
Binding to privileged ports 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 -p tcp --dport 22 -m addrtype --dst-t
+
Supported Password Hashing Algorithms + +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. + +
+ ## Telemetry Server The telemetry server exposes the following endpoints: diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 08e23dd4..8e444d74 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -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())