mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-25 09:00:27 +00:00
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:
parent
bc88503f25
commit
6ef85d6026
10 changed files with 160 additions and 4 deletions
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
60
dataprovider/cachedpassword.go
Normal file
60
dataprovider/cachedpassword.go
Normal 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)
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 "aUpdater{
|
return quotaUpdater{
|
||||||
pendingUserQuotaUpdates: make(map[string]quotaObject),
|
pendingUserQuotaUpdates: make(map[string]quotaObject),
|
||||||
pendingFolderQuotaUpdates: make(map[string]quotaObject),
|
pendingFolderQuotaUpdates: make(map[string]quotaObject),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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") {
|
||||||
|
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in a new issue