Explorar o código

WebDAV: add caching for authenticated users

In this way we get a big performance boost
Nicola Murino %!s(int64=4) %!d(string=hai) anos
pai
achega
dbed110d02

+ 5 - 0
config/config.go

@@ -101,6 +101,11 @@ func init() {
 				AllowCredentials: false,
 				AllowCredentials: false,
 				MaxAge:           0,
 				MaxAge:           0,
 			},
 			},
+			Cache: webdavd.Cache{
+				Enabled:        true,
+				ExpirationTime: 0,
+				MaxSize:        50,
+			},
 		},
 		},
 		ProviderConf: dataprovider.Config{
 		ProviderConf: dataprovider.Config{
 			Driver:           "sqlite",
 			Driver:           "sqlite",

+ 66 - 11
dataprovider/dataprovider.go

@@ -98,10 +98,13 @@ var (
 	ValidProtocols = []string{"SSH", "FTP", "DAV"}
 	ValidProtocols = []string{"SSH", "FTP", "DAV"}
 	// ErrNoInitRequired defines the error returned by InitProvider if no inizialization is required
 	// ErrNoInitRequired defines the error returned by InitProvider if no inizialization is required
 	ErrNoInitRequired = errors.New("Data provider initialization is not required")
 	ErrNoInitRequired = errors.New("Data provider initialization is not required")
-	config            Config
-	provider          Provider
-	sqlPlaceholders   []string
-	hashPwdPrefixes   = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix,
+	// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
+	ErrInvalidCredentials = errors.New("Invalid credentials")
+	webDAVUsersCache      sync.Map
+	config                Config
+	provider              Provider
+	sqlPlaceholders       []string
+	hashPwdPrefixes       = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix,
 		pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix}
 		pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix}
 	pbkdfPwdPrefixes        = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix}
 	pbkdfPwdPrefixes        = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix}
 	pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix}
 	pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix}
@@ -507,6 +510,9 @@ func UpdateUserQuota(user User, filesAdd int, sizeAdd int64, reset bool) error {
 	if config.ManageUsers == 0 {
 	if config.ManageUsers == 0 {
 		return &MethodDisabledError{err: manageUsersDisabledError}
 		return &MethodDisabledError{err: manageUsersDisabledError}
 	}
 	}
+	if filesAdd == 0 && sizeAdd == 0 && !reset {
+		return nil
+	}
 	return provider.updateQuota(user.Username, filesAdd, sizeAdd, reset)
 	return provider.updateQuota(user.Username, filesAdd, sizeAdd, reset)
 }
 }
 
 
@@ -519,6 +525,9 @@ func UpdateVirtualFolderQuota(vfolder vfs.BaseVirtualFolder, filesAdd int, sizeA
 	if config.ManageUsers == 0 {
 	if config.ManageUsers == 0 {
 		return &MethodDisabledError{err: manageUsersDisabledError}
 		return &MethodDisabledError{err: manageUsersDisabledError}
 	}
 	}
+	if filesAdd == 0 && sizeAdd == 0 && !reset {
+		return nil
+	}
 	return provider.updateFolderQuota(vfolder.MappedPath, filesAdd, sizeAdd, reset)
 	return provider.updateFolderQuota(vfolder.MappedPath, filesAdd, sizeAdd, reset)
 }
 }
 
 
@@ -543,7 +552,7 @@ func UserExists(username string) (User, error) {
 	return provider.userExists(username)
 	return provider.userExists(username)
 }
 }
 
 
-// AddUser adds a new SFTP user.
+// AddUser adds a new SFTPGo user.
 // ManageUsers configuration must be set to 1 to enable this method
 // ManageUsers configuration must be set to 1 to enable this method
 func AddUser(user User) error {
 func AddUser(user User) error {
 	if config.ManageUsers == 0 {
 	if config.ManageUsers == 0 {
@@ -556,7 +565,7 @@ func AddUser(user User) error {
 	return err
 	return err
 }
 }
 
 
-// UpdateUser updates an existing SFTP user.
+// UpdateUser updates an existing SFTPGo user.
 // ManageUsers configuration must be set to 1 to enable this method
 // ManageUsers configuration must be set to 1 to enable this method
 func UpdateUser(user User) error {
 func UpdateUser(user User) error {
 	if config.ManageUsers == 0 {
 	if config.ManageUsers == 0 {
@@ -564,6 +573,7 @@ func UpdateUser(user User) error {
 	}
 	}
 	err := provider.updateUser(user)
 	err := provider.updateUser(user)
 	if err == nil {
 	if err == nil {
+		RemoveCachedWebDAVUser(user.Username)
 		go executeAction(operationUpdate, user)
 		go executeAction(operationUpdate, user)
 	}
 	}
 	return err
 	return err
@@ -577,6 +587,7 @@ func DeleteUser(user User) error {
 	}
 	}
 	err := provider.deleteUser(user)
 	err := provider.deleteUser(user)
 	if err == nil {
 	if err == nil {
+		RemoveCachedWebDAVUser(user.Username)
 		go executeAction(operationDelete, user)
 		go executeAction(operationDelete, user)
 	}
 	}
 	return err
 	return err
@@ -1092,12 +1103,12 @@ func checkUserAndPass(user User, password, ip, protocol string) (User, error) {
 		password = hookResponse.ToVerify
 		password = hookResponse.ToVerify
 	default:
 	default:
 		providerLog(logger.LevelDebug, "password rejected by check password hook, status: %v", hookResponse.Status)
 		providerLog(logger.LevelDebug, "password rejected by check password hook, status: %v", hookResponse.Status)
-		return user, errors.New("Invalid credentials")
+		return user, ErrInvalidCredentials
 	}
 	}
 
 
 	match, err := isPasswordOK(&user, password)
 	match, err := isPasswordOK(&user, password)
 	if !match {
 	if !match {
-		err = errors.New("Invalid credentials")
+		err = ErrInvalidCredentials
 	}
 	}
 	return user, err
 	return user, err
 }
 }
@@ -1108,7 +1119,7 @@ func checkUserAndPubKey(user User, pubKey []byte) (User, string, error) {
 		return user, "", err
 		return user, "", err
 	}
 	}
 	if len(user.PublicKeys) == 0 {
 	if len(user.PublicKeys) == 0 {
-		return user, "", errors.New("Invalid credentials")
+		return user, "", ErrInvalidCredentials
 	}
 	}
 	for i, k := range user.PublicKeys {
 	for i, k := range user.PublicKeys {
 		storedPubKey, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
 		storedPubKey, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
@@ -1126,7 +1137,7 @@ func checkUserAndPubKey(user User, pubKey []byte) (User, string, error) {
 			return user, fmt.Sprintf("%v:%v%v", ssh.FingerprintSHA256(storedPubKey), comment, certInfo), nil
 			return user, fmt.Sprintf("%v:%v%v", ssh.FingerprintSHA256(storedPubKey), comment, certInfo), nil
 		}
 		}
 	}
 	}
-	return user, "", errors.New("Invalid credentials")
+	return user, "", ErrInvalidCredentials
 }
 }
 
 
 func compareUnixPasswordAndHash(user *User, password string) (bool, error) {
 func compareUnixPasswordAndHash(user *User, password string) (bool, error) {
@@ -1803,7 +1814,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
 		return user, fmt.Errorf("Invalid external auth response: %v", err)
 		return user, fmt.Errorf("Invalid external auth response: %v", err)
 	}
 	}
 	if len(user.Username) == 0 {
 	if len(user.Username) == 0 {
-		return user, errors.New("Invalid credentials")
+		return user, ErrInvalidCredentials
 	}
 	}
 	if len(password) > 0 {
 	if len(password) > 0 {
 		user.Password = password
 		user.Password = password
@@ -1919,3 +1930,47 @@ func updateVFoldersQuotaAfterRestore(foldersToScan []string) {
 		providerLog(logger.LevelDebug, "quota updated for virtual folder %#v, error: %v", vfolder.MappedPath, err)
 		providerLog(logger.LevelDebug, "quota updated for virtual folder %#v, error: %v", vfolder.MappedPath, err)
 	}
 	}
 }
 }
+
+// CacheWebDAVUser add a user to the WebDAV cache
+func CacheWebDAVUser(cachedUser CachedUser, maxSize int) {
+	if maxSize > 0 {
+		var cacheSize int
+		var userToRemove string
+		var expirationTime time.Time
+
+		webDAVUsersCache.Range(func(k, v interface{}) bool {
+			cacheSize++
+			if len(userToRemove) == 0 {
+				userToRemove = k.(string)
+				expirationTime = v.(CachedUser).Expiration
+				return true
+			}
+			expireTime := v.(CachedUser).Expiration
+			if !expireTime.IsZero() && expireTime.Before(expirationTime) {
+				userToRemove = k.(string)
+				expirationTime = expireTime
+			}
+			return true
+		})
+
+		if cacheSize >= maxSize {
+			RemoveCachedWebDAVUser(userToRemove)
+		}
+	}
+
+	if len(cachedUser.User.Username) > 0 {
+		webDAVUsersCache.Store(cachedUser.User.Username, cachedUser)
+	}
+}
+
+// GetCachedWebDAVUser returns a previously cached WebDAV user
+func GetCachedWebDAVUser(username string) (interface{}, bool) {
+	return webDAVUsersCache.Load(username)
+}
+
+// RemoveCachedWebDAVUser removes a cached WebDAV user
+func RemoveCachedWebDAVUser(username string) {
+	if len(username) > 0 {
+		webDAVUsersCache.Delete(username)
+	}
+}

+ 16 - 1
dataprovider/user.go

@@ -60,6 +60,21 @@ var (
 	errNoMatchingVirtualFolder = errors.New("no matching virtual folder found")
 	errNoMatchingVirtualFolder = errors.New("no matching virtual folder found")
 )
 )
 
 
+// CachedUser adds fields useful for caching to a SFTPGo user
+type CachedUser struct {
+	User       User
+	Expiration time.Time
+	Password   string
+}
+
+// IsExpired returns true if the cached user is expired
+func (c CachedUser) IsExpired() bool {
+	if c.Expiration.IsZero() {
+		return false
+	}
+	return c.Expiration.Before(time.Now())
+}
+
 // ExtensionsFilter defines filters based on file extensions.
 // ExtensionsFilter defines filters based on file extensions.
 // These restrictions do not apply to files listing for performance reasons, so
 // These restrictions do not apply to files listing for performance reasons, so
 // a denied file cannot be downloaded/overwritten/renamed but will still be
 // a denied file cannot be downloaded/overwritten/renamed but will still be
@@ -112,7 +127,7 @@ type Filesystem struct {
 	GCSConfig vfs.GCSFsConfig `json:"gcsconfig,omitempty"`
 	GCSConfig vfs.GCSFsConfig `json:"gcsconfig,omitempty"`
 }
 }
 
 
-// User defines an SFTP user
+// User defines a SFTPGo user
 type User struct {
 type User struct {
 	// Database unique identifier
 	// Database unique identifier
 	ID int64 `json:"id"`
 	ID int64 `json:"id"`

+ 4 - 0
docs/full-configuration.md

@@ -103,6 +103,10 @@ The configuration file contains the following sections:
     - `exposed_headers`, list of strings.
     - `exposed_headers`, list of strings.
     - `allow_credentials` boolean.
     - `allow_credentials` boolean.
     - `max_age`, integer.
     - `max_age`, integer.
+  - `cache` struct containing cache configuration.
+    - `enabled`, boolean, set to true to enable user caching. Default: true.
+    - `expiration_time`, integer. Expiration time, in minutes, for the cached users. 0 means unlimited. Default: 0.
+    - `max_size`, integer. Maximum number of users to cache. 0 means unlimited. Default: 50.
 - **"data_provider"**, the configuration for the data provider
 - **"data_provider"**, the configuration for the data provider
   - `driver`, string. Supported drivers are `sqlite`, `mysql`, `postgresql`, `bolt`, `memory`
   - `driver`, string. Supported drivers are `sqlite`, `mysql`, `postgresql`, `bolt`, `memory`
   - `name`, string. Database name. For driver `sqlite` this can be the database name relative to the config dir or the absolute path to the SQLite database. For driver `memory` this is the (optional) path relative to the config dir or the absolute path to the users dump, obtained using the `dumpdata` REST API, to load. This dump will be loaded at startup and can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows. The `memory` provider will not modify the provided file so quota usage and last login will not be persisted
   - `name`, string. Database name. For driver `sqlite` this can be the database name relative to the config dir or the absolute path to the SQLite database. For driver `memory` this is the (optional) path relative to the config dir or the absolute path to the users dump, obtained using the `dumpdata` REST API, to load. This dump will be loaded at startup and can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows. The `memory` provider will not modify the provided file so quota usage and last login will not be persisted

+ 10 - 1
docs/webdav.md

@@ -4,6 +4,16 @@ The experimental `WebDAV` support can be enabled setting a `bind_port` inside th
 
 
 Each user has his own path like `http/s://<SFTPGo ip>:<WevDAVPORT>/<username>` and it must authenticate using password credentials.
 Each user has his own path like `http/s://<SFTPGo ip>:<WevDAVPORT>/<username>` and it must authenticate using password credentials.
 
 
+WebDAV is quite a different protocol than SCP/FTP, there is no session concept, each command is a separate HTTP request and must be authenticated, performance can be greatly improved enabling caching for the authenticated users (it is enabled by default). This way SFTPGo don't need to do a dataprovider query and a password check for each request.
+If you enable quota support a dataprovider query is required, to update the user quota, after each file upload.
+
+The caching configuration allows to set:
+
+- `expiration_time` in minutes. If a user is cached for more than the specificied minutes it will be removed from the cache and a new dataprovider query will be performed. Please note that the `last_login` field will not be updated and `external_auth_hook`, `pre_login_hook` and `check_password_hook` will not be executed is the user is obtained from the cache.
+- `max_size`. Maximum number of users to cache. When this limit is reached the user with the oldest `expiration_time` is removed from the cache. 0 means no limit however the cache size cannot exceed the number of users so if you have a small number of users you can leave this setting to 0.
+
+Users are automatically removed from the cache after an update/delete.
+
 WebDAV should work as expected for most use cases but there are some minor issues and some missing features.
 WebDAV should work as expected for most use cases but there are some minor issues and some missing features.
 
 
 Know issues:
 Know issues:
@@ -11,7 +21,6 @@ Know issues:
 - removing a directory tree on Cloud Storage backends could generate a `not found` error when removing the last (virtual) directory. This happen if the client cycles the directories tree itself and removes files and directories one by one instead of issuing a single remove command
 - removing a directory tree on Cloud Storage backends could generate a `not found` error when removing the last (virtual) directory. This happen if the client cycles the directories tree itself and removes files and directories one by one instead of issuing a single remove command
 - the used [WebDAV library](https://pkg.go.dev/golang.org/x/net/webdav?tab=doc) asks to open a file to execute a `stat` and sometime reads some bytes to find the content type. We are unable to distinguish a `stat` from a `download` for now, so to be able to proper list a directory you need to grant both `list` and `download` permissions
 - the used [WebDAV library](https://pkg.go.dev/golang.org/x/net/webdav?tab=doc) asks to open a file to execute a `stat` and sometime reads some bytes to find the content type. We are unable to distinguish a `stat` from a `download` for now, so to be able to proper list a directory you need to grant both `list` and `download` permissions
 - the used `WebDAV library` not always returns a proper error code/message, most of the times it simply returns `Method not Allowed`. I'll try to improve the library error codes in the future
 - the used `WebDAV library` not always returns a proper error code/message, most of the times it simply returns `Method not Allowed`. I'll try to improve the library error codes in the future
-- WebDAV is quite a different protocol than SCP/FTP, there is no session concept, each command is a separate HTTP request, we could improve the performance by caching, for a small time, the user info so we don't need a user lookup (and so a dataprovider query) for each request. Some clients issue a lot of requests only for listing a directory contents. This needs more investigation and a design decision anyway the protocol itself is quite heavy
 - if an object within a directory cannot be accessed, for example due to OS permissions issues or because is a missing mapped path for a virtual folder, the directory listing will fail. In SFTP/FTP the directory listing will succeed and you'll only get an error if you try to access to the problematic file/directory
 - if an object within a directory cannot be accessed, for example due to OS permissions issues or because is a missing mapped path for a virtual folder, the directory listing will fail. In SFTP/FTP the directory listing will succeed and you'll only get an error if you try to access to the problematic file/directory
 
 
 We plan to add [Dead Properties](https://tools.ietf.org/html/rfc4918#section-3) support in future releases. We need a design decision here, probably the best solution is to store dead properties inside the data provider but this could increase a lot its size. Alternately we could store them on disk for local filesystem and add as metadata for Cloud Storage, this means that we need to do a separate `HEAD` request to retrieve dead properties for an S3 file. For big folders will do a lot of requests to the Cloud Provider, I don't like this solution. Another option is to expose a hook and allow you to implement `dead properties` outside SFTPGo.
 We plan to add [Dead Properties](https://tools.ietf.org/html/rfc4918#section-3) support in future releases. We need a design decision here, probably the best solution is to store dead properties inside the data provider but this could increase a lot its size. Alternately we could store them on disk for local filesystem and add as metadata for Cloud Storage, this means that we need to do a separate `HEAD` request to retrieve dead properties for an S3 file. For big folders will do a lot of requests to the Cloud Provider, I don't like this solution. Another option is to expose a hook and allow you to implement `dead properties` outside SFTPGo.

+ 1 - 1
go.mod

@@ -8,7 +8,7 @@ require (
 	github.com/alexedwards/argon2id v0.0.0-20200802152012-2464efd3196b
 	github.com/alexedwards/argon2id v0.0.0-20200802152012-2464efd3196b
 	github.com/aws/aws-sdk-go v1.34.13
 	github.com/aws/aws-sdk-go v1.34.13
 	github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d
 	github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d
-	github.com/fclairamb/ftpserverlib v0.8.1-0.20200824203441-87a3864e6de5
+	github.com/fclairamb/ftpserverlib v0.8.1-0.20200828235935-8e22c5f260e1
 	github.com/fsnotify/fsnotify v1.4.9 // indirect
 	github.com/fsnotify/fsnotify v1.4.9 // indirect
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-chi/render v1.0.1
 	github.com/go-chi/render v1.0.1

+ 2 - 2
go.sum

@@ -126,8 +126,8 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fclairamb/ftpserverlib v0.8.1-0.20200824203441-87a3864e6de5 h1:Kr7UEYS2FqcSUyItHImjszBJqJdmt4noGKIhMi0Ul4Y=
-github.com/fclairamb/ftpserverlib v0.8.1-0.20200824203441-87a3864e6de5/go.mod h1:ShLpSOXbtoMDYxTb5eRs9wDBfkQ7VINYghclB4P2z4E=
+github.com/fclairamb/ftpserverlib v0.8.1-0.20200828235935-8e22c5f260e1 h1:0futNS5JlIOTHAPljFKGcCdnO9U2o4JDI94wuTIuZAQ=
+github.com/fclairamb/ftpserverlib v0.8.1-0.20200828235935-8e22c5f260e1/go.mod h1:ShLpSOXbtoMDYxTb5eRs9wDBfkQ7VINYghclB4P2z4E=
 github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
 github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
 github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
 github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=

+ 2 - 2
sftpd/internal_test.go

@@ -1671,7 +1671,7 @@ func TestTransferFailingReader(t *testing.T) {
 	assert.EqualError(t, err, sftp.ErrSSHFxOpUnsupported.Error())
 	assert.EqualError(t, err, sftp.ErrSSHFxOpUnsupported.Error())
 	if c, ok := transfer.(io.Closer); ok {
 	if c, ok := transfer.(io.Closer); ok {
 		err = c.Close()
 		err = c.Close()
-		assert.EqualError(t, err, sftp.ErrSSHFxFailure.Error())
+		assert.NoError(t, err)
 	}
 	}
 
 
 	fsPath := filepath.Join(os.TempDir(), "afile.txt")
 	fsPath := filepath.Join(os.TempDir(), "afile.txt")
@@ -1685,7 +1685,7 @@ func TestTransferFailingReader(t *testing.T) {
 	assert.EqualError(t, err, errRead.Error())
 	assert.EqualError(t, err, errRead.Error())
 
 
 	err = tr.Close()
 	err = tr.Close()
-	assert.EqualError(t, err, sftp.ErrSSHFxFailure.Error())
+	assert.NoError(t, err)
 
 
 	err = os.Remove(fsPath)
 	err = os.Remove(fsPath)
 	assert.NoError(t, err)
 	assert.NoError(t, err)

+ 38 - 5
sftpd/sftpd_test.go

@@ -365,8 +365,6 @@ func TestOpenReadWrite(t *testing.T) {
 	u.QuotaSize = 6553600
 	u.QuotaSize = 6553600
 	user, _, err := httpd.AddUser(u, http.StatusOK)
 	user, _, err := httpd.AddUser(u, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	err = os.RemoveAll(user.GetHomeDir())
-	assert.NoError(t, err)
 	client, err := getSftpClient(user, usePubKey)
 	client, err := getSftpClient(user, usePubKey)
 	if assert.NoError(t, err) {
 	if assert.NoError(t, err) {
 		defer client.Close()
 		defer client.Close()
@@ -381,7 +379,8 @@ func TestOpenReadWrite(t *testing.T) {
 			assert.EqualError(t, err, io.EOF.Error())
 			assert.EqualError(t, err, io.EOF.Error())
 			assert.Equal(t, len(testData)-1, n)
 			assert.Equal(t, len(testData)-1, n)
 			assert.Equal(t, testData[1:], buffer[:n])
 			assert.Equal(t, testData[1:], buffer[:n])
-			sftpFile.Close()
+			err = sftpFile.Close()
+			assert.NoError(t, err)
 		}
 		}
 		sftpFile, err = client.OpenFile(testFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC)
 		sftpFile, err = client.OpenFile(testFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC)
 		if assert.NoError(t, err) {
 		if assert.NoError(t, err) {
@@ -394,8 +393,42 @@ func TestOpenReadWrite(t *testing.T) {
 			assert.EqualError(t, err, io.EOF.Error())
 			assert.EqualError(t, err, io.EOF.Error())
 			assert.Equal(t, len(testData)-1, n)
 			assert.Equal(t, len(testData)-1, n)
 			assert.Equal(t, testData[1:], buffer[:n])
 			assert.Equal(t, testData[1:], buffer[:n])
-			sftpFile.Close()
-			sftpFile.Close()
+			err = sftpFile.Close()
+			assert.NoError(t, err)
+		}
+	}
+	_, err = httpd.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestOpenReadWritePerm(t *testing.T) {
+	usePubKey := true
+	u := getTestUser(usePubKey)
+	// we cannot read inside "/sub"
+	u.Permissions["/sub"] = []string{dataprovider.PermUpload, dataprovider.PermListItems}
+	user, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+	client, err := getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer client.Close()
+		err = client.Mkdir("sub")
+		assert.NoError(t, err)
+		sftpFileName := path.Join("sub", "file.txt")
+		sftpFile, err := client.OpenFile(sftpFileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC)
+		if assert.NoError(t, err) {
+			testData := []byte("test data")
+			n, err := sftpFile.Write(testData)
+			assert.NoError(t, err)
+			assert.Equal(t, len(testData), n)
+			buffer := make([]byte, 128)
+			_, err = sftpFile.ReadAt(buffer, 1)
+			if assert.Error(t, err) {
+				assert.Contains(t, strings.ToLower(err.Error()), "permission denied")
+			}
+			err = sftpFile.Close()
+			assert.NoError(t, err)
 		}
 		}
 	}
 	}
 	_, err = httpd.RemoveUser(user, http.StatusOK)
 	_, err = httpd.RemoveUser(user, http.StatusOK)

+ 3 - 1
sftpd/transfer.go

@@ -89,7 +89,9 @@ func (t *transfer) ReadAt(p []byte, off int64) (n int, err error) {
 	atomic.AddInt64(&t.BytesSent, int64(readed))
 	atomic.AddInt64(&t.BytesSent, int64(readed))
 
 
 	if e != nil && e != io.EOF {
 	if e != nil && e != io.EOF {
-		t.TransferError(e)
+		if t.GetType() == common.TransferDownload {
+			t.TransferError(e)
+		}
 		return readed, e
 		return readed, e
 	}
 	}
 	t.HandleThrottle()
 	t.HandleThrottle()

+ 5 - 0
sftpgo.json

@@ -59,6 +59,11 @@
       "exposed_headers": [],
       "exposed_headers": [],
       "allow_credentials": false,
       "allow_credentials": false,
       "max_age": 0
       "max_age": 0
+    },
+    "cache": {
+      "enabled": true,
+      "expiration_time": 0,
+      "max_size": 50
     }
     }
   },
   },
   "data_provider": {
   "data_provider": {

+ 261 - 0
webdavd/internal_test.go

@@ -20,6 +20,7 @@ import (
 
 
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/dataprovider"
+	"github.com/drakkan/sftpgo/httpd"
 	"github.com/drakkan/sftpgo/vfs"
 	"github.com/drakkan/sftpgo/vfs"
 )
 )
 
 
@@ -611,3 +612,263 @@ func TestTransferSeek(t *testing.T) {
 	err = os.Remove(testFilePath)
 	err = os.Remove(testFilePath)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
+
+func TestBasicUsersCache(t *testing.T) {
+	username := "webdav_internal_test"
+	password := "pwd"
+	u := dataprovider.User{
+		Username:       username,
+		Password:       password,
+		HomeDir:        filepath.Join(os.TempDir(), username),
+		Status:         1,
+		ExpirationDate: 0,
+	}
+	u.Permissions = make(map[string][]string)
+	u.Permissions["/"] = []string{dataprovider.PermAny}
+	user, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+
+	c := &Configuration{
+		BindPort: 9000,
+		Cache: Cache{
+			Enabled:        true,
+			MaxSize:        50,
+			ExpirationTime: 1,
+		},
+	}
+	server, err := newServer(c, configDir)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user.Username), nil)
+	assert.NoError(t, err)
+
+	_, _, err = server.authenticate(req)
+	assert.Error(t, err)
+
+	now := time.Now()
+	req.SetBasicAuth(username, password)
+	_, isCached, err := server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+	// now the user should be cached
+	var cachedUser dataprovider.CachedUser
+	result, ok := dataprovider.GetCachedWebDAVUser(username)
+	if assert.True(t, ok) {
+		cachedUser = result.(dataprovider.CachedUser)
+		assert.False(t, cachedUser.IsExpired())
+		assert.True(t, cachedUser.Expiration.After(now.Add(time.Duration(c.Cache.ExpirationTime)*time.Minute)))
+		// authenticate must return the cached user now
+		authUser, isCached, err := server.authenticate(req)
+		assert.NoError(t, err)
+		assert.True(t, isCached)
+		assert.Equal(t, cachedUser.User, authUser)
+	}
+	// a wrong password must fail
+	req.SetBasicAuth(username, "wrong")
+	_, _, err = server.authenticate(req)
+	assert.EqualError(t, err, dataprovider.ErrInvalidCredentials.Error())
+	req.SetBasicAuth(username, password)
+
+	// force cached user expiration
+	cachedUser.Expiration = now
+	dataprovider.CacheWebDAVUser(cachedUser, c.Cache.MaxSize)
+	result, ok = dataprovider.GetCachedWebDAVUser(username)
+	if assert.True(t, ok) {
+		cachedUser = result.(dataprovider.CachedUser)
+		assert.True(t, cachedUser.IsExpired())
+	}
+	// now authenticate should get the user from the data provider and update the cache
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+	result, ok = dataprovider.GetCachedWebDAVUser(username)
+	if assert.True(t, ok) {
+		cachedUser = result.(dataprovider.CachedUser)
+		assert.False(t, cachedUser.IsExpired())
+	}
+	// cache is invalidated after a user modification
+	user, _, err = httpd.UpdateUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	_, ok = dataprovider.GetCachedWebDAVUser(username)
+	assert.False(t, ok)
+
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+	_, ok = dataprovider.GetCachedWebDAVUser(username)
+	assert.True(t, ok)
+	// cache is invalidated after user deletion
+	_, err = httpd.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	_, ok = dataprovider.GetCachedWebDAVUser(username)
+	assert.False(t, ok)
+}
+
+func TestUsersCacheSizeAndExpiration(t *testing.T) {
+	username := "webdav_internal_test"
+	password := "pwd"
+	u := dataprovider.User{
+		HomeDir:        filepath.Join(os.TempDir(), username),
+		Status:         1,
+		ExpirationDate: 0,
+	}
+	u.Username = username + "1"
+	u.Password = password + "1"
+	u.Permissions = make(map[string][]string)
+	u.Permissions["/"] = []string{dataprovider.PermAny}
+	user1, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+	u.Username = username + "2"
+	u.Password = password + "2"
+	user2, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+	u.Username = username + "3"
+	u.Password = password + "3"
+	user3, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+	u.Username = username + "4"
+	u.Password = password + "4"
+	user4, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+
+	c := &Configuration{
+		BindPort: 9000,
+		Cache: Cache{
+			Enabled:        true,
+			MaxSize:        3,
+			ExpirationTime: 1,
+		},
+	}
+	server, err := newServer(c, configDir)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user1.Username, password+"1")
+	_, isCached, err := server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user2.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user2.Username, password+"2")
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user3.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user3.Username, password+"3")
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+
+	// the first 3 users are now cached
+	_, ok := dataprovider.GetCachedWebDAVUser(user1.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user2.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user3.Username)
+	assert.True(t, ok)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user4.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user4.Username, password+"4")
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+	// user1, the first cached, should be removed now
+	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
+	assert.False(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user2.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user3.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
+	assert.True(t, ok)
+
+	// user1 logins, user2 should be removed
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user1.Username, password+"1")
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+	_, ok = dataprovider.GetCachedWebDAVUser(user2.Username)
+	assert.False(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user3.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
+	assert.True(t, ok)
+
+	// user2 logins, user3 should be removed
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user2.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user2.Username, password+"2")
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+	_, ok = dataprovider.GetCachedWebDAVUser(user3.Username)
+	assert.False(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user2.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
+	assert.True(t, ok)
+
+	// user3 logins, user4 should be removed
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user3.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user3.Username, password+"3")
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+	_, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
+	assert.False(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user2.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user3.Username)
+	assert.True(t, ok)
+
+	// now remove user1 after an update
+	user1, _, err = httpd.UpdateUser(user1, http.StatusOK)
+	assert.NoError(t, err)
+	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
+	assert.False(t, ok)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user4.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user4.Username, password+"4")
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil)
+	assert.NoError(t, err)
+	req.SetBasicAuth(user1.Username, password+"1")
+	_, isCached, err = server.authenticate(req)
+	assert.NoError(t, err)
+	assert.False(t, isCached)
+	_, ok = dataprovider.GetCachedWebDAVUser(user2.Username)
+	assert.False(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user3.Username)
+	assert.True(t, ok)
+	_, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
+	assert.True(t, ok)
+
+	_, err = httpd.RemoveUser(user1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpd.RemoveUser(user2, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpd.RemoveUser(user3, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpd.RemoveUser(user4, http.StatusOK)
+	assert.NoError(t, err)
+}

+ 34 - 9
webdavd/server.go

@@ -90,7 +90,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		http.Error(w, common.ErrConnectionDenied.Error(), http.StatusForbidden)
 		http.Error(w, common.ErrConnectionDenied.Error(), http.StatusForbidden)
 		return
 		return
 	}
 	}
-	user, err := s.authenticate(r)
+	user, isCached, err := s.authenticate(r)
 	if err != nil {
 	if err != nil {
 		w.Header().Set("WWW-Authenticate", "Basic realm=\"SFTPGo WebDAV\"")
 		w.Header().Set("WWW-Authenticate", "Basic realm=\"SFTPGo WebDAV\"")
 		http.Error(w, err401.Error(), http.StatusUnauthorized)
 		http.Error(w, err401.Error(), http.StatusUnauthorized)
@@ -123,10 +123,11 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	common.Connections.Add(connection)
 	common.Connections.Add(connection)
 	defer common.Connections.Remove(connection.GetID())
 	defer common.Connections.Remove(connection.GetID())
 
 
-	connection.Fs.CheckRootPath(connection.GetUsername(), user.GetUID(), user.GetGID())
-	connection.Log(logger.LevelInfo, "User id: %d, logged in with WebDAV, method: %v, username: %#v, home_dir: %#v remote addr: %#v",
-		user.ID, r.Method, user.Username, user.HomeDir, r.RemoteAddr)
-	dataprovider.UpdateLastLogin(user) //nolint:errcheck
+	if !isCached {
+		// we update last login and check for home directory only if the user is not cached
+		connection.Fs.CheckRootPath(connection.GetUsername(), user.GetUID(), user.GetGID())
+		dataprovider.UpdateLastLogin(user) //nolint:errcheck
+	}
 
 
 	prefix := path.Join("/", user.Username)
 	prefix := path.Join("/", user.Username)
 	// see RFC4918, section 9.4
 	// see RFC4918, section 9.4
@@ -150,19 +151,43 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	handler.ServeHTTP(w, r.WithContext(ctx))
 	handler.ServeHTTP(w, r.WithContext(ctx))
 }
 }
 
 
-func (s *webDavServer) authenticate(r *http.Request) (dataprovider.User, error) {
+func (s *webDavServer) authenticate(r *http.Request) (dataprovider.User, bool, error) {
 	var user dataprovider.User
 	var user dataprovider.User
 	var err error
 	var err error
 	username, password, ok := r.BasicAuth()
 	username, password, ok := r.BasicAuth()
 	if !ok {
 	if !ok {
-		return user, err401
+		return user, false, err401
+	}
+	if s.config.Cache.Enabled {
+		result, ok := dataprovider.GetCachedWebDAVUser(username)
+		if ok {
+			if result.(dataprovider.CachedUser).IsExpired() {
+				dataprovider.RemoveCachedWebDAVUser(username)
+			} else {
+				if len(password) > 0 && result.(dataprovider.CachedUser).Password == password {
+					return result.(dataprovider.CachedUser).User, true, nil
+				}
+				updateLoginMetrics(username, r.RemoteAddr, dataprovider.ErrInvalidCredentials)
+				return user, false, dataprovider.ErrInvalidCredentials
+			}
+		}
 	}
 	}
 	user, err = dataprovider.CheckUserAndPass(username, password, utils.GetIPFromRemoteAddress(r.RemoteAddr), common.ProtocolWebDAV)
 	user, err = dataprovider.CheckUserAndPass(username, password, utils.GetIPFromRemoteAddress(r.RemoteAddr), common.ProtocolWebDAV)
 	if err != nil {
 	if err != nil {
 		updateLoginMetrics(username, r.RemoteAddr, err)
 		updateLoginMetrics(username, r.RemoteAddr, err)
-		return user, err
+		return user, false, err
+	}
+	if s.config.Cache.Enabled && len(password) > 0 {
+		cachedUser := dataprovider.CachedUser{
+			User:     user,
+			Password: password,
+		}
+		if s.config.Cache.ExpirationTime > 0 {
+			cachedUser.Expiration = time.Now().Add(time.Duration(s.config.Cache.ExpirationTime) * time.Minute)
+		}
+		dataprovider.CacheWebDAVUser(cachedUser, s.config.Cache.MaxSize)
 	}
 	}
-	return user, err
+	return user, false, err
 }
 }
 
 
 func (s *webDavServer) validateUser(user dataprovider.User, r *http.Request) (string, error) {
 func (s *webDavServer) validateUser(user dataprovider.User, r *http.Request) (string, error) {

+ 9 - 0
webdavd/webdavd.go

@@ -34,6 +34,13 @@ type Cors struct {
 	MaxAge           int      `json:"max_age" mapstructure:"max_age"`
 	MaxAge           int      `json:"max_age" mapstructure:"max_age"`
 }
 }
 
 
+// Cache configuration
+type Cache struct {
+	Enabled        bool `json:"enabled" mapstructure:"enabled"`
+	ExpirationTime int  `json:"expiration_time" mapstructure:"expiration_time"`
+	MaxSize        int  `json:"max_size" mapstructure:"max_size"`
+}
+
 // Configuration defines the configuration for the WevDAV server
 // Configuration defines the configuration for the WevDAV server
 type Configuration struct {
 type Configuration struct {
 	// The port used for serving FTP requests
 	// The port used for serving FTP requests
@@ -48,6 +55,8 @@ type Configuration struct {
 	CertificateKeyFile string `json:"certificate_key_file" mapstructure:"certificate_key_file"`
 	CertificateKeyFile string `json:"certificate_key_file" mapstructure:"certificate_key_file"`
 	// CORS configuration
 	// CORS configuration
 	Cors Cors `json:"cors" mapstructure:"cors"`
 	Cors Cors `json:"cors" mapstructure:"cors"`
+	// Cache configuration
+	Cache Cache `json:"cache" mapstructure:"cache"`
 }
 }
 
 
 // Initialize configures and starts the WebDav server
 // Initialize configures and starts the WebDav server

+ 9 - 1
webdavd/webdavd_test.go

@@ -277,8 +277,10 @@ func TestLoginInvalidPwd(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	user, _, err := httpd.AddUser(u, http.StatusOK)
 	user, _, err := httpd.AddUser(u, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	user.Password = "wrong"
 	client := getWebDavClient(user)
 	client := getWebDavClient(user)
+	assert.NoError(t, checkBasicFunc(client))
+	user.Password = "wrong"
+	client = getWebDavClient(user)
 	assert.Error(t, checkBasicFunc(client))
 	assert.Error(t, checkBasicFunc(client))
 	_, err = httpd.RemoveUser(user, http.StatusOK)
 	_, err = httpd.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -374,8 +376,14 @@ func TestPreLoginHook(t *testing.T) {
 	assert.NoError(t, checkBasicFunc(client))
 	assert.NoError(t, checkBasicFunc(client))
 	err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), os.ModePerm)
 	err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+	// update the user to remove it from the cache
+	user, _, err = httpd.UpdateUser(user, http.StatusOK)
+	assert.NoError(t, err)
 	client = getWebDavClient(user)
 	client = getWebDavClient(user)
 	assert.Error(t, checkBasicFunc(client))
 	assert.Error(t, checkBasicFunc(client))
+	// update the user to remove it from the cache
+	user, _, err = httpd.UpdateUser(user, http.StatusOK)
+	assert.NoError(t, err)
 	user.Status = 0
 	user.Status = 0
 	err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm)
 	err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)