diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index a4ff236f..94c5ae8c 100644 --- a/internal/common/protocol_test.go +++ b/internal/common/protocol_test.go @@ -7767,18 +7767,18 @@ func TestBuiltinKeyboardInteractiveAuthentication(t *testing.T) { assert.NoError(t, err) } // add multi-factor authentication - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, 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), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH}, } err = dataprovider.UpdateUser(&user, "", "", "") assert.NoError(t, err) - passcode, err := generateTOTPPasscode(secret, otp.AlgorithmSHA1) + passcode, err := generateTOTPPasscode(key.Secret(), otp.AlgorithmSHA1) assert.NoError(t, err) passwordAsked := false passcodeAsked := false diff --git a/internal/ftpd/ftpd.go b/internal/ftpd/ftpd.go index 2ee6e702..17f2ef96 100644 --- a/internal/ftpd/ftpd.go +++ b/internal/ftpd/ftpd.go @@ -116,9 +116,6 @@ type Binding struct { func (b *Binding) setCiphers() { b.ciphers = util.GetTLSCiphersFromNames(b.TLSCipherSuites) - if len(b.ciphers) == 0 { - b.ciphers = nil - } } func (b *Binding) isMutualTLSEnabled() bool { diff --git a/internal/ftpd/ftpd_test.go b/internal/ftpd/ftpd_test.go index 332ef5db..9c00df78 100644 --- a/internal/ftpd/ftpd_test.go +++ b/internal/ftpd/ftpd_test.go @@ -1064,13 +1064,13 @@ func TestMultiFactorAuth(t *testing.T) { user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, 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), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolFTP}, } err = dataprovider.UpdateUser(&user, "", "", "") @@ -1081,7 +1081,7 @@ func TestMultiFactorAuth(t *testing.T) { if assert.Error(t, err) { assert.Contains(t, err.Error(), dataprovider.ErrInvalidCredentials.Error()) } - passcode, err := generateTOTPPasscode(secret, otp.AlgorithmSHA1) + passcode, err := generateTOTPPasscode(key.Secret(), otp.AlgorithmSHA1) assert.NoError(t, err) user.Password = defaultPassword + passcode client, err := getFTPClient(user, true, nil) @@ -1138,18 +1138,18 @@ func TestSecondFactorRequirement(t *testing.T) { assert.Contains(t, err.Error(), "second factor authentication is not set") } - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, 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), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolFTP}, } err = dataprovider.UpdateUser(&user, "", "", "") assert.NoError(t, err) - passcode, err := generateTOTPPasscode(secret, otp.AlgorithmSHA1) + passcode, err := generateTOTPPasscode(key.Secret(), otp.AlgorithmSHA1) assert.NoError(t, err) user.Password = defaultPassword + passcode client, err := getFTPClient(user, true, nil) diff --git a/internal/ftpd/internal_test.go b/internal/ftpd/internal_test.go index 1dbaee6a..f31cb2d7 100644 --- a/internal/ftpd/internal_test.go +++ b/internal/ftpd/internal_test.go @@ -1016,7 +1016,7 @@ func TestCiphers(t *testing.T) { TLSCipherSuites: []string{}, } b.setCiphers() - require.Nil(t, b.ciphers) + require.Equal(t, util.GetTLSCiphersFromNames(nil), b.ciphers) b.TLSCipherSuites = []string{"TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384"} b.setCiphers() require.Len(t, b.ciphers, 2) diff --git a/internal/httpd/api_mfa.go b/internal/httpd/api_mfa.go index a42f2b91..6da1c8a5 100644 --- a/internal/httpd/api_mfa.go +++ b/internal/httpd/api_mfa.go @@ -15,9 +15,12 @@ package httpd import ( + "bytes" "errors" "fmt" + "io" "net/http" + "strconv" "strings" "github.com/go-chi/render" @@ -40,6 +43,7 @@ type generateTOTPResponse struct { ConfigName string `json:"config_name"` Issuer string `json:"issuer"` Secret string `json:"secret"` + URL string `json:"url"` QRCode []byte `json:"qr_code"` } @@ -79,19 +83,33 @@ func generateTOTPSecret(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "", http.StatusBadRequest) return } - configName, issuer, secret, qrCode, err := mfa.GenerateTOTPSecret(req.ConfigName, accountName) + configName, key, qrCode, err := mfa.GenerateTOTPSecret(req.ConfigName, accountName) if err != nil { sendAPIResponse(w, r, err, "", http.StatusBadRequest) return } render.JSON(w, r, generateTOTPResponse{ ConfigName: configName, - Issuer: issuer, - Secret: secret, + Issuer: key.Issuer(), + Secret: key.Secret(), + URL: key.URL(), QRCode: qrCode, }) } +func getQRCode(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + img, err := mfa.GenerateQRCodeFromURL(r.URL.Query().Get("url"), 400, 400) + if err != nil { + sendAPIResponse(w, r, nil, "unable to generate qr code", http.StatusInternalServerError) + return + } + imgSize := int64(len(img)) + w.Header().Set("Content-Length", strconv.FormatInt(imgSize, 10)) + w.Header().Set("Content-Type", "image/png") + io.CopyN(w, bytes.NewBuffer(img), imgSize) //nolint:errcheck +} + func saveTOTPConfig(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) claims, err := getTokenClaims(r) diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 4cf03607..c937facd 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -3016,14 +3016,14 @@ func TestPermMFADisabled(t *testing.T) { user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) assert.NoError(t, err) token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) userTOTPConfig := dataprovider.UserTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH}, } asJSON, err := json.Marshal(userTOTPConfig) @@ -3309,12 +3309,12 @@ func TestTwoFactorRequirements(t *testing.T) { 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) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) assert.NoError(t, err) userTOTPConfig := dataprovider.UserTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolHTTP}, } asJSON, err := json.Marshal(userTOTPConfig) @@ -3335,7 +3335,7 @@ func TestTwoFactorRequirements(t *testing.T) { 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) + passcode, err := generateTOTPPasscode(key.Secret()) assert.NoError(t, err) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) assert.NoError(t, err) @@ -3371,14 +3371,14 @@ func TestLoginUserAPITOTP(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) assert.NoError(t, err) token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) userTOTPConfig := dataprovider.UserTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolHTTP}, } asJSON, err := json.Marshal(userTOTPConfig) @@ -3431,7 +3431,7 @@ func TestLoginUserAPITOTP(t *testing.T) { err = resp.Body.Close() assert.NoError(t, err) - passcode, err := generateTOTPPasscode(secret) + passcode, err := generateTOTPPasscode(key.Secret()) assert.NoError(t, err) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) assert.NoError(t, err) @@ -3471,14 +3471,14 @@ func TestLoginAdminAPITOTP(t *testing.T) { admin, _, err := httpdtest.AddAdmin(admin, http.StatusCreated) assert.NoError(t, err) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], admin.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], admin.Username) assert.NoError(t, err) altToken, err := getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword) assert.NoError(t, err) adminTOTPConfig := dataprovider.AdminTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), } asJSON, err := json.Marshal(adminTOTPConfig) assert.NoError(t, err) @@ -3512,7 +3512,7 @@ func TestLoginAdminAPITOTP(t *testing.T) { err = resp.Body.Close() assert.NoError(t, err) - passcode, err := generateTOTPPasscode(secret) + passcode, err := generateTOTPPasscode(key.Secret()) assert.NoError(t, err) req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil) assert.NoError(t, err) @@ -6266,12 +6266,12 @@ func TestAdminGenerateRecoveryCodesSaveError(t *testing.T) { a.Username = "adMiN@example.com " admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated) assert.NoError(t, err) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], admin.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], admin.Username) assert.NoError(t, err) admin.Filters.TOTPConfig = dataprovider.AdminTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), } admin.Password = defaultTokenAuthPass err = dataprovider.UpdateAdmin(&admin, "", "", "") @@ -6280,7 +6280,7 @@ func TestAdminGenerateRecoveryCodesSaveError(t *testing.T) { assert.NoError(t, err) assert.True(t, admin.Filters.TOTPConfig.Enabled) - passcode, err := generateTOTPPasscode(secret) + passcode, err := generateTOTPPasscode(key.Secret()) assert.NoError(t, err) adminAPIToken, err := getJWTAPITokenFromTestServerWithPasscode(a.Username, defaultTokenAuthPass, passcode) assert.NoError(t, err) @@ -6332,12 +6332,12 @@ func TestNamingRules(t *testing.T) { user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) assert.Equal(t, "user@user.me", user.Username) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) assert.NoError(t, err) user.Filters.TOTPConfig = dataprovider.UserTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH}, } user.Password = u.Password @@ -6626,13 +6626,13 @@ func TestSaveErrors(t *testing.T) { assert.NoError(t, err) user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) assert.NoError(t, err) user.Password = u.Password user.Filters.TOTPConfig = dataprovider.UserTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH, common.ProtocolHTTP}, } user.Filters.RecoveryCodes = recoveryCodes @@ -6654,7 +6654,7 @@ func TestSaveErrors(t *testing.T) { admin.Filters.TOTPConfig = dataprovider.AdminTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), } admin.Filters.RecoveryCodes = recoveryCodes err = dataprovider.UpdateAdmin(&admin, "", "", "") @@ -8764,14 +8764,14 @@ func TestAdminTwoFactorLogin(t *testing.T) { admin, _, err := httpdtest.AddAdmin(admin, http.StatusCreated) assert.NoError(t, err) // enable two factor authentication - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], admin.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], admin.Username) assert.NoError(t, err) altToken, err := getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword) assert.NoError(t, err) adminTOTPConfig := dataprovider.AdminTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), } asJSON, err := json.Marshal(adminTOTPConfig) assert.NoError(t, err) @@ -8876,7 +8876,7 @@ func TestAdminTwoFactorLogin(t *testing.T) { checkResponseCode(t, http.StatusFound, rr) assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) - passcode, err := generateTOTPPasscode(secret) + passcode, err := generateTOTPPasscode(key.Secret()) assert.NoError(t, err) form = make(url.Values) form.Set("passcode", passcode) @@ -9481,7 +9481,7 @@ func TestWebUserTwoFactorLogin(t *testing.T) { user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) // enable two factor authentication - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) assert.NoError(t, err) token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) @@ -9493,7 +9493,7 @@ func TestWebUserTwoFactorLogin(t *testing.T) { userTOTPConfig := dataprovider.UserTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolHTTP}, } asJSON, err := json.Marshal(userTOTPConfig) @@ -9593,7 +9593,7 @@ func TestWebUserTwoFactorLogin(t *testing.T) { checkResponseCode(t, http.StatusFound, rr) assert.Equal(t, webLoginPath, rr.Header().Get("Location")) - passcode, err := generateTOTPPasscode(secret) + passcode, err := generateTOTPPasscode(key.Secret()) assert.NoError(t, err) form = make(url.Values) form.Set("passcode", passcode) @@ -9698,7 +9698,21 @@ func TestWebUserTwoFactorLogin(t *testing.T) { setJWTCookieForReq(req, authenticatedCookie) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) - + // get MFA qrcode + req, err = http.NewRequest(http.MethodGet, path.Join(webClientMFAPath, "qrcode?url="+url.QueryEscape(key.URL())), nil) + assert.NoError(t, err) + req.RemoteAddr = defaultRemoteAddr + setJWTCookieForReq(req, authenticatedCookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, "image/png", rr.Header().Get("Content-Type")) + // invalid MFA url + req, err = http.NewRequest(http.MethodGet, path.Join(webClientMFAPath, "qrcode?url="+url.QueryEscape("http://foo\x7f.eu")), nil) + assert.NoError(t, err) + req.RemoteAddr = defaultRemoteAddr + setJWTCookieForReq(req, authenticatedCookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) // check that the recovery code was marked as used req, err = http.NewRequest(http.MethodGet, user2FARecoveryCodesPath, nil) assert.NoError(t, err) @@ -9827,7 +9841,7 @@ func TestWebUserTwoFactorLogin(t *testing.T) { func TestWebUserTwoFactoryLoginRedirect(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) assert.NoError(t, err) token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) @@ -9835,7 +9849,7 @@ func TestWebUserTwoFactoryLoginRedirect(t *testing.T) { userTOTPConfig := dataprovider.UserTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolHTTP}, } asJSON, err := json.Marshal(userTOTPConfig) @@ -9890,7 +9904,7 @@ func TestWebUserTwoFactoryLoginRedirect(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), fmt.Sprintf("action=%q", expectedURI)) // login with the passcode - passcode, err := generateTOTPPasscode(secret) + passcode, err := generateTOTPPasscode(key.Secret()) assert.NoError(t, err) form = make(url.Values) form.Set("passcode", passcode) @@ -15432,7 +15446,7 @@ func TestWebEditFile(t *testing.T) { testFile1 := "testfile1.txt" testFile2 := "testfile2" file1Size := int64(65536) - file2Size := int64(1048576 * 2) + file2Size := int64(1048576 * 5) err = createTestFile(filepath.Join(user.GetHomeDir(), testFile1), file1Size) assert.NoError(t, err) err = createTestFile(filepath.Join(user.GetHomeDir(), testFile2), file2Size) @@ -19110,14 +19124,14 @@ func TestWebAdminBasicMock(t *testing.T) { checkResponseCode(t, http.StatusSeeOther, rr) // add TOTP config - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], altAdminUsername) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], altAdminUsername) assert.NoError(t, err) altToken, err := getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword) assert.NoError(t, err) adminTOTPConfig := dataprovider.AdminTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), } asJSON, err := json.Marshal(adminTOTPConfig) assert.NoError(t, err) @@ -20093,14 +20107,14 @@ func TestWebUserUpdateMock(t *testing.T) { lastPwdChange := user.LastPasswordChange assert.Greater(t, lastPwdChange, int64(0)) // add TOTP config - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) assert.NoError(t, err) userToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) userTOTPConfig := dataprovider.UserTOTPConfig{ Enabled: true, ConfigName: configName, - Secret: kms.NewPlainSecret(secret), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH, common.ProtocolFTP}, } asJSON, err := json.Marshal(userTOTPConfig) diff --git a/internal/httpd/server.go b/internal/httpd/server.go index e107eb67..cf40f90d 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -1571,6 +1571,8 @@ func (s *httpdServer) setupWebClientRoutes() { Post(webChangeClientPwdPath, s.handleWebClientChangePwdPost) router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie). Get(webClientMFAPath, s.handleWebClientMFA) + router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie). + Get(webClientMFAPath+"/qrcode", getQRCode) router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader). Post(webClientTOTPGeneratePath, generateTOTPSecret) router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader). @@ -1644,6 +1646,7 @@ func (s *httpdServer) setupWebAdminRoutes() { router.With(s.requireBuiltinLogin).Post(webChangeAdminPwdPath, s.handleWebAdminChangePwdPost) router.With(s.refreshCookie, s.requireBuiltinLogin).Get(webAdminMFAPath, s.handleWebAdminMFA) + router.With(s.refreshCookie, s.requireBuiltinLogin).Get(webAdminMFAPath+"/qrcode", getQRCode) router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPGeneratePath, generateTOTPSecret) router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPValidatePath, validateTOTPPasscode) router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPSavePath, saveTOTPConfig) diff --git a/internal/mfa/mfa.go b/internal/mfa/mfa.go index 3eea299c..23c7097b 100644 --- a/internal/mfa/mfa.go +++ b/internal/mfa/mfa.go @@ -16,8 +16,12 @@ package mfa import ( + "bytes" "fmt" + "image/png" "time" + + "github.com/pquerna/otp" ) var ( @@ -94,15 +98,30 @@ func ValidateTOTPPasscode(configName, passcode, secret string) (bool, error) { // GenerateTOTPSecret generates a new TOTP secret and QR code for the given username // using the configuration with configName -func GenerateTOTPSecret(configName, username string) (string, string, string, []byte, error) { +func GenerateTOTPSecret(configName, username string) (string, *otp.Key, []byte, error) { for _, config := range totpConfigs { if config.Name == configName { - issuer, secret, qrCode, err := config.generate(username, 200, 200) - return configName, issuer, secret, qrCode, err + key, qrCode, err := config.generate(username, 200, 200) + return configName, key, qrCode, err } } - return "", "", "", nil, fmt.Errorf("totp: no configuration %q", configName) + return "", nil, nil, fmt.Errorf("totp: no configuration %q", configName) +} + +// GenerateQRCodeFromURL generates a QR code from a TOTP URL +func GenerateQRCodeFromURL(url string, width, height int) ([]byte, error) { + key, err := otp.NewKeyFromURL(url) + if err != nil { + return nil, err + } + var buf bytes.Buffer + img, err := key.Image(width, height) + if err != nil { + return nil, err + } + err = png.Encode(&buf, img) + return buf.Bytes(), err } // the ticker cannot be started/stopped from multiple goroutines diff --git a/internal/mfa/mfa_test.go b/internal/mfa/mfa_test.go index 6be7d025..4d8958a1 100644 --- a/internal/mfa/mfa_test.go +++ b/internal/mfa/mfa_test.go @@ -21,6 +21,7 @@ import ( "github.com/pquerna/otp" "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMFAConfig(t *testing.T) { @@ -76,38 +77,56 @@ func TestMFAConfig(t *testing.T) { assert.Equal(t, configName3, status.TOTPConfigs[2].Name) } // now generate some secrets and validate some passcodes - _, _, _, _, err = GenerateTOTPSecret("", "") //nolint:dogsled + _, _, _, err = GenerateTOTPSecret("", "") //nolint:dogsled assert.Error(t, err) match, err := ValidateTOTPPasscode("", "", "") assert.Error(t, err) assert.False(t, match) - cfgName, _, secret, _, err := GenerateTOTPSecret(configName1, "user1") + cfgName, key, _, err := GenerateTOTPSecret(configName1, "user1") assert.NoError(t, err) - assert.NotEmpty(t, secret) + assert.NotEmpty(t, key.Secret()) assert.Equal(t, configName1, cfgName) - passcode, err := generatePasscode(secret, otp.AlgorithmSHA1) + passcode, err := generatePasscode(key.Secret(), otp.AlgorithmSHA1) assert.NoError(t, err) - match, err = ValidateTOTPPasscode(configName1, passcode, secret) + match, err = ValidateTOTPPasscode(configName1, passcode, key.Secret()) assert.NoError(t, err) assert.True(t, match) - match, err = ValidateTOTPPasscode(configName1, passcode, secret) + match, err = ValidateTOTPPasscode(configName1, passcode, key.Secret()) assert.ErrorIs(t, err, errPasscodeUsed) assert.False(t, match) - passcode, err = generatePasscode(secret, otp.AlgorithmSHA256) + passcode, err = generatePasscode(key.Secret(), otp.AlgorithmSHA256) assert.NoError(t, err) // config1 uses sha1 algo - match, err = ValidateTOTPPasscode(configName1, passcode, secret) + match, err = ValidateTOTPPasscode(configName1, passcode, key.Secret()) assert.NoError(t, err) assert.False(t, match) // config3 use the expected algo - match, err = ValidateTOTPPasscode(configName3, passcode, secret) + match, err = ValidateTOTPPasscode(configName3, passcode, key.Secret()) assert.NoError(t, err) assert.True(t, match) stopCleanupTicker() } +func TestGenerateQRCodeFromURL(t *testing.T) { + _, err := GenerateQRCodeFromURL("http://foo\x7f.cloud", 200, 200) + assert.Error(t, err) + config := TOTPConfig{ + Name: "config name", + Issuer: "SFTPGo", + Algo: TOTPAlgoSHA256, + } + key, qrCode, err := config.generate("a", 150, 150) + require.NoError(t, err) + + qrCode1, err := GenerateQRCodeFromURL(key.URL(), 150, 150) + require.NoError(t, err) + assert.Equal(t, qrCode, qrCode1) + _, err = GenerateQRCodeFromURL(key.URL(), 10, 10) + assert.Error(t, err) +} + func TestCleanupPasscodes(t *testing.T) { usedPasscodes.Store("key", time.Now().Add(-24*time.Hour).UTC()) startCleanupTicker(30 * time.Millisecond) @@ -125,11 +144,11 @@ func TestTOTPGenerateErrors(t *testing.T) { algo: otp.AlgorithmSHA1, } // issuer cannot be empty - _, _, _, err := config.generate("username", 200, 200) //nolint:dogsled + _, _, err := config.generate("username", 200, 200) //nolint:dogsled assert.Error(t, err) config.Issuer = "issuer" // we cannot encode an image smaller than 45x45 - _, _, _, err = config.generate("username", 30, 30) //nolint:dogsled + _, _, err = config.generate("username", 30, 30) //nolint:dogsled assert.Error(t, err) } diff --git a/internal/mfa/totp.go b/internal/mfa/totp.go index db42fb78..4bf6b340 100644 --- a/internal/mfa/totp.go +++ b/internal/mfa/totp.go @@ -90,7 +90,7 @@ func (c *TOTPConfig) validatePasscode(passcode, secret string) (bool, error) { } // generate generates a new TOTP secret and QR code for the given username -func (c *TOTPConfig) generate(username string, qrCodeWidth, qrCodeHeight int) (string, string, []byte, error) { +func (c *TOTPConfig) generate(username string, qrCodeWidth, qrCodeHeight int) (*otp.Key, []byte, error) { key, err := totp.Generate(totp.GenerateOpts{ Issuer: c.Issuer, AccountName: username, @@ -98,15 +98,15 @@ func (c *TOTPConfig) generate(username string, qrCodeWidth, qrCodeHeight int) (s Algorithm: c.algo, }) if err != nil { - return "", "", nil, err + return nil, nil, err } var buf bytes.Buffer img, err := key.Image(qrCodeWidth, qrCodeHeight) if err != nil { - return "", "", nil, err + return nil, nil, err } err = png.Encode(&buf, img) - return key.Issuer(), key.Secret(), buf.Bytes(), err + return key, buf.Bytes(), err } func cleanupUsedPasscodes() { diff --git a/internal/sftpd/sftpd_test.go b/internal/sftpd/sftpd_test.go index 5812f23e..eb462335 100644 --- a/internal/sftpd/sftpd_test.go +++ b/internal/sftpd/sftpd_test.go @@ -2811,19 +2811,19 @@ func TestInteractiveLoginWithPasscode(t *testing.T) { _, _, err = getKeyboardInteractiveSftpClient(user, []string{"wrong_password"}) assert.Error(t, err) // add multi-factor authentication - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, 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), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH}, } err = dataprovider.UpdateUser(&user, "", "", "") assert.NoError(t, err) - passcode, err := totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{ + passcode, err := totp.GenerateCodeCustom(key.Secret(), time.Now(), totp.ValidateOpts{ Period: 30, Skew: 1, Digits: otp.DigitsSix, @@ -2860,18 +2860,18 @@ func TestInteractiveLoginWithPasscode(t *testing.T) { _, _, err = getCustomAuthSftpClient(user, authMethods, "") assert.Error(t, err) // correct passcode but the script returns an error - configName, _, secret, _, err = mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, 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), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH}, } err = dataprovider.UpdateUser(&user, "", "", "") assert.NoError(t, err) - passcode, err = totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{ + passcode, err = totp.GenerateCodeCustom(key.Secret(), time.Now(), totp.ValidateOpts{ Period: 30, Skew: 1, Digits: otp.DigitsSix, @@ -2950,13 +2950,13 @@ func TestSecondFactorRequirement(t *testing.T) { _, _, err = getSftpClient(user, usePubKey) assert.Error(t, err) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, 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), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH}, } err = dataprovider.UpdateUser(&user, "", "", "") @@ -3159,13 +3159,13 @@ func TestPreLoginHookPreserveMFAConfig(t *testing.T) { assert.NoError(t, err) assert.Len(t, user.Filters.RecoveryCodes, 0) assert.False(t, user.Filters.TOTPConfig.Enabled) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, 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), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH}, } for i := 0; i < 12; i++ { @@ -4235,13 +4235,13 @@ func TestExternalAuthPreserveMFAConfig(t *testing.T) { assert.NoError(t, err) assert.Len(t, user.Filters.RecoveryCodes, 0) assert.False(t, user.Filters.TOTPConfig.Enabled) - configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + configName, key, _, 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), + Secret: kms.NewPlainSecret(key.Secret()), Protocols: []string{common.ProtocolSSH}, } for i := 0; i < 12; i++ { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 2f87c60b..915ccba5 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -635,6 +635,8 @@ paths: type: string secret: type: string + url: + type: string qr_code: type: string format: byte diff --git a/templates/common/base.html b/templates/common/base.html index c2ddf5fe..fe87ea06 100644 --- a/templates/common/base.html +++ b/templates/common/base.html @@ -151,6 +151,12 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). }); } + function clearChilds(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } + } + KTUtil.onDOMContentLoaded(function () { var dismissErrorBtn = $('#id_dismiss_error_msg'); if (dismissErrorBtn){ diff --git a/templates/webclient/files.html b/templates/webclient/files.html index 03d7fecc..46a4923c 100644 --- a/templates/webclient/files.html +++ b/templates/webclient/files.html @@ -285,9 +285,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). dt.on('draw', function () { let dirBrowserNav = document.getElementById("dirs_browser_nav"); - while (dirBrowserNav.firstChild) { - dirBrowserNav.removeChild(dirBrowserNav.firstChild); - } + clearChilds(dirBrowserNav); let mainNavIcon = document.createElement("i"); mainNavIcon.classList.add("ki-duotone", "ki-home", "fs-1", "text-primary", "me-3"); let mainNavLink = document.createElement("a"); diff --git a/templates/webclient/mfa.html b/templates/webclient/mfa.html index 46881a50..d3052a5e 100644 --- a/templates/webclient/mfa.html +++ b/templates/webclient/mfa.html @@ -217,8 +217,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).