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

allow to require two-factor auth for users

Fixes #721

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino пре 3 година
родитељ
комит
d8de0faef5

+ 27 - 19
dataprovider/dataprovider.go

@@ -119,9 +119,9 @@ var (
 		PermRenameFiles, PermRenameDirs, PermDelete, PermDeleteFiles, PermDeleteDirs, PermCreateSymlinks, PermChmod,
 		PermRenameFiles, PermRenameDirs, PermDelete, PermDeleteFiles, PermDeleteDirs, PermCreateSymlinks, PermChmod,
 		PermChown, PermChtimes}
 		PermChown, PermChtimes}
 	// ValidLoginMethods defines all the valid login methods
 	// ValidLoginMethods defines all the valid login methods
-	ValidLoginMethods = []string{SSHLoginMethodPublicKey, LoginMethodPassword, SSHLoginMethodKeyboardInteractive,
-		SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt, LoginMethodTLSCertificate,
-		LoginMethodTLSCertificateAndPwd}
+	ValidLoginMethods = []string{SSHLoginMethodPublicKey, LoginMethodPassword, SSHLoginMethodPassword,
+		SSHLoginMethodKeyboardInteractive, SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt,
+		LoginMethodTLSCertificate, LoginMethodTLSCertificateAndPwd}
 	// SSHMultiStepsLoginMethods defines the supported Multi-Step Authentications
 	// SSHMultiStepsLoginMethods defines the supported Multi-Step Authentications
 	SSHMultiStepsLoginMethods = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt}
 	SSHMultiStepsLoginMethods = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt}
 	// ErrNoAuthTryed defines the error for connection closed before authentication
 	// ErrNoAuthTryed defines the error for connection closed before authentication
@@ -872,7 +872,7 @@ func CheckCachedUserCredentials(user *CachedUser, password, loginMethod, protoco
 			return err
 			return err
 		}
 		}
 		if loginMethod == LoginMethodTLSCertificate {
 		if loginMethod == LoginMethodTLSCertificate {
-			if !user.User.IsLoginMethodAllowed(LoginMethodTLSCertificate, nil) {
+			if !user.User.IsLoginMethodAllowed(LoginMethodTLSCertificate, protocol, nil) {
 				return fmt.Errorf("certificate login method is not allowed for user %#v", user.User.Username)
 				return fmt.Errorf("certificate login method is not allowed for user %#v", user.User.Username)
 			}
 			}
 			return nil
 			return nil
@@ -918,7 +918,7 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str
 	if err != nil {
 	if err != nil {
 		return user, loginMethod, err
 		return user, loginMethod, err
 	}
 	}
-	if loginMethod == LoginMethodTLSCertificate && !user.IsLoginMethodAllowed(LoginMethodTLSCertificate, nil) {
+	if loginMethod == LoginMethodTLSCertificate && !user.IsLoginMethodAllowed(LoginMethodTLSCertificate, protocol, nil) {
 		return user, loginMethod, fmt.Errorf("certificate login method is not allowed for user %#v", user.Username)
 		return user, loginMethod, fmt.Errorf("certificate login method is not allowed for user %#v", user.Username)
 	}
 	}
 	if loginMethod == LoginMethodTLSCertificateAndPwd {
 	if loginMethod == LoginMethodTLSCertificateAndPwd {
@@ -1805,7 +1805,6 @@ func validateUserTOTPConfig(c *UserTOTPConfig, username string) error {
 			return util.NewValidationError(fmt.Sprintf("totp: unable to encrypt secret: %v", err))
 			return util.NewValidationError(fmt.Sprintf("totp: unable to encrypt secret: %v", err))
 		}
 		}
 	}
 	}
-	c.Protocols = util.RemoveDuplicates(c.Protocols)
 	if len(c.Protocols) == 0 {
 	if len(c.Protocols) == 0 {
 		return util.NewValidationError("totp: specify at least one protocol")
 		return util.NewValidationError("totp: specify at least one protocol")
 	}
 	}
@@ -1987,7 +1986,6 @@ func validateBandwidthLimit(bl sdk.BandwidthLimit) error {
 
 
 func validateBandwidthLimitsFilter(user *User) error {
 func validateBandwidthLimitsFilter(user *User) error {
 	for idx, bandwidthLimit := range user.Filters.BandwidthLimits {
 	for idx, bandwidthLimit := range user.Filters.BandwidthLimits {
-		user.Filters.BandwidthLimits[idx].Sources = util.RemoveDuplicates(bandwidthLimit.Sources)
 		if err := validateBandwidthLimit(bandwidthLimit); err != nil {
 		if err := validateBandwidthLimit(bandwidthLimit); err != nil {
 			return err
 			return err
 		}
 		}
@@ -2033,6 +2031,24 @@ func updateFiltersValues(user *User) {
 	}
 	}
 }
 }
 
 
+func validateFilterProtocols(user *User) error {
+	if len(user.Filters.DeniedProtocols) >= len(ValidProtocols) {
+		return util.NewValidationError("invalid denied_protocols")
+	}
+	for _, p := range user.Filters.DeniedProtocols {
+		if !util.IsStringInSlice(p, ValidProtocols) {
+			return util.NewValidationError(fmt.Sprintf("invalid denied protocol %#v", p))
+		}
+	}
+
+	for _, p := range user.Filters.TwoFactorAuthProtocols {
+		if !util.IsStringInSlice(p, MFAProtocols) {
+			return util.NewValidationError(fmt.Sprintf("invalid two factor protocol %#v", p))
+		}
+	}
+	return nil
+}
+
 func validateFilters(user *User) error {
 func validateFilters(user *User) error {
 	checkEmptyFiltersStruct(user)
 	checkEmptyFiltersStruct(user)
 	if err := validateIPFilters(user); err != nil {
 	if err := validateIPFilters(user); err != nil {
@@ -2044,7 +2060,6 @@ func validateFilters(user *User) error {
 	if err := validateTransferLimitsFilter(user); err != nil {
 	if err := validateTransferLimitsFilter(user); err != nil {
 		return err
 		return err
 	}
 	}
-	user.Filters.DeniedLoginMethods = util.RemoveDuplicates(user.Filters.DeniedLoginMethods)
 	if len(user.Filters.DeniedLoginMethods) >= len(ValidLoginMethods) {
 	if len(user.Filters.DeniedLoginMethods) >= len(ValidLoginMethods) {
 		return util.NewValidationError("invalid denied_login_methods")
 		return util.NewValidationError("invalid denied_login_methods")
 	}
 	}
@@ -2053,21 +2068,14 @@ func validateFilters(user *User) error {
 			return util.NewValidationError(fmt.Sprintf("invalid login method: %#v", loginMethod))
 			return util.NewValidationError(fmt.Sprintf("invalid login method: %#v", loginMethod))
 		}
 		}
 	}
 	}
-	user.Filters.DeniedProtocols = util.RemoveDuplicates(user.Filters.DeniedProtocols)
-	if len(user.Filters.DeniedProtocols) >= len(ValidProtocols) {
-		return util.NewValidationError("invalid denied_protocols")
-	}
-	for _, p := range user.Filters.DeniedProtocols {
-		if !util.IsStringInSlice(p, ValidProtocols) {
-			return util.NewValidationError(fmt.Sprintf("invalid protocol: %#v", p))
-		}
+	if err := validateFilterProtocols(user); err != nil {
+		return err
 	}
 	}
 	if user.Filters.TLSUsername != "" {
 	if user.Filters.TLSUsername != "" {
 		if !util.IsStringInSlice(string(user.Filters.TLSUsername), validTLSUsernames) {
 		if !util.IsStringInSlice(string(user.Filters.TLSUsername), validTLSUsernames) {
 			return util.NewValidationError(fmt.Sprintf("invalid TLS username: %#v", user.Filters.TLSUsername))
 			return util.NewValidationError(fmt.Sprintf("invalid TLS username: %#v", user.Filters.TLSUsername))
 		}
 		}
 	}
 	}
-	user.Filters.WebClient = util.RemoveDuplicates(user.Filters.WebClient)
 	for _, opts := range user.Filters.WebClient {
 	for _, opts := range user.Filters.WebClient {
 		if !util.IsStringInSlice(opts, sdk.WebClientOptions) {
 		if !util.IsStringInSlice(opts, sdk.WebClientOptions) {
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
@@ -2244,7 +2252,7 @@ func ValidateUser(user *User) error {
 		return err
 		return err
 	}
 	}
 	if user.Filters.TOTPConfig.Enabled && util.IsStringInSlice(sdk.WebClientMFADisabled, user.Filters.WebClient) {
 	if user.Filters.TOTPConfig.Enabled && util.IsStringInSlice(sdk.WebClientMFADisabled, user.Filters.WebClient) {
-		return util.NewValidationError("multi-factor authentication cannot be disabled for a user with an active configuration")
+		return util.NewValidationError("two-factor authentication cannot be disabled for a user with an active configuration")
 	}
 	}
 	return saveGCSCredentials(&user.FsConfig, user)
 	return saveGCSCredentials(&user.FsConfig, user)
 }
 }
@@ -2405,7 +2413,7 @@ func checkUserAndPubKey(user *User, pubKey []byte) (User, string, error) {
 				certInfo = fmt.Sprintf(" %v ID: %v Serial: %v CA: %v", cert.Type(), cert.KeyId, cert.Serial,
 				certInfo = fmt.Sprintf(" %v ID: %v Serial: %v CA: %v", cert.Type(), cert.KeyId, cert.Serial,
 					ssh.FingerprintSHA256(cert.SignatureKey))
 					ssh.FingerprintSHA256(cert.SignatureKey))
 			}
 			}
-			return *user, fmt.Sprintf("%v:%v%v", ssh.FingerprintSHA256(storedPubKey), comment, certInfo), nil
+			return *user, fmt.Sprintf("%s:%s%s", ssh.FingerprintSHA256(storedPubKey), comment, certInfo), nil
 		}
 		}
 	}
 	}
 	return *user, "", ErrInvalidCredentials
 	return *user, "", ErrInvalidCredentials

+ 43 - 2
dataprovider/user.go

@@ -65,6 +65,7 @@ const (
 const (
 const (
 	LoginMethodNoAuthTryed            = "no_auth_tryed"
 	LoginMethodNoAuthTryed            = "no_auth_tryed"
 	LoginMethodPassword               = "password"
 	LoginMethodPassword               = "password"
+	SSHLoginMethodPassword            = "password-over-SSH"
 	SSHLoginMethodPublicKey           = "publickey"
 	SSHLoginMethodPublicKey           = "publickey"
 	SSHLoginMethodKeyboardInteractive = "keyboard-interactive"
 	SSHLoginMethodKeyboardInteractive = "keyboard-interactive"
 	SSHLoginMethodKeyAndPassword      = "publickey+password"
 	SSHLoginMethodKeyAndPassword      = "publickey+password"
@@ -827,7 +828,7 @@ func (u *User) HasNoQuotaRestrictions(checkFiles bool) bool {
 }
 }
 
 
 // IsLoginMethodAllowed returns true if the specified login method is allowed
 // IsLoginMethodAllowed returns true if the specified login method is allowed
-func (u *User) IsLoginMethodAllowed(loginMethod string, partialSuccessMethods []string) bool {
+func (u *User) IsLoginMethodAllowed(loginMethod, protocol string, partialSuccessMethods []string) bool {
 	if len(u.Filters.DeniedLoginMethods) == 0 {
 	if len(u.Filters.DeniedLoginMethods) == 0 {
 		return true
 		return true
 	}
 	}
@@ -841,6 +842,11 @@ func (u *User) IsLoginMethodAllowed(loginMethod string, partialSuccessMethods []
 	if util.IsStringInSlice(loginMethod, u.Filters.DeniedLoginMethods) {
 	if util.IsStringInSlice(loginMethod, u.Filters.DeniedLoginMethods) {
 		return false
 		return false
 	}
 	}
+	if protocol == protocolSSH && loginMethod == LoginMethodPassword {
+		if util.IsStringInSlice(SSHLoginMethodPassword, u.Filters.DeniedLoginMethods) {
+			return false
+		}
+	}
 	return true
 	return true
 }
 }
 
 
@@ -875,7 +881,8 @@ func (u *User) IsPartialAuth(loginMethod string) bool {
 		return false
 		return false
 	}
 	}
 	for _, method := range u.GetAllowedLoginMethods() {
 	for _, method := range u.GetAllowedLoginMethods() {
-		if method == LoginMethodTLSCertificate || method == LoginMethodTLSCertificateAndPwd {
+		if method == LoginMethodTLSCertificate || method == LoginMethodTLSCertificateAndPwd ||
+			method == SSHLoginMethodPassword {
 			continue
 			continue
 		}
 		}
 		if !util.IsStringInSlice(method, SSHMultiStepsLoginMethods) {
 		if !util.IsStringInSlice(method, SSHMultiStepsLoginMethods) {
@@ -889,6 +896,9 @@ func (u *User) IsPartialAuth(loginMethod string) bool {
 func (u *User) GetAllowedLoginMethods() []string {
 func (u *User) GetAllowedLoginMethods() []string {
 	var allowedMethods []string
 	var allowedMethods []string
 	for _, method := range ValidLoginMethods {
 	for _, method := range ValidLoginMethods {
+		if method == SSHLoginMethodPassword {
+			continue
+		}
 		if !util.IsStringInSlice(method, u.Filters.DeniedLoginMethods) {
 		if !util.IsStringInSlice(method, u.Filters.DeniedLoginMethods) {
 			allowedMethods = append(allowedMethods, method)
 			allowedMethods = append(allowedMethods, method)
 		}
 		}
@@ -1055,6 +1065,35 @@ func (u *User) CanDeleteFromWeb(target string) bool {
 	return u.HasAnyPerm(permsDeleteAny, target)
 	return u.HasAnyPerm(permsDeleteAny, target)
 }
 }
 
 
+// MustSetSecondFactor returns true if the user must set a second factor authentication
+func (u *User) MustSetSecondFactor() bool {
+	if len(u.Filters.TwoFactorAuthProtocols) > 0 {
+		if !u.Filters.TOTPConfig.Enabled {
+			return true
+		}
+		for _, p := range u.Filters.TwoFactorAuthProtocols {
+			if !util.IsStringInSlice(p, u.Filters.TOTPConfig.Protocols) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// MustSetSecondFactorForProtocol returns true if the user must set a second factor authentication
+// for the specified protocol
+func (u *User) MustSetSecondFactorForProtocol(protocol string) bool {
+	if util.IsStringInSlice(protocol, u.Filters.TwoFactorAuthProtocols) {
+		if !u.Filters.TOTPConfig.Enabled {
+			return true
+		}
+		if !util.IsStringInSlice(protocol, u.Filters.TOTPConfig.Protocols) {
+			return true
+		}
+	}
+	return false
+}
+
 // GetSignature returns a signature for this admin.
 // GetSignature returns a signature for this admin.
 // It could change after an update
 // It could change after an update
 func (u *User) GetSignature() string {
 func (u *User) GetSignature() string {
@@ -1437,6 +1476,8 @@ func (u *User) getACopy() User {
 	copy(filters.FilePatterns, u.Filters.FilePatterns)
 	copy(filters.FilePatterns, u.Filters.FilePatterns)
 	filters.DeniedProtocols = make([]string, len(u.Filters.DeniedProtocols))
 	filters.DeniedProtocols = make([]string, len(u.Filters.DeniedProtocols))
 	copy(filters.DeniedProtocols, u.Filters.DeniedProtocols)
 	copy(filters.DeniedProtocols, u.Filters.DeniedProtocols)
+	filters.TwoFactorAuthProtocols = make([]string, len(u.Filters.TwoFactorAuthProtocols))
+	copy(filters.TwoFactorAuthProtocols, u.Filters.TwoFactorAuthProtocols)
 	filters.Hooks.ExternalAuthDisabled = u.Filters.Hooks.ExternalAuthDisabled
 	filters.Hooks.ExternalAuthDisabled = u.Filters.Hooks.ExternalAuthDisabled
 	filters.Hooks.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled
 	filters.Hooks.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled
 	filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled
 	filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled

+ 38 - 0
ftpd/ftpd_test.go

@@ -703,6 +703,44 @@ func TestMultiFactorAuth(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestSecondFactorRequirement(t *testing.T) {
+	u := getTestUser()
+	u.Filters.TwoFactorAuthProtocols = []string{common.ProtocolFTP}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	_, err = getFTPClient(user, true, nil)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "second factor authentication is not set")
+	}
+
+	configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username)
+	assert.NoError(t, err)
+	user.Password = defaultPassword
+	user.Filters.TOTPConfig = dataprovider.UserTOTPConfig{
+		Enabled:    true,
+		ConfigName: configName,
+		Secret:     kms.NewPlainSecret(secret),
+		Protocols:  []string{common.ProtocolFTP},
+	}
+	err = dataprovider.UpdateUser(&user, "", "")
+	assert.NoError(t, err)
+	passcode, err := generateTOTPPasscode(secret, otp.AlgorithmSHA1)
+	assert.NoError(t, err)
+	user.Password = defaultPassword + passcode
+	client, err := getFTPClient(user, true, nil)
+	if assert.NoError(t, err) {
+		err = checkBasicFTP(client)
+		assert.NoError(t, err)
+		err := client.Quit()
+		assert.NoError(t, err)
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestLoginInvalidCredentials(t *testing.T) {
 func TestLoginInvalidCredentials(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)

+ 7 - 2
ftpd/server.go

@@ -239,7 +239,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo
 
 
 				s.setTLSConnVerified(cc.ID(), true)
 				s.setTLSConnVerified(cc.ID(), true)
 
 
-				if dbUser.IsLoginMethodAllowed(dataprovider.LoginMethodTLSCertificate, nil) {
+				if dbUser.IsLoginMethodAllowed(dataprovider.LoginMethodTLSCertificate, common.ProtocolFTP, nil) {
 					connection, err := s.validateUser(dbUser, cc, dataprovider.LoginMethodTLSCertificate)
 					connection, err := s.validateUser(dbUser, cc, dataprovider.LoginMethodTLSCertificate)
 
 
 					defer updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err)
 					defer updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err)
@@ -330,11 +330,16 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
 		logger.Info(logSender, connectionID, "cannot login user %#v, protocol FTP is not allowed", user.Username)
 		logger.Info(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)
 		return nil, fmt.Errorf("protocol FTP is not allowed for user %#v", user.Username)
 	}
 	}
-	if !user.IsLoginMethodAllowed(loginMethod, nil) {
+	if !user.IsLoginMethodAllowed(loginMethod, common.ProtocolFTP, nil) {
 		logger.Info(logSender, connectionID, "cannot login user %#v, %v login method is not allowed",
 		logger.Info(logSender, connectionID, "cannot login user %#v, %v login method is not allowed",
 			user.Username, loginMethod)
 			user.Username, loginMethod)
 		return nil, fmt.Errorf("login method %v is not allowed for user %#v", loginMethod, user.Username)
 		return nil, fmt.Errorf("login method %v is not allowed for user %#v", loginMethod, user.Username)
 	}
 	}
+	if user.MustSetSecondFactorForProtocol(common.ProtocolFTP) {
+		logger.Info(logSender, connectionID, "cannot login user %#v, second factor authentication is not set",
+			user.Username)
+		return nil, fmt.Errorf("second factor authentication is not set for user %#v", user.Username)
+	}
 	if user.MaxSessions > 0 {
 	if user.MaxSessions > 0 {
 		activeSessions := common.Connections.GetActiveSessions(user.Username)
 		activeSessions := common.Connections.GetActiveSessions(user.Username)
 		if activeSessions >= user.MaxSessions {
 		if activeSessions >= user.MaxSessions {

+ 3 - 3
go.mod

@@ -8,7 +8,7 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
-	github.com/aws/aws-sdk-go v1.43.11
+	github.com/aws/aws-sdk-go v1.43.12
 	github.com/cockroachdb/cockroach-go/v2 v2.2.8
 	github.com/cockroachdb/cockroach-go/v2 v2.2.8
 	github.com/coreos/go-oidc/v3 v3.1.0
 	github.com/coreos/go-oidc/v3 v3.1.0
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
@@ -41,7 +41,7 @@ require (
 	github.com/rs/cors v1.8.2
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.3.0
 	github.com/rs/xid v1.3.0
 	github.com/rs/zerolog v1.26.2-0.20220227173336-263b0bde3672
 	github.com/rs/zerolog v1.26.2-0.20220227173336-263b0bde3672
-	github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712
+	github.com/sftpgo/sdk v0.1.1-0.20220306155429-3a036106d884
 	github.com/shirou/gopsutil/v3 v3.22.2
 	github.com/shirou/gopsutil/v3 v3.22.2
 	github.com/spf13/afero v1.8.1
 	github.com/spf13/afero v1.8.1
 	github.com/spf13/cobra v1.3.0
 	github.com/spf13/cobra v1.3.0
@@ -98,7 +98,7 @@ require (
 	github.com/lestrrat-go/httpcc v1.0.0 // indirect
 	github.com/lestrrat-go/httpcc v1.0.0 // indirect
 	github.com/lestrrat-go/iter v1.0.1 // indirect
 	github.com/lestrrat-go/iter v1.0.1 // indirect
 	github.com/lestrrat-go/option v1.0.0 // indirect
 	github.com/lestrrat-go/option v1.0.0 // indirect
-	github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+	github.com/lufia/plan9stats v0.0.0-20220305071607-d0b38dbe16db // indirect
 	github.com/magiconair/properties v1.8.6 // indirect
 	github.com/magiconair/properties v1.8.6 // indirect
 	github.com/mattn/go-colorable v0.1.12 // indirect
 	github.com/mattn/go-colorable v0.1.12 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect

+ 6 - 5
go.sum

@@ -145,8 +145,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.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.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.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.43.11 h1:NebCNJ2QvsFCnsKT1ei98bfwTPEoO2qwtWT42tJ3N3Q=
-github.com/aws/aws-sdk-go v1.43.11/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
+github.com/aws/aws-sdk-go v1.43.12 h1:wOdx6+reSDpUBFEuJDA6edCrojzy8rOtMzhS2rD9+7M=
+github.com/aws/aws-sdk-go v1.43.12/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 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/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=
 github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
@@ -562,8 +562,9 @@ github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
 github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
 github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
 github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
 github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/lufia/plan9stats v0.0.0-20220305071607-d0b38dbe16db h1:QT3DrSQsMWGKZMArbkP9FlS2ZnPLA2z8D7fU+G3BZ3o=
+github.com/lufia/plan9stats v0.0.0-20220305071607-d0b38dbe16db/go.mod h1:VgrrWVwBO2+6XKn8ypT3WUqvoxCa8R2M5to2tRzGovI=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
 github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
@@ -702,8 +703,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/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 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712 h1:+Rgx0SgsDnFSI5JBwL4mcCH2lkx3yKhLWcQnf0s2JKE=
-github.com/sftpgo/sdk v0.1.1-0.20220303113613-e279f0a57712/go.mod h1:zqCRMcwS28IViwekJHNkFu4GqSfyVmOQTlh8h3icAXE=
+github.com/sftpgo/sdk v0.1.1-0.20220306155429-3a036106d884 h1:YrOexWq3hwNk/QM3ZyP/VI2E7UcCj/PMqJd1PLA1EME=
+github.com/sftpgo/sdk v0.1.1-0.20220306155429-3a036106d884/go.mod h1:zqCRMcwS28IViwekJHNkFu4GqSfyVmOQTlh8h3icAXE=
 github.com/shirou/gopsutil/v3 v3.22.2 h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks=
 github.com/shirou/gopsutil/v3 v3.22.2 h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks=
 github.com/shirou/gopsutil/v3 v3.22.2/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
 github.com/shirou/gopsutil/v3 v3.22.2/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=

+ 16 - 0
httpd/api_mfa.go

@@ -90,6 +90,13 @@ func saveTOTPConfig(w http.ResponseWriter, r *http.Request) {
 			sendAPIResponse(w, r, err, "", getRespStatus(err))
 			sendAPIResponse(w, r, err, "", getRespStatus(err))
 			return
 			return
 		}
 		}
+		if claims.MustSetTwoFactorAuth {
+			// force logout
+			defer func() {
+				c := jwtTokenClaims{}
+				c.removeCookie(w, r, webBaseClientPath)
+			}()
+		}
 	} else {
 	} else {
 		if err := saveAdminTOTPConfig(claims.Username, r, recoveryCodes); err != nil {
 		if err := saveAdminTOTPConfig(claims.Username, r, recoveryCodes); err != nil {
 			sendAPIResponse(w, r, err, "", getRespStatus(err))
 			sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -210,6 +217,15 @@ func saveUserTOTPConfig(username string, r *http.Request, recoveryCodes []datapr
 	if err != nil {
 	if err != nil {
 		return util.NewValidationError(fmt.Sprintf("unable to decode JSON body: %v", err))
 		return util.NewValidationError(fmt.Sprintf("unable to decode JSON body: %v", err))
 	}
 	}
+	if !user.Filters.TOTPConfig.Enabled && len(user.Filters.TwoFactorAuthProtocols) > 0 {
+		return util.NewValidationError("two-factor authentication must be enabled")
+	}
+	for _, p := range user.Filters.TwoFactorAuthProtocols {
+		if !util.IsStringInSlice(p, user.Filters.TOTPConfig.Protocols) {
+			return util.NewValidationError(fmt.Sprintf("totp: the following protocols are required: %#v",
+				strings.Join(user.Filters.TwoFactorAuthProtocols, ", ")))
+		}
+	}
 	if user.Filters.TOTPConfig.Secret == nil || !user.Filters.TOTPConfig.Secret.IsPlain() {
 	if user.Filters.TOTPConfig.Secret == nil || !user.Filters.TOTPConfig.Secret.IsPlain() {
 		user.Filters.TOTPConfig.Secret = currentTOTPSecret
 		user.Filters.TOTPConfig.Secret = currentTOTPSecret
 	}
 	}

+ 5 - 0
httpd/api_shares.go

@@ -396,6 +396,11 @@ func checkPublicShare(w http.ResponseWriter, r *http.Request, shareShope datapro
 		renderError(err, "", getRespStatus(err))
 		renderError(err, "", getRespStatus(err))
 		return share, nil, err
 		return share, nil, err
 	}
 	}
+	if user.MustSetSecondFactorForProtocol(common.ProtocolHTTP) {
+		err := util.NewMethodDisabledError("two-factor authentication requirements not met")
+		renderError(err, "", getRespStatus(err))
+		return share, nil, err
+	}
 	connID := xid.New().String()
 	connID := xid.New().String()
 	connection := &Connection{
 	connection := &Connection{
 		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTPShare, util.GetHTTPLocalAddress(r),
 		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTPShare, util.GetHTTPLocalAddress(r),

+ 2 - 2
httpd/api_utils.go

@@ -503,7 +503,7 @@ func checkHTTPClientUser(user *dataprovider.User, r *http.Request, connectionID
 		logger.Info(logSender, connectionID, "cannot login user %#v, protocol HTTP is not allowed", user.Username)
 		logger.Info(logSender, connectionID, "cannot login user %#v, protocol HTTP is not allowed", user.Username)
 		return fmt.Errorf("protocol HTTP is not allowed for user %#v", user.Username)
 		return fmt.Errorf("protocol HTTP is not allowed for user %#v", user.Username)
 	}
 	}
-	if !isLoggedInWithOIDC(r) && !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
+	if !isLoggedInWithOIDC(r) && !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolHTTP, nil) {
 		logger.Info(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
 		logger.Info(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
 		return fmt.Errorf("login method password is not allowed for user %#v", user.Username)
 		return fmt.Errorf("login method password is not allowed for user %#v", user.Username)
 	}
 	}
@@ -634,7 +634,7 @@ func isUserAllowedToResetPassword(r *http.Request, user *dataprovider.User) bool
 	if util.IsStringInSlice(common.ProtocolHTTP, user.Filters.DeniedProtocols) {
 	if util.IsStringInSlice(common.ProtocolHTTP, user.Filters.DeniedProtocols) {
 		return false
 		return false
 	}
 	}
-	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
+	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolHTTP, nil) {
 		return false
 		return false
 	}
 	}
 	if !user.IsLoginFromAddrAllowed(r.RemoteAddr) {
 	if !user.IsLoginFromAddrAllowed(r.RemoteAddr) {

+ 33 - 10
httpd/auth_utils.go

@@ -28,11 +28,13 @@ const (
 )
 )
 
 
 const (
 const (
-	claimUsernameKey    = "username"
-	claimPermissionsKey = "permissions"
-	claimAPIKey         = "api_key"
-	basicRealm          = "Basic realm=\"SFTPGo\""
-	jwtCookieKey        = "jwt"
+	claimUsernameKey                = "username"
+	claimPermissionsKey             = "permissions"
+	claimAPIKey                     = "api_key"
+	claimMustSetSecondFactorKey     = "2fa_required"
+	claimRequiredTwoFactorProtocols = "2fa_protocols"
+	basicRealm                      = "Basic realm=\"SFTPGo\""
+	jwtCookieKey                    = "jwt"
 )
 )
 
 
 var (
 var (
@@ -44,11 +46,13 @@ var (
 )
 )
 
 
 type jwtTokenClaims struct {
 type jwtTokenClaims struct {
-	Username    string
-	Permissions []string
-	Signature   string
-	Audience    string
-	APIKeyID    string
+	Username                   string
+	Permissions                []string
+	Signature                  string
+	Audience                   string
+	APIKeyID                   string
+	MustSetTwoFactorAuth       bool
+	RequiredTwoFactorProtocols []string
 }
 }
 
 
 func (c *jwtTokenClaims) hasUserAudience() bool {
 func (c *jwtTokenClaims) hasUserAudience() bool {
@@ -67,6 +71,8 @@ func (c *jwtTokenClaims) asMap() map[string]interface{} {
 		claims[claimAPIKey] = c.APIKeyID
 		claims[claimAPIKey] = c.APIKeyID
 	}
 	}
 	claims[jwt.SubjectKey] = c.Signature
 	claims[jwt.SubjectKey] = c.Signature
+	claims[claimMustSetSecondFactorKey] = c.MustSetTwoFactorAuth
+	claims[claimRequiredTwoFactorProtocols] = c.RequiredTwoFactorProtocols
 
 
 	return claims
 	return claims
 }
 }
@@ -113,6 +119,23 @@ func (c *jwtTokenClaims) Decode(token map[string]interface{}) {
 			}
 			}
 		}
 		}
 	}
 	}
+
+	secondFactorRequired := token[claimMustSetSecondFactorKey]
+	switch v := secondFactorRequired.(type) {
+	case bool:
+		c.MustSetTwoFactorAuth = v
+	}
+
+	secondFactorProtocols := token[claimRequiredTwoFactorProtocols]
+	switch v := secondFactorProtocols.(type) {
+	case []interface{}:
+		for _, elem := range v {
+			switch elemValue := elem.(type) {
+			case string:
+				c.RequiredTwoFactorProtocols = append(c.RequiredTwoFactorProtocols, elemValue)
+			}
+		}
+	}
 }
 }
 
 
 func (c *jwtTokenClaims) isCriticalPermRemoved(permissions []string) bool {
 func (c *jwtTokenClaims) isCriticalPermRemoved(permissions []string) bool {

+ 169 - 5
httpd/httpd_test.go

@@ -989,7 +989,7 @@ func TestPermMFADisabled(t *testing.T) {
 	user.Filters.WebClient = []string{sdk.WebClientMFADisabled}
 	user.Filters.WebClient = []string{sdk.WebClientMFADisabled}
 	_, resp, err := httpdtest.UpdateUser(user, http.StatusBadRequest, "")
 	_, resp, err := httpdtest.UpdateUser(user, http.StatusBadRequest, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	assert.Contains(t, string(resp), "multi-factor authentication cannot be disabled for a user with an active configuration")
+	assert.Contains(t, string(resp), "two-factor authentication cannot be disabled for a user with an active configuration")
 
 
 	saveReq := make(map[string]bool)
 	saveReq := make(map[string]bool)
 	saveReq["enabled"] = false
 	saveReq["enabled"] = false
@@ -1027,6 +1027,90 @@ func TestPermMFADisabled(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestTwoFactorRequirements(t *testing.T) {
+	u := getTestUser()
+	u.Filters.TwoFactorAuthProtocols = []string{common.ProtocolHTTP, common.ProtocolFTP}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, userDirsPath, nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols")
+
+	req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
+	assert.NoError(t, err)
+	req.RequestURI = webClientFilesPath
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols")
+
+	configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username)
+	assert.NoError(t, err)
+	userTOTPConfig := dataprovider.UserTOTPConfig{
+		Enabled:    true,
+		ConfigName: configName,
+		Secret:     kms.NewPlainSecret(secret),
+		Protocols:  []string{common.ProtocolHTTP},
+	}
+	asJSON, err := json.Marshal(userTOTPConfig)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "the following protocols are required")
+
+	userTOTPConfig.Protocols = []string{common.ProtocolHTTP, common.ProtocolFTP}
+	asJSON, err = json.Marshal(userTOTPConfig)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	// now get new tokens and check that the two factor requirements are now met
+	passcode, err := generateTOTPPasscode(secret)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
+	assert.NoError(t, err)
+	req.Header.Set("X-SFTPGO-OTP", passcode)
+	req.SetBasicAuth(defaultUsername, defaultPassword)
+	resp, err := httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	responseHolder := make(map[string]interface{})
+	err = render.DecodeJSON(resp.Body, &responseHolder)
+	assert.NoError(t, err)
+	userToken := responseHolder["access_token"].(string)
+	assert.NotEmpty(t, userToken)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userDirsPath), nil)
+	assert.NoError(t, err)
+	setBearerForReq(req, userToken)
+	resp, err = httpclient.GetHTTPClient().Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	err = resp.Body.Close()
+	assert.NoError(t, err)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestLoginUserAPITOTP(t *testing.T) {
 func TestLoginUserAPITOTP(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1048,6 +1132,39 @@ func TestLoginUserAPITOTP(t *testing.T) {
 	setBearerForReq(req, token)
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
+	// now require HTTP and SSH for TOTP
+	user.Filters.TwoFactorAuthProtocols = []string{common.ProtocolHTTP, common.ProtocolSSH}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	// two factor auth cannot be disabled
+	config := make(map[string]interface{})
+	config["enabled"] = false
+	asJSON, err = json.Marshal(config)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "two-factor authentication must be enabled")
+	// all the required protocols must be enabled
+	asJSON, err = json.Marshal(userTOTPConfig)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "the following protocols are required")
+	// setting all the required protocols should work
+	userTOTPConfig.Protocols = []string{common.ProtocolHTTP, common.ProtocolSSH}
+	asJSON, err = json.Marshal(userTOTPConfig)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
 
 
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1070,8 +1187,8 @@ func TestLoginUserAPITOTP(t *testing.T) {
 	responseHolder := make(map[string]interface{})
 	responseHolder := make(map[string]interface{})
 	err = render.DecodeJSON(resp.Body, &responseHolder)
 	err = render.DecodeJSON(resp.Body, &responseHolder)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	adminToken := responseHolder["access_token"].(string)
-	assert.NotEmpty(t, adminToken)
+	userToken := responseHolder["access_token"].(string)
+	assert.NotEmpty(t, userToken)
 	err = resp.Body.Close()
 	err = resp.Body.Close()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
@@ -1543,7 +1660,11 @@ func TestAddUserInvalidFilters(t *testing.T) {
 	u.Filters.DeniedLoginMethods = dataprovider.ValidLoginMethods
 	u.Filters.DeniedLoginMethods = dataprovider.ValidLoginMethods
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	u.Filters.DeniedLoginMethods = []string{}
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodTLSCertificateAndPwd}
+	u.Filters.DeniedProtocols = dataprovider.ValidProtocols
+	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
+	assert.NoError(t, err)
+	u.Filters.DeniedProtocols = []string{common.ProtocolFTP}
 	u.Filters.FilePatterns = []sdk.PatternsFilter{
 	u.Filters.FilePatterns = []sdk.PatternsFilter{
 		{
 		{
 			Path:            "relative",
 			Path:            "relative",
@@ -9949,6 +10070,22 @@ func TestBrowseShares(t *testing.T) {
 	err = json.Unmarshal(rr.Body.Bytes(), &contents)
 	err = json.Unmarshal(rr.Body.Bytes(), &contents)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Len(t, contents, 1)
 	assert.Len(t, contents, 1)
+	// if we require two-factor auth for HTTP protocol the share should not work anymore
+	user.Filters.TwoFactorAuthProtocols = []string{common.ProtocolSSH}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	user.Filters.TwoFactorAuthProtocols = []string{common.ProtocolSSH, common.ProtocolHTTP}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodGet, path.Join(sharesPath, objectID, "dirs?path=%2F"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "two-factor authentication requirements not met")
 
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -14560,7 +14697,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	form.Set("pattern_path0", "/dir1")
 	form.Set("pattern_path0", "/dir1")
 	form.Set("patterns0", "*.zip")
 	form.Set("patterns0", "*.zip")
 	form.Set("pattern_type0", "denied")
 	form.Set("pattern_type0", "denied")
-	form.Set("ssh_login_methods", dataprovider.SSHLoginMethodKeyboardInteractive)
+	form.Set("denied_login_methods", dataprovider.SSHLoginMethodKeyboardInteractive)
 	form.Set("denied_protocols", common.ProtocolFTP)
 	form.Set("denied_protocols", common.ProtocolFTP)
 	form.Set("max_upload_file_size", "100")
 	form.Set("max_upload_file_size", "100")
 	form.Set("disconnect", "1")
 	form.Set("disconnect", "1")
@@ -17005,6 +17142,33 @@ func TestStaticFilesMock(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
 }
 }
 
 
+func TestSecondFactorRequirements(t *testing.T) {
+	user := getTestUser()
+	user.Filters.TwoFactorAuthProtocols = []string{common.ProtocolHTTP, common.ProtocolSSH}
+	assert.True(t, user.MustSetSecondFactor())
+	assert.False(t, user.MustSetSecondFactorForProtocol(common.ProtocolFTP))
+	assert.True(t, user.MustSetSecondFactorForProtocol(common.ProtocolHTTP))
+	assert.True(t, user.MustSetSecondFactorForProtocol(common.ProtocolSSH))
+
+	user.Filters.TOTPConfig.Enabled = true
+	assert.True(t, user.MustSetSecondFactor())
+	assert.False(t, user.MustSetSecondFactorForProtocol(common.ProtocolFTP))
+	assert.True(t, user.MustSetSecondFactorForProtocol(common.ProtocolHTTP))
+	assert.True(t, user.MustSetSecondFactorForProtocol(common.ProtocolSSH))
+
+	user.Filters.TOTPConfig.Protocols = []string{common.ProtocolHTTP}
+	assert.True(t, user.MustSetSecondFactor())
+	assert.False(t, user.MustSetSecondFactorForProtocol(common.ProtocolFTP))
+	assert.False(t, user.MustSetSecondFactorForProtocol(common.ProtocolHTTP))
+	assert.True(t, user.MustSetSecondFactorForProtocol(common.ProtocolSSH))
+
+	user.Filters.TOTPConfig.Protocols = []string{common.ProtocolHTTP, common.ProtocolSSH}
+	assert.False(t, user.MustSetSecondFactor())
+	assert.False(t, user.MustSetSecondFactorForProtocol(common.ProtocolFTP))
+	assert.False(t, user.MustSetSecondFactorForProtocol(common.ProtocolHTTP))
+	assert.False(t, user.MustSetSecondFactorForProtocol(common.ProtocolSSH))
+}
+
 func startOIDCMockServer() {
 func startOIDCMockServer() {
 	go func() {
 	go func() {
 		http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 		http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

+ 15 - 0
httpd/internal_test.go

@@ -1002,6 +1002,21 @@ func TestJWTTokenValidation(t *testing.T) {
 	ctx = jwtauth.NewContext(req.Context(), token, errTest)
 	ctx = jwtauth.NewContext(req.Context(), token, errTest)
 	fn.ServeHTTP(rr, req.WithContext(ctx))
 	fn.ServeHTTP(rr, req.WithContext(ctx))
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
+
+	fn = checkSecondFactorRequirement(r)
+	rr = httptest.NewRecorder()
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, nil)
+	req.RequestURI = webClientProfilePath
+	ctx = jwtauth.NewContext(req.Context(), token, errTest)
+	fn.ServeHTTP(rr, req.WithContext(ctx))
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+
+	rr = httptest.NewRecorder()
+	req, _ = http.NewRequest(http.MethodPost, userSharesPath, nil)
+	req.RequestURI = userSharesPath
+	ctx = jwtauth.NewContext(req.Context(), token, errTest)
+	fn.ServeHTTP(rr, req.WithContext(ctx))
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
 }
 }
 
 
 func TestUpdateContextFromCookie(t *testing.T) {
 func TestUpdateContextFromCookie(t *testing.T) {

+ 28 - 0
httpd/middleware.go

@@ -198,6 +198,34 @@ func checkHTTPUserPerm(perm string) func(next http.Handler) http.Handler {
 	}
 	}
 }
 }
 
 
+func checkSecondFactorRequirement(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, claims, err := jwtauth.FromContext(r.Context())
+		if err != nil {
+			if isWebRequest(r) {
+				renderClientBadRequestPage(w, r, err)
+			} else {
+				sendAPIResponse(w, r, err, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+			}
+			return
+		}
+		tokenClaims := jwtTokenClaims{}
+		tokenClaims.Decode(claims)
+		if tokenClaims.MustSetTwoFactorAuth {
+			message := fmt.Sprintf("Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols: %v",
+				strings.Join(tokenClaims.RequiredTwoFactorProtocols, ", "))
+			if isWebRequest(r) {
+				renderClientForbiddenPage(w, r, message)
+			} else {
+				sendAPIResponse(w, r, nil, message, http.StatusForbidden)
+			}
+			return
+		}
+
+		next.ServeHTTP(w, r)
+	})
+}
+
 func requireBuiltinLogin(next http.Handler) http.Handler {
 func requireBuiltinLogin(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if isLoggedInWithOIDC(r) {
 		if isLoggedInWithOIDC(r) {

+ 76 - 55
httpd/server.go

@@ -640,9 +640,11 @@ func (s *httpdServer) loginUser(
 	isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, error string),
 	isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, error string),
 ) {
 ) {
 	c := jwtTokenClaims{
 	c := jwtTokenClaims{
-		Username:    user.Username,
-		Permissions: user.Filters.WebClient,
-		Signature:   user.GetSignature(),
+		Username:                   user.Username,
+		Permissions:                user.Filters.WebClient,
+		Signature:                  user.GetSignature(),
+		MustSetTwoFactorAuth:       user.MustSetSecondFactor(),
+		RequiredTwoFactorProtocols: user.Filters.TwoFactorAuthProtocols,
 	}
 	}
 
 
 	audience := tokenAudienceWebClient
 	audience := tokenAudienceWebClient
@@ -792,9 +794,11 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) {
 
 
 func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Request, ipAddr string, user dataprovider.User) {
 func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Request, ipAddr string, user dataprovider.User) {
 	c := jwtTokenClaims{
 	c := jwtTokenClaims{
-		Username:    user.Username,
-		Permissions: user.Filters.WebClient,
-		Signature:   user.GetSignature(),
+		Username:                   user.Username,
+		Permissions:                user.Filters.WebClient,
+		Signature:                  user.GetSignature(),
+		MustSetTwoFactorAuth:       user.MustSetSecondFactor(),
+		RequiredTwoFactorProtocols: user.Filters.TwoFactorAuthProtocols,
 	}
 	}
 
 
 	resp, err := c.createTokenResponse(s.tokenAuth, tokenAudienceAPIUser)
 	resp, err := c.createTokenResponse(s.tokenAuth, tokenAudienceAPIUser)
@@ -1241,14 +1245,14 @@ func (s *httpdServer) initializeRouter() {
 		router.Use(jwtAuthenticatorAPIUser)
 		router.Use(jwtAuthenticatorAPIUser)
 
 
 		router.With(forbidAPIKeyAuthentication).Get(userLogoutPath, s.logout)
 		router.With(forbidAPIKeyAuthentication).Get(userLogoutPath, s.logout)
-		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
-			Put(userPwdPath, changeUserPassword)
-		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).
-			Get(userPublicKeysPath, getUserPublicKeys)
-		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).
-			Put(userPublicKeysPath, setUserPublicKeys)
+		router.With(forbidAPIKeyAuthentication, checkSecondFactorRequirement,
+			checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).Put(userPwdPath, changeUserPassword)
+		router.With(forbidAPIKeyAuthentication, checkSecondFactorRequirement,
+			checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).Get(userPublicKeysPath, getUserPublicKeys)
+		router.With(forbidAPIKeyAuthentication, checkSecondFactorRequirement,
+			checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).Put(userPublicKeysPath, setUserPublicKeys)
 		router.With(forbidAPIKeyAuthentication).Get(userProfilePath, getUserProfile)
 		router.With(forbidAPIKeyAuthentication).Get(userProfilePath, getUserProfile)
-		router.With(forbidAPIKeyAuthentication).Put(userProfilePath, updateUserProfile)
+		router.With(forbidAPIKeyAuthentication, checkSecondFactorRequirement).Put(userProfilePath, updateUserProfile)
 		// user TOTP APIs
 		// user TOTP APIs
 		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
 		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
 			Get(userTOTPConfigsPath, getTOTPConfigs)
 			Get(userTOTPConfigsPath, getTOTPConfigs)
@@ -1264,25 +1268,38 @@ func (s *httpdServer) initializeRouter() {
 			Post(user2FARecoveryCodesPath, generateRecoveryCodes)
 			Post(user2FARecoveryCodesPath, generateRecoveryCodes)
 
 
 		// compatibility layer to remove in v2.3
 		// compatibility layer to remove in v2.3
-		router.With(compressor.Handler).Get(userFolderPath, readUserFolder)
-		router.Get(userFilePath, getUserFile)
-
-		router.With(compressor.Handler).Get(userDirsPath, readUserFolder)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userDirsPath, createUserDir)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userDirsPath, renameUserDir)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userDirsPath, deleteUserDir)
-		router.Get(userFilesPath, getUserFile)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userFilesPath, uploadUserFiles)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFilesPath, renameUserFile)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userFilesPath, deleteUserFile)
-		router.Post(userStreamZipPath, getUserFilesAsZipStream)
-		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Get(userSharesPath, getShares)
-		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Post(userSharesPath, addShare)
-		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Get(userSharesPath+"/{id}", getShareByID)
-		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Put(userSharesPath+"/{id}", updateShare)
-		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Delete(userSharesPath+"/{id}", deleteShare)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Post(userUploadFilePath, uploadUserFile)
-		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFilesDirsMetadataPath, setFileDirMetadata)
+		router.With(checkSecondFactorRequirement, compressor.Handler).Get(userFolderPath, readUserFolder)
+		router.With(checkSecondFactorRequirement).Get(userFilePath, getUserFile)
+
+		router.With(checkSecondFactorRequirement, compressor.Handler).Get(userDirsPath, readUserFolder)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+			Post(userDirsPath, createUserDir)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+			Patch(userDirsPath, renameUserDir)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+			Delete(userDirsPath, deleteUserDir)
+		router.With(checkSecondFactorRequirement).Get(userFilesPath, getUserFile)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+			Post(userFilesPath, uploadUserFiles)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+			Patch(userFilesPath, renameUserFile)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+			Delete(userFilesPath, deleteUserFile)
+		router.With(checkSecondFactorRequirement).Post(userStreamZipPath, getUserFilesAsZipStream)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+			Get(userSharesPath, getShares)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+			Post(userSharesPath, addShare)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+			Get(userSharesPath+"/{id}", getShareByID)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+			Put(userSharesPath+"/{id}", updateShare)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+			Delete(userSharesPath+"/{id}", deleteShare)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+			Post(userUploadFilePath, uploadUserFile)
+		router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
+			Patch(userFilesDirsMetadataPath, setFileDirMetadata)
 	})
 	})
 
 
 	if s.renderOpenAPI {
 	if s.renderOpenAPI {
@@ -1368,29 +1385,33 @@ func (s *httpdServer) setupWebClientRoutes() {
 			router.Use(jwtAuthenticatorWebClient)
 			router.Use(jwtAuthenticatorWebClient)
 
 
 			router.Get(webClientLogoutPath, s.handleWebClientLogout)
 			router.Get(webClientLogoutPath, s.handleWebClientLogout)
-			router.With(s.refreshCookie).Get(webClientFilesPath, s.handleClientGetFiles)
-			router.With(s.refreshCookie).Get(webClientViewPDFPath, handleClientViewPDF)
-			router.With(s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, s.refreshCookie).Get(webClientFilesPath, s.handleClientGetFiles)
+			router.With(checkSecondFactorRequirement, s.refreshCookie).Get(webClientViewPDFPath, handleClientViewPDF)
+			router.With(checkSecondFactorRequirement, s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Post(webClientFilePath, uploadUserFile)
 				Post(webClientFilePath, uploadUserFile)
-			router.With(s.refreshCookie).Get(webClientEditFilePath, handleClientEditFile)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, s.refreshCookie).Get(webClientEditFilePath, handleClientEditFile)
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Patch(webClientFilesPath, renameUserFile)
 				Patch(webClientFilesPath, renameUserFile)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Delete(webClientFilesPath, deleteUserFile)
 				Delete(webClientFilesPath, deleteUserFile)
-			router.With(compressor.Handler, s.refreshCookie).Get(webClientDirsPath, s.handleClientGetDirContents)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, compressor.Handler, s.refreshCookie).
+				Get(webClientDirsPath, s.handleClientGetDirContents)
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Post(webClientDirsPath, createUserDir)
 				Post(webClientDirsPath, createUserDir)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Patch(webClientDirsPath, renameUserDir)
 				Patch(webClientDirsPath, renameUserDir)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Delete(webClientDirsPath, deleteUserDir)
 				Delete(webClientDirsPath, deleteUserDir)
-			router.With(s.refreshCookie).Get(webClientDownloadZipPath, handleWebClientDownloadZip)
-			router.With(s.refreshCookie, requireBuiltinLogin).Get(webClientProfilePath, handleClientGetProfile)
-			router.With(requireBuiltinLogin).Post(webClientProfilePath, handleWebClientProfilePost)
-			router.With(checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
+			router.With(checkSecondFactorRequirement, s.refreshCookie).
+				Get(webClientDownloadZipPath, handleWebClientDownloadZip)
+			router.With(checkSecondFactorRequirement, s.refreshCookie, requireBuiltinLogin).
+				Get(webClientProfilePath, handleClientGetProfile)
+			router.With(checkSecondFactorRequirement, requireBuiltinLogin).
+				Post(webClientProfilePath, handleWebClientProfilePost)
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
 				Get(webChangeClientPwdPath, handleWebClientChangePwd)
 				Get(webChangeClientPwdPath, handleWebClientChangePwd)
-			router.With(checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
 				Post(webChangeClientPwdPath, s.handleWebClientChangePwdPost)
 				Post(webChangeClientPwdPath, s.handleWebClientChangePwdPost)
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie).
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie).
 				Get(webClientMFAPath, handleWebClientMFA)
 				Get(webClientMFAPath, handleWebClientMFA)
@@ -1404,17 +1425,17 @@ func (s *httpdServer) setupWebClientRoutes() {
 				Get(webClientRecoveryCodesPath, getRecoveryCodes)
 				Get(webClientRecoveryCodesPath, getRecoveryCodes)
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
 				Post(webClientRecoveryCodesPath, generateRecoveryCodes)
 				Post(webClientRecoveryCodesPath, generateRecoveryCodes)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
 				Get(webClientSharesPath, handleClientGetShares)
 				Get(webClientSharesPath, handleClientGetShares)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
 				Get(webClientSharePath, handleClientAddShareGet)
 				Get(webClientSharePath, handleClientAddShareGet)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Post(webClientSharePath,
-				handleClientAddSharePost)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+				Post(webClientSharePath, handleClientAddSharePost)
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
 				Get(webClientSharePath+"/{id}", handleClientUpdateShareGet)
 				Get(webClientSharePath+"/{id}", handleClientUpdateShareGet)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
 				Post(webClientSharePath+"/{id}", handleClientUpdateSharePost)
 				Post(webClientSharePath+"/{id}", handleClientUpdateSharePost)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled), verifyCSRFHeader).
 				Delete(webClientSharePath+"/{id}", deleteShare)
 				Delete(webClientSharePath+"/{id}", deleteShare)
 		})
 		})
 	}
 	}

+ 28 - 25
httpd/webadmin.go

@@ -153,19 +153,20 @@ type fsWrapper struct {
 
 
 type userPage struct {
 type userPage struct {
 	basePage
 	basePage
-	User              *dataprovider.User
-	RootPerms         []string
-	Error             string
-	ValidPerms        []string
-	ValidLoginMethods []string
-	ValidProtocols    []string
-	WebClientOptions  []string
-	RootDirPerms      []string
-	RedactedSecret    string
-	Mode              userPageMode
-	VirtualFolders    []vfs.BaseVirtualFolder
-	CanImpersonate    bool
-	FsWrapper         fsWrapper
+	User               *dataprovider.User
+	RootPerms          []string
+	Error              string
+	ValidPerms         []string
+	ValidLoginMethods  []string
+	ValidProtocols     []string
+	TwoFactorProtocols []string
+	WebClientOptions   []string
+	RootDirPerms       []string
+	RedactedSecret     string
+	Mode               userPageMode
+	VirtualFolders     []vfs.BaseVirtualFolder
+	CanImpersonate     bool
+	FsWrapper          fsWrapper
 }
 }
 
 
 type adminPage struct {
 type adminPage struct {
@@ -606,17 +607,18 @@ func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.U
 	}
 	}
 	user.FsConfig.RedactedSecret = redactedSecret
 	user.FsConfig.RedactedSecret = redactedSecret
 	data := userPage{
 	data := userPage{
-		basePage:          getBasePageData(title, currentURL, r),
-		Mode:              mode,
-		Error:             error,
-		User:              user,
-		ValidPerms:        dataprovider.ValidPerms,
-		ValidLoginMethods: dataprovider.ValidLoginMethods,
-		ValidProtocols:    dataprovider.ValidProtocols,
-		WebClientOptions:  sdk.WebClientOptions,
-		RootDirPerms:      user.GetPermissionsForPath("/"),
-		VirtualFolders:    folders,
-		CanImpersonate:    os.Getuid() == 0,
+		basePage:           getBasePageData(title, currentURL, r),
+		Mode:               mode,
+		Error:              error,
+		User:               user,
+		ValidPerms:         dataprovider.ValidPerms,
+		ValidLoginMethods:  dataprovider.ValidLoginMethods,
+		ValidProtocols:     dataprovider.ValidProtocols,
+		TwoFactorProtocols: dataprovider.MFAProtocols,
+		WebClientOptions:   sdk.WebClientOptions,
+		RootDirPerms:       user.GetPermissionsForPath("/"),
+		VirtualFolders:     folders,
+		CanImpersonate:     os.Getuid() == 0,
 		FsWrapper: fsWrapper{
 		FsWrapper: fsWrapper{
 			Filesystem:      user.FsConfig,
 			Filesystem:      user.FsConfig,
 			IsUserPage:      true,
 			IsUserPage:      true,
@@ -930,8 +932,9 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	filters.DataTransferLimits = dtLimits
 	filters.DataTransferLimits = dtLimits
 	filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
 	filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
 	filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",")
 	filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",")
-	filters.DeniedLoginMethods = r.Form["ssh_login_methods"]
+	filters.DeniedLoginMethods = r.Form["denied_login_methods"]
 	filters.DeniedProtocols = r.Form["denied_protocols"]
 	filters.DeniedProtocols = r.Form["denied_protocols"]
+	filters.TwoFactorAuthProtocols = r.Form["required_two_factor_protocols"]
 	filters.FilePatterns = getFilePatternsFromPostField(r)
 	filters.FilePatterns = getFilePatternsFromPostField(r)
 	filters.TLSUsername = sdk.TLSUsername(r.Form.Get("tls_username"))
 	filters.TLSUsername = sdk.TLSUsername(r.Form.Get("tls_username"))
 	filters.WebClient = r.Form["web_client_options"]
 	filters.WebClient = r.Form["web_client_options"]

+ 101 - 101
logger/logger.go

@@ -41,116 +41,15 @@ var (
 	rollingLogger *lumberjack.Logger
 	rollingLogger *lumberjack.Logger
 )
 )
 
 
-// StdLoggerWrapper is a wrapper for standard logger compatibility
-type StdLoggerWrapper struct {
-	Sender string
-}
-
 func init() {
 func init() {
 	zerolog.TimeFieldFormat = dateFormat
 	zerolog.TimeFieldFormat = dateFormat
 }
 }
 
 
-// Write implements the io.Writer interface. This is useful to set as a writer
-// for the standard library log.
-func (l *StdLoggerWrapper) Write(p []byte) (n int, err error) {
-	n = len(p)
-	if n > 0 && p[n-1] == '\n' {
-		// Trim CR added by stdlog.
-		p = p[0 : n-1]
-	}
-
-	Log(LevelError, l.Sender, "", string(p))
-	return
-}
-
-// LeveledLogger is a logger that accepts a message string and a variadic number of key-value pairs
-type LeveledLogger struct {
-	Sender            string
-	additionalKeyVals []interface{}
-}
-
-func addKeysAndValues(ev *zerolog.Event, keysAndValues ...interface{}) {
-	kvLen := len(keysAndValues)
-	if kvLen%2 != 0 {
-		extra := keysAndValues[kvLen-1]
-		keysAndValues = append(keysAndValues[:kvLen-1], "EXTRA_VALUE_AT_END", extra)
-	}
-	for i := 0; i < len(keysAndValues); i += 2 {
-		key, val := keysAndValues[i], keysAndValues[i+1]
-		if keyStr, ok := key.(string); ok && keyStr != "timestamp" {
-			ev.Str(keyStr, fmt.Sprintf("%v", val))
-		}
-	}
-}
-
-// Error logs at error level for the specified sender
-func (l *LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
-	ev := logger.Error()
-	ev.Timestamp().Str("sender", l.Sender)
-	if len(l.additionalKeyVals) > 0 {
-		addKeysAndValues(ev, l.additionalKeyVals...)
-	}
-	addKeysAndValues(ev, keysAndValues...)
-	ev.Msg(msg)
-}
-
-// Info logs at info level for the specified sender
-func (l *LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
-	ev := logger.Info()
-	ev.Timestamp().Str("sender", l.Sender)
-	if len(l.additionalKeyVals) > 0 {
-		addKeysAndValues(ev, l.additionalKeyVals...)
-	}
-	addKeysAndValues(ev, keysAndValues...)
-	ev.Msg(msg)
-}
-
-// Debug logs at debug level for the specified sender
-func (l *LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
-	ev := logger.Debug()
-	ev.Timestamp().Str("sender", l.Sender)
-	if len(l.additionalKeyVals) > 0 {
-		addKeysAndValues(ev, l.additionalKeyVals...)
-	}
-	addKeysAndValues(ev, keysAndValues...)
-	ev.Msg(msg)
-}
-
-// Warn logs at warn level for the specified sender
-func (l *LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
-	ev := logger.Warn()
-	ev.Timestamp().Str("sender", l.Sender)
-	if len(l.additionalKeyVals) > 0 {
-		addKeysAndValues(ev, l.additionalKeyVals...)
-	}
-	addKeysAndValues(ev, keysAndValues...)
-	ev.Msg(msg)
-}
-
-// With returns a LeveledLogger with additional context specific keyvals
-func (l *LeveledLogger) With(keysAndValues ...interface{}) ftpserverlog.Logger {
-	return &LeveledLogger{
-		Sender:            l.Sender,
-		additionalKeyVals: append(l.additionalKeyVals, keysAndValues...),
-	}
-}
-
 // GetLogger get the configured logger instance
 // GetLogger get the configured logger instance
 func GetLogger() *zerolog.Logger {
 func GetLogger() *zerolog.Logger {
 	return &logger
 	return &logger
 }
 }
 
 
-// SetLogTime sets logging time related setting
-func SetLogTime(utc bool) {
-	if utc {
-		zerolog.TimestampFunc = func() time.Time {
-			return time.Now().UTC()
-		}
-	} else {
-		zerolog.TimestampFunc = time.Now
-	}
-}
-
 // InitLogger configures the logger using the given parameters
 // InitLogger configures the logger using the given parameters
 func InitLogger(logFilePath string, logMaxSize int, logMaxBackups int, logMaxAge int, logCompress, logUTCTime bool,
 func InitLogger(logFilePath string, logMaxSize int, logMaxBackups int, logMaxAge int, logCompress, logUTCTime bool,
 	level zerolog.Level,
 	level zerolog.Level,
@@ -215,6 +114,17 @@ func RotateLogFile() error {
 	return errors.New("logging to file is disabled")
 	return errors.New("logging to file is disabled")
 }
 }
 
 
+// SetLogTime sets logging time related setting
+func SetLogTime(utc bool) {
+	if utc {
+		zerolog.TimestampFunc = func() time.Time {
+			return time.Now().UTC()
+		}
+	} else {
+		zerolog.TimestampFunc = time.Now
+	}
+}
+
 // Log logs at the specified level for the specified sender
 // Log logs at the specified level for the specified sender
 func Log(level LogLevel, sender string, connectionID string, format string, v ...interface{}) {
 func Log(level LogLevel, sender string, connectionID string, format string, v ...interface{}) {
 	var ev *zerolog.Event
 	var ev *zerolog.Event
@@ -341,3 +251,93 @@ func isLogFilePathValid(logFilePath string) bool {
 	}
 	}
 	return true
 	return true
 }
 }
+
+// StdLoggerWrapper is a wrapper for standard logger compatibility
+type StdLoggerWrapper struct {
+	Sender string
+}
+
+// Write implements the io.Writer interface. This is useful to set as a writer
+// for the standard library log.
+func (l *StdLoggerWrapper) Write(p []byte) (n int, err error) {
+	n = len(p)
+	if n > 0 && p[n-1] == '\n' {
+		// Trim CR added by stdlog.
+		p = p[0 : n-1]
+	}
+
+	Log(LevelError, l.Sender, "", string(p))
+	return
+}
+
+// LeveledLogger is a logger that accepts a message string and a variadic number of key-value pairs
+type LeveledLogger struct {
+	Sender            string
+	additionalKeyVals []interface{}
+}
+
+func addKeysAndValues(ev *zerolog.Event, keysAndValues ...interface{}) {
+	kvLen := len(keysAndValues)
+	if kvLen%2 != 0 {
+		extra := keysAndValues[kvLen-1]
+		keysAndValues = append(keysAndValues[:kvLen-1], "EXTRA_VALUE_AT_END", extra)
+	}
+	for i := 0; i < len(keysAndValues); i += 2 {
+		key, val := keysAndValues[i], keysAndValues[i+1]
+		if keyStr, ok := key.(string); ok && keyStr != "timestamp" {
+			ev.Str(keyStr, fmt.Sprintf("%v", val))
+		}
+	}
+}
+
+// Error logs at error level for the specified sender
+func (l *LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
+	ev := logger.Error()
+	ev.Timestamp().Str("sender", l.Sender)
+	if len(l.additionalKeyVals) > 0 {
+		addKeysAndValues(ev, l.additionalKeyVals...)
+	}
+	addKeysAndValues(ev, keysAndValues...)
+	ev.Msg(msg)
+}
+
+// Info logs at info level for the specified sender
+func (l *LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
+	ev := logger.Info()
+	ev.Timestamp().Str("sender", l.Sender)
+	if len(l.additionalKeyVals) > 0 {
+		addKeysAndValues(ev, l.additionalKeyVals...)
+	}
+	addKeysAndValues(ev, keysAndValues...)
+	ev.Msg(msg)
+}
+
+// Debug logs at debug level for the specified sender
+func (l *LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
+	ev := logger.Debug()
+	ev.Timestamp().Str("sender", l.Sender)
+	if len(l.additionalKeyVals) > 0 {
+		addKeysAndValues(ev, l.additionalKeyVals...)
+	}
+	addKeysAndValues(ev, keysAndValues...)
+	ev.Msg(msg)
+}
+
+// Warn logs at warn level for the specified sender
+func (l *LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
+	ev := logger.Warn()
+	ev.Timestamp().Str("sender", l.Sender)
+	if len(l.additionalKeyVals) > 0 {
+		addKeysAndValues(ev, l.additionalKeyVals...)
+	}
+	addKeysAndValues(ev, keysAndValues...)
+	ev.Msg(msg)
+}
+
+// With returns a LeveledLogger with additional context specific keyvals
+func (l *LeveledLogger) With(keysAndValues ...interface{}) ftpserverlog.Logger {
+	return &LeveledLogger{
+		Sender:            l.Sender,
+		additionalKeyVals: append(l.additionalKeyVals, keysAndValues...),
+	}
+}

+ 8 - 1
openapi/openapi.yaml

@@ -4308,6 +4308,7 @@ components:
       enum:
       enum:
         - publickey
         - publickey
         - password
         - password
+        - password-over-SSH
         - keyboard-interactive
         - keyboard-interactive
         - publickey+password
         - publickey+password
         - publickey+keyboard-interactive
         - publickey+keyboard-interactive
@@ -4316,7 +4317,8 @@ components:
       description: |
       description: |
         Available login methods. To enable multi-step authentication you have to allow only multi-step login methods
         Available login methods. To enable multi-step authentication you have to allow only multi-step login methods
           * `publickey`
           * `publickey`
-          * `password`
+          * `password`, password for all the supported protocols
+          * `password-over-SSH`, password over SSH protocol (SSH/SFTP/SCP)
           * `keyboard-interactive`
           * `keyboard-interactive`
           * `publickey+password` - multi-step auth: public key and password
           * `publickey+password` - multi-step auth: public key and password
           * `publickey+keyboard-interactive` - multi-step auth: public key and keyboard interactive
           * `publickey+keyboard-interactive` - multi-step auth: public key and keyboard interactive
@@ -4682,6 +4684,11 @@ components:
         start_directory:
         start_directory:
           type: string
           type: string
           description: 'Specifies an alternate starting directory. If not set, the default is "/". This option is supported for SFTP/SCP, FTP and HTTP (WebClient/REST API) protocols. Relative paths will use this directory as base.'
           description: 'Specifies an alternate starting directory. If not set, the default is "/". This option is supported for SFTP/SCP, FTP and HTTP (WebClient/REST API) protocols. Relative paths will use this directory as base.'
+        2fa_protocols:
+          type: array
+          items:
+            $ref: '#/components/schemas/MFAProtocols'
+          description: 'Defines protocols that require two factor authentication'
       description: Additional user options
       description: Additional user options
     Secret:
     Secret:
       type: object
       type: object

+ 7 - 2
sftpd/server.go

@@ -632,11 +632,16 @@ func loginUser(user *dataprovider.User, loginMethod, publicKey string, conn ssh.
 			return nil, fmt.Errorf("too many open sessions: %v", activeSessions)
 			return nil, fmt.Errorf("too many open sessions: %v", activeSessions)
 		}
 		}
 	}
 	}
-	if !user.IsLoginMethodAllowed(loginMethod, conn.PartialSuccessMethods()) {
+	if !user.IsLoginMethodAllowed(loginMethod, common.ProtocolSSH, conn.PartialSuccessMethods()) {
 		logger.Info(logSender, connectionID, "cannot login user %#v, login method %#v is not allowed",
 		logger.Info(logSender, connectionID, "cannot login user %#v, login method %#v is not allowed",
 			user.Username, loginMethod)
 			user.Username, loginMethod)
 		return nil, fmt.Errorf("login method %#v is not allowed for user %#v", loginMethod, user.Username)
 		return nil, fmt.Errorf("login method %#v is not allowed for user %#v", loginMethod, user.Username)
 	}
 	}
+	if user.MustSetSecondFactorForProtocol(common.ProtocolSSH) {
+		logger.Info(logSender, connectionID, "cannot login user %#v, second factor authentication is not set",
+			user.Username)
+		return nil, fmt.Errorf("second factor authentication is not set for user %#v", user.Username)
+	}
 	remoteAddr := conn.RemoteAddr().String()
 	remoteAddr := conn.RemoteAddr().String()
 	if !user.IsLoginFromAddrAllowed(remoteAddr) {
 	if !user.IsLoginFromAddrAllowed(remoteAddr) {
 		logger.Info(logSender, connectionID, "cannot login user %#v, remote address is not allowed: %v",
 		logger.Info(logSender, connectionID, "cannot login user %#v, remote address is not allowed: %v",
@@ -649,7 +654,7 @@ func loginUser(user *dataprovider.User, loginMethod, publicKey string, conn ssh.
 		logger.Warn(logSender, connectionID, "error serializing user info: %v, authentication rejected", err)
 		logger.Warn(logSender, connectionID, "error serializing user info: %v, authentication rejected", err)
 		return nil, err
 		return nil, err
 	}
 	}
-	if len(publicKey) > 0 {
+	if publicKey != "" {
 		loginMethod = fmt.Sprintf("%v: %v", loginMethod, publicKey)
 		loginMethod = fmt.Sprintf("%v: %v", loginMethod, publicKey)
 	}
 	}
 	p := &ssh.Permissions{}
 	p := &ssh.Permissions{}

+ 55 - 7
sftpd/sftpd_test.go

@@ -2497,6 +2497,41 @@ func TestInteractiveLoginWithPasscode(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestSecondFactorRequirement(t *testing.T) {
+	usePubKey := true
+	u := getTestUser(usePubKey)
+	u.Filters.TwoFactorAuthProtocols = []string{common.ProtocolSSH}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	_, _, err = getSftpClient(user, usePubKey)
+	assert.Error(t, err)
+
+	configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username)
+	assert.NoError(t, err)
+	user.Password = defaultPassword
+	user.Filters.TOTPConfig = dataprovider.UserTOTPConfig{
+		Enabled:    true,
+		ConfigName: configName,
+		Secret:     kms.NewPlainSecret(secret),
+		Protocols:  []string{common.ProtocolSSH},
+	}
+	err = dataprovider.UpdateUser(&user, "", "")
+	assert.NoError(t, err)
+
+	conn, client, err := getSftpClient(user, usePubKey)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		assert.NoError(t, checkBasicSFTP(client))
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestNamingRules(t *testing.T) {
 func TestNamingRules(t *testing.T) {
 	err := dataprovider.Close()
 	err := dataprovider.Close()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -7830,18 +7865,31 @@ func TestUserIsLoginMethodAllowed(t *testing.T) {
 		dataprovider.SSHLoginMethodPublicKey,
 		dataprovider.SSHLoginMethodPublicKey,
 		dataprovider.SSHLoginMethodKeyboardInteractive,
 		dataprovider.SSHLoginMethodKeyboardInteractive,
 	}
 	}
-	assert.False(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil))
-	assert.False(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodPublicKey, nil))
-	assert.False(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodKeyboardInteractive, nil))
-	assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, []string{dataprovider.SSHLoginMethodPublicKey}))
-	assert.True(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodKeyboardInteractive, []string{dataprovider.SSHLoginMethodPublicKey}))
-	assert.True(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodKeyAndPassword, []string{dataprovider.SSHLoginMethodPublicKey}))
+	assert.False(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolSSH, nil))
+	assert.False(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolFTP, nil))
+	assert.False(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolWebDAV, nil))
+	assert.False(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodPublicKey, common.ProtocolSSH, nil))
+	assert.False(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodKeyboardInteractive, common.ProtocolSSH, nil))
+	assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolSSH,
+		[]string{dataprovider.SSHLoginMethodPublicKey}))
+	assert.True(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodKeyboardInteractive, common.ProtocolSSH,
+		[]string{dataprovider.SSHLoginMethodPublicKey}))
+	assert.True(t, user.IsLoginMethodAllowed(dataprovider.SSHLoginMethodKeyAndPassword, common.ProtocolSSH,
+		[]string{dataprovider.SSHLoginMethodPublicKey}))
 
 
 	user.Filters.DeniedLoginMethods = []string{
 	user.Filters.DeniedLoginMethods = []string{
 		dataprovider.SSHLoginMethodPublicKey,
 		dataprovider.SSHLoginMethodPublicKey,
 		dataprovider.SSHLoginMethodKeyboardInteractive,
 		dataprovider.SSHLoginMethodKeyboardInteractive,
 	}
 	}
-	assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil))
+	assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolSSH, nil))
+
+	user.Filters.DeniedLoginMethods = []string{
+		dataprovider.SSHLoginMethodPassword,
+	}
+	assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolHTTP, nil))
+	assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolFTP, nil))
+	assert.True(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolWebDAV, nil))
+	assert.False(t, user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolSSH, nil))
 }
 }
 
 
 func TestUserEmptySubDirPerms(t *testing.T) {
 func TestUserEmptySubDirPerms(t *testing.T) {

+ 16 - 1
templates/webadmin/user.html

@@ -482,12 +482,27 @@
                             <div class="form-group row">
                             <div class="form-group row">
                                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                                 <div class="col-sm-10">
                                 <div class="col-sm-10">
-                                    <select class="form-control" id="idLoginMethods" name="ssh_login_methods" multiple>
+                                    <select class="form-control" id="idLoginMethods" name="denied_login_methods" multiple aria-describedby="deniedLoginMethodsHelpBlock">
                                         {{range $method := .ValidLoginMethods}}
                                         {{range $method := .ValidLoginMethods}}
                                         <option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
                                         <option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
                                         </option>
                                         </option>
                                         {{end}}
                                         {{end}}
                                     </select>
                                     </select>
+                                    <small id="deniedLoginMethodsHelpBlock" class="form-text text-muted">
+                                        "password" is valid for all supported protocols, "password-over-SSH" only for SSH/SFTP/SCP
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idTwoFactorProtocols" class="col-sm-2 col-form-label">Require two-factor auth for</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control" id="idTwoFactorProtocols" name="required_two_factor_protocols" multiple>
+                                        {{range $protocol := .TwoFactorProtocols}}
+                                        <option value="{{$protocol}}" {{range $p :=$.User.Filters.TwoFactorAuthProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
+                                        </option>
+                                        {{end}}
+                                    </select>
                                 </div>
                                 </div>
                             </div>
                             </div>
 
 

+ 1 - 1
webdavd/server.go

@@ -306,7 +306,7 @@ func (s *webDavServer) validateUser(user *dataprovider.User, r *http.Request, lo
 		logger.Info(logSender, connectionID, "cannot login user %#v, protocol DAV is not allowed", user.Username)
 		logger.Info(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)
 		return connID, fmt.Errorf("protocol DAV is not allowed for user %#v", user.Username)
 	}
 	}
-	if !user.IsLoginMethodAllowed(loginMethod, nil) {
+	if !user.IsLoginMethodAllowed(loginMethod, common.ProtocolWebDAV, nil) {
 		logger.Info(logSender, connectionID, "cannot login user %#v, %v login method is not allowed",
 		logger.Info(logSender, connectionID, "cannot login user %#v, %v login method is not allowed",
 			user.Username, loginMethod)
 			user.Username, loginMethod)
 		return connID, fmt.Errorf("login method %v is not allowed for user %#v", loginMethod, user.Username)
 		return connID, fmt.Errorf("login method %v is not allowed for user %#v", loginMethod, user.Username)