Преглед изворни кода

add check password hook

its main use case is to allow to easily support things like password+OTP for
protocols without keyboard interactive support such as FTP and WebDAV
Nicola Murino пре 5 година
родитељ
комит
8b0a1817b3

+ 1 - 0
README.md

@@ -143,6 +143,7 @@ Directories outside the user home directory can be exposed as virtual folders, m
 ## Other hooks
 
 You can get notified as soon as a new connection is established using the [Post-connect hook](./docs/post-connect-hook.md) and after each login using the [Post-login hook](./docs/post-login-hook.md).
+You can use your own hook to [check passwords](./docs/check-password-hook.md).
 
 ## Storage backends
 

+ 8 - 6
config/config.go

@@ -120,12 +120,14 @@ func init() {
 				ExecuteOn: []string{},
 				Hook:      "",
 			},
-			ExternalAuthHook:  "",
-			ExternalAuthScope: 0,
-			CredentialsPath:   "credentials",
-			PreLoginHook:      "",
-			PostLoginHook:     "",
-			PostLoginScope:    0,
+			ExternalAuthHook:   "",
+			ExternalAuthScope:  0,
+			CredentialsPath:    "credentials",
+			PreLoginHook:       "",
+			PostLoginHook:      "",
+			PostLoginScope:     0,
+			CheckPasswordHook:  "",
+			CheckPasswordScope: 0,
 		},
 		HTTPDConfig: httpd.Conf{
 			BindPort:           8080,

+ 2 - 2
dataprovider/bolt.go

@@ -121,7 +121,7 @@ func (p BoltProvider) checkAvailability() error {
 	return err
 }
 
-func (p BoltProvider) validateUserAndPass(username string, password string) (User, error) {
+func (p BoltProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
 	var user User
 	if len(password) == 0 {
 		return user, errors.New("Credentials cannot be null or empty")
@@ -131,7 +131,7 @@ func (p BoltProvider) validateUserAndPass(username string, password string) (Use
 		providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
 		return user, err
 	}
-	return checkUserAndPass(user, password)
+	return checkUserAndPass(user, password, ip, protocol)
 }
 
 func (p BoltProvider) validateUserAndPubKey(username string, pubKey []byte) (User, string, error) {

+ 177 - 50
dataprovider/dataprovider.go

@@ -216,6 +216,20 @@ type Config struct {
 	// - 1 means notify failed logins
 	// - 2 means notify successful logins
 	PostLoginScope int `json:"post_login_scope" mapstructure:"post_login_scope"`
+	// Absolute path to an external program or an HTTP URL to invoke just before password
+	// authentication. This hook allows you to externally check the provided password,
+	// its main use case is to allow to easily support things like password+OTP for protocols
+	// without keyboard interactive support such as FTP and WebDAV. You can ask your users
+	// to login using a string consisting of a fixed password and a One Time Token, you
+	// can verify the token inside the hook and ask to SFTPGo to verify the fixed part.
+	CheckPasswordHook string `json:"check_password_hook" mapstructure:"check_password_hook"`
+	// CheckPasswordScope defines the scope for the check password hook.
+	// - 0 means all protocols
+	// - 1 means SSH
+	// - 2 means FTP
+	// - 4 means WebDAV
+	// you can combine the scopes, for example 6 means FTP and WebDAV
+	CheckPasswordScope int `json:"check_password_scope" mapstructure:"check_password_scope"`
 }
 
 // BackupData defines the structure for the backup/restore files
@@ -241,6 +255,21 @@ type keyboardAuthHookResponse struct {
 	CheckPwd    int      `json:"check_password"`
 }
 
+type checkPasswordRequest struct {
+	Username string `json:"username"`
+	IP       string `json:"ip"`
+	Password string `json:"password"`
+	Protocol string `json:"protocol"`
+}
+
+type checkPasswordResponse struct {
+	// 0 KO, 1 OK, 2 partial success, -1 not executed
+	Status int `json:"status"`
+	// for status = 2 this is the password to check against the one stored
+	// inside the SFTPGo data provider
+	ToVerify string `json:"to_verify"`
+}
+
 type virtualFoldersCompact struct {
 	VirtualPath      string `json:"virtual_path"`
 	MappedPath       string `json:"mapped_path"`
@@ -291,7 +320,7 @@ func GetQuotaTracking() int {
 
 // Provider defines the interface that data providers must implement.
 type Provider interface {
-	validateUserAndPass(username string, password string) (User, error)
+	validateUserAndPass(username, password, ip, protocol string) (User, error)
 	validateUserAndPubKey(username string, pubKey []byte) (User, string, error)
 	updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error
 	getUsedQuota(username string) (int, int64, error)
@@ -343,36 +372,31 @@ func Initialize(cnf Config, basePath string) error {
 }
 
 func validateHooks() error {
+	var hooks []string
 	if len(config.PreLoginHook) > 0 && !strings.HasPrefix(config.PreLoginHook, "http") {
-		if !filepath.IsAbs(config.PreLoginHook) {
-			return fmt.Errorf("invalid pre-login hook: %#v must be an absolute path", config.PreLoginHook)
-		}
-		_, err := os.Stat(config.PreLoginHook)
-		if err != nil {
-			providerLog(logger.LevelWarn, "invalid pre-login hook: %v", err)
-			return err
-		}
+		hooks = append(hooks, config.PreLoginHook)
 	}
 	if len(config.ExternalAuthHook) > 0 && !strings.HasPrefix(config.ExternalAuthHook, "http") {
-		if !filepath.IsAbs(config.ExternalAuthHook) {
-			return fmt.Errorf("invalid external auth hook: %#v must be an absolute path", config.ExternalAuthHook)
-		}
-		_, err := os.Stat(config.ExternalAuthHook)
-		if err != nil {
-			providerLog(logger.LevelWarn, "invalid external auth hook: %v", err)
-			return err
-		}
+		hooks = append(hooks, config.ExternalAuthHook)
 	}
 	if len(config.PostLoginHook) > 0 && !strings.HasPrefix(config.PostLoginHook, "http") {
-		if !filepath.IsAbs(config.PostLoginHook) {
-			return fmt.Errorf("invalid post-login hook: %#v must be an absolute path", config.PostLoginHook)
+		hooks = append(hooks, config.PostLoginHook)
+	}
+	if len(config.CheckPasswordHook) > 0 && !strings.HasPrefix(config.CheckPasswordHook, "http") {
+		hooks = append(hooks, config.CheckPasswordHook)
+	}
+
+	for _, hook := range hooks {
+		if !filepath.IsAbs(hook) {
+			return fmt.Errorf("invalid hook: %#v must be an absolute path", hook)
 		}
-		_, err := os.Stat(config.PostLoginHook)
+		_, err := os.Stat(hook)
 		if err != nil {
-			providerLog(logger.LevelWarn, "invalid post-login hook: %v", err)
+			providerLog(logger.LevelWarn, "invalid hook: %v", err)
 			return err
 		}
 	}
+
 	return nil
 }
 
@@ -414,16 +438,16 @@ func CheckUserAndPass(username, password, ip, protocol string) (User, error) {
 		if err != nil {
 			return user, err
 		}
-		return checkUserAndPass(user, password)
+		return checkUserAndPass(user, password, ip, protocol)
 	}
 	if len(config.PreLoginHook) > 0 {
 		user, err := executePreLoginHook(username, LoginMethodPassword, ip, protocol)
 		if err != nil {
 			return user, err
 		}
-		return checkUserAndPass(user, password)
+		return checkUserAndPass(user, password, ip, protocol)
 	}
-	return provider.validateUserAndPass(username, password)
+	return provider.validateUserAndPass(username, password, ip, protocol)
 }
 
 // CheckUserAndPubKey retrieves the SFTP user with the given username and public key if a match is found or an error
@@ -460,7 +484,7 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
 	if err != nil {
 		return user, err
 	}
-	return doKeyboardInteractiveAuth(user, authHook, client, ip)
+	return doKeyboardInteractiveAuth(user, authHook, client, ip, protocol)
 }
 
 // UpdateLastLogin updates the last login fields for the given SFTP user
@@ -1014,38 +1038,63 @@ func checkLoginConditions(user User) error {
 	return nil
 }
 
-func checkUserAndPass(user User, password string) (User, error) {
-	err := checkLoginConditions(user)
-	if err != nil {
-		return user, err
-	}
-	if len(user.Password) == 0 {
-		return user, errors.New("Credentials cannot be null or empty")
-	}
+func isPasswordOK(user *User, password string) (bool, error) {
 	match := false
+	var err error
 	if strings.HasPrefix(user.Password, argonPwdPrefix) {
 		match, err = argon2id.ComparePasswordAndHash(password, user.Password)
 		if err != nil {
 			providerLog(logger.LevelWarn, "error comparing password with argon hash: %v", err)
-			return user, err
+			return match, err
 		}
 	} else if strings.HasPrefix(user.Password, bcryptPwdPrefix) {
 		if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
 			providerLog(logger.LevelWarn, "error comparing password with bcrypt hash: %v", err)
-			return user, err
+			return match, err
 		}
 		match = true
 	} else if utils.IsStringPrefixInSlice(user.Password, pbkdfPwdPrefixes) {
 		match, err = comparePbkdf2PasswordAndHash(password, user.Password)
 		if err != nil {
-			return user, err
+			return match, err
 		}
 	} else if utils.IsStringPrefixInSlice(user.Password, unixPwdPrefixes) {
 		match, err = compareUnixPasswordAndHash(user, password)
 		if err != nil {
-			return user, err
+			return match, err
 		}
 	}
+	return match, err
+}
+
+func checkUserAndPass(user User, password, ip, protocol string) (User, error) {
+	err := checkLoginConditions(user)
+	if err != nil {
+		return user, err
+	}
+	if len(user.Password) == 0 {
+		return user, errors.New("Credentials cannot be null or empty")
+	}
+	hookResponse, err := executeCheckPasswordHook(user.Username, password, ip, protocol)
+	if err != nil {
+		providerLog(logger.LevelDebug, "error executing check password hook: %v", err)
+		return user, errors.New("Unable to check credentials")
+	}
+	switch hookResponse.Status {
+	case -1:
+		// no hook configured
+	case 1:
+		providerLog(logger.LevelDebug, "password accepted by check password hook")
+		return user, nil
+	case 2:
+		providerLog(logger.LevelDebug, "partial success from check password hook")
+		password = hookResponse.ToVerify
+	default:
+		providerLog(logger.LevelDebug, "password rejected by check password hook, status: %v", hookResponse.Status)
+		return user, errors.New("Invalid credentials")
+	}
+
+	match, err := isPasswordOK(&user, password)
 	if !match {
 		err = errors.New("Invalid credentials")
 	}
@@ -1079,7 +1128,7 @@ func checkUserAndPubKey(user User, pubKey []byte) (User, string, error) {
 	return user, "", errors.New("Invalid credentials")
 }
 
-func compareUnixPasswordAndHash(user User, password string) (bool, error) {
+func compareUnixPasswordAndHash(user *User, password string) (bool, error) {
 	match := false
 	var err error
 	if strings.HasPrefix(user.Password, sha512cryptPwdPrefix) {
@@ -1287,7 +1336,7 @@ func sendKeyboardAuthHTTPReq(url *url.URL, request keyboardAuthHookRequest) (key
 	return response, err
 }
 
-func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (int, error) {
+func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
 	authResult := 0
 	var url *url.URL
 	url, err := url.Parse(authHook)
@@ -1314,7 +1363,7 @@ func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.K
 		if err = validateKeyboardAuthResponse(response); err != nil {
 			return authResult, err
 		}
-		answers, err := getKeyboardInteractiveAnswers(client, response, user)
+		answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
 		if err != nil {
 			return authResult, err
 		}
@@ -1329,7 +1378,7 @@ func executeKeyboardInteractiveHTTPHook(user User, authHook string, client ssh.K
 }
 
 func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, response keyboardAuthHookResponse,
-	user User) ([]string, error) {
+	user User, ip, protocol string) ([]string, error) {
 	questions := response.Questions
 	answers, err := client(user.Username, response.Instruction, questions, response.Echos)
 	if err != nil {
@@ -1342,7 +1391,7 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
 		return answers, err
 	}
 	if len(answers) == 1 && response.CheckPwd > 0 {
-		_, err = checkUserAndPass(user, answers[0])
+		_, err = checkUserAndPass(user, answers[0], ip, protocol)
 		providerLog(logger.LevelInfo, "interactive auth hook requested password validation for user %#v, validation error: %v",
 			user.Username, err)
 		if err != nil {
@@ -1354,8 +1403,8 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
 }
 
 func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge, response keyboardAuthHookResponse,
-	user User, stdin io.WriteCloser) error {
-	answers, err := getKeyboardInteractiveAnswers(client, response, user)
+	user User, stdin io.WriteCloser, ip, protocol string) error {
+	answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
 	if err != nil {
 		return err
 	}
@@ -1373,7 +1422,7 @@ func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge,
 	return nil
 }
 
-func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (int, error) {
+func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
 	authResult := 0
 	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
 	defer cancel()
@@ -1413,7 +1462,7 @@ func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.Ke
 			break
 		}
 		go func() {
-			err := handleProgramInteractiveQuestions(client, response, user, stdin)
+			err := handleProgramInteractiveQuestions(client, response, user, stdin, ip, protocol)
 			if err != nil {
 				once.Do(func() { terminateInteractiveAuthProgram(cmd, false) })
 			}
@@ -1431,13 +1480,13 @@ func executeKeyboardInteractiveProgram(user User, authHook string, client ssh.Ke
 	return authResult, err
 }
 
-func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip string) (User, error) {
+func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (User, error) {
 	var authResult int
 	var err error
 	if strings.HasPrefix(authHook, "http") {
-		authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client, ip)
+		authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client, ip, protocol)
 	} else {
-		authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip)
+		authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip, protocol)
 	}
 	if err != nil {
 		return user, err
@@ -1452,6 +1501,84 @@ func doKeyboardInteractiveAuth(user User, authHook string, client ssh.KeyboardIn
 	return user, nil
 }
 
+func isCheckPasswordHookDefined(protocol string) bool {
+	if len(config.CheckPasswordHook) == 0 {
+		return false
+	}
+	if config.CheckPasswordScope == 0 {
+		return true
+	}
+	switch protocol {
+	case "SSH":
+		return config.CheckPasswordScope&1 != 0
+	case "FTP":
+		return config.CheckPasswordScope&2 != 0
+	case "DAV":
+		return config.CheckPasswordScope&4 != 0
+	default:
+		return false
+	}
+}
+
+func getPasswordHookResponse(username, password, ip, protocol string) ([]byte, error) {
+	if strings.HasPrefix(config.CheckPasswordHook, "http") {
+		var result []byte
+		var url *url.URL
+		url, err := url.Parse(config.CheckPasswordHook)
+		if err != nil {
+			providerLog(logger.LevelWarn, "invalid url for check password hook %#v, error: %v", config.CheckPasswordHook, err)
+			return result, err
+		}
+		req := checkPasswordRequest{
+			Username: username,
+			Password: password,
+			IP:       ip,
+			Protocol: protocol,
+		}
+		reqAsJSON, err := json.Marshal(req)
+		if err != nil {
+			return result, err
+		}
+		httpClient := httpclient.GetHTTPClient()
+		resp, err := httpClient.Post(url.String(), "application/json", bytes.NewBuffer(reqAsJSON))
+		if err != nil {
+			providerLog(logger.LevelWarn, "error getting check password hook response: %v", err)
+			return result, err
+		}
+		defer resp.Body.Close()
+		if resp.StatusCode != http.StatusOK {
+			return result, fmt.Errorf("wrong http status code from chek password hook: %v, expected 200", resp.StatusCode)
+		}
+		return ioutil.ReadAll(resp.Body)
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, config.CheckPasswordHook)
+	cmd.Env = append(os.Environ(),
+		fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", username),
+		fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", password),
+		fmt.Sprintf("SFTPGO_AUTHD_IP=%v", ip),
+		fmt.Sprintf("SFTPGO_AUTHD_PROTOCOL=%v", protocol),
+	)
+	return cmd.Output()
+}
+
+func executeCheckPasswordHook(username, password, ip, protocol string) (checkPasswordResponse, error) {
+	var response checkPasswordResponse
+
+	if !isCheckPasswordHookDefined(protocol) {
+		response.Status = -1
+		return response, nil
+	}
+
+	out, err := getPasswordHookResponse(username, password, ip, protocol)
+	if err != nil {
+		return response, err
+	}
+	err = json.Unmarshal(out, &response)
+	return response, err
+}
+
 func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte) ([]byte, error) {
 	if strings.HasPrefix(config.PreLoginHook, "http") {
 		var url *url.URL
@@ -1488,7 +1615,7 @@ func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte
 		fmt.Sprintf("SFTPGO_LOGIND_USER=%v", string(userAsJSON)),
 		fmt.Sprintf("SFTPGO_LOGIND_METHOD=%v", loginMethod),
 		fmt.Sprintf("SFTPGO_LOGIND_IP=%v", ip),
-		fmt.Sprintf("SFTPGO_LOGIND_PROTOCOL=%v", ip),
+		fmt.Sprintf("SFTPGO_LOGIND_PROTOCOL=%v", protocol),
 	)
 	return cmd.Output()
 }

+ 2 - 2
dataprovider/memory.go

@@ -84,7 +84,7 @@ func (p MemoryProvider) close() error {
 	return nil
 }
 
-func (p MemoryProvider) validateUserAndPass(username string, password string) (User, error) {
+func (p MemoryProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
 	var user User
 	if len(password) == 0 {
 		return user, errors.New("Credentials cannot be null or empty")
@@ -94,7 +94,7 @@ func (p MemoryProvider) validateUserAndPass(username string, password string) (U
 		providerLog(logger.LevelWarn, "error authenticating user %#v, error: %v", username, err)
 		return user, err
 	}
-	return checkUserAndPass(user, password)
+	return checkUserAndPass(user, password, ip, protocol)
 }
 
 func (p MemoryProvider) validateUserAndPubKey(username string, pubKey []byte) (User, string, error) {

+ 2 - 2
dataprovider/mysql.go

@@ -84,8 +84,8 @@ func (p MySQLProvider) checkAvailability() error {
 	return sqlCommonCheckAvailability(p.dbHandle)
 }
 
-func (p MySQLProvider) validateUserAndPass(username string, password string) (User, error) {
-	return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
+func (p MySQLProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
+	return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
 }
 
 func (p MySQLProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {

+ 2 - 2
dataprovider/pgsql.go

@@ -83,8 +83,8 @@ func (p PGSQLProvider) checkAvailability() error {
 	return sqlCommonCheckAvailability(p.dbHandle)
 }
 
-func (p PGSQLProvider) validateUserAndPass(username string, password string) (User, error) {
-	return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
+func (p PGSQLProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
+	return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
 }
 
 func (p PGSQLProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {

+ 2 - 2
dataprovider/sqlcommon.go

@@ -46,7 +46,7 @@ func getUserByUsername(username string, dbHandle sqlQuerier) (User, error) {
 	return getUserWithVirtualFolders(user, dbHandle)
 }
 
-func sqlCommonValidateUserAndPass(username string, password string, dbHandle *sql.DB) (User, error) {
+func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHandle *sql.DB) (User, error) {
 	var user User
 	if len(password) == 0 {
 		return user, errors.New("Credentials cannot be null or empty")
@@ -56,7 +56,7 @@ func sqlCommonValidateUserAndPass(username string, password string, dbHandle *sq
 		providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
 		return user, err
 	}
-	return checkUserAndPass(user, password)
+	return checkUserAndPass(user, password, ip, protocol)
 }
 
 func sqlCommonValidateUserAndPubKey(username string, pubKey []byte, dbHandle *sql.DB) (User, string, error) {

+ 2 - 2
dataprovider/sqlite.go

@@ -106,8 +106,8 @@ func (p SQLiteProvider) checkAvailability() error {
 	return sqlCommonCheckAvailability(p.dbHandle)
 }
 
-func (p SQLiteProvider) validateUserAndPass(username string, password string) (User, error) {
-	return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
+func (p SQLiteProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
+	return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
 }
 
 func (p SQLiteProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {

+ 1 - 1
docs/account.md

@@ -31,7 +31,7 @@ For each account, the following properties can be configured:
 - `allowed_ip`, List of IP/Mask allowed to login. Any IP address not contained in this list cannot login. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32"
 - `denied_ip`, List of IP/Mask not allowed to login. If an IP address is both allowed and denied then login will be denied
 - `max_upload_file_size`, max allowed size, as bytes, for a single file upload. The upload will be aborted if/when the size of the file being sent exceeds this limit. 0 means unlimited. This restriction does not apply for SSH system commands such as `git` and `rsync`
-- `denied_login_methods`, List of login methods not allowed. To enable multi-step authentication you have to allow only multi-step login methods. The following login methods are supported:
+- `denied_login_methods`, List of login methods not allowed. To enable multi-step authentication you have to allow only multi-step login methods. If password login method is denied or no password is set then FTP and WebDAV users cannot login. The following login methods are supported:
   - `publickey`
   - `password`
   - `keyboard-interactive`

+ 45 - 0
docs/check-password-hook.md

@@ -0,0 +1,45 @@
+# Check password hook
+
+This hook allows you to externally check the provided password, its main use case is to allow to easily support things like password+OTP for protocols without keyboard interactive support such as FTP and WebDAV. You can ask your users to login using a string consisting of a fixed password and a One Time Token, you can verify the token inside the hook and ask to SFTPGo to verify the fixed part.
+
+The same thing can be achieved using [External authentication](./external-auth.md) but using this hook is simpler in some use cases.
+
+The `check password hook` can be defined as the absolute path of your program or an HTTP URL.
+
+The expected response is a JSON serialized struct containing the following keys:
+
+- `status` integer. 0 means KO, 1 means OK, 2 means partial success
+- `to_verify` string. For `status` = 2 SFTPGo will check this password against the one stored inside SFTPGo data provider
+
+If the hook defines an external program it can reads the following environment variables:
+
+- `SFTPGO_AUTHD_USERNAME`
+- `SFTPGO_AUTHD_PASSWORD`
+- `SFTPGO_AUTHD_IP`
+- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`
+
+Previous global environment variables aren't cleared when the script is called. The content of these variables is _not_ quoted. They may contain special characters. They are under the control of a possibly malicious remote user.
+
+The program must write, on its standard output, the expected JSON serialized response described above.
+
+If the hook is an HTTP URL then it will be invoked as HTTP POST. The request body will contain a JSON serialized struct with the following fields:
+
+- `username`
+- `password`
+- `ip`
+- `protocol`, possible values are `SSH`, `FTP`, `DAV`
+
+If authentication succeed the HTTP response code must be 200 and the response body must contain the expected JSON serialized response described above.
+
+The program hook must finish within 30 seconds, the HTTP hook timeout will use the global configuration for HTTP clients.
+
+You can also restrict the hook scope using the `check_password_scope` configuration key:
+
+- `0` means all supported protocols.
+- `1` means SSH only
+- `2` means FTP only
+- `4` means WebDAV only
+
+You can combine the scopes. For example, 6 means FTP and WebDAV.
+
+An example check password program allowing 2FA using password + one time token can be found inside the source tree [checkpwd](../examples/OTP/authy/checkpwd) directory.

+ 4 - 4
docs/external-auth.md

@@ -32,10 +32,10 @@ The program hook must finish within 30 seconds, the HTTP hook timeout will use t
 This method is slower than built-in authentication, but it's very flexible as anyone can easily write his own authentication hooks.
 You can also restrict the authentication scope for the hook using the `external_auth_scope` configuration key:
 
-- 0 means all supported authetication scopes. The external hook will be used for password, public key and keyboard interactive authentication
-- 1 means passwords only
-- 2 means public keys only
-- 4 means keyboard interactive only
+- `0` means all supported authetication scopes. The external hook will be used for password, public key and keyboard interactive authentication
+- `1` means passwords only
+- `2` means public keys only
+- `4` means keyboard interactive only
 
 You can combine the scopes. For example, 3 means password and public key, 5 means password and keyboard interactive, and so on.
 

+ 2 - 0
docs/full-configuration.md

@@ -131,6 +131,8 @@ The configuration file contains the following sections:
   - `pre_login_hook`, string. Absolute path to an external program or an HTTP URL to invoke to modify user details just before the login. See [Dynamic user modification](./dynamic-user-mod.md) for more details. Leave empty to disable.
   - `post_login_hook`, string. Absolute path to an external program or an HTTP URL to invoke to notify a successul or failed login. See [Post-login hook](./post-login-hook.md) for more details. Leave empty to disable.
   - `post_login_scope`, defines the scope for the post-login hook. 0 means notify both failed and successful logins. 1 means notify failed logins. 2 means notify successful logins.
+  - `check_password_hook`, string.  Absolute path to an external program or an HTTP URL to invoke to check the user provided password. See [Check password hook](./check-password-hook.md) for more details. Leave empty to disable.
+  - `check_password_scope`, defines the scope for the check password hook. 0 means all protocols, 1 means SSH, 2 means FTP, 4 means WebDAV. You can combine the scopes, for example 6 means FTP and WebDAV.
 - **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface
   - `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 8080
   - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "127.0.0.1"

+ 2 - 1
examples/OTP/authy/README.md

@@ -49,9 +49,10 @@ So inside your hook you need to check:
 
 If these conditions are met the token is valid and you allow the user to login.
 
-We provide two examples:
+We provide the following examples:
 
 - [Keyboard interactive authentication](./keyint/README.md) for 2FA using password + Authy one time token.
 - [External authentication](./extauth/README.md) using Authy one time tokens as passwords.
+- [Check password hook](./checkpwd/README.md) for 2FA using a password consisting of a fixed string and a One Time Token.
 
 Please note that these are sample programs not intended for production use, you should write your own hook based on them and you should prefer HTTP based hooks if performance is a concern.

+ 3 - 0
examples/OTP/authy/checkpwd/README.md

@@ -0,0 +1,3 @@
+# Authy 2FA via check password hook
+
+This example shows how to use 2FA via the check password hook using a password consisting of a fixed part and an Authy TOTP token. The hook will check the TOTP token using the Authy API and SFTPGo will check the fixed part. Please read the [sample code](./main.go), it should be self explanatory.

+ 3 - 0
examples/OTP/authy/checkpwd/go.mod

@@ -0,0 +1,3 @@
+module github.com/drakkan/sftpgo/authy/checkpwd
+
+go 1.15

+ 106 - 0
examples/OTP/authy/checkpwd/main.go

@@ -0,0 +1,106 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"os"
+	"time"
+)
+
+type userMapping struct {
+	SFTPGoUsername string
+	AuthyID        int64
+	AuthyAPIKey    string
+}
+
+type checkPasswordResponse struct {
+	// 0 KO, 1 OK, 2 partial success
+	Status int `json:"status"`
+	// for status == 2 this is the password that SFTPGo will check against the one stored
+	// inside the data provider
+	ToVerify string `json:"to_verify"`
+}
+
+var (
+	mapping []userMapping
+)
+
+func init() {
+	// this is for demo only, you probably want to get this mapping dynamically, for example using a database query
+	mapping = append(mapping, userMapping{
+		SFTPGoUsername: "<SFTPGo username>",
+		AuthyID:        1234567,
+		AuthyAPIKey:    "<your api key>",
+	})
+}
+
+func printResponse(status int, toVerify string) {
+	r := checkPasswordResponse{
+		Status:   status,
+		ToVerify: toVerify,
+	}
+	resp, _ := json.Marshal(r)
+	fmt.Printf("%v\n", string(resp))
+	if status > 0 {
+		os.Exit(0)
+	} else {
+		os.Exit(1)
+	}
+}
+
+func main() {
+	// get credentials from env vars
+	username := os.Getenv("SFTPGO_AUTHD_USERNAME")
+	password := os.Getenv("SFTPGO_AUTHD_PASSWORD")
+
+	for _, m := range mapping {
+		if m.SFTPGoUsername == username {
+			// Authy token len is 7, we assume that we have the password followed by the token
+			pwdLen := len(password)
+			if pwdLen <= 7 {
+				printResponse(0, "")
+			}
+			pwd := password[:pwdLen-7]
+			authyToken := password[pwdLen-7:]
+			// now verify the authy token and instruct SFTPGo to check the password if the token is OK
+			url := fmt.Sprintf("https://api.authy.com/protected/json/verify/%v/%v", authyToken, m.AuthyID)
+			req, err := http.NewRequest(http.MethodGet, url, nil)
+			if err != nil {
+				log.Fatal(err)
+			}
+			req.Header.Set("X-Authy-API-Key", m.AuthyAPIKey)
+			httpClient := &http.Client{
+				Timeout: 10 * time.Second,
+			}
+			resp, err := httpClient.Do(req)
+			if err != nil {
+				printResponse(0, "")
+			}
+			defer resp.Body.Close()
+			if resp.StatusCode != http.StatusOK {
+				// status code 200 is expected
+				printResponse(0, "")
+			}
+			var authyResponse map[string]interface{}
+			respBody, err := ioutil.ReadAll(resp.Body)
+			if err != nil {
+				printResponse(0, "")
+			}
+			err = json.Unmarshal(respBody, &authyResponse)
+			if err != nil {
+				printResponse(0, "")
+			}
+			if authyResponse["success"].(string) == "true" {
+				printResponse(2, pwd)
+			}
+			printResponse(0, "")
+			break
+		}
+	}
+
+	// no mapping found
+	printResponse(0, "")
+}

+ 1 - 1
examples/OTP/authy/keyint/README.md

@@ -1,3 +1,3 @@
-# Authy keyboard interactive authentication
+# Authy 2FA using keyboard interactive authentication
 
 This example shows how to authenticate SFTP users using 2FA (password + Authy token). Please read the [sample code](./main.go), it should be self explanatory.

+ 24 - 0
ftpd/ftpd_test.go

@@ -676,6 +676,30 @@ func TestResume(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+//nolint:dupl
+func TestDeniedLoginMethod(t *testing.T) {
+	u := getTestUser()
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
+	user, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = getFTPClient(user, false)
+	assert.Error(t, err)
+	user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyAndPassword}
+	user, _, err = httpd.UpdateUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	client, err := getFTPClient(user, true)
+	if assert.NoError(t, err) {
+		assert.NoError(t, checkBasicFTP(client))
+		err = client.Quit()
+		assert.NoError(t, err)
+	}
+	_, err = httpd.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+//nolint:dupl
 func TestDeniedProtocols(t *testing.T) {
 	u := getTestUser()
 	u.Filters.DeniedProtocols = []string{common.ProtocolFTP}

+ 4 - 0
ftpd/server.go

@@ -161,6 +161,10 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
 		logger.Debug(logSender, connectionID, "cannot login user %#v, protocol FTP is not allowed", user.Username)
 		return nil, fmt.Errorf("Protocol FTP is not allowed for user %#v", user.Username)
 	}
+	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
+		logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
+		return nil, fmt.Errorf("Password login method is not allowed for user %#v", user.Username)
+	}
 	if user.MaxSessions > 0 {
 		activeSessions := common.Connections.GetActiveSessions(user.Username)
 		if activeSessions >= user.MaxSessions {

+ 2 - 2
go.mod

@@ -24,7 +24,7 @@ require (
 	github.com/otiai10/copy v1.2.0
 	github.com/pelletier/go-toml v1.8.0 // indirect
 	github.com/pires/go-proxyproto v0.1.3
-	github.com/pkg/sftp v1.11.1-0.20200731124947-b508b936bef3
+	github.com/pkg/sftp v1.11.1-0.20200819110714-3ee8d0ba91c0
 	github.com/prometheus/client_golang v1.7.1
 	github.com/prometheus/common v0.12.0 // indirect
 	github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b
@@ -41,7 +41,7 @@ require (
 	go.etcd.io/bbolt v1.3.5
 	golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de
 	golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc
-	golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed
+	golang.org/x/sys v0.0.0-20200819091447-39769834ee22
 	golang.org/x/tools v0.0.0-20200814230902-9882f1d1823d // indirect
 	google.golang.org/api v0.30.0
 	google.golang.org/genproto v0.0.0-20200815001618-f69a88009b70 // indirect

+ 4 - 4
go.sum

@@ -365,8 +365,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
 github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
-github.com/pkg/sftp v1.11.1-0.20200731124947-b508b936bef3 h1:Rmu3suy8Whb1i+nmhVke1aeZRB+lrjghG81bZVstpLc=
-github.com/pkg/sftp v1.11.1-0.20200731124947-b508b936bef3/go.mod h1:i24A96cQ6ZvWut9G/Uv3LvC4u3VebGsBR5JFvPyChLc=
+github.com/pkg/sftp v1.11.1-0.20200819110714-3ee8d0ba91c0 h1:3LRfXAQrcWKQ0LQZ9wjp9wgzYRsxFKVjP5zZIEv9NFY=
+github.com/pkg/sftp v1.11.1-0.20200819110714-3ee8d0ba91c0/go.mod h1:i24A96cQ6ZvWut9G/Uv3LvC4u3VebGsBR5JFvPyChLc=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -578,8 +578,8 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200722175500-76b94024e4b6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed h1:J22ig1FUekjjkmZUM7pTKixYm8DvrYsvrBZdunYeIuQ=
-golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200819091447-39769834ee22 h1:YUxhQGxYV280Da2a0XZiHblyJZN6NuXS1f4dahsm0SM=
+golang.org/x/sys v0.0.0-20200819091447-39769834ee22/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

+ 2 - 0
httpd/schema/openapi.yaml

@@ -1479,6 +1479,8 @@ components:
         - 'keyboard-interactive'
         - 'publickey+password'
         - 'publickey+keyboard-interactive'
+      description: >
+        To enable multi-step authentication you have to allow only multi-step login methods. If password login method is denied or no password is set then FTP and WebDAV users cannot login
     SupportedProtocols:
       type: string
       enum:

+ 85 - 0
sftpd/sftpd_test.go

@@ -129,6 +129,7 @@ var (
 	keyIntAuthPath   string
 	preLoginPath     string
 	postConnectPath  string
+	checkPwdPath     string
 	logFilePath      string
 )
 
@@ -203,6 +204,7 @@ func TestMain(m *testing.M) {
 	extAuthPath = filepath.Join(homeBasePath, "extauth.sh")
 	preLoginPath = filepath.Join(homeBasePath, "prelogin.sh")
 	postConnectPath = filepath.Join(homeBasePath, "postconnect.sh")
+	checkPwdPath = filepath.Join(homeBasePath, "checkpwd.sh")
 	err = ioutil.WriteFile(pubKeyPath, []byte(testPubKey+"\n"), 0600)
 	if err != nil {
 		logger.WarnToConsole("unable to save public key to file: %v", err)
@@ -277,6 +279,7 @@ func TestMain(m *testing.M) {
 	os.Remove(preLoginPath)
 	os.Remove(postConnectPath)
 	os.Remove(keyIntAuthPath)
+	os.Remove(checkPwdPath)
 	os.Exit(exitCode)
 }
 
@@ -1487,6 +1490,77 @@ func TestPostConnectHook(t *testing.T) {
 	common.Config.PostConnectHook = ""
 }
 
+func TestCheckPwdHook(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	usePubKey := false
+	u := getTestUser(usePubKey)
+	u.QuotaFiles = 1000
+	err := dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = ioutil.WriteFile(checkPwdPath, getCheckPwdScriptsContents(2, defaultPassword), os.ModePerm)
+	assert.NoError(t, err)
+	providerConf.CheckPasswordHook = checkPwdPath
+	providerConf.CheckPasswordScope = 1
+	err = dataprovider.Initialize(providerConf, configDir)
+	assert.NoError(t, err)
+	user, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+
+	client, err := getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		err = checkBasicSFTP(client)
+		assert.NoError(t, err)
+		client.Close()
+	}
+
+	err = ioutil.WriteFile(checkPwdPath, getCheckPwdScriptsContents(0, defaultPassword), os.ModePerm)
+	assert.NoError(t, err)
+	client, err = getSftpClient(user, usePubKey)
+	if !assert.Error(t, err) {
+		client.Close()
+	}
+
+	err = ioutil.WriteFile(checkPwdPath, getCheckPwdScriptsContents(1, ""), os.ModePerm)
+	assert.NoError(t, err)
+	user.Password = defaultPassword + "1"
+	client, err = getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		err = checkBasicSFTP(client)
+		assert.NoError(t, err)
+		client.Close()
+	}
+
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	providerConf.CheckPasswordScope = 6
+	err = dataprovider.Initialize(providerConf, configDir)
+	assert.NoError(t, err)
+	client, err = getSftpClient(user, usePubKey)
+	if !assert.Error(t, err) {
+		client.Close()
+	}
+
+	_, err = httpd.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)
+	assert.NoError(t, err)
+	err = os.Remove(checkPwdPath)
+	assert.NoError(t, err)
+}
+
 func TestLoginExternalAuthPwdAndPubKey(t *testing.T) {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")
@@ -7557,6 +7631,17 @@ func getPostConnectScriptContent(exitCode int) []byte {
 	return content
 }
 
+func getCheckPwdScriptsContents(status int, toVerify string) []byte {
+	content := []byte("#!/bin/sh\n\n")
+	content = append(content, []byte(fmt.Sprintf("echo '{\"status\":%v,\"to_verify\":\"%v\"}'\n", status, toVerify))...)
+	if status > 0 {
+		content = append(content, []byte("exit 0")...)
+	} else {
+		content = append(content, []byte("exit 1")...)
+	}
+	return content
+}
+
 func printLatestLogs(maxNumberOfLines int) {
 	var lines []string
 	f, err := os.Open(logFilePath)

+ 4 - 2
sftpgo.json

@@ -84,7 +84,9 @@
     "credentials_path": "credentials",
     "pre_login_hook": "",
     "post_login_hook": "",
-    "post_login_scope": 0
+    "post_login_scope": 0,
+    "check_password_hook": "",
+    "check_password_scope": 0
   },
   "httpd": {
     "bind_port": 8080,
@@ -101,4 +103,4 @@
     "ca_certificates": [],
     "skip_tls_verify": false
   }
-}
+}

+ 4 - 0
webdavd/server.go

@@ -184,6 +184,10 @@ func (s *webDavServer) validateUser(user dataprovider.User, r *http.Request) (st
 		logger.Debug(logSender, connectionID, "cannot login user %#v, protocol DAV is not allowed", user.Username)
 		return connID, fmt.Errorf("Protocol DAV is not allowed for user %#v", user.Username)
 	}
+	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
+		logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
+		return connID, fmt.Errorf("Password login method is not allowed for user %#v", user.Username)
+	}
 	if user.MaxSessions > 0 {
 		activeSessions := common.Connections.GetActiveSessions(user.Username)
 		if activeSessions >= user.MaxSessions {

+ 20 - 0
webdavd/webdavd_test.go

@@ -566,6 +566,26 @@ func TestUploadErrors(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestDeniedLoginMethod(t *testing.T) {
+	u := getTestUser()
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
+	user, _, err := httpd.AddUser(u, http.StatusOK)
+	assert.NoError(t, err)
+	client := getWebDavClient(user)
+	assert.Error(t, checkBasicFunc(client))
+
+	user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyAndKeyboardInt}
+	user, _, err = httpd.UpdateUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	client = getWebDavClient(user)
+	assert.NoError(t, checkBasicFunc(client))
+
+	_, err = httpd.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestDeniedProtocols(t *testing.T) {
 	u := getTestUser()
 	u.Filters.DeniedProtocols = []string{common.ProtocolWebDAV}