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
This commit is contained in:
Nicola Murino 2021-04-20 09:39:36 +02:00
parent bc88503f25
commit 6ef85d6026
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
10 changed files with 160 additions and 4 deletions

View file

@ -2043,6 +2043,65 @@ func TestDelayedQuotaUpdater(t *testing.T) {
assert.NoError(t, err) 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) { func TestQuotaTrackDisabled(t *testing.T) {
err := dataprovider.Close() err := dataprovider.Close()
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -221,6 +221,7 @@ func Init() {
Parallelism: 2, Parallelism: 2,
}, },
}, },
PasswordCaching: true,
UpdateMode: 0, UpdateMode: 0,
PreferDatabaseCredentials: false, PreferDatabaseCredentials: false,
SkipNaturalKeysValidation: false, SkipNaturalKeysValidation: false,
@ -941,6 +942,7 @@ func setViperDefaults() {
viper.SetDefault("data_provider.update_mode", globalConf.ProviderConf.UpdateMode) viper.SetDefault("data_provider.update_mode", globalConf.ProviderConf.UpdateMode)
viper.SetDefault("data_provider.skip_natural_keys_validation", globalConf.ProviderConf.SkipNaturalKeysValidation) 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.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.templates_path", globalConf.HTTPDConfig.TemplatesPath)
viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath) viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath)
viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath) viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath)

View file

@ -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)
}

View file

@ -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 // folder name. These keys are used in URIs for REST API and Web admin. By default only unreserved URI
// characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". // characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~".
SkipNaturalKeysValidation bool `json:"skip_natural_keys_validation" mapstructure:"skip_natural_keys_validation"` 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. // 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 // If there are a lot of close uploads, accumulating quota updates can save you many
// queries to the data provider. // queries to the data provider.
@ -874,6 +877,7 @@ func UpdateUser(user *User) error {
err := provider.updateUser(user) err := provider.updateUser(user)
if err == nil { if err == nil {
webDAVUsersCache.swap(user) webDAVUsersCache.swap(user)
cachedPasswords.Remove(user.Username)
executeAction(operationUpdate, user) executeAction(operationUpdate, user)
} }
return err return err
@ -889,6 +893,7 @@ func DeleteUser(username string) error {
if err == nil { if err == nil {
RemoveCachedWebDAVUser(user.Username) RemoveCachedWebDAVUser(user.Username)
delayedQuotaUpdater.resetUserQuota(username) delayedQuotaUpdater.resetUserQuota(username)
cachedPasswords.Remove(username)
executeAction(operationDelete, &user) executeAction(operationDelete, &user)
} }
return err return err
@ -1581,6 +1586,13 @@ func checkLoginConditions(user *User) error {
} }
func isPasswordOK(user *User, password string) (bool, 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 match := false
var err error var err error
if strings.HasPrefix(user.Password, argonPwdPrefix) { if strings.HasPrefix(user.Password, argonPwdPrefix) {
@ -1606,6 +1618,9 @@ func isPasswordOK(user *User, password string) (bool, error) {
return match, err return match, err
} }
} }
if err == nil && match {
cachedPasswords.Add(user.Username, password)
}
return match, err return match, err
} }
@ -2198,6 +2213,7 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro
} }
userID := u.ID userID := u.ID
userPwd := u.Password
userUsedQuotaSize := u.UsedQuotaSize userUsedQuotaSize := u.UsedQuotaSize
userUsedQuotaFiles := u.UsedQuotaFiles userUsedQuotaFiles := u.UsedQuotaFiles
userLastQuotaUpdate := u.LastQuotaUpdate userLastQuotaUpdate := u.LastQuotaUpdate
@ -2217,6 +2233,9 @@ func executePreLoginHook(username, loginMethod, ip, protocol string) (User, erro
err = provider.updateUser(&u) err = provider.updateUser(&u)
if err == nil { if err == nil {
webDAVUsersCache.swap(&u) webDAVUsersCache.swap(&u)
if u.Password != userPwd {
cachedPasswords.Remove(username)
}
} }
} }
if err != nil { if err != nil {
@ -2421,6 +2440,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
err = provider.updateUser(&user) err = provider.updateUser(&user)
if err == nil { if err == nil {
webDAVUsersCache.swap(&user) webDAVUsersCache.swap(&user)
cachedPasswords.Add(user.Username, password)
} }
return user, err return user, err
} }

View file

@ -7,7 +7,7 @@ import (
"github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/logger"
) )
var delayedQuotaUpdater *quotaUpdater var delayedQuotaUpdater quotaUpdater
func init() { func init() {
delayedQuotaUpdater = newQuotaUpdater() delayedQuotaUpdater = newQuotaUpdater()
@ -26,8 +26,8 @@ type quotaUpdater struct {
pendingFolderQuotaUpdates map[string]quotaObject pendingFolderQuotaUpdates map[string]quotaObject
} }
func newQuotaUpdater() *quotaUpdater { func newQuotaUpdater() quotaUpdater {
return &quotaUpdater{ return quotaUpdater{
pendingUserQuotaUpdates: make(map[string]quotaObject), pendingUserQuotaUpdates: make(map[string]quotaObject),
pendingFolderQuotaUpdates: make(map[string]quotaObject), pendingFolderQuotaUpdates: make(map[string]quotaObject),
} }

View file

@ -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. - `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. - `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. - `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. - `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`. - `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. - `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

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
NFPM_VERSION=2.3.1 NFPM_VERSION=2.4.0
NFPM_ARCH=${NFPM_ARCH:-amd64} NFPM_ARCH=${NFPM_ARCH:-amd64}
if [ -z ${SFTPGO_VERSION} ] if [ -z ${SFTPGO_VERSION} ]
then then

View file

@ -2609,6 +2609,11 @@ func TestLoginExternalAuth(t *testing.T) {
defer client.Close() defer client.Close()
assert.NoError(t, checkBasicSFTP(client)) 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" u.Username = defaultUsername + "1"
client, err = getSftpClient(u, usePubKey) client, err = getSftpClient(u, usePubKey)
if !assert.Error(t, err, "external auth login with invalid user must fail") { if !assert.Error(t, err, "external auth login with invalid user must fail") {

View file

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

View file

@ -1199,6 +1199,8 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
_, ok = dataprovider.GetCachedWebDAVUser(user4.Username) _, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
assert.True(t, ok) assert.True(t, ok)
// a sleep ensures that expiration times are different
time.Sleep(20 * time.Millisecond)
// user1 logins, user2 should be removed // user1 logins, user2 should be removed
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil)
assert.NoError(t, err) assert.NoError(t, err)
@ -1216,6 +1218,8 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
_, ok = dataprovider.GetCachedWebDAVUser(user4.Username) _, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
assert.True(t, ok) assert.True(t, ok)
// a sleep ensures that expiration times are different
time.Sleep(20 * time.Millisecond)
// user2 logins, user3 should be removed // user2 logins, user3 should be removed
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user2.Username), nil) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user2.Username), nil)
assert.NoError(t, err) assert.NoError(t, err)
@ -1233,6 +1237,8 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
_, ok = dataprovider.GetCachedWebDAVUser(user4.Username) _, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
assert.True(t, ok) assert.True(t, ok)
// a sleep ensures that expiration times are different
time.Sleep(20 * time.Millisecond)
// user3 logins, user4 should be removed // user3 logins, user4 should be removed
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user3.Username), nil) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user3.Username), nil)
assert.NoError(t, err) assert.NoError(t, err)
@ -1265,6 +1271,8 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
assert.False(t, isCached) assert.False(t, isCached)
assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod) 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) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil)
assert.NoError(t, err) assert.NoError(t, err)
req.SetBasicAuth(user1.Username, password+"1") req.SetBasicAuth(user1.Username, password+"1")