浏览代码

WebClient: remove data schema usage from mfa page

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 1 年之前
父节点
当前提交
ac309cf9a3

+ 3 - 3
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

+ 0 - 3
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 {

+ 6 - 6
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)

+ 1 - 1
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)

+ 21 - 3
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)

+ 48 - 34
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)

+ 3 - 0
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)

+ 23 - 4
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

+ 30 - 11
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)
 }
 

+ 4 - 4
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() {

+ 12 - 12
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++ {

+ 2 - 0
openapi/openapi.yaml

@@ -635,6 +635,8 @@ paths:
                     type: string
                   secret:
                     type: string
+                  url:
+                    type: string
                   qr_code:
                     type: string
                     format: byte

+ 6 - 0
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){

+ 1 - 3
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");

+ 8 - 3
templates/webclient/mfa.html

@@ -217,8 +217,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
                 <div class="text-gray-700 fw-semibold fs-6 mb-10">
                     Use your preferred Authenticator App (e.g. Microsoft Authenticator, Google Authenticator, Authy, 1Password etc. ) to scan the QR code. It will generate an authentication code for you to enter below.
                 </div>
-                <div class="pt-5 text-center">
-                    <img id="id_qr_code" src="data:image/gif;base64, R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="QR code" class="mw-150px"/>
+                <div id="id_qr_code_container" class="pt-5 text-center">
                 </div>
                 <div class="notice d-flex bg-light-warning rounded border-warning border border-dashed my-10 p-6">
                     <i class="ki-duotone ki-information fs-2tx text-warning me-4">
@@ -520,9 +519,15 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
                     return;
                 }
                 $('#id_secret').text(response.data.secret);
-                $('#id_qr_code').attr('src', 'data:image/png;base64, '+ response.data.qr_code);
                 $('#errorModalMsg').addClass("d-none");
                 $('#id_passcode').val("");
+                let qrCodeContainer = document.getElementById("id_qr_code_container");
+                clearChilds(qrCodeContainer);
+                let qrCodeImg = document.createElement("img");
+                qrCodeImg.classList.add("mw-150px");
+                qrCodeImg.src = "{{.MFAURL}}/qrcode?url="+encodeURIComponent(response.data.url);
+                qrCodeImg.alt = "QR code";
+                qrCodeContainer.appendChild(qrCodeImg);
                 qrModal.show();
             }).catch(function (error){
                 el.removeAttribute('data-kt-indicator');