Browse Source

allow to cache external authentications

Fixes #733

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 3 years ago
parent
commit
4e9dae6fa4

+ 5 - 1
.github/workflows/development.yml

@@ -448,8 +448,12 @@ jobs:
     name: golangci-lint
     runs-on: ubuntu-latest
     steps:
+      - name: Set up Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: 1.17
       - uses: actions/checkout@v2
       - name: Run golangci-lint
-        uses: golangci/golangci-lint-action@v2
+        uses: golangci/golangci-lint-action@v3
         with:
           version: latest

+ 26 - 9
dataprovider/dataprovider.go

@@ -1122,7 +1122,11 @@ func UpdateAPIKeyLastUse(apiKey *APIKey) error {
 
 // UpdateLastLogin updates the last login field for the given SFTPGo user
 func UpdateLastLogin(user *User) {
-	if !isLastActivityRecent(user.LastLogin) {
+	delay := lastLoginMinDelay
+	if user.Filters.ExternalAuthCacheTime > 0 {
+		delay = time.Duration(user.Filters.ExternalAuthCacheTime) * time.Second
+	}
+	if !isLastActivityRecent(user.LastLogin, delay) {
 		err := provider.updateLastLogin(user.Username)
 		if err == nil {
 			webDAVUsersCache.updateLastLogin(user.Username)
@@ -1132,7 +1136,7 @@ func UpdateLastLogin(user *User) {
 
 // UpdateAdminLastLogin updates the last login field for the given SFTPGo admin
 func UpdateAdminLastLogin(admin *Admin) {
-	if !isLastActivityRecent(admin.LastLogin) {
+	if !isLastActivityRecent(admin.LastLogin, lastLoginMinDelay) {
 		provider.updateAdminLastLogin(admin.Username) //nolint:errcheck
 	}
 }
@@ -2052,6 +2056,9 @@ func validateFilters(user *User) error {
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
 		}
 	}
+	if !user.HasExternalAuth() {
+		user.Filters.ExternalAuthCacheTime = 0
+	}
 
 	return validateFiltersPatternExtensions(user)
 }
@@ -3207,7 +3214,9 @@ func updateUserFromExtAuthResponse(user *User, password, pkey string) {
 	}
 }
 
-func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive, ip, protocol string, tlsCert *x509.Certificate) (User, error) {
+func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive, ip, protocol string,
+	tlsCert *x509.Certificate,
+) (User, error) {
 	var user User
 
 	u, userAsJSON, err := getUserAndJSONForHook(username)
@@ -3219,6 +3228,10 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
 		return u, nil
 	}
 
+	if u.isExternalAuthCached() {
+		return u, nil
+	}
+
 	pkey, err := util.GetSSHPublicKeyAsString(pubKey)
 	if err != nil {
 		return user, err
@@ -3295,6 +3308,10 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
 		return u, nil
 	}
 
+	if u.isExternalAuthCached() {
+		return u, nil
+	}
+
 	pkey, err := util.GetSSHPublicKeyAsString(pubKey)
 	if err != nil {
 		return user, err
@@ -3368,15 +3385,15 @@ func getUserAndJSONForHook(username string) (User, []byte, error) {
 	return u, userAsJSON, err
 }
 
-func providerLog(level logger.LogLevel, format string, v ...interface{}) {
-	logger.Log(level, logSender, "", format, v...)
-}
-
-func isLastActivityRecent(lastActivity int64) bool {
+func isLastActivityRecent(lastActivity int64, minDelay time.Duration) bool {
 	lastActivityTime := util.GetTimeFromMsecSinceEpoch(lastActivity)
 	diff := -time.Until(lastActivityTime)
 	if diff < -10*time.Second {
 		return false
 	}
-	return diff < lastLoginMinDelay
+	return diff < minDelay
+}
+
+func providerLog(level logger.LogLevel, format string, v ...interface{}) {
+	logger.Log(level, logSender, "", format, v...)
 }

+ 34 - 2
dataprovider/user.go

@@ -19,6 +19,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/mfa"
+	"github.com/drakkan/sftpgo/v2/plugin"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/vfs"
 )
@@ -203,7 +204,14 @@ func (u *User) CheckFsRoot(connectionID string) error {
 	if u.Filters.DisableFsChecks {
 		return nil
 	}
-	if isLastActivityRecent(u.LastLogin) {
+	delay := lastLoginMinDelay
+	if u.Filters.ExternalAuthCacheTime > 0 {
+		cacheTime := time.Duration(u.Filters.ExternalAuthCacheTime) * time.Second
+		if cacheTime > delay {
+			delay = cacheTime
+		}
+	}
+	if isLastActivityRecent(u.LastLogin, delay) {
 		return nil
 	}
 	fs, err := u.GetFilesystemForPath("/", connectionID)
@@ -924,6 +932,17 @@ func (u *User) CanManageMFA() bool {
 	return len(mfa.GetAvailableTOTPConfigs()) > 0
 }
 
+func (u *User) isExternalAuthCached() bool {
+	if u.ID <= 0 {
+		return false
+	}
+	if u.Filters.ExternalAuthCacheTime <= 0 {
+		return false
+	}
+
+	return isLastActivityRecent(u.LastLogin, time.Duration(u.Filters.ExternalAuthCacheTime)*time.Second)
+}
+
 // CanManageShares returns true if the user can add, update and list shares
 func (u *User) CanManageShares() bool {
 	return !util.IsStringInSlice(sdk.WebClientSharesDisabled, u.Filters.WebClient)
@@ -1106,7 +1125,7 @@ func (u *User) GetHomeDir() string {
 
 // HasRecentActivity returns true if the last user login is recent and so we can skip some expensive checks
 func (u *User) HasRecentActivity() bool {
-	return isLastActivityRecent(u.LastLogin)
+	return isLastActivityRecent(u.LastLogin, lastLoginMinDelay)
 }
 
 // HasQuotaRestrictions returns true if there are any disk quota restrictions
@@ -1310,6 +1329,18 @@ func (u *User) GetDeniedIPAsString() string {
 	return strings.Join(u.Filters.DeniedIP, ",")
 }
 
+// HasExternalAuth returns true if the external authentication is globally enabled
+// and it is not disabled for this user
+func (u *User) HasExternalAuth() bool {
+	if u.Filters.Hooks.ExternalAuthDisabled {
+		return false
+	}
+	if config.ExternalAuthHook != "" {
+		return true
+	}
+	return plugin.Handler.HasAuthenticators()
+}
+
 // CountUnusedRecoveryCodes returns the number of unused recovery codes
 func (u *User) CountUnusedRecoveryCodes() int {
 	unused := 0
@@ -1372,6 +1403,7 @@ func (u *User) getACopy() User {
 	filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled
 	filters.DisableFsChecks = u.Filters.DisableFsChecks
 	filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth
+	filters.ExternalAuthCacheTime = u.Filters.ExternalAuthCacheTime
 	filters.WebClient = make([]string, len(u.Filters.WebClient))
 	copy(filters.WebClient, u.Filters.WebClient)
 	filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))

+ 2 - 0
docs/external-auth.md

@@ -70,6 +70,8 @@ fi
 
 The structure for SFTPGo users can be found within the [OpenAPI schema](../openapi/openapi.yaml).
 
+You can instruct SFTPGo to cache the external user by setting an `external_auth_cache_time` in user object returned by your hook. The `external_auth_cache_time` defines the cache time in seconds.
+
 You can disable the hook on a per-user basis so that you can mix external and internal users.
 
 An example authentication program allowing to authenticate against an LDAP server can be found inside the source tree [ldapauth](../examples/ldapauth) directory.

+ 3 - 3
go.mod

@@ -8,7 +8,7 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
-	github.com/aws/aws-sdk-go v1.43.5
+	github.com/aws/aws-sdk-go v1.43.6
 	github.com/cockroachdb/cockroach-go/v2 v2.2.8
 	github.com/coreos/go-oidc/v3 v3.1.0
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
@@ -41,7 +41,7 @@ require (
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.3.0
 	github.com/rs/zerolog v1.26.2-0.20220203140311-fc26014bd4e1
-	github.com/sftpgo/sdk v0.1.1-0.20220221175917-da8bdf77ce76
+	github.com/sftpgo/sdk v0.1.1-0.20220225104414-9e485ac5bc94
 	github.com/shirou/gopsutil/v3 v3.22.1
 	github.com/spf13/afero v1.8.1
 	github.com/spf13/cobra v1.3.0
@@ -59,7 +59,7 @@ require (
 	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd
 	golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b
 	golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7
-	golang.org/x/time v0.0.0-20220210224613-90d013bbcef8
+	golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
 	google.golang.org/api v0.70.0
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )

+ 6 - 6
go.sum

@@ -144,8 +144,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI
 github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.43.5 h1:N7arnx54E4QyW69c45UW5o8j2DCSjzpoxzJW3yU6OSo=
-github.com/aws/aws-sdk-go v1.43.5/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
+github.com/aws/aws-sdk-go v1.43.6 h1:FkwmndZR4LjnT2fiKaD18bnqfQ188E8A1IMNI5rcv00=
+github.com/aws/aws-sdk-go v1.43.6/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
 github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
@@ -698,8 +698,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/sftpgo/sdk v0.1.1-0.20220221175917-da8bdf77ce76 h1:6mLGNio6XJaweaKvVmUHLanDznABa2F2PEbS16fWnxg=
-github.com/sftpgo/sdk v0.1.1-0.20220221175917-da8bdf77ce76/go.mod h1:zqCRMcwS28IViwekJHNkFu4GqSfyVmOQTlh8h3icAXE=
+github.com/sftpgo/sdk v0.1.1-0.20220225104414-9e485ac5bc94 h1:IllQqdyqETJdbik04oorF/oGwSkeY35RTPQjn/eQhO0=
+github.com/sftpgo/sdk v0.1.1-0.20220225104414-9e485ac5bc94/go.mod h1:zqCRMcwS28IViwekJHNkFu4GqSfyVmOQTlh8h3icAXE=
 github.com/shirou/gopsutil/v3 v3.22.1 h1:33y31Q8J32+KstqPfscvFwBlNJ6xLaBy4xqBXzlYV5w=
 github.com/shirou/gopsutil/v3 v3.22.1/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
@@ -976,8 +976,8 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
-golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
+golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=

+ 20 - 0
httpd/httpd_test.go

@@ -13942,6 +13942,7 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Add("hooks", "external_auth_disabled")
 	form.Set("disable_fs_checks", "checked")
 	form.Set("total_data_transfer", "0")
+	form.Set("external_auth_cache_time", "0")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	// test invalid url escape
 	req, _ = http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
@@ -14164,6 +14165,15 @@ func TestWebUserAddMock(t *testing.T) {
 	form.Set("download_data_transfer_source12", "100")
 	form.Set("upload_data_transfer_source12", "120")
 	form.Set("total_data_transfer_source12", "200")
+	// invalid external auth cache size
+	form.Set("external_auth_cache_time", "a")
+	b, contentType, _ = getMultipartFormData(form, "", "")
+	req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", contentType)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	form.Set("external_auth_cache_time", "0")
 	form.Set(csrfFormToken, "invalid form token")
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
@@ -14412,6 +14422,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	form.Set("description", user.Description)
 	form.Set("tls_username", string(sdk.TLSUsernameCN))
 	form.Set("allow_api_key_auth", "1")
+	form.Set("external_auth_cache_time", "120")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
 	setJWTCookieForReq(req, webToken)
@@ -14482,6 +14493,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	assert.Equal(t, int64(0), updateUser.TotalDataTransfer)
 	assert.Equal(t, int64(0), updateUser.DownloadDataTransfer)
 	assert.Equal(t, int64(0), updateUser.UploadDataTransfer)
+	assert.Equal(t, int64(0), updateUser.Filters.ExternalAuthCacheTime)
 	if val, ok := updateUser.Permissions["/otherdir"]; ok {
 		assert.True(t, util.IsStringInSlice(dataprovider.PermListItems, val))
 		assert.True(t, util.IsStringInSlice(dataprovider.PermUpload, val))
@@ -14592,6 +14604,7 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
 	form.Set("expiration_date", "2020-01-01 00:00:00")
 	form.Set("fs_provider", "0")
 	form.Set("max_upload_file_size", "0")
+	form.Set("external_auth_cache_time", "0")
 	form.Set("description", "desc %username% %password%")
 	form.Set("vfolder_path", "/vdir%username%")
 	form.Set("vfolder_name", folder.Name)
@@ -14687,6 +14700,7 @@ func TestUserSaveFromTemplateMock(t *testing.T) {
 	form.Set("expiration_date", "")
 	form.Set("fs_provider", "0")
 	form.Set("max_upload_file_size", "0")
+	form.Set("external_auth_cache_time", "0")
 	form.Add("tpl_username", user1)
 	form.Add("tpl_password", "password1")
 	form.Add("tpl_public_keys", " ")
@@ -14761,6 +14775,7 @@ func TestUserTemplateMock(t *testing.T) {
 	form.Set("upload_data_transfer", "0")
 	form.Set("download_data_transfer", "0")
 	form.Set("total_data_transfer", "0")
+	form.Set("external_auth_cache_time", "0")
 	form.Set("permissions", "*")
 	form.Set("status", strconv.Itoa(user.Status))
 	form.Set("expiration_date", "2020-01-01 00:00:00")
@@ -15109,6 +15124,7 @@ func TestWebUserS3Mock(t *testing.T) {
 	form.Set("upload_data_transfer", "0")
 	form.Set("download_data_transfer", "0")
 	form.Set("total_data_transfer", "0")
+	form.Set("external_auth_cache_time", "0")
 	form.Set("permissions", "*")
 	form.Set("status", strconv.Itoa(user.Status))
 	form.Set("expiration_date", "2020-01-01 00:00:00")
@@ -15322,6 +15338,7 @@ func TestWebUserGCSMock(t *testing.T) {
 	form.Set("upload_data_transfer", "0")
 	form.Set("download_data_transfer", "0")
 	form.Set("total_data_transfer", "0")
+	form.Set("external_auth_cache_time", "0")
 	form.Set("permissions", "*")
 	form.Set("status", strconv.Itoa(user.Status))
 	form.Set("expiration_date", "2020-01-01 00:00:00")
@@ -15439,6 +15456,7 @@ func TestWebUserAzureBlobMock(t *testing.T) {
 	form.Set("upload_data_transfer", "0")
 	form.Set("download_data_transfer", "0")
 	form.Set("total_data_transfer", "0")
+	form.Set("external_auth_cache_time", "0")
 	form.Set("permissions", "*")
 	form.Set("status", strconv.Itoa(user.Status))
 	form.Set("expiration_date", "2020-01-01 00:00:00")
@@ -15622,6 +15640,7 @@ func TestWebUserCryptMock(t *testing.T) {
 	form.Set("upload_data_transfer", "0")
 	form.Set("download_data_transfer", "0")
 	form.Set("total_data_transfer", "0")
+	form.Set("external_auth_cache_time", "0")
 	form.Set("permissions", "*")
 	form.Set("status", strconv.Itoa(user.Status))
 	form.Set("expiration_date", "2020-01-01 00:00:00")
@@ -15727,6 +15746,7 @@ func TestWebUserSFTPFsMock(t *testing.T) {
 	form.Set("upload_data_transfer", "0")
 	form.Set("download_data_transfer", "0")
 	form.Set("total_data_transfer", "0")
+	form.Set("external_auth_cache_time", "0")
 	form.Set("permissions", "*")
 	form.Set("status", strconv.Itoa(user.Status))
 	form.Set("expiration_date", "2020-01-01 00:00:00")

+ 2 - 1
httpd/webadmin.go

@@ -943,7 +943,8 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	}
 	filters.DisableFsChecks = len(r.Form.Get("disable_fs_checks")) > 0
 	filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
-	return filters, nil
+	filters.ExternalAuthCacheTime, err = strconv.ParseInt(r.Form.Get("external_auth_cache_time"), 10, 64)
+	return filters, err
 }
 
 func getSecretFromFormField(r *http.Request, field string) *kms.Secret {

+ 3 - 0
httpdtest/httpdtest.go

@@ -1518,6 +1518,9 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User)
 	if expected.Filters.AllowAPIKeyAuth != actual.Filters.AllowAPIKeyAuth {
 		return errors.New("allow_api_key_auth mismatch")
 	}
+	if expected.Filters.ExternalAuthCacheTime != actual.Filters.ExternalAuthCacheTime {
+		return errors.New("external_auth_cache_time mismatch")
+	}
 	if err := compareUserFilterSubStructs(expected, actual); err != nil {
 		return err
 	}

+ 3 - 0
openapi/openapi.yaml

@@ -4659,6 +4659,9 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/DataTransferLimit'
+        external_auth_cache_time:
+          type: integer
+          description: 'Defines the cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache'
       description: Additional user options
     Secret:
       type: object

+ 10 - 0
plugin/plugin.go

@@ -96,6 +96,7 @@ type Manager struct {
 	hasSearcher   bool
 	hasMetadater  bool
 	hasNotifiers  bool
+	hasAuths      bool
 }
 
 // Initialize initializes the configured plugins
@@ -174,6 +175,7 @@ func (m *Manager) validateConfigs() error {
 	m.hasSearcher = false
 	m.hasMetadater = false
 	m.hasNotifiers = false
+	m.hasAuths = false
 
 	for _, config := range m.Configs {
 		if config.Type == kmsplugin.PluginName {
@@ -201,10 +203,18 @@ func (m *Manager) validateConfigs() error {
 		if config.Type == notifier.PluginName {
 			m.hasNotifiers = true
 		}
+		if config.Type == auth.PluginName {
+			m.hasAuths = true
+		}
 	}
 	return nil
 }
 
+// HasAuthenticators returns true if there is at least an auth plugin
+func (m *Manager) HasAuthenticators() bool {
+	return m.hasAuths
+}
+
 // HasNotifiers returns true if there is at least a notifier plugin
 func (m *Manager) HasNotifiers() bool {
 	return m.hasNotifiers

+ 57 - 0
sftpd/sftpd_test.go

@@ -3430,6 +3430,63 @@ func TestLoginExternalAuth(t *testing.T) {
 	}
 }
 
+func TestLoginExternalAuthCache(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	u := getTestUser(false)
+	u.Filters.ExternalAuthCacheTime = 120
+	err := dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, false, ""), os.ModePerm)
+	assert.NoError(t, err)
+	providerConf.ExternalAuthHook = extAuthPath
+	providerConf.ExternalAuthScope = 1
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(u, false)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		assert.NoError(t, checkBasicSFTP(client))
+	}
+	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	lastLogin := user.LastLogin
+	assert.Greater(t, lastLogin, int64(0))
+	assert.Equal(t, u.Filters.ExternalAuthCacheTime, user.Filters.ExternalAuthCacheTime)
+	// the auth should be now cached so update the hook to return an error
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, true, false, ""), os.ModePerm)
+	assert.NoError(t, err)
+	conn, client, err = getSftpClient(u, false)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		assert.NoError(t, checkBasicSFTP(client))
+	}
+	user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, lastLogin, user.LastLogin)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf = config.GetProviderConf()
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	err = os.Remove(extAuthPath)
+	assert.NoError(t, err)
+}
+
 func TestLoginExternalAuthInteractive(t *testing.T) {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")

+ 11 - 0
templates/webadmin/user.html

@@ -876,6 +876,17 @@
                                 </div>
                             </div>
 
+                            <div class="form-group row {{if not .User.HasExternalAuth}}d-none{{end}}">
+                                <label for="idExtAuthCacheTime" class="col-sm-2 col-form-label">External auth cache time</label>
+                                <div class="col-sm-10">
+                                    <input type="number" min="0" class="form-control" id="idExtAuthCacheTime" name="external_auth_cache_time" placeholder=""
+                                        value="{{.User.Filters.ExternalAuthCacheTime}}" aria-describedby="extAuthCacheHelpBlock">
+                                    <small id="extAuthCacheHelpBlock" class="form-text text-muted">
+                                        Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache
+                                    </small>
+                                </div>
+                            </div>
+
                         </div>
                     </div>
                 </div>