diff --git a/common/protocol_test.go b/common/protocol_test.go index c61d593e..f8c13090 100644 --- a/common/protocol_test.go +++ b/common/protocol_test.go @@ -2043,6 +2043,65 @@ func TestDelayedQuotaUpdater(t *testing.T) { assert.NoError(t, err) } +func TestPasswordCaching(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + found, match := dataprovider.CheckCachedPassword(user.Username, defaultPassword) + assert.False(t, found) + assert.False(t, match) + + user.Password = "wrong" + _, err = getSftpClient(user) + assert.Error(t, err) + found, match = dataprovider.CheckCachedPassword(user.Username, defaultPassword) + assert.False(t, found) + assert.False(t, match) + user.Password = "" + + client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer client.Close() + err = checkBasicSFTP(client) + assert.NoError(t, err) + } + found, match = dataprovider.CheckCachedPassword(user.Username, defaultPassword) + assert.True(t, found) + assert.True(t, match) + + found, match = dataprovider.CheckCachedPassword(user.Username, defaultPassword+"_") + assert.True(t, found) + assert.False(t, match) + + found, match = dataprovider.CheckCachedPassword(user.Username+"_", defaultPassword) + assert.False(t, found) + assert.False(t, match) + + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + found, match = dataprovider.CheckCachedPassword(user.Username, defaultPassword) + assert.False(t, found) + assert.False(t, match) + + client, err = getSftpClient(user) + if assert.NoError(t, err) { + defer client.Close() + err = checkBasicSFTP(client) + assert.NoError(t, err) + } + + found, match = dataprovider.CheckCachedPassword(user.Username, defaultPassword) + assert.True(t, found) + assert.True(t, match) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + found, match = dataprovider.CheckCachedPassword(user.Username, defaultPassword) + assert.False(t, found) + assert.False(t, match) +} + func TestQuotaTrackDisabled(t *testing.T) { err := dataprovider.Close() assert.NoError(t, err) diff --git a/config/config.go b/config/config.go index 1a4512a7..ca109b68 100644 --- a/config/config.go +++ b/config/config.go @@ -221,6 +221,7 @@ func Init() { Parallelism: 2, }, }, + PasswordCaching: true, UpdateMode: 0, PreferDatabaseCredentials: false, SkipNaturalKeysValidation: false, @@ -941,6 +942,7 @@ func setViperDefaults() { 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) + viper.SetDefault("data_provider.password_caching", globalConf.ProviderConf.PasswordCaching) viper.SetDefault("httpd.templates_path", globalConf.HTTPDConfig.TemplatesPath) viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath) viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath) diff --git a/dataprovider/cachedpassword.go b/dataprovider/cachedpassword.go new file mode 100644 index 00000000..dd96a588 --- /dev/null +++ b/dataprovider/cachedpassword.go @@ -0,0 +1,60 @@ +package dataprovider + +import "sync" + +var cachedPasswords passwordsCache + +func init() { + cachedPasswords = passwordsCache{ + cache: make(map[string]string), + } +} + +type passwordsCache struct { + sync.RWMutex + cache map[string]string +} + +func (c *passwordsCache) Add(username, password string) { + if !config.PasswordCaching || username == "" || password == "" { + return + } + + c.Lock() + defer c.Unlock() + + c.cache[username] = password +} + +func (c *passwordsCache) Remove(username string) { + if !config.PasswordCaching { + return + } + + c.Lock() + defer c.Unlock() + + delete(c.cache, username) +} + +// returns if the user is found and if the password match +func (c *passwordsCache) Check(username, password string) (bool, bool) { + if username == "" || password == "" { + return false, false + } + + c.RLock() + defer c.RUnlock() + + pwd, ok := c.cache[username] + if !ok { + return false, false + } + + return true, pwd == password +} + +// CheckCachedPassword is an utility method used only in test cases +func CheckCachedPassword(username, password string) (bool, bool) { + return cachedPasswords.Check(username, password) +} diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index cf07f549..c0f79c93 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -279,6 +279,9 @@ type Config struct { // folder name. These keys are used in URIs for REST API and Web admin. By default only unreserved URI // characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". SkipNaturalKeysValidation bool `json:"skip_natural_keys_validation" mapstructure:"skip_natural_keys_validation"` + // Verifying argon2 passwords has a high memory and computational cost, + // by enabling, in memory, password caching you reduce this cost. + PasswordCaching bool `json:"password_caching" mapstructure:"password_caching"` // DelayedQuotaUpdate 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. @@ -874,6 +877,7 @@ func UpdateUser(user *User) error { err := provider.updateUser(user) if err == nil { webDAVUsersCache.swap(user) + cachedPasswords.Remove(user.Username) executeAction(operationUpdate, user) } return err @@ -889,6 +893,7 @@ func DeleteUser(username string) error { if err == nil { RemoveCachedWebDAVUser(user.Username) delayedQuotaUpdater.resetUserQuota(username) + cachedPasswords.Remove(username) executeAction(operationDelete, &user) } return err @@ -1581,6 +1586,13 @@ func checkLoginConditions(user *User) error { } func isPasswordOK(user *User, password string) (bool, error) { + if config.PasswordCaching { + found, match := cachedPasswords.Check(user.Username, password) + if found { + return match, nil + } + } + match := false var err error if strings.HasPrefix(user.Password, argonPwdPrefix) { @@ -1606,6 +1618,9 @@ func isPasswordOK(user *User, password string) (bool, error) { return match, err } } + if err == nil && match { + cachedPasswords.Add(user.Username, password) + } return match, err } @@ -2198,6 +2213,7 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro } userID := u.ID + userPwd := u.Password userUsedQuotaSize := u.UsedQuotaSize userUsedQuotaFiles := u.UsedQuotaFiles userLastQuotaUpdate := u.LastQuotaUpdate @@ -2217,6 +2233,9 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro err = provider.updateUser(&u) if err == nil { webDAVUsersCache.swap(&u) + if u.Password != userPwd { + cachedPasswords.Remove(username) + } } } if err != nil { @@ -2421,6 +2440,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv err = provider.updateUser(&user) if err == nil { webDAVUsersCache.swap(&user) + cachedPasswords.Add(user.Username, password) } return user, err } diff --git a/dataprovider/quotaupdater.go b/dataprovider/quotaupdater.go index 8645e9e7..5c2909dc 100644 --- a/dataprovider/quotaupdater.go +++ b/dataprovider/quotaupdater.go @@ -7,7 +7,7 @@ import ( "github.com/drakkan/sftpgo/logger" ) -var delayedQuotaUpdater *quotaUpdater +var delayedQuotaUpdater quotaUpdater func init() { delayedQuotaUpdater = newQuotaUpdater() @@ -26,8 +26,8 @@ type quotaUpdater struct { pendingFolderQuotaUpdates map[string]quotaObject } -func newQuotaUpdater() *quotaUpdater { - return "aUpdater{ +func newQuotaUpdater() quotaUpdater { + return quotaUpdater{ pendingUserQuotaUpdates: make(map[string]quotaObject), pendingFolderQuotaUpdates: make(map[string]quotaObject), } diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 38eab73c..79410547 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -199,6 +199,7 @@ The configuration file contains the following sections: - `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` - `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. diff --git a/pkgs/build.sh b/pkgs/build.sh index 733f149a..685321e5 100755 --- a/pkgs/build.sh +++ b/pkgs/build.sh @@ -1,6 +1,6 @@ #!/bin/bash -NFPM_VERSION=2.3.1 +NFPM_VERSION=2.4.0 NFPM_ARCH=${NFPM_ARCH:-amd64} if [ -z ${SFTPGO_VERSION} ] then diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index f46c690e..0cf66b99 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -2609,6 +2609,11 @@ func TestLoginExternalAuth(t *testing.T) { defer client.Close() assert.NoError(t, checkBasicSFTP(client)) } + if !usePubKey { + found, match := dataprovider.CheckCachedPassword(defaultUsername, defaultPassword) + assert.True(t, found) + assert.True(t, match) + } u.Username = defaultUsername + "1" client, err = getSftpClient(u, usePubKey) if !assert.Error(t, err, "external auth login with invalid user must fail") { diff --git a/sftpgo.json b/sftpgo.json index eb2b6d79..3cf11850 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -167,6 +167,7 @@ "parallelism": 2 } }, + "password_caching": true, "update_mode": 0, "skip_natural_keys_validation": false }, diff --git a/webdavd/internal_test.go b/webdavd/internal_test.go index fd8626e9..eedd2a0d 100644 --- a/webdavd/internal_test.go +++ b/webdavd/internal_test.go @@ -1199,6 +1199,8 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) { _, ok = dataprovider.GetCachedWebDAVUser(user4.Username) assert.True(t, ok) + // a sleep ensures that expiration times are different + time.Sleep(20 * time.Millisecond) // user1 logins, user2 should be removed req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil) assert.NoError(t, err) @@ -1216,6 +1218,8 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) { _, ok = dataprovider.GetCachedWebDAVUser(user4.Username) assert.True(t, ok) + // a sleep ensures that expiration times are different + time.Sleep(20 * time.Millisecond) // user2 logins, user3 should be removed req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user2.Username), nil) assert.NoError(t, err) @@ -1233,6 +1237,8 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) { _, ok = dataprovider.GetCachedWebDAVUser(user4.Username) assert.True(t, ok) + // a sleep ensures that expiration times are different + time.Sleep(20 * time.Millisecond) // user3 logins, user4 should be removed req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user3.Username), nil) assert.NoError(t, err) @@ -1265,6 +1271,8 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) { assert.False(t, isCached) assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod) + // a sleep ensures that expiration times are different + time.Sleep(20 * time.Millisecond) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil) assert.NoError(t, err) req.SetBasicAuth(user1.Username, password+"1")