浏览代码

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,
 		PermChown, PermChtimes}
 	// 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 = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt}
 	// ErrNoAuthTryed defines the error for connection closed before authentication
@@ -872,7 +872,7 @@ func CheckCachedUserCredentials(user *CachedUser, password, loginMethod, protoco
 			return err
 		}
 		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 nil
@@ -918,7 +918,7 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str
 	if err != nil {
 		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)
 	}
 	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))
 		}
 	}
-	c.Protocols = util.RemoveDuplicates(c.Protocols)
 	if len(c.Protocols) == 0 {
 		return util.NewValidationError("totp: specify at least one protocol")
 	}
@@ -1987,7 +1986,6 @@ func validateBandwidthLimit(bl sdk.BandwidthLimit) error {
 
 func validateBandwidthLimitsFilter(user *User) error {
 	for idx, bandwidthLimit := range user.Filters.BandwidthLimits {
-		user.Filters.BandwidthLimits[idx].Sources = util.RemoveDuplicates(bandwidthLimit.Sources)
 		if err := validateBandwidthLimit(bandwidthLimit); err != nil {
 			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 {
 	checkEmptyFiltersStruct(user)
 	if err := validateIPFilters(user); err != nil {
@@ -2044,7 +2060,6 @@ func validateFilters(user *User) error {
 	if err := validateTransferLimitsFilter(user); err != nil {
 		return err
 	}
-	user.Filters.DeniedLoginMethods = util.RemoveDuplicates(user.Filters.DeniedLoginMethods)
 	if len(user.Filters.DeniedLoginMethods) >= len(ValidLoginMethods) {
 		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))
 		}
 	}
-	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 !util.IsStringInSlice(string(user.Filters.TLSUsername), validTLSUsernames) {
 			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 {
 		if !util.IsStringInSlice(opts, sdk.WebClientOptions) {
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %#v", opts))
@@ -2244,7 +2252,7 @@ func ValidateUser(user *User) error {
 		return err
 	}
 	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)
 }
@@ -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,
 					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

+ 43 - 2
dataprovider/user.go

@@ -65,6 +65,7 @@ const (
 const (
 	LoginMethodNoAuthTryed            = "no_auth_tryed"
 	LoginMethodPassword               = "password"
+	SSHLoginMethodPassword            = "password-over-SSH"
 	SSHLoginMethodPublicKey           = "publickey"
 	SSHLoginMethodKeyboardInteractive = "keyboard-interactive"
 	SSHLoginMethodKeyAndPassword      = "publickey+password"
@@ -827,7 +828,7 @@ func (u *User) HasNoQuotaRestrictions(checkFiles bool) bool {
 }
 
 // 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 {
 		return true
 	}
@@ -841,6 +842,11 @@ func (u *User) IsLoginMethodAllowed(loginMethod string, partialSuccessMethods []
 	if util.IsStringInSlice(loginMethod, u.Filters.DeniedLoginMethods) {
 		return false
 	}
+	if protocol == protocolSSH && loginMethod == LoginMethodPassword {
+		if util.IsStringInSlice(SSHLoginMethodPassword, u.Filters.DeniedLoginMethods) {
+			return false
+		}
+	}
 	return true
 }
 
@@ -875,7 +881,8 @@ func (u *User) IsPartialAuth(loginMethod string) bool {
 		return false
 	}
 	for _, method := range u.GetAllowedLoginMethods() {
-		if method == LoginMethodTLSCertificate || method == LoginMethodTLSCertificateAndPwd {
+		if method == LoginMethodTLSCertificate || method == LoginMethodTLSCertificateAndPwd ||
+			method == SSHLoginMethodPassword {
 			continue
 		}
 		if !util.IsStringInSlice(method, SSHMultiStepsLoginMethods) {
@@ -889,6 +896,9 @@ func (u *User) IsPartialAuth(loginMethod string) bool {
 func (u *User) GetAllowedLoginMethods() []string {
 	var allowedMethods []string
 	for _, method := range ValidLoginMethods {
+		if method == SSHLoginMethodPassword {
+			continue
+		}
 		if !util.IsStringInSlice(method, u.Filters.DeniedLoginMethods) {
 			allowedMethods = append(allowedMethods, method)
 		}
@@ -1055,6 +1065,35 @@ func (u *User) CanDeleteFromWeb(target string) bool {
 	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.
 // It could change after an update
 func (u *User) GetSignature() string {
@@ -1437,6 +1476,8 @@ func (u *User) getACopy() User {
 	copy(filters.FilePatterns, u.Filters.FilePatterns)
 	filters.DeniedProtocols = make([]string, len(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.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled
 	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)
 }
 
+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) {
 	u := getTestUser()
 	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)
 
-				if dbUser.IsLoginMethodAllowed(dataprovider.LoginMethodTLSCertificate, nil) {
+				if dbUser.IsLoginMethodAllowed(dataprovider.LoginMethodTLSCertificate, common.ProtocolFTP, nil) {
 					connection, err := s.validateUser(dbUser, cc, dataprovider.LoginMethodTLSCertificate)
 
 					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)
 		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",
 			user.Username, loginMethod)
 		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 {
 		activeSessions := common.Connections.GetActiveSessions(user.Username)
 		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/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	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/coreos/go-oidc/v3 v3.1.0
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
@@ -41,7 +41,7 @@ require (
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.3.0
 	github.com/rs/zerolog v1.26.2-0.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/spf13/afero v1.8.1
 	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/iter v1.0.1 // 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/mattn/go-colorable v0.1.12 // 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.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.43.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/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=
@@ -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/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
 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-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/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 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/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/sftpgo/sdk v0.1.1-0.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/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
 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))
 			return
 		}
+		if claims.MustSetTwoFactorAuth {
+			// force logout
+			defer func() {
+				c := jwtTokenClaims{}
+				c.removeCookie(w, r, webBaseClientPath)
+			}()
+		}
 	} else {
 		if err := saveAdminTOTPConfig(claims.Username, r, recoveryCodes); err != nil {
 			sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -210,6 +217,15 @@ func saveUserTOTPConfig(username string, r *http.Request, recoveryCodes []datapr
 	if err != nil {
 		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() {
 		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))
 		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()
 	connection := &Connection{
 		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)
 		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)
 		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) {
 		return false
 	}
-	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
+	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolHTTP, nil) {
 		return false
 	}
 	if !user.IsLoginFromAddrAllowed(r.RemoteAddr) {

+ 33 - 10
httpd/auth_utils.go

@@ -28,11 +28,13 @@ 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 (
@@ -44,11 +46,13 @@ var (
 )
 
 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 {
@@ -67,6 +71,8 @@ func (c *jwtTokenClaims) asMap() map[string]interface{} {
 		claims[claimAPIKey] = c.APIKeyID
 	}
 	claims[jwt.SubjectKey] = c.Signature
+	claims[claimMustSetSecondFactorKey] = c.MustSetTwoFactorAuth
+	claims[claimRequiredTwoFactorProtocols] = c.RequiredTwoFactorProtocols
 
 	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 {

+ 169 - 5
httpd/httpd_test.go

@@ -989,7 +989,7 @@ func TestPermMFADisabled(t *testing.T) {
 	user.Filters.WebClient = []string{sdk.WebClientMFADisabled}
 	_, resp, err := httpdtest.UpdateUser(user, http.StatusBadRequest, "")
 	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["enabled"] = false
@@ -1027,6 +1027,90 @@ func TestPermMFADisabled(t *testing.T) {
 	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) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
@@ -1048,6 +1132,39 @@ func TestLoginUserAPITOTP(t *testing.T) {
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	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)
 	assert.NoError(t, err)
@@ -1070,8 +1187,8 @@ func TestLoginUserAPITOTP(t *testing.T) {
 	responseHolder := make(map[string]interface{})
 	err = render.DecodeJSON(resp.Body, &responseHolder)
 	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()
 	assert.NoError(t, err)
 
@@ -1543,7 +1660,11 @@ func TestAddUserInvalidFilters(t *testing.T) {
 	u.Filters.DeniedLoginMethods = dataprovider.ValidLoginMethods
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	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{
 		{
 			Path:            "relative",
@@ -9949,6 +10070,22 @@ func TestBrowseShares(t *testing.T) {
 	err = json.Unmarshal(rr.Body.Bytes(), &contents)
 	assert.NoError(t, err)
 	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)
 	assert.NoError(t, err)
@@ -14560,7 +14697,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	form.Set("pattern_path0", "/dir1")
 	form.Set("patterns0", "*.zip")
 	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("max_upload_file_size", "100")
 	form.Set("disconnect", "1")
@@ -17005,6 +17142,33 @@ func TestStaticFilesMock(t *testing.T) {
 	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() {
 	go func() {
 		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)
 	fn.ServeHTTP(rr, req.WithContext(ctx))
 	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) {

+ 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 {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		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),
 ) {
 	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
@@ -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) {
 	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)
@@ -1241,14 +1245,14 @@ func (s *httpdServer) initializeRouter() {
 		router.Use(jwtAuthenticatorAPIUser)
 
 		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).Put(userProfilePath, updateUserProfile)
+		router.With(forbidAPIKeyAuthentication, checkSecondFactorRequirement).Put(userProfilePath, updateUserProfile)
 		// user TOTP APIs
 		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
 			Get(userTOTPConfigsPath, getTOTPConfigs)
@@ -1264,25 +1268,38 @@ func (s *httpdServer) initializeRouter() {
 			Post(user2FARecoveryCodesPath, generateRecoveryCodes)
 
 		// 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 {
@@ -1368,29 +1385,33 @@ func (s *httpdServer) setupWebClientRoutes() {
 			router.Use(jwtAuthenticatorWebClient)
 
 			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)
-			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)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				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)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Patch(webClientDirsPath, renameUserDir)
-			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				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)
-			router.With(checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
 				Post(webChangeClientPwdPath, s.handleWebClientChangePwdPost)
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie).
 				Get(webClientMFAPath, handleWebClientMFA)
@@ -1404,17 +1425,17 @@ func (s *httpdServer) setupWebClientRoutes() {
 				Get(webClientRecoveryCodesPath, getRecoveryCodes)
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
 				Post(webClientRecoveryCodesPath, generateRecoveryCodes)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
 				Get(webClientSharesPath, handleClientGetShares)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
 				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)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
 				Post(webClientSharePath+"/{id}", handleClientUpdateSharePost)
-			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), verifyCSRFHeader).
+			router.With(checkSecondFactorRequirement, checkHTTPUserPerm(sdk.WebClientSharesDisabled), verifyCSRFHeader).
 				Delete(webClientSharePath+"/{id}", deleteShare)
 		})
 	}

+ 28 - 25
httpd/webadmin.go

@@ -153,19 +153,20 @@ type fsWrapper struct {
 
 type userPage struct {
 	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 {
@@ -606,17 +607,18 @@ func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.U
 	}
 	user.FsConfig.RedactedSecret = redactedSecret
 	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{
 			Filesystem:      user.FsConfig,
 			IsUserPage:      true,
@@ -930,8 +932,9 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
 	filters.DataTransferLimits = dtLimits
 	filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_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.TwoFactorAuthProtocols = r.Form["required_two_factor_protocols"]
 	filters.FilePatterns = getFilePatternsFromPostField(r)
 	filters.TLSUsername = sdk.TLSUsername(r.Form.Get("tls_username"))
 	filters.WebClient = r.Form["web_client_options"]

+ 101 - 101
logger/logger.go

@@ -41,116 +41,15 @@ var (
 	rollingLogger *lumberjack.Logger
 )
 
-// StdLoggerWrapper is a wrapper for standard logger compatibility
-type StdLoggerWrapper struct {
-	Sender string
-}
-
 func init() {
 	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
 func GetLogger() *zerolog.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
 func InitLogger(logFilePath string, logMaxSize int, logMaxBackups int, logMaxAge int, logCompress, logUTCTime bool,
 	level zerolog.Level,
@@ -215,6 +114,17 @@ func RotateLogFile() error {
 	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
 func Log(level LogLevel, sender string, connectionID string, format string, v ...interface{}) {
 	var ev *zerolog.Event
@@ -341,3 +251,93 @@ func isLogFilePathValid(logFilePath string) bool {
 	}
 	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:
         - publickey
         - password
+        - password-over-SSH
         - keyboard-interactive
         - publickey+password
         - publickey+keyboard-interactive
@@ -4316,7 +4317,8 @@ components:
       description: |
         Available login methods. To enable multi-step authentication you have to allow only multi-step login methods
           * `publickey`
-          * `password`
+          * `password`, password for all the supported protocols
+          * `password-over-SSH`, password over SSH protocol (SSH/SFTP/SCP)
           * `keyboard-interactive`
           * `publickey+password` - multi-step auth: public key and password
           * `publickey+keyboard-interactive` - multi-step auth: public key and keyboard interactive
@@ -4682,6 +4684,11 @@ components:
         start_directory:
           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.'
+        2fa_protocols:
+          type: array
+          items:
+            $ref: '#/components/schemas/MFAProtocols'
+          description: 'Defines protocols that require two factor authentication'
       description: Additional user options
     Secret:
       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)
 		}
 	}
-	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",
 			user.Username, loginMethod)
 		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()
 	if !user.IsLoginFromAddrAllowed(remoteAddr) {
 		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)
 		return nil, err
 	}
-	if len(publicKey) > 0 {
+	if publicKey != "" {
 		loginMethod = fmt.Sprintf("%v: %v", loginMethod, publicKey)
 	}
 	p := &ssh.Permissions{}

+ 55 - 7
sftpd/sftpd_test.go

@@ -2497,6 +2497,41 @@ func TestInteractiveLoginWithPasscode(t *testing.T) {
 	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) {
 	err := dataprovider.Close()
 	assert.NoError(t, err)
@@ -7830,18 +7865,31 @@ func TestUserIsLoginMethodAllowed(t *testing.T) {
 		dataprovider.SSHLoginMethodPublicKey,
 		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{
 		dataprovider.SSHLoginMethodPublicKey,
 		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) {

+ 16 - 1
templates/webadmin/user.html

@@ -482,12 +482,27 @@
                             <div class="form-group row">
                                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                                 <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}}
                                         <option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
                                         </option>
                                         {{end}}
                                     </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>
 

+ 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)
 		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",
 			user.Username, loginMethod)
 		return connID, fmt.Errorf("login method %v is not allowed for user %#v", loginMethod, user.Username)