From d8de0faef5b89c610f84e10e892b2ad0f5ad5844 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 6 Mar 2022 16:57:13 +0100 Subject: [PATCH] allow to require two-factor auth for users Fixes #721 Signed-off-by: Nicola Murino --- dataprovider/dataprovider.go | 46 ++++---- dataprovider/user.go | 45 +++++++- ftpd/ftpd_test.go | 38 +++++++ ftpd/server.go | 9 +- go.mod | 6 +- go.sum | 11 +- httpd/api_mfa.go | 16 +++ httpd/api_shares.go | 5 + httpd/api_utils.go | 4 +- httpd/auth_utils.go | 43 ++++++-- httpd/httpd_test.go | 174 +++++++++++++++++++++++++++++- httpd/internal_test.go | 15 +++ httpd/middleware.go | 28 +++++ httpd/server.go | 129 ++++++++++++---------- httpd/webadmin.go | 53 ++++----- logger/logger.go | 202 +++++++++++++++++------------------ openapi/openapi.yaml | 9 +- sftpd/server.go | 9 +- sftpd/sftpd_test.go | 62 +++++++++-- templates/webadmin/user.html | 17 ++- webdavd/server.go | 2 +- 21 files changed, 683 insertions(+), 240 deletions(-) diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index c252d1b7..1b234ea0 100644 --- a/dataprovider/dataprovider.go +++ b/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 diff --git a/dataprovider/user.go b/dataprovider/user.go index 0fc6ea41..d3ae61cf 100644 --- a/dataprovider/user.go +++ b/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 diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index 143838ac..72b2c0ee 100644 --- a/ftpd/ftpd_test.go +++ b/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) diff --git a/ftpd/server.go b/ftpd/server.go index 8bb40ed7..d2a03958 100644 --- a/ftpd/server.go +++ b/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 { diff --git a/go.mod b/go.mod index a8dc709d..c2e925c8 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 7c661e4f..373be36b 100644 --- a/go.sum +++ b/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= diff --git a/httpd/api_mfa.go b/httpd/api_mfa.go index a72eb09e..d46edff0 100644 --- a/httpd/api_mfa.go +++ b/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 } diff --git a/httpd/api_shares.go b/httpd/api_shares.go index c13249d9..83dc78bf 100644 --- a/httpd/api_shares.go +++ b/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), diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 6749fda6..4dabf0a1 100644 --- a/httpd/api_utils.go +++ b/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) { diff --git a/httpd/auth_utils.go b/httpd/auth_utils.go index 2b13855b..93ef43df 100644 --- a/httpd/auth_utils.go +++ b/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 { diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 63d8b0b0..ef34aec1 100644 --- a/httpd/httpd_test.go +++ b/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) { diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 834e8644..96ceaffa 100644 --- a/httpd/internal_test.go +++ b/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) { diff --git a/httpd/middleware.go b/httpd/middleware.go index 9408c0d7..d6b024eb 100644 --- a/httpd/middleware.go +++ b/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) { diff --git a/httpd/server.go b/httpd/server.go index 831f813c..8a8e2b62 100644 --- a/httpd/server.go +++ b/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(checkSecondFactorRequirement, compressor.Handler).Get(userFolderPath, readUserFolder) + router.With(checkSecondFactorRequirement).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(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) }) } diff --git a/httpd/webadmin.go b/httpd/webadmin.go index 019d4fde..494991f2 100644 --- a/httpd/webadmin.go +++ b/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"] diff --git a/logger/logger.go b/logger/logger.go index 51800323..52d6e64a 100644 --- a/logger/logger.go +++ b/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...), + } +} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 31468d4e..4f8220e5 100644 --- a/openapi/openapi.yaml +++ b/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 diff --git a/sftpd/server.go b/sftpd/server.go index 77d933d4..b8f62005 100644 --- a/sftpd/server.go +++ b/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{} diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 10f662af..5aa1adda 100644 --- a/sftpd/sftpd_test.go +++ b/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) { diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index 94038fd9..0ad00869 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -482,12 +482,27 @@
- {{range $method := .ValidLoginMethods}} {{end}} + + "password" is valid for all supported protocols, "password-over-SSH" only for SSH/SFTP/SCP + +
+
+ +
+ +
+
diff --git a/webdavd/server.go b/webdavd/server.go index 981be240..01f8eacc 100644 --- a/webdavd/server.go +++ b/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)