Browse Source

add, optional, in memory password caching

Verifying argon2 passwords has a high memory and computational cost,
by enabling, in memory, password caching you reduce this cost
Nicola Murino 4 years ago
parent
commit
6ef85d6026

+ 59 - 0
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)

+ 2 - 0
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)

+ 60 - 0
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)
+}

+ 20 - 0
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
 	}

+ 3 - 3
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 &quotaUpdater{
+func newQuotaUpdater() quotaUpdater {
+	return quotaUpdater{
 		pendingUserQuotaUpdates:   make(map[string]quotaObject),
 		pendingFolderQuotaUpdates: make(map[string]quotaObject),
 	}

+ 1 - 0
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.

+ 1 - 1
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

+ 5 - 0
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") {

+ 1 - 0
sftpgo.json

@@ -167,6 +167,7 @@
         "parallelism": 2
       }
     },
+    "password_caching": true,
     "update_mode": 0,
     "skip_natural_keys_validation": false
   },

+ 8 - 0
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")