Browse Source

add builtin two-factor auth support

The builtin two-factor authentication is based on time-based one time
passwords (RFC 6238) which works with Authy, Google Authenticator and
other compatible apps.
Nicola Murino 3 years ago
parent
commit
8a4c21b64a
52 changed files with 6032 additions and 598 deletions
  1. 1 1
      .github/FUNDING.yml
  2. 3 2
      .github/workflows/development.yml
  3. 1 0
      README.md
  4. 6 0
      cmd/startsubsys.go
  5. 3 2
      common/connection.go
  6. 102 0
      common/protocol_test.go
  7. 66 13
      config/config.go
  8. 56 0
      config/config_test.go
  9. 136 11
      dataprovider/admin.go
  10. 168 11
      dataprovider/dataprovider.go
  11. 1 0
      dataprovider/sqlcommon.go
  12. 43 2
      dataprovider/user.go
  13. 24 1
      docs/full-configuration.md
  14. 1 1
      docs/rest-api.md
  15. 62 0
      ftpd/ftpd_test.go
  16. 28 27
      go.mod
  17. 117 89
      go.sum
  18. 23 0
      httpd/api_admin.go
  19. 240 0
      httpd/api_mfa.go
  20. 23 0
      httpd/api_user.go
  21. 27 8
      httpd/auth_utils.go
  22. 70 14
      httpd/httpd.go
  23. 1823 137
      httpd/httpd_test.go
  24. 72 3
      httpd/internal_test.go
  25. 60 6
      httpd/middleware.go
  26. 590 6
      httpd/schema/openapi.yaml
  27. 373 18
      httpd/server.go
  28. 22 10
      httpd/web.go
  29. 95 0
      httpd/webadmin.go
  30. 105 8
      httpd/webclient.go
  31. 118 0
      mfa/mfa.go
  32. 129 0
      mfa/mfa_test.go
  33. 106 0
      mfa/totp.go
  34. 45 1
      sdk/user.go
  35. 7 0
      service/service.go
  36. 5 2
      sftpd/server.go
  37. 2 0
      sftpd/sftpd_test.go
  38. 10 0
      sftpgo.json
  39. 3 3
      templates/webadmin/adminsetup.html
  40. 6 0
      templates/webadmin/base.html
  41. 114 0
      templates/webadmin/baselogin.html
  42. 4 109
      templates/webadmin/login.html
  43. 401 0
      templates/webadmin/mfa.html
  44. 19 0
      templates/webadmin/status.html
  45. 29 0
      templates/webadmin/twofactor-recovery.html
  46. 34 0
      templates/webadmin/twofactor.html
  47. 7 1
      templates/webclient/base.html
  48. 117 0
      templates/webclient/baselogin.html
  49. 4 112
      templates/webclient/login.html
  50. 474 0
      templates/webclient/mfa.html
  51. 26 0
      templates/webclient/twofactor-recovery.html
  52. 31 0
      templates/webclient/twofactor.html

+ 1 - 1
.github/FUNDING.yml

@@ -9,4 +9,4 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
 liberapay: # Replace with a single Liberapay username
 liberapay: # Replace with a single Liberapay username
 issuehunt: # Replace with a single IssueHunt username
 issuehunt: # Replace with a single IssueHunt username
 otechie: # Replace with a single Otechie username
 otechie: # Replace with a single Otechie username
-custom: #['https://www.paypal.com/donate?hosted_button_id=JQL6GBT8GXRKC'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

+ 3 - 2
.github/workflows/development.yml

@@ -63,6 +63,7 @@ jobs:
           go test -v -p 1 -timeout 5m ./ftpd -covermode=atomic
           go test -v -p 1 -timeout 5m ./ftpd -covermode=atomic
           go test -v -p 1 -timeout 5m ./webdavd -covermode=atomic
           go test -v -p 1 -timeout 5m ./webdavd -covermode=atomic
           go test -v -p 1 -timeout 2m ./telemetry -covermode=atomic
           go test -v -p 1 -timeout 2m ./telemetry -covermode=atomic
+          go test -v -p 1 -timeout 2m ./mfa -covermode=atomic
         env:
         env:
           SFTPGO_DATA_PROVIDER__DRIVER: bolt
           SFTPGO_DATA_PROVIDER__DRIVER: bolt
           SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db'
           SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db'
@@ -107,7 +108,7 @@ jobs:
           path: output
           path: output
 
 
   test-goarch-386:
   test-goarch-386:
-    name: Run test cases with GOARCH=386
+    name: Run test cases on 32 bit arch
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     steps:
     steps:
@@ -123,7 +124,7 @@ jobs:
         env:
         env:
           GOARCH: 386
           GOARCH: 386
 
 
-      - name: Run test cases using memory provider with GOARCH=386
+      - name: Run test cases
         run: go test -v -p 1 -timeout 10m ./... -covermode=atomic
         run: go test -v -p 1 -timeout 10m ./... -covermode=atomic
         env:
         env:
           SFTPGO_DATA_PROVIDER__DRIVER: memory
           SFTPGO_DATA_PROVIDER__DRIVER: memory

+ 1 - 0
README.md

@@ -27,6 +27,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy
 - Keyboard interactive authentication. You can easily setup a customizable multi-factor authentication.
 - Keyboard interactive authentication. You can easily setup a customizable multi-factor authentication.
 - Partial authentication. You can configure multi-step authentication requiring, for example, the user password after successful public key authentication.
 - Partial authentication. You can configure multi-step authentication requiring, for example, the user password after successful public key authentication.
 - Per user authentication methods.
 - Per user authentication methods.
+- Two-factor authentication based on time-based one time passwords (RFC 6238) which works with Authy, Google Authenticator and other compatible apps.
 - Custom authentication via external programs/HTTP API.
 - Custom authentication via external programs/HTTP API.
 - [Data At Rest Encryption](./docs/dare.md).
 - [Data At Rest Encryption](./docs/dare.md).
 - Dynamic user modification before login via external programs/HTTP API.
 - Dynamic user modification before login via external programs/HTTP API.

+ 6 - 0
cmd/startsubsys.go

@@ -76,6 +76,12 @@ Command-line flags should be specified in the Subsystem declaration.
 				logger.Error(logSender, connectionID, "unable to initialize KMS: %v", err)
 				logger.Error(logSender, connectionID, "unable to initialize KMS: %v", err)
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
+			mfaConfig := config.GetMFAConfig()
+			err = mfaConfig.Initialize()
+			if err != nil {
+				logger.Error(logSender, "", "unable to initialize MFA: %v", err)
+				os.Exit(1)
+			}
 			if err := plugin.Initialize(config.GetPluginsConfig(), logVerbose); err != nil {
 			if err := plugin.Initialize(config.GetPluginsConfig(), logVerbose); err != nil {
 				logger.Error(logSender, connectionID, "unable to initialize plugin system: %v", err)
 				logger.Error(logSender, connectionID, "unable to initialize plugin system: %v", err)
 				os.Exit(1)
 				os.Exit(1)

+ 3 - 2
common/connection.go

@@ -23,9 +23,10 @@ import (
 // BaseConnection defines common fields for a connection using any supported protocol
 // BaseConnection defines common fields for a connection using any supported protocol
 type BaseConnection struct {
 type BaseConnection struct {
 	// last activity for this connection.
 	// last activity for this connection.
-	// Since this is accessed atomically we put as first element of the struct achieve 64 bit alignment
+	// Since this field is accessed atomically we put it as first element of the struct to achieve 64 bit alignment
 	lastActivity int64
 	lastActivity int64
-	// transferID is accessed atomically so we put it at the beginning of the struct to achieve 64 bit alignment
+	// unique ID for a transfer.
+	// This field is accessed atomically so we put it at the beginning of the struct to achieve 64 bit alignment
 	transferID uint64
 	transferID uint64
 	// Unique identifier for the connection
 	// Unique identifier for the connection
 	ID string
 	ID string

+ 102 - 0
common/protocol_test.go

@@ -20,6 +20,8 @@ import (
 	_ "github.com/lib/pq"
 	_ "github.com/lib/pq"
 	_ "github.com/mattn/go-sqlite3"
 	_ "github.com/mattn/go-sqlite3"
 	"github.com/pkg/sftp"
 	"github.com/pkg/sftp"
+	"github.com/pquerna/otp"
+	"github.com/pquerna/otp/totp"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
 	"golang.org/x/crypto/ssh"
 	"golang.org/x/crypto/ssh"
@@ -33,6 +35,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/httpdtest"
 	"github.com/drakkan/sftpgo/v2/httpdtest"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/vfs"
 	"github.com/drakkan/sftpgo/v2/vfs"
 )
 )
@@ -94,9 +97,16 @@ func TestMain(m *testing.M) {
 		logger.ErrorToConsole("error initializing kms: %v", err)
 		logger.ErrorToConsole("error initializing kms: %v", err)
 		os.Exit(1)
 		os.Exit(1)
 	}
 	}
+	mfaConfig := config.GetMFAConfig()
+	err = mfaConfig.Initialize()
+	if err != nil {
+		logger.ErrorToConsole("error initializing MFA: %v", err)
+		os.Exit(1)
+	}
 
 
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf.Bindings[0].Port = 4022
 	sftpdConf.Bindings[0].Port = 4022
+	sftpdConf.KeyboardInteractiveAuthentication = true
 
 
 	httpdConf := config.GetHTTPDConfig()
 	httpdConf := config.GetHTTPDConfig()
 	httpdConf.Bindings[0].Port = 4080
 	httpdConf.Bindings[0].Port = 4080
@@ -2338,6 +2348,69 @@ func TestRenameDir(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestBuiltinKeyboardInteractiveAuthentication(t *testing.T) {
+	u := getTestUser()
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	authMethods := []ssh.AuthMethod{
+		ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
+			return []string{defaultPassword}, nil
+		}),
+	}
+	conn, client, err := getCustomAuthSftpClient(user, authMethods)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		assert.NoError(t, checkBasicSFTP(client))
+		err = writeSFTPFile(testFileName, 4096, client)
+		assert.NoError(t, err)
+	}
+	// add multi-factor authentication
+	configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username)
+	assert.NoError(t, err)
+	user.Password = defaultPassword
+	user.Filters.TOTPConfig = sdk.TOTPConfig{
+		Enabled:    true,
+		ConfigName: configName,
+		Secret:     kms.NewPlainSecret(secret),
+		Protocols:  []string{common.ProtocolSSH},
+	}
+	err = dataprovider.UpdateUser(&user)
+	assert.NoError(t, err)
+	passcode, err := generateTOTPPasscode(secret, otp.AlgorithmSHA1)
+	assert.NoError(t, err)
+	passwordAsked := false
+	passcodeAsked := false
+	authMethods = []ssh.AuthMethod{
+		ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
+			var answers []string
+			if strings.HasPrefix(questions[0], "Password") {
+				answers = append(answers, defaultPassword)
+				passwordAsked = true
+			} else {
+				answers = append(answers, passcode)
+				passcodeAsked = true
+			}
+			return answers, nil
+		}),
+	}
+	conn, client, err = getCustomAuthSftpClient(user, authMethods)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		assert.NoError(t, checkBasicSFTP(client))
+		err = writeSFTPFile(testFileName, 4096, client)
+		assert.NoError(t, err)
+	}
+	assert.True(t, passwordAsked)
+	assert.True(t, passcodeAsked)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestRenameSymlink(t *testing.T) {
 func TestRenameSymlink(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	testDir := "/dir-no-create-links"
 	testDir := "/dir-no-create-links"
@@ -2690,6 +2763,26 @@ func checkBasicSFTP(client *sftp.Client) error {
 	return err
 	return err
 }
 }
 
 
+func getCustomAuthSftpClient(user dataprovider.User, authMethods []ssh.AuthMethod) (*ssh.Client, *sftp.Client, error) {
+	var sftpClient *sftp.Client
+	config := &ssh.ClientConfig{
+		User: user.Username,
+		HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
+			return nil
+		},
+		Auth: authMethods,
+	}
+	conn, err := ssh.Dial("tcp", sftpServerAddr, config)
+	if err != nil {
+		return conn, sftpClient, err
+	}
+	sftpClient, err = sftp.NewClient(conn)
+	if err != nil {
+		conn.Close()
+	}
+	return conn, sftpClient, err
+}
+
 func getSftpClient(user dataprovider.User) (*ssh.Client, *sftp.Client, error) {
 func getSftpClient(user dataprovider.User) (*ssh.Client, *sftp.Client, error) {
 	var sftpClient *sftp.Client
 	var sftpClient *sftp.Client
 	config := &ssh.ClientConfig{
 	config := &ssh.ClientConfig{
@@ -2786,3 +2879,12 @@ func getUploadScriptContent(movedPath string) []byte {
 	content = append(content, []byte(fmt.Sprintf("mv ${SFTPGO_ACTION_PATH} %v\n", movedPath))...)
 	content = append(content, []byte(fmt.Sprintf("mv ${SFTPGO_ACTION_PATH} %v\n", movedPath))...)
 	return content
 	return content
 }
 }
+
+func generateTOTPPasscode(secret string, algo otp.Algorithm) (string, error) {
+	return totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{
+		Period:    30,
+		Skew:      1,
+		Digits:    otp.DigitsSix,
+		Algorithm: algo,
+	})
+}

+ 66 - 13
config/config.go

@@ -18,6 +18,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/httpd"
 	"github.com/drakkan/sftpgo/v2/httpd"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk/plugin"
 	"github.com/drakkan/sftpgo/v2/sdk/plugin"
 	"github.com/drakkan/sftpgo/v2/sftpd"
 	"github.com/drakkan/sftpgo/v2/sftpd"
 	"github.com/drakkan/sftpgo/v2/telemetry"
 	"github.com/drakkan/sftpgo/v2/telemetry"
@@ -87,6 +88,11 @@ var (
 		EntriesSoftLimit:       100,
 		EntriesSoftLimit:       100,
 		EntriesHardLimit:       150,
 		EntriesHardLimit:       150,
 	}
 	}
+	defaultTOTP = mfa.TOTPConfig{
+		Name:   "Default",
+		Issuer: "SFTPGo",
+		Algo:   mfa.TOTPAlgoSHA1,
+	}
 )
 )
 
 
 type globalConfig struct {
 type globalConfig struct {
@@ -98,6 +104,7 @@ type globalConfig struct {
 	HTTPDConfig     httpd.Conf            `json:"httpd" mapstructure:"httpd"`
 	HTTPDConfig     httpd.Conf            `json:"httpd" mapstructure:"httpd"`
 	HTTPConfig      httpclient.Config     `json:"http" mapstructure:"http"`
 	HTTPConfig      httpclient.Config     `json:"http" mapstructure:"http"`
 	KMSConfig       kms.Configuration     `json:"kms" mapstructure:"kms"`
 	KMSConfig       kms.Configuration     `json:"kms" mapstructure:"kms"`
+	MFAConfig       mfa.Config            `json:"mfa" mapstructure:"mfa"`
 	TelemetryConfig telemetry.Conf        `json:"telemetry" mapstructure:"telemetry"`
 	TelemetryConfig telemetry.Conf        `json:"telemetry" mapstructure:"telemetry"`
 	PluginsConfig   []plugin.Config       `json:"plugins" mapstructure:"plugins"`
 	PluginsConfig   []plugin.Config       `json:"plugins" mapstructure:"plugins"`
 }
 }
@@ -144,19 +151,20 @@ func Init() {
 			RateLimitersConfig: []common.RateLimiterConfig{defaultRateLimiter},
 			RateLimitersConfig: []common.RateLimiterConfig{defaultRateLimiter},
 		},
 		},
 		SFTPD: sftpd.Configuration{
 		SFTPD: sftpd.Configuration{
-			Banner:                  defaultSFTPDBanner,
-			Bindings:                []sftpd.Binding{defaultSFTPDBinding},
-			MaxAuthTries:            0,
-			HostKeys:                []string{},
-			KexAlgorithms:           []string{},
-			Ciphers:                 []string{},
-			MACs:                    []string{},
-			TrustedUserCAKeys:       []string{},
-			LoginBannerFile:         "",
-			EnabledSSHCommands:      []string{},
-			KeyboardInteractiveHook: "",
-			PasswordAuthentication:  true,
-			FolderPrefix:            "",
+			Banner:                            defaultSFTPDBanner,
+			Bindings:                          []sftpd.Binding{defaultSFTPDBinding},
+			MaxAuthTries:                      0,
+			HostKeys:                          []string{},
+			KexAlgorithms:                     []string{},
+			Ciphers:                           []string{},
+			MACs:                              []string{},
+			TrustedUserCAKeys:                 []string{},
+			LoginBannerFile:                   "",
+			EnabledSSHCommands:                []string{},
+			KeyboardInteractiveAuthentication: false,
+			KeyboardInteractiveHook:           "",
+			PasswordAuthentication:            true,
+			FolderPrefix:                      "",
 		},
 		},
 		FTPD: ftpd.Configuration{
 		FTPD: ftpd.Configuration{
 			Bindings:                 []ftpd.Binding{defaultFTPDBinding},
 			Bindings:                 []ftpd.Binding{defaultFTPDBinding},
@@ -284,6 +292,9 @@ func Init() {
 				MasterKeyPath:   "",
 				MasterKeyPath:   "",
 			},
 			},
 		},
 		},
+		MFAConfig: mfa.Config{
+			TOTP: nil,
+		},
 		TelemetryConfig: telemetry.Conf{
 		TelemetryConfig: telemetry.Conf{
 			BindPort:           10000,
 			BindPort:           10000,
 			BindAddress:        "127.0.0.1",
 			BindAddress:        "127.0.0.1",
@@ -395,6 +406,11 @@ func GetPluginsConfig() []plugin.Config {
 	return globalConf.PluginsConfig
 	return globalConf.PluginsConfig
 }
 }
 
 
+// GetMFAConfig returns multi-factor authentication config
+func GetMFAConfig() mfa.Config {
+	return globalConf.MFAConfig
+}
+
 // HasServicesToStart returns true if the config defines at least a service to start.
 // HasServicesToStart returns true if the config defines at least a service to start.
 // Supported services are SFTP, FTP and WebDAV
 // Supported services are SFTP, FTP and WebDAV
 func HasServicesToStart() bool {
 func HasServicesToStart() bool {
@@ -511,6 +527,7 @@ func LoadConfig(configDir, configFile string) error {
 
 
 func loadBindingsFromEnv() {
 func loadBindingsFromEnv() {
 	for idx := 0; idx < 10; idx++ {
 	for idx := 0; idx < 10; idx++ {
+		getTOTPFromEnv(idx)
 		getRateLimitersFromEnv(idx)
 		getRateLimitersFromEnv(idx)
 		getPluginsFromEnv(idx)
 		getPluginsFromEnv(idx)
 		getSFTPDBindindFromEnv(idx)
 		getSFTPDBindindFromEnv(idx)
@@ -522,6 +539,41 @@ func loadBindingsFromEnv() {
 	}
 	}
 }
 }
 
 
+func getTOTPFromEnv(idx int) {
+	totpConfig := defaultTOTP
+	if len(globalConf.MFAConfig.TOTP) > idx {
+		totpConfig = globalConf.MFAConfig.TOTP[idx]
+	}
+
+	isSet := false
+
+	name, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_MFA__TOTP__%v__NAME", idx))
+	if ok {
+		totpConfig.Name = name
+		isSet = true
+	}
+
+	issuer, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_MFA__TOTP__%v__ISSUER", idx))
+	if ok {
+		totpConfig.Issuer = issuer
+		isSet = true
+	}
+
+	algo, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_MFA__TOTP__%v__ALGO", idx))
+	if ok {
+		totpConfig.Algo = algo
+		isSet = true
+	}
+
+	if isSet {
+		if len(globalConf.MFAConfig.TOTP) > idx {
+			globalConf.MFAConfig.TOTP[idx] = totpConfig
+		} else {
+			globalConf.MFAConfig.TOTP = append(globalConf.MFAConfig.TOTP, totpConfig)
+		}
+	}
+}
+
 func getRateLimitersFromEnv(idx int) {
 func getRateLimitersFromEnv(idx int) {
 	rtlConfig := defaultRateLimiter
 	rtlConfig := defaultRateLimiter
 	if len(globalConf.Common.RateLimitersConfig) > idx {
 	if len(globalConf.Common.RateLimitersConfig) > idx {
@@ -1000,6 +1052,7 @@ func setViperDefaults() {
 	viper.SetDefault("sftpd.trusted_user_ca_keys", globalConf.SFTPD.TrustedUserCAKeys)
 	viper.SetDefault("sftpd.trusted_user_ca_keys", globalConf.SFTPD.TrustedUserCAKeys)
 	viper.SetDefault("sftpd.login_banner_file", globalConf.SFTPD.LoginBannerFile)
 	viper.SetDefault("sftpd.login_banner_file", globalConf.SFTPD.LoginBannerFile)
 	viper.SetDefault("sftpd.enabled_ssh_commands", sftpd.GetDefaultSSHCommands())
 	viper.SetDefault("sftpd.enabled_ssh_commands", sftpd.GetDefaultSSHCommands())
+	viper.SetDefault("sftpd.keyboard_interactive_authentication", globalConf.SFTPD.KeyboardInteractiveAuthentication)
 	viper.SetDefault("sftpd.keyboard_interactive_auth_hook", globalConf.SFTPD.KeyboardInteractiveHook)
 	viper.SetDefault("sftpd.keyboard_interactive_auth_hook", globalConf.SFTPD.KeyboardInteractiveHook)
 	viper.SetDefault("sftpd.password_authentication", globalConf.SFTPD.PasswordAuthentication)
 	viper.SetDefault("sftpd.password_authentication", globalConf.SFTPD.PasswordAuthentication)
 	viper.SetDefault("sftpd.folder_prefix", globalConf.SFTPD.FolderPrefix)
 	viper.SetDefault("sftpd.folder_prefix", globalConf.SFTPD.FolderPrefix)

+ 56 - 0
config/config_test.go

@@ -18,6 +18,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/httpclient"
 	"github.com/drakkan/sftpgo/v2/httpclient"
 	"github.com/drakkan/sftpgo/v2/httpd"
 	"github.com/drakkan/sftpgo/v2/httpd"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/kms"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sftpd"
 	"github.com/drakkan/sftpgo/v2/sftpd"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 )
 )
@@ -339,6 +340,61 @@ func TestSSHCommandsFromEnv(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestMFAFromEnv(t *testing.T) {
+	reset()
+
+	os.Setenv("SFTPGO_MFA__TOTP__0__NAME", "main")
+	os.Setenv("SFTPGO_MFA__TOTP__1__NAME", "additional_name")
+	os.Setenv("SFTPGO_MFA__TOTP__1__ISSUER", "additional_issuer")
+	os.Setenv("SFTPGO_MFA__TOTP__1__ALGO", "sha256")
+	t.Cleanup(func() {
+		os.Unsetenv("SFTPGO_MFA__TOTP__0__NAME")
+		os.Unsetenv("SFTPGO_MFA__TOTP__1__NAME")
+		os.Unsetenv("SFTPGO_MFA__TOTP__1__ISSUER")
+		os.Unsetenv("SFTPGO_MFA__TOTP__1__ALGO")
+	})
+
+	configDir := ".."
+	err := config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	mfaConf := config.GetMFAConfig()
+	require.Len(t, mfaConf.TOTP, 2)
+	require.Equal(t, "main", mfaConf.TOTP[0].Name)
+	require.Equal(t, "SFTPGo", mfaConf.TOTP[0].Issuer)
+	require.Equal(t, "sha1", mfaConf.TOTP[0].Algo)
+	require.Equal(t, "additional_name", mfaConf.TOTP[1].Name)
+	require.Equal(t, "additional_issuer", mfaConf.TOTP[1].Issuer)
+	require.Equal(t, "sha256", mfaConf.TOTP[1].Algo)
+}
+
+func TestDisabledMFAConfig(t *testing.T) {
+	reset()
+
+	configDir := ".."
+	confName := tempConfigName + ".json"
+	configFilePath := filepath.Join(configDir, confName)
+
+	err := config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	mfaConf := config.GetMFAConfig()
+	assert.Len(t, mfaConf.TOTP, 1)
+
+	reset()
+
+	c := make(map[string]mfa.Config)
+	c["mfa"] = mfa.Config{}
+	jsonConf, err := json.Marshal(c)
+	assert.NoError(t, err)
+	err = os.WriteFile(configFilePath, jsonConf, os.ModePerm)
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, confName)
+	assert.NoError(t, err)
+	mfaConf = config.GetMFAConfig()
+	assert.Len(t, mfaConf.TOTP, 0)
+	err = os.Remove(configFilePath)
+	assert.NoError(t, err)
+}
+
 func TestPluginsFromEnv(t *testing.T) {
 func TestPluginsFromEnv(t *testing.T) {
 	reset()
 	reset()
 
 

+ 136 - 11
dataprovider/admin.go

@@ -14,6 +14,9 @@ import (
 	passwordvalidator "github.com/wagslane/go-password-validator"
 	passwordvalidator "github.com/wagslane/go-password-validator"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
 
 
+	"github.com/drakkan/sftpgo/v2/kms"
+	"github.com/drakkan/sftpgo/v2/mfa"
+	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 )
 )
 
 
@@ -43,6 +46,36 @@ var (
 		PermAdminManageDefender, PermAdminViewDefender}
 		PermAdminManageDefender, PermAdminViewDefender}
 )
 )
 
 
+// TOTPConfig defines the time-based one time password configuration
+type TOTPConfig struct {
+	Enabled    bool        `json:"enabled,omitempty"`
+	ConfigName string      `json:"config_name,omitempty"`
+	Secret     *kms.Secret `json:"secret,omitempty"`
+}
+
+func (c *TOTPConfig) validate() error {
+	if !c.Enabled {
+		c.ConfigName = ""
+		c.Secret = kms.NewEmptySecret()
+		return nil
+	}
+	if c.ConfigName == "" {
+		return util.NewValidationError("totp: config name is mandatory")
+	}
+	if !util.IsStringInSlice(c.ConfigName, mfa.GetAvailableTOTPConfigNames()) {
+		return util.NewValidationError(fmt.Sprintf("totp: config name %#v not found", c.ConfigName))
+	}
+	if c.Secret.IsEmpty() {
+		return util.NewValidationError("totp: secret is mandatory")
+	}
+	if c.Secret.IsPlain() {
+		if err := c.Secret.Encrypt(); err != nil {
+			return util.NewValidationError(fmt.Sprintf("totp: unable to encrypt secret: %v", err))
+		}
+	}
+	return nil
+}
+
 // AdminFilters defines additional restrictions for SFTPGo admins
 // AdminFilters defines additional restrictions for SFTPGo admins
 // TODO: rename to AdminOptions in v3
 // TODO: rename to AdminOptions in v3
 type AdminFilters struct {
 type AdminFilters struct {
@@ -52,6 +85,12 @@ type AdminFilters struct {
 	AllowList []string `json:"allow_list,omitempty"`
 	AllowList []string `json:"allow_list,omitempty"`
 	// API key auth allows to impersonate this administrator with an API key
 	// API key auth allows to impersonate this administrator with an API key
 	AllowAPIKeyAuth bool `json:"allow_api_key_auth,omitempty"`
 	AllowAPIKeyAuth bool `json:"allow_api_key_auth,omitempty"`
+	// Time-based one time passwords configuration
+	TOTPConfig TOTPConfig `json:"totp_config,omitempty"`
+	// Recovery codes to use if the user loses access to their second factor auth device.
+	// Each code can only be used once, you should use these codes to login and disable or
+	// reset 2FA for your account
+	RecoveryCodes []sdk.RecoveryCode `json:"recovery_codes,omitempty"`
 }
 }
 
 
 // Admin defines a SFTPGo admin
 // Admin defines a SFTPGo admin
@@ -76,6 +115,17 @@ type Admin struct {
 	LastLogin int64 `json:"last_login"`
 	LastLogin int64 `json:"last_login"`
 }
 }
 
 
+// CountUnusedRecoveryCodes returns the number of unused recovery codes
+func (a *Admin) CountUnusedRecoveryCodes() int {
+	unused := 0
+	for _, code := range a.Filters.RecoveryCodes {
+		if !code.Used {
+			unused++
+		}
+	}
+	return unused
+}
+
 func (a *Admin) checkPassword() error {
 func (a *Admin) checkPassword() error {
 	if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) {
 	if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) {
 		if config.PasswordValidation.Admins.MinEntropy > 0 {
 		if config.PasswordValidation.Admins.MinEntropy > 0 {
@@ -100,30 +150,66 @@ func (a *Admin) checkPassword() error {
 	return nil
 	return nil
 }
 }
 
 
+func (a *Admin) hasRedactedSecret() bool {
+	return a.Filters.TOTPConfig.Secret.IsRedacted()
+}
+
+func (a *Admin) validateRecoveryCodes() error {
+	for i := 0; i < len(a.Filters.RecoveryCodes); i++ {
+		code := &a.Filters.RecoveryCodes[i]
+		if code.Secret.IsEmpty() {
+			return util.NewValidationError("mfa: recovery code cannot be empty")
+		}
+		if code.Secret.IsPlain() {
+			if err := code.Secret.Encrypt(); err != nil {
+				return util.NewValidationError(fmt.Sprintf("mfa: unable to encrypt recovery code: %v", err))
+			}
+		}
+	}
+	return nil
+}
+
+func (a *Admin) validatePermissions() error {
+	a.Permissions = util.RemoveDuplicates(a.Permissions)
+	if len(a.Permissions) == 0 {
+		return util.NewValidationError("please grant some permissions to this admin")
+	}
+	if util.IsStringInSlice(PermAdminAny, a.Permissions) {
+		a.Permissions = []string{PermAdminAny}
+	}
+	for _, perm := range a.Permissions {
+		if !util.IsStringInSlice(perm, validAdminPerms) {
+			return util.NewValidationError(fmt.Sprintf("invalid permission: %#v", perm))
+		}
+	}
+	return nil
+}
+
 func (a *Admin) validate() error {
 func (a *Admin) validate() error {
+	a.SetEmptySecretsIfNil()
 	if a.Username == "" {
 	if a.Username == "" {
 		return util.NewValidationError("username is mandatory")
 		return util.NewValidationError("username is mandatory")
 	}
 	}
 	if a.Password == "" {
 	if a.Password == "" {
 		return util.NewValidationError("please set a password")
 		return util.NewValidationError("please set a password")
 	}
 	}
+	if a.hasRedactedSecret() {
+		return util.NewValidationError("cannot save an admin with a redacted secret")
+	}
+	if err := a.Filters.TOTPConfig.validate(); err != nil {
+		return err
+	}
+	if err := a.validateRecoveryCodes(); err != nil {
+		return err
+	}
 	if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(a.Username) {
 	if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(a.Username) {
 		return util.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username))
 		return util.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username))
 	}
 	}
 	if err := a.checkPassword(); err != nil {
 	if err := a.checkPassword(); err != nil {
 		return err
 		return err
 	}
 	}
-	a.Permissions = util.RemoveDuplicates(a.Permissions)
-	if len(a.Permissions) == 0 {
-		return util.NewValidationError("please grant some permissions to this admin")
-	}
-	if util.IsStringInSlice(PermAdminAny, a.Permissions) {
-		a.Permissions = []string{PermAdminAny}
-	}
-	for _, perm := range a.Permissions {
-		if !util.IsStringInSlice(perm, validAdminPerms) {
-			return util.NewValidationError(fmt.Sprintf("invalid permission: %#v", perm))
-		}
+	if err := a.validatePermissions(); err != nil {
+		return err
 	}
 	}
 	if a.Email != "" && !emailRegex.MatchString(a.Email) {
 	if a.Email != "" && !emailRegex.MatchString(a.Email) {
 		return util.NewValidationError(fmt.Sprintf("email %#v is not valid", a.Email))
 		return util.NewValidationError(fmt.Sprintf("email %#v is not valid", a.Email))
@@ -202,6 +288,26 @@ func (a *Admin) checkUserAndPass(password, ip string) error {
 // HideConfidentialData hides admin confidential data
 // HideConfidentialData hides admin confidential data
 func (a *Admin) HideConfidentialData() {
 func (a *Admin) HideConfidentialData() {
 	a.Password = ""
 	a.Password = ""
+	if a.Filters.TOTPConfig.Secret != nil {
+		a.Filters.TOTPConfig.Secret.Hide()
+	}
+	a.SetNilSecretsIfEmpty()
+}
+
+// SetEmptySecretsIfNil sets the secrets to empty if nil
+func (a *Admin) SetEmptySecretsIfNil() {
+	if a.Filters.TOTPConfig.Secret == nil {
+		a.Filters.TOTPConfig.Secret = kms.NewEmptySecret()
+	}
+}
+
+// SetNilSecretsIfEmpty set the secrets to nil if empty.
+// This is useful before rendering as JSON so the empty fields
+// will not be serialized.
+func (a *Admin) SetNilSecretsIfEmpty() {
+	if a.Filters.TOTPConfig.Secret.IsEmpty() {
+		a.Filters.TOTPConfig.Secret = nil
+	}
 }
 }
 
 
 // HasPermission returns true if the admin has the specified permission
 // HasPermission returns true if the admin has the specified permission
@@ -239,6 +345,11 @@ func (a *Admin) GetInfoString() string {
 	return result
 	return result
 }
 }
 
 
+// CanManageMFA returns true if the admin can add a multi-factor authentication configuration
+func (a *Admin) CanManageMFA() bool {
+	return len(mfa.GetAvailableTOTPConfigs()) > 0
+}
+
 // GetSignature returns a signature for this admin.
 // GetSignature returns a signature for this admin.
 // It could change after an update
 // It could change after an update
 func (a *Admin) GetSignature() string {
 func (a *Admin) GetSignature() string {
@@ -249,12 +360,26 @@ func (a *Admin) GetSignature() string {
 }
 }
 
 
 func (a *Admin) getACopy() Admin {
 func (a *Admin) getACopy() Admin {
+	a.SetEmptySecretsIfNil()
 	permissions := make([]string, len(a.Permissions))
 	permissions := make([]string, len(a.Permissions))
 	copy(permissions, a.Permissions)
 	copy(permissions, a.Permissions)
 	filters := AdminFilters{}
 	filters := AdminFilters{}
 	filters.AllowList = make([]string, len(a.Filters.AllowList))
 	filters.AllowList = make([]string, len(a.Filters.AllowList))
 	filters.AllowAPIKeyAuth = a.Filters.AllowAPIKeyAuth
 	filters.AllowAPIKeyAuth = a.Filters.AllowAPIKeyAuth
+	filters.TOTPConfig.Enabled = a.Filters.TOTPConfig.Enabled
+	filters.TOTPConfig.ConfigName = a.Filters.TOTPConfig.ConfigName
+	filters.TOTPConfig.Secret = a.Filters.TOTPConfig.Secret.Clone()
 	copy(filters.AllowList, a.Filters.AllowList)
 	copy(filters.AllowList, a.Filters.AllowList)
+	filters.RecoveryCodes = make([]sdk.RecoveryCode, 0)
+	for _, code := range a.Filters.RecoveryCodes {
+		if code.Secret == nil {
+			code.Secret = kms.NewEmptySecret()
+		}
+		filters.RecoveryCodes = append(filters.RecoveryCodes, sdk.RecoveryCode{
+			Secret: code.Secret.Clone(),
+			Used:   code.Used,
+		})
+	}
 
 
 	return Admin{
 	return Admin{
 		ID:             a.ID,
 		ID:             a.ID,

+ 168 - 11
dataprovider/dataprovider.go

@@ -48,6 +48,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/metric"
 	"github.com/drakkan/sftpgo/v2/metric"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sdk/plugin"
 	"github.com/drakkan/sftpgo/v2/sdk/plugin"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
@@ -101,6 +102,13 @@ const (
 	OrderDESC = "DESC"
 	OrderDESC = "DESC"
 )
 )
 
 
+const (
+	protocolSSH    = "SSH"
+	protocolFTP    = "FTP"
+	protocolWebDAV = "DAV"
+	protocolHTTP   = "HTTP"
+)
+
 var (
 var (
 	// SupportedProviders defines the supported data providers
 	// SupportedProviders defines the supported data providers
 	SupportedProviders = []string{SQLiteDataProviderName, PGSQLDataProviderName, MySQLDataProviderName,
 	SupportedProviders = []string{SQLiteDataProviderName, PGSQLDataProviderName, MySQLDataProviderName,
@@ -117,7 +125,9 @@ var (
 	// ErrNoAuthTryed defines the error for connection closed before authentication
 	// ErrNoAuthTryed defines the error for connection closed before authentication
 	ErrNoAuthTryed = errors.New("no auth tryed")
 	ErrNoAuthTryed = errors.New("no auth tryed")
 	// ValidProtocols defines all the valid protcols
 	// ValidProtocols defines all the valid protcols
-	ValidProtocols = []string{"SSH", "FTP", "DAV", "HTTP"}
+	ValidProtocols = []string{protocolSSH, protocolFTP, protocolWebDAV, protocolHTTP}
+	// MFAProtocols defines the supported protocols for multi-factor authentication
+	MFAProtocols = []string{protocolHTTP, protocolSSH, protocolFTP}
 	// ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required
 	// ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required
 	ErrNoInitRequired = errors.New("the data provider is up to date")
 	ErrNoInitRequired = errors.New("the data provider is up to date")
 	// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
 	// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
@@ -950,6 +960,10 @@ func HasAdmin() bool {
 
 
 // AddAdmin adds a new SFTPGo admin
 // AddAdmin adds a new SFTPGo admin
 func AddAdmin(admin *Admin) error {
 func AddAdmin(admin *Admin) error {
+	admin.Filters.RecoveryCodes = nil
+	admin.Filters.TOTPConfig = TOTPConfig{
+		Enabled: false,
+	}
 	err := provider.addAdmin(admin)
 	err := provider.addAdmin(admin)
 	if err == nil {
 	if err == nil {
 		atomic.StoreInt32(&isAdminCreated, 1)
 		atomic.StoreInt32(&isAdminCreated, 1)
@@ -983,6 +997,10 @@ func UserExists(username string) (User, error) {
 
 
 // AddUser adds a new SFTPGo user.
 // AddUser adds a new SFTPGo user.
 func AddUser(user *User) error {
 func AddUser(user *User) error {
+	user.Filters.RecoveryCodes = nil
+	user.Filters.TOTPConfig = sdk.TOTPConfig{
+		Enabled: false,
+	}
 	err := provider.addUser(user)
 	err := provider.addUser(user)
 	if err == nil {
 	if err == nil {
 		executeAction(operationAdd, user)
 		executeAction(operationAdd, user)
@@ -1321,6 +1339,54 @@ func validateUserVirtualFolders(user *User) error {
 	return nil
 	return nil
 }
 }
 
 
+func validateUserTOTPConfig(c *sdk.TOTPConfig) error {
+	if !c.Enabled {
+		c.ConfigName = ""
+		c.Secret = kms.NewEmptySecret()
+		c.Protocols = nil
+		return nil
+	}
+	if c.ConfigName == "" {
+		return util.NewValidationError("totp: config name is mandatory")
+	}
+	if !util.IsStringInSlice(c.ConfigName, mfa.GetAvailableTOTPConfigNames()) {
+		return util.NewValidationError(fmt.Sprintf("totp: config name %#v not found", c.ConfigName))
+	}
+	if c.Secret.IsEmpty() {
+		return util.NewValidationError("totp: secret is mandatory")
+	}
+	if c.Secret.IsPlain() {
+		if err := c.Secret.Encrypt(); err != nil {
+			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")
+	}
+	for _, protocol := range c.Protocols {
+		if !util.IsStringInSlice(protocol, MFAProtocols) {
+			return util.NewValidationError(fmt.Sprintf("totp: invalid protocol %#v", protocol))
+		}
+	}
+	return nil
+}
+
+func validateUserRecoveryCodes(user *User) error {
+	for i := 0; i < len(user.Filters.RecoveryCodes); i++ {
+		code := &user.Filters.RecoveryCodes[i]
+		if code.Secret.IsEmpty() {
+			return util.NewValidationError("mfa: recovery code cannot be empty")
+		}
+		if code.Secret.IsPlain() {
+			if err := code.Secret.Encrypt(); err != nil {
+				return util.NewValidationError(fmt.Sprintf("mfa: unable to encrypt recovery code: %v", err))
+			}
+		}
+	}
+	return nil
+}
+
 func validatePermissions(user *User) error {
 func validatePermissions(user *User) error {
 	if len(user.Permissions) == 0 {
 	if len(user.Permissions) == 0 {
 		return util.NewValidationError("please grant some permissions to this user")
 		return util.NewValidationError("please grant some permissions to this user")
@@ -1607,7 +1673,13 @@ func ValidateUser(user *User) error {
 		return err
 		return err
 	}
 	}
 	if user.hasRedactedSecret() {
 	if user.hasRedactedSecret() {
-		return errors.New("cannot save a user with a redacted secret")
+		return util.NewValidationError("cannot save a user with a redacted secret")
+	}
+	if err := validateUserTOTPConfig(&user.Filters.TOTPConfig); err != nil {
+		return err
+	}
+	if err := validateUserRecoveryCodes(user); err != nil {
+		return err
 	}
 	}
 	if err := user.FsConfig.Validate(user); err != nil {
 	if err := user.FsConfig.Validate(user); err != nil {
 		return err
 		return err
@@ -1627,6 +1699,9 @@ func ValidateUser(user *User) error {
 	if err := validateFilters(user); err != nil {
 	if err := validateFilters(user); err != nil {
 		return err
 		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 saveGCSCredentials(&user.FsConfig, user)
 	return saveGCSCredentials(&user.FsConfig, user)
 }
 }
 
 
@@ -1674,7 +1749,7 @@ func checkUserAndTLSCertificate(user *User, protocol string, tlsCert *x509.Certi
 		return *user, err
 		return *user, err
 	}
 	}
 	switch protocol {
 	switch protocol {
-	case "FTP", "DAV":
+	case protocolFTP, protocolWebDAV:
 		if user.Filters.TLSUsername == sdk.TLSUsernameCN {
 		if user.Filters.TLSUsername == sdk.TLSUsernameCN {
 			if user.Username == tlsCert.Subject.CommonName {
 			if user.Username == tlsCert.Subject.CommonName {
 				return *user, nil
 				return *user, nil
@@ -1692,6 +1767,10 @@ func checkUserAndPass(user *User, password, ip, protocol string) (User, error) {
 	if err != nil {
 	if err != nil {
 		return *user, err
 		return *user, err
 	}
 	}
+	password, err = checkUserPasscode(user, password, protocol)
+	if err != nil {
+		return *user, ErrInvalidCredentials
+	}
 	if user.Password == "" {
 	if user.Password == "" {
 		return *user, errors.New("credentials cannot be null or empty")
 		return *user, errors.New("credentials cannot be null or empty")
 	}
 	}
@@ -1727,6 +1806,40 @@ func checkUserAndPass(user *User, password, ip, protocol string) (User, error) {
 	return *user, err
 	return *user, err
 }
 }
 
 
+func checkUserPasscode(user *User, password, protocol string) (string, error) {
+	if user.Filters.TOTPConfig.Enabled {
+		switch protocol {
+		case protocolFTP:
+			if util.IsStringInSlice(protocol, user.Filters.TOTPConfig.Protocols) {
+				// the TOTP passcode has six digits
+				pwdLen := len(password)
+				if pwdLen < 7 {
+					providerLog(logger.LevelDebug, "password len %v is too short to contain a passcode, user %#v, protocol %v",
+						pwdLen, user.Username, protocol)
+					return "", util.NewValidationError("password too short, cannot contain the passcode")
+				}
+				err := user.Filters.TOTPConfig.Secret.TryDecrypt()
+				if err != nil {
+					providerLog(logger.LevelWarn, "unable to decrypt TOTP secret for user %#v, protocol %v, err: %v",
+						user.Username, protocol, err)
+					return "", err
+				}
+				pwd := password[0:(pwdLen - 6)]
+				passcode := password[(pwdLen - 6):]
+				match, err := mfa.ValidateTOTPPasscode(user.Filters.TOTPConfig.ConfigName, passcode,
+					user.Filters.TOTPConfig.Secret.GetPayload())
+				if !match || err != nil {
+					providerLog(logger.LevelWarn, "invalid passcode for user %#v, protocol %v, err: %v",
+						user.Username, protocol, err)
+					return "", util.NewValidationError("invalid passcode")
+				}
+				return pwd, nil
+			}
+		}
+	}
+	return password, nil
+}
+
 func checkUserAndPubKey(user *User, pubKey []byte) (User, string, error) {
 func checkUserAndPubKey(user *User, pubKey []byte) (User, string, error) {
 	err := user.CheckLoginConditions()
 	err := user.CheckLoginConditions()
 	if err != nil {
 	if err != nil {
@@ -1978,6 +2091,44 @@ func sendKeyboardAuthHTTPReq(url string, request *plugin.KeyboardAuthRequest) (*
 	return &response, err
 	return &response, err
 }
 }
 
 
+func doBuiltinKeyboardInteractiveAuth(user *User, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
+	answers, err := client(user.Username, "", []string{"Password: "}, []bool{false})
+	if err != nil {
+		return 0, err
+	}
+	if len(answers) != 1 {
+		return 0, fmt.Errorf("unexpected number of answers: %v", len(answers))
+	}
+	_, err = checkUserAndPass(user, answers[0], ip, protocol)
+	if err != nil {
+		return 0, err
+	}
+	if !user.Filters.TOTPConfig.Enabled || !util.IsStringInSlice(protocolSSH, user.Filters.TOTPConfig.Protocols) {
+		return 1, nil
+	}
+	err = user.Filters.TOTPConfig.Secret.TryDecrypt()
+	if err != nil {
+		providerLog(logger.LevelWarn, "unable to decrypt TOTP secret for user %#v, protocol %v, err: %v",
+			user.Username, protocol, err)
+		return 0, err
+	}
+	answers, err = client(user.Username, "", []string{"Authentication code: "}, []bool{false})
+	if err != nil {
+		return 0, err
+	}
+	if len(answers) != 1 {
+		return 0, fmt.Errorf("unexpected number of answers: %v", len(answers))
+	}
+	match, err := mfa.ValidateTOTPPasscode(user.Filters.TOTPConfig.ConfigName, answers[0],
+		user.Filters.TOTPConfig.Secret.GetPayload())
+	if !match || err != nil {
+		providerLog(logger.LevelWarn, "invalid passcode for user %#v, protocol %v, err: %v",
+			user.Username, protocol, err)
+		return 0, util.NewValidationError("invalid passcode")
+	}
+	return 1, nil
+}
+
 func executeKeyboardInteractivePlugin(user *User, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
 func executeKeyboardInteractivePlugin(user *User, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) {
 	authResult := 0
 	authResult := 0
 	requestID := xid.New().String()
 	requestID := xid.New().String()
@@ -2061,7 +2212,8 @@ func executeKeyboardInteractiveHTTPHook(user *User, authHook string, client ssh.
 }
 }
 
 
 func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, response *plugin.KeyboardAuthResponse,
 func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, response *plugin.KeyboardAuthResponse,
-	user *User, ip, protocol string) ([]string, error) {
+	user *User, ip, protocol string,
+) ([]string, error) {
 	questions := response.Questions
 	questions := response.Questions
 	answers, err := client(user.Username, response.Instruction, questions, response.Echos)
 	answers, err := client(user.Username, response.Instruction, questions, response.Echos)
 	if err != nil {
 	if err != nil {
@@ -2086,7 +2238,8 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
 }
 }
 
 
 func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge, response *plugin.KeyboardAuthResponse,
 func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge, response *plugin.KeyboardAuthResponse,
-	user *User, stdin io.WriteCloser, ip, protocol string) error {
+	user *User, stdin io.WriteCloser, ip, protocol string,
+) error {
 	answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
 	answers, err := getKeyboardInteractiveAnswers(client, response, user, ip, protocol)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -2169,10 +2322,14 @@ func doKeyboardInteractiveAuth(user *User, authHook string, client ssh.KeyboardI
 	var err error
 	var err error
 	if plugin.Handler.HasAuthScope(plugin.AuthScopeKeyboardInteractive) {
 	if plugin.Handler.HasAuthScope(plugin.AuthScopeKeyboardInteractive) {
 		authResult, err = executeKeyboardInteractivePlugin(user, client, ip, protocol)
 		authResult, err = executeKeyboardInteractivePlugin(user, client, ip, protocol)
-	} else if strings.HasPrefix(authHook, "http") {
-		authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client, ip, protocol)
+	} else if authHook != "" {
+		if strings.HasPrefix(authHook, "http") {
+			authResult, err = executeKeyboardInteractiveHTTPHook(user, authHook, client, ip, protocol)
+		} else {
+			authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip, protocol)
+		}
 	} else {
 	} else {
-		authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip, protocol)
+		authResult, err = doBuiltinKeyboardInteractiveAuth(user, client, ip, protocol)
 	}
 	}
 	if err != nil {
 	if err != nil {
 		return *user, err
 		return *user, err
@@ -2195,11 +2352,11 @@ func isCheckPasswordHookDefined(protocol string) bool {
 		return true
 		return true
 	}
 	}
 	switch protocol {
 	switch protocol {
-	case "SSH":
+	case protocolSSH:
 		return config.CheckPasswordScope&1 != 0
 		return config.CheckPasswordScope&1 != 0
-	case "FTP":
+	case protocolFTP:
 		return config.CheckPasswordScope&2 != 0
 		return config.CheckPasswordScope&2 != 0
-	case "DAV":
+	case protocolWebDAV:
 		return config.CheckPasswordScope&4 != 0
 		return config.CheckPasswordScope&4 != 0
 	default:
 	default:
 		return false
 		return false

+ 1 - 0
dataprovider/sqlcommon.go

@@ -806,6 +806,7 @@ func getAdminFromDbRow(row sqlScanner) (Admin, error) {
 		admin.Description = description.String
 		admin.Description = description.String
 	}
 	}
 
 
+	admin.SetEmptySecretsIfNil()
 	return admin, nil
 	return admin, nil
 }
 }
 
 

+ 43 - 2
dataprovider/user.go

@@ -17,6 +17,7 @@ import (
 
 
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/vfs"
 	"github.com/drakkan/sftpgo/v2/vfs"
@@ -195,6 +196,7 @@ func (u *User) CheckLoginConditions() error {
 func (u *User) hideConfidentialData() {
 func (u *User) hideConfidentialData() {
 	u.Password = ""
 	u.Password = ""
 	u.FsConfig.HideConfidentialData()
 	u.FsConfig.HideConfidentialData()
+	u.Filters.TOTPConfig.Secret.Hide()
 }
 }
 
 
 // GetSubDirPermissions returns permissions for sub directories
 // GetSubDirPermissions returns permissions for sub directories
@@ -252,7 +254,7 @@ func (u *User) hasRedactedSecret() bool {
 		}
 		}
 	}
 	}
 
 
-	return false
+	return u.Filters.TOTPConfig.Secret.IsRedacted()
 }
 }
 
 
 // CloseFs closes the underlying filesystems
 // CloseFs closes the underlying filesystems
@@ -298,6 +300,7 @@ func (u *User) SetEmptySecrets() {
 		folder := &u.VirtualFolders[idx]
 		folder := &u.VirtualFolders[idx]
 		folder.FsConfig.SetEmptySecretsIfNil()
 		folder.FsConfig.SetEmptySecretsIfNil()
 	}
 	}
+	u.Filters.TOTPConfig.Secret = kms.NewEmptySecret()
 }
 }
 
 
 // GetPermissionsForPath returns the permissions for the given path.
 // GetPermissionsForPath returns the permissions for the given path.
@@ -708,7 +711,15 @@ func (u *User) isFilePatternAllowed(virtualPath string) bool {
 	return true
 	return true
 }
 }
 
 
-// CanManagePublicKeys return true if this user is allowed to manage public keys
+// CanManageMFA returns true if the user can add a multi-factor authentication configuration
+func (u *User) CanManageMFA() bool {
+	if util.IsStringInSlice(sdk.WebClientMFADisabled, u.Filters.WebClient) {
+		return false
+	}
+	return len(mfa.GetAvailableTOTPConfigs()) > 0
+}
+
+// CanManagePublicKeys returns true if this user is allowed to manage public keys
 // from the web client. Used in web client UI
 // from the web client. Used in web client UI
 func (u *User) CanManagePublicKeys() bool {
 func (u *User) CanManagePublicKeys() bool {
 	return !util.IsStringInSlice(sdk.WebClientPubKeyChangeDisabled, u.Filters.WebClient)
 	return !util.IsStringInSlice(sdk.WebClientPubKeyChangeDisabled, u.Filters.WebClient)
@@ -968,6 +979,17 @@ func (u *User) GetDeniedIPAsString() string {
 	return strings.Join(u.Filters.DeniedIP, ",")
 	return strings.Join(u.Filters.DeniedIP, ",")
 }
 }
 
 
+// CountUnusedRecoveryCodes returns the number of unused recovery codes
+func (u *User) CountUnusedRecoveryCodes() int {
+	unused := 0
+	for _, code := range u.Filters.RecoveryCodes {
+		if !code.Used {
+			unused++
+		}
+	}
+	return unused
+}
+
 // SetEmptySecretsIfNil sets the secrets to empty if nil
 // SetEmptySecretsIfNil sets the secrets to empty if nil
 func (u *User) SetEmptySecretsIfNil() {
 func (u *User) SetEmptySecretsIfNil() {
 	u.FsConfig.SetEmptySecretsIfNil()
 	u.FsConfig.SetEmptySecretsIfNil()
@@ -975,6 +997,9 @@ func (u *User) SetEmptySecretsIfNil() {
 		vfolder := &u.VirtualFolders[idx]
 		vfolder := &u.VirtualFolders[idx]
 		vfolder.FsConfig.SetEmptySecretsIfNil()
 		vfolder.FsConfig.SetEmptySecretsIfNil()
 	}
 	}
+	if u.Filters.TOTPConfig.Secret == nil {
+		u.Filters.TOTPConfig.Secret = kms.NewEmptySecret()
+	}
 }
 }
 
 
 func (u *User) getACopy() User {
 func (u *User) getACopy() User {
@@ -995,6 +1020,12 @@ func (u *User) getACopy() User {
 	filters := sdk.UserFilters{}
 	filters := sdk.UserFilters{}
 	filters.MaxUploadFileSize = u.Filters.MaxUploadFileSize
 	filters.MaxUploadFileSize = u.Filters.MaxUploadFileSize
 	filters.TLSUsername = u.Filters.TLSUsername
 	filters.TLSUsername = u.Filters.TLSUsername
+	filters.UserType = u.Filters.UserType
+	filters.TOTPConfig.Enabled = u.Filters.TOTPConfig.Enabled
+	filters.TOTPConfig.ConfigName = u.Filters.TOTPConfig.ConfigName
+	filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
+	filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
+	copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
 	filters.AllowedIP = make([]string, len(u.Filters.AllowedIP))
 	filters.AllowedIP = make([]string, len(u.Filters.AllowedIP))
 	copy(filters.AllowedIP, u.Filters.AllowedIP)
 	copy(filters.AllowedIP, u.Filters.AllowedIP)
 	filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))
 	filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))
@@ -1012,6 +1043,16 @@ func (u *User) getACopy() User {
 	filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth
 	filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth
 	filters.WebClient = make([]string, len(u.Filters.WebClient))
 	filters.WebClient = make([]string, len(u.Filters.WebClient))
 	copy(filters.WebClient, u.Filters.WebClient)
 	copy(filters.WebClient, u.Filters.WebClient)
+	filters.RecoveryCodes = make([]sdk.RecoveryCode, 0)
+	for _, code := range u.Filters.RecoveryCodes {
+		if code.Secret == nil {
+			code.Secret = kms.NewEmptySecret()
+		}
+		filters.RecoveryCodes = append(filters.RecoveryCodes, sdk.RecoveryCode{
+			Secret: code.Secret.Clone(),
+			Used:   code.Used,
+		})
+	}
 
 
 	return User{
 	return User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{

+ 24 - 1
docs/full-configuration.md

@@ -104,8 +104,9 @@ The configuration file contains the following sections:
   - `trusted_user_ca_keys`, list of public keys paths of certificate authorities that are trusted to sign user certificates for authentication. The paths can be absolute or relative to the configuration directory.
   - `trusted_user_ca_keys`, list of public keys paths of certificate authorities that are trusted to sign user certificates for authentication. The paths can be absolute or relative to the configuration directory.
   - `login_banner_file`, path to the login banner file. The contents of the specified file, if any, are sent to the remote user before authentication is allowed. It can be a path relative to the config dir or an absolute one. Leave empty to disable login banner.
   - `login_banner_file`, path to the login banner file. The contents of the specified file, if any, are sent to the remote user before authentication is allowed. It can be a path relative to the config dir or an absolute one. Leave empty to disable login banner.
   - `enabled_ssh_commands`, list of enabled SSH commands. `*` enables all supported commands. More information can be found [here](./ssh-commands.md).
   - `enabled_ssh_commands`, list of enabled SSH commands. `*` enables all supported commands. More information can be found [here](./ssh-commands.md).
+  - `keyboard_interactive_authentication`, boolean. This setting specifies whether keyboard interactive authentication is allowed. If no keyboard interactive hook or auth plugin is defined the default is to prompt for the user password and then the one time authentication code, if defined. Default: `false`.
   - `keyboard_interactive_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for keyboard interactive authentication. See [Keyboard Interactive Authentication](./keyboard-interactive.md) for more details.
   - `keyboard_interactive_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for keyboard interactive authentication. See [Keyboard Interactive Authentication](./keyboard-interactive.md) for more details.
-  - `password_authentication`, boolean. Set to false to disable password authentication. This setting will disable multi-step authentication method using public key + password too. It is useful for public key only configurations if you need to manage old clients that will not attempt to authenticate with public keys if the password login method is advertised. Default: true.
+  - `password_authentication`, boolean. Set to false to disable password authentication. This setting will disable multi-step authentication method using public key + password too. It is useful for public key only configurations if you need to manage old clients that will not attempt to authenticate with public keys if the password login method is advertised. Default: `true`.
   - `folder_prefix`, string. Virtual root folder prefix to include in all file operations (ex: `/files`). The virtual paths used for per-directory permissions, file patterns etc. must not include the folder prefix. The prefix is only applied to SFTP requests (in SFTP server mode), SCP and other SSH commands will be automatically disabled if you configure a prefix.  The prefix is ignored while running as OpenSSH's SFTP subsystem. This setting can help some specific migrations from SFTP servers based on OpenSSH and it is not recommended for general usage. Default: empty.
   - `folder_prefix`, string. Virtual root folder prefix to include in all file operations (ex: `/files`). The virtual paths used for per-directory permissions, file patterns etc. must not include the folder prefix. The prefix is only applied to SFTP requests (in SFTP server mode), SCP and other SSH commands will be automatically disabled if you configure a prefix.  The prefix is ignored while running as OpenSSH's SFTP subsystem. This setting can help some specific migrations from SFTP servers based on OpenSSH and it is not recommended for general usage. Default: empty.
 - **"ftpd"**, the configuration for the FTP server
 - **"ftpd"**, the configuration for the FTP server
   - `bindings`, list of structs. Each struct has the following fields:
   - `bindings`, list of structs. Each struct has the following fields:
@@ -251,6 +252,11 @@ The configuration file contains the following sections:
     - `url`, string. Defines the URI to the KMS service. Default empty.
     - `url`, string. Defines the URI to the KMS service. Default empty.
     - `master_key`, string. Defines the master encryption key as string. If not empty, it takes precedence over `master_key_path`. Default empty.
     - `master_key`, string. Defines the master encryption key as string. If not empty, it takes precedence over `master_key_path`. Default empty.
     - `master_key_path, string. Defines the absolute path to a file containing the master encryption key. Default empty.
     - `master_key_path, string. Defines the absolute path to a file containing the master encryption key. Default empty.
+- **mfa**, multi-factor authentication settings
+  - `totp`, list of struct that define settings for time-based one time passwords (RFC 6238). Each struct has the following fields:
+    - `name`, string. Unique configuration name. This name should not be changed if there are users or admins using the configuration. The name is not exposed to the authentication apps. Default: `Default`.
+    - `issuer`, string. Name of the issuing Organization/Company. Default: `SFTPGo`.
+    - `algo`, string. Algorithm to use for HMAC. The supported algorithms are: `sha1`, `sha256`, `sha512`. Currently Google Authenticator app on iPhone seems to only support `sha1`, please check the compatibility with your target apps/device before setting a different algorithm. You can also define multiple configurations, for example one that uses `sha256` or `sha512` and another one that uses `sha1` and instruct your users to use the appropriate configuration for their devices/apps. The algorithm should not be changed if there are users or admins using the configuration. Default: `sha1`.
 - **plugins**, list of external plugins. Each plugin is configured using a struct with the following fields:
 - **plugins**, list of external plugins. Each plugin is configured using a struct with the following fields:
   - `type`, string. Defines the plugin type. Supported types: `notifier`, `kms`, `auth`.
   - `type`, string. Defines the plugin type. Supported types: `notifier`, `kms`, `auth`.
   - `notifier_options`, struct. Defines the options for notifier plugins.
   - `notifier_options`, struct. Defines the options for notifier plugins.
@@ -298,6 +304,23 @@ then SFTPGo will try to create `id_rsa`, `id_ecdsa` and `id_ed25519`, if they ar
 
 
 The configuration can be read from JSON, TOML, YAML, HCL, envfile and Java properties config files. If your `config-file` flag is set to `sftpgo` (default value), you need to create a configuration file called `sftpgo.json` or `sftpgo.yaml` and so on inside `config-dir`.
 The configuration can be read from JSON, TOML, YAML, HCL, envfile and Java properties config files. If your `config-file` flag is set to `sftpgo` (default value), you need to create a configuration file called `sftpgo.json` or `sftpgo.yaml` and so on inside `config-dir`.
 
 
+## Binding to privileged ports
+
+On Linux, if you want to use Internet domain privileged ports (port numbers less than 1024) instead of running the SFTPGo service as root user you can set the `cap_net_bind_service` capability on the `sftpgo` binary. To set the capability you need to be root:
+
+```shell
+root@myhost # setcap cap_net_bind_service=+ep /usr/bin/sftpgo
+```
+
+Check that the capability is added:
+
+```shell
+root@myhost # getcap /usr/bin/sftpgo
+/usr/bin/sftpgo cap_net_bind_service=ep
+```
+
+Now you can use privileged ports such as 21, 22, 443 etc.. without running the SFTPGo service as root user.
+
 ## Environment variables
 ## Environment variables
 
 
 You can also override all the available configuration options using environment variables. SFTPGo will check for environment variables with a name matching the key uppercased and prefixed with the `SFTPGO_`. You need to use `__` to traverse a struct.
 You can also override all the available configuration options using environment variables. SFTPGo will check for environment variables with a name matching the key uppercased and prefixed with the `SFTPGO_`. You need to use `__` to traverse a struct.

+ 1 - 1
docs/rest-api.md

@@ -57,7 +57,7 @@ The generated API key is returned in the response body when you create a new API
 API keys are not allowed for the following REST APIs:
 API keys are not allowed for the following REST APIs:
 
 
 - manage API keys itself. You cannot create, update, delete, enumerate API keys if you are logged in with an API key
 - manage API keys itself. You cannot create, update, delete, enumerate API keys if you are logged in with an API key
-- change password or public keys for the associated user
+- change password, public keys or second factor authentication for the associated user
 - update the impersonated admin
 - update the impersonated admin
 
 
 Please keep in mind that using an API key not associated with any administrator it is still possible to create a new administrator, with full permissions, and then impersonate it: be careful if you share unassociated API keys with third parties and with the `manage adminis` permission granted, they will basically allow full access, the only restriction is that the impersonated admin cannot be modified.
 Please keep in mind that using an API key not associated with any administrator it is still possible to create a new administrator, with full permissions, and then impersonate it: be careful if you share unassociated API keys with third parties and with the `manage adminis` permission granted, they will basically allow full access, the only restriction is that the impersonated admin cannot be modified.

+ 62 - 0
ftpd/ftpd_test.go

@@ -21,6 +21,8 @@ import (
 
 
 	ftpserver "github.com/fclairamb/ftpserverlib"
 	ftpserver "github.com/fclairamb/ftpserverlib"
 	"github.com/jlaffaye/ftp"
 	"github.com/jlaffaye/ftp"
+	"github.com/pquerna/otp"
+	"github.com/pquerna/otp/totp"
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
@@ -32,6 +34,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/httpdtest"
 	"github.com/drakkan/sftpgo/v2/httpdtest"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sftpd"
 	"github.com/drakkan/sftpgo/v2/sftpd"
 	"github.com/drakkan/sftpgo/v2/vfs"
 	"github.com/drakkan/sftpgo/v2/vfs"
@@ -305,6 +308,12 @@ func TestMain(m *testing.M) {
 		logger.ErrorToConsole("error initializing kms: %v", err)
 		logger.ErrorToConsole("error initializing kms: %v", err)
 		os.Exit(1)
 		os.Exit(1)
 	}
 	}
+	mfaConfig := config.GetMFAConfig()
+	err = mfaConfig.Initialize()
+	if err != nil {
+		logger.ErrorToConsole("error initializing MFA: %v", err)
+		os.Exit(1)
+	}
 
 
 	httpdConf := config.GetHTTPDConfig()
 	httpdConf := config.GetHTTPDConfig()
 	httpdConf.Bindings[0].Port = 8079
 	httpdConf.Bindings[0].Port = 8079
@@ -587,6 +596,50 @@ func TestBasicFTPHandling(t *testing.T) {
 		50*time.Millisecond)
 		50*time.Millisecond)
 }
 }
 
 
+func TestMultiFactorAuth(t *testing.T) {
+	u := getTestUser()
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username)
+	assert.NoError(t, err)
+	user.Password = defaultPassword
+	user.Filters.TOTPConfig = sdk.TOTPConfig{
+		Enabled:    true,
+		ConfigName: configName,
+		Secret:     kms.NewPlainSecret(secret),
+		Protocols:  []string{common.ProtocolFTP},
+	}
+	err = dataprovider.UpdateUser(&user)
+	assert.NoError(t, err)
+
+	user.Password = defaultPassword
+	_, err = getFTPClient(user, true, nil)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), dataprovider.ErrInvalidCredentials.Error())
+	}
+	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)
+	}
+	// reusing the same passcode should not work
+	_, err = getFTPClient(user, true, nil)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), dataprovider.ErrInvalidCredentials.Error())
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestLoginInvalidCredentials(t *testing.T) {
 func TestLoginInvalidCredentials(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
@@ -3096,3 +3149,12 @@ func writeCerts(certPath, keyPath, caCrtPath, caCRLPath string) error {
 	}
 	}
 	return nil
 	return nil
 }
 }
+
+func generateTOTPPasscode(secret string, algo otp.Algorithm) (string, error) {
+	return totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{
+		Period:    30,
+		Skew:      1,
+		Digits:    otp.DigitsSix,
+		Algorithm: algo,
+	})
+}

+ 28 - 27
go.mod

@@ -3,17 +3,17 @@ module github.com/drakkan/sftpgo/v2
 go 1.17
 go 1.17
 
 
 require (
 require (
-	cloud.google.com/go/storage v1.16.0
+	cloud.google.com/go/storage v1.16.1
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
-	github.com/aws/aws-sdk-go v1.40.25
+	github.com/aws/aws-sdk-go v1.40.37
 	github.com/cockroachdb/cockroach-go/v2 v2.1.1
 	github.com/cockroachdb/cockroach-go/v2 v2.1.1
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/fatih/color v1.12.0 // indirect
 	github.com/fatih/color v1.12.0 // indirect
 	github.com/fclairamb/ftpserverlib v0.15.0
 	github.com/fclairamb/ftpserverlib v0.15.0
 	github.com/fclairamb/go-log v0.1.0
 	github.com/fclairamb/go-log v0.1.0
-	github.com/go-chi/chi/v5 v5.0.4-0.20210817181946-13e9eff8bd29
+	github.com/go-chi/chi/v5 v5.0.4
 	github.com/go-chi/jwtauth/v5 v5.0.1
 	github.com/go-chi/jwtauth/v5 v5.0.1
 	github.com/go-chi/render v1.0.1
 	github.com/go-chi/render v1.0.1
 	github.com/go-sql-driver/mysql v1.6.0
 	github.com/go-sql-driver/mysql v1.6.0
@@ -24,14 +24,13 @@ require (
 	github.com/hashicorp/go-hclog v0.16.2
 	github.com/hashicorp/go-hclog v0.16.2
 	github.com/hashicorp/go-plugin v1.4.2
 	github.com/hashicorp/go-plugin v1.4.2
 	github.com/hashicorp/go-retryablehttp v0.7.0
 	github.com/hashicorp/go-retryablehttp v0.7.0
-	github.com/hashicorp/yamux v0.0.0-20210707203944-259a57b3608c // indirect
+	github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 // indirect
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
-	github.com/klauspost/compress v1.13.4
-	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
+	github.com/klauspost/compress v1.13.5
 	github.com/kr/text v0.2.0 // indirect
 	github.com/kr/text v0.2.0 // indirect
 	github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
 	github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
-	github.com/lestrrat-go/jwx v1.2.5
-	github.com/lib/pq v1.10.2
+	github.com/lestrrat-go/jwx v1.2.6
+	github.com/lib/pq v1.10.3
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/mattn/go-isatty v0.0.13 // indirect
 	github.com/mattn/go-isatty v0.0.13 // indirect
 	github.com/mattn/go-sqlite3 v1.14.8
 	github.com/mattn/go-sqlite3 v1.14.8
@@ -42,13 +41,14 @@ require (
 	github.com/otiai10/copy v1.6.0
 	github.com/otiai10/copy v1.6.0
 	github.com/pires/go-proxyproto v0.6.0
 	github.com/pires/go-proxyproto v0.6.0
 	github.com/pkg/sftp v1.13.2
 	github.com/pkg/sftp v1.13.2
+	github.com/pquerna/otp v1.3.0
 	github.com/prometheus/client_golang v1.11.0
 	github.com/prometheus/client_golang v1.11.0
 	github.com/prometheus/common v0.30.0 // indirect
 	github.com/prometheus/common v0.30.0 // indirect
 	github.com/rs/cors v1.8.0
 	github.com/rs/cors v1.8.0
 	github.com/rs/xid v1.3.0
 	github.com/rs/xid v1.3.0
-	github.com/rs/zerolog v1.23.0
+	github.com/rs/zerolog v1.24.0
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	github.com/shirou/gopsutil/v3 v3.21.7
+	github.com/shirou/gopsutil/v3 v3.21.8
 	github.com/spf13/afero v1.6.0
 	github.com/spf13/afero v1.6.0
 	github.com/spf13/cobra v1.2.1
 	github.com/spf13/cobra v1.2.1
 	github.com/spf13/viper v1.8.1
 	github.com/spf13/viper v1.8.1
@@ -58,46 +58,47 @@ require (
 	github.com/yl2chen/cidranger v1.0.2
 	github.com/yl2chen/cidranger v1.0.2
 	go.etcd.io/bbolt v1.3.6
 	go.etcd.io/bbolt v1.3.6
 	go.uber.org/automaxprocs v1.4.0
 	go.uber.org/automaxprocs v1.4.0
-	gocloud.dev v0.23.0
-	golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
-	golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d
-	golang.org/x/sys v0.0.0-20210819072135-bce67f096156
+	gocloud.dev v0.24.0
+	golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
+	golang.org/x/net v0.0.0-20210825183410-e898025ed96a
+	golang.org/x/sys v0.0.0-20210903071746-97244b99971b
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
-	google.golang.org/api v0.54.0
-	google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f // indirect
+	google.golang.org/api v0.56.0
+	google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83 // indirect
 	google.golang.org/grpc v1.40.0
 	google.golang.org/grpc v1.40.0
 	google.golang.org/protobuf v1.27.1
 	google.golang.org/protobuf v1.27.1
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )
 )
 
 
 require (
 require (
-	cloud.google.com/go v0.93.3 // indirect
-	cloud.google.com/go/kms v0.1.0 // indirect
+	cloud.google.com/go v0.94.1 // indirect
 	github.com/Azure/azure-pipeline-go v0.2.3 // indirect
 	github.com/Azure/azure-pipeline-go v0.2.3 // indirect
 	github.com/StackExchange/wmi v1.2.1 // indirect
 	github.com/StackExchange/wmi v1.2.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/boombuler/barcode v1.0.1 // indirect
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
-	github.com/cespare/xxhash/v2 v2.1.1 // indirect
+	github.com/cespare/xxhash/v2 v2.1.2 // indirect
 	github.com/coreos/go-systemd/v22 v22.3.2 // indirect
 	github.com/coreos/go-systemd/v22 v22.3.2 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 // indirect
-	github.com/fsnotify/fsnotify v1.5.0 // indirect
+	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210901152745-8830d9c9cdba // indirect
+	github.com/fsnotify/fsnotify v1.5.1 // indirect
 	github.com/go-ole/go-ole v1.2.5 // indirect
 	github.com/go-ole/go-ole v1.2.5 // indirect
-	github.com/goccy/go-json v0.7.6 // indirect
+	github.com/goccy/go-json v0.7.8 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/google/go-cmp v0.5.6 // indirect
 	github.com/google/uuid v1.3.0 // indirect
 	github.com/google/uuid v1.3.0 // indirect
-	github.com/googleapis/gax-go/v2 v2.0.5 // indirect
+	github.com/googleapis/gax-go/v2 v2.1.0 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
+	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
 	github.com/kr/fs v0.1.0 // indirect
 	github.com/kr/fs v0.1.0 // indirect
 	github.com/lestrrat-go/blackmagic v1.0.0 // indirect
 	github.com/lestrrat-go/blackmagic v1.0.0 // indirect
 	github.com/lestrrat-go/httpcc v1.0.0 // indirect
 	github.com/lestrrat-go/httpcc v1.0.0 // indirect
 	github.com/lestrrat-go/iter v1.0.1 // indirect
 	github.com/lestrrat-go/iter v1.0.1 // indirect
 	github.com/lestrrat-go/option v1.0.0 // indirect
 	github.com/lestrrat-go/option v1.0.0 // indirect
-	github.com/lestrrat-go/pdebug/v3 v3.0.1 // indirect
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/mattn/go-colorable v0.1.8 // indirect
 	github.com/mattn/go-colorable v0.1.8 // indirect
 	github.com/mattn/go-ieproxy v0.0.1 // indirect
 	github.com/mattn/go-ieproxy v0.0.1 // indirect
@@ -113,14 +114,14 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
-	github.com/tklauser/go-sysconf v0.3.8 // indirect
+	github.com/tklauser/go-sysconf v0.3.9 // indirect
 	github.com/tklauser/numcpus v0.3.0 // indirect
 	github.com/tklauser/numcpus v0.3.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
-	golang.org/x/oauth2 v0.0.0-20210817223510-7df4dd6e12ab // indirect
+	golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	gopkg.in/ini.v1 v1.62.0 // indirect
+	gopkg.in/ini.v1 v1.62.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 )
 )

+ 117 - 89
go.sum

@@ -2,7 +2,6 @@ bazil.org/fuse v0.0.0-20180421153158-65cc252bf669/go.mod h1:Xbm+BRKSBEpa4q4hTSxo
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.39.0/go.mod h1:rVLT6fkc8chs9sfPtFc1SBH6em7n+ZoXaG+87tDISts=
 cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
 cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
 cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
 cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
 cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
 cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
@@ -21,13 +20,19 @@ cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPT
 cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
 cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
 cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
 cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
 cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
 cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
+cloud.google.com/go v0.82.0/go.mod h1:vlKccHJGuFBFufnAnuB08dfEH9Y3H7dzDzRECFdC2TA=
 cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
 cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
 cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
 cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
 cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
 cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
+cloud.google.com/go v0.88.0/go.mod h1:dnKwfYbP9hQhefiUvpbcAyoGSHUrOxR20JVElLiUvEY=
+cloud.google.com/go v0.89.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
 cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
 cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
 cloud.google.com/go v0.92.2/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
 cloud.google.com/go v0.92.2/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
-cloud.google.com/go v0.93.3 h1:wPBktZFzYBcCZVARvwVKqH1uEj+aLXofJEtrb4oOsio=
+cloud.google.com/go v0.92.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
 cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
 cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
+cloud.google.com/go v0.94.0/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
+cloud.google.com/go v0.94.1 h1:DwuSvDZ1pTYGbXo8yOJevCTr3BoBlE+OVkHAKiYQUXc=
+cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -40,49 +45,54 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl
 cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo=
 cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo=
 cloud.google.com/go/kms v0.1.0 h1:VXAb5OzejDcyhFzIDeZ5n5AUdlsFnCyexuascIwWMj0=
 cloud.google.com/go/kms v0.1.0 h1:VXAb5OzejDcyhFzIDeZ5n5AUdlsFnCyexuascIwWMj0=
 cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c=
 cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c=
+cloud.google.com/go/monitoring v0.1.0/go.mod h1:Hpm3XfzJv+UTiXzCG5Ffp0wijzHTC7Cv4eR7o3x/fEE=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
 cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
 cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
 cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
 cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/pubsub v1.10.3/go.mod h1:FUcc28GpGxxACoklPsE1sCtbkY4Ix+ro7yvw+h82Jn4=
+cloud.google.com/go/pubsub v1.16.0/go.mod h1:6A8EfoWZ/lUvCWStKGwAWauJZSiuV0Mkmu6WilK/TxQ=
+cloud.google.com/go/secretmanager v0.1.0/go.mod h1:3nGKHvnzDUVit7U0S9KAKJ4aOsO1xtwRG+7ey5LK1bM=
 cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
 cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
 cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
 cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-cloud.google.com/go/storage v1.15.0/go.mod h1:mjjQMoxxyGH7Jr8K5qrx6N2O0AHsczI61sMNn03GIZI=
-cloud.google.com/go/storage v1.16.0 h1:1UwAux2OZP4310YXg5ohqBEpV16Y93uZG4+qOX7K2Kg=
-cloud.google.com/go/storage v1.16.0/go.mod h1:ieKBmUyzcftN5tbxwnXClMKH00CfcQ+xL6NN0r5QfmE=
+cloud.google.com/go/storage v1.16.1 h1:sMEIc4wxvoY3NXG7Rn9iP7jb/2buJgWR1vNXCR/UPfs=
+cloud.google.com/go/storage v1.16.1/go.mod h1:LaNorbty3ehnU3rEjXSNV/NRgQA0O8Y+uh6bPe5UOk4=
+cloud.google.com/go/trace v0.1.0/go.mod h1:wxEwsoeRVPbeSkt7ZC9nWCgmoKQRAoySN7XHW2AmI7g=
 contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA=
 contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA=
-contrib.go.opencensus.io/exporter/stackdriver v0.13.5/go.mod h1:aXENhDJ1Y4lIg4EUaVTwzvYETVNZk10Pu26tevFKLUc=
+contrib.go.opencensus.io/exporter/stackdriver v0.13.8/go.mod h1:huNtlWx75MwO7qMs0KrMxPZXzNNWebav1Sq/pm02JdQ=
 contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
 contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/Azure/azure-amqp-common-go/v3 v3.1.0/go.mod h1:PBIGdzcO1teYoufTKMcGibdKaYZv4avS+O6LNIp8bq0=
 github.com/Azure/azure-amqp-common-go/v3 v3.1.0/go.mod h1:PBIGdzcO1teYoufTKMcGibdKaYZv4avS+O6LNIp8bq0=
+github.com/Azure/azure-amqp-common-go/v3 v3.1.1/go.mod h1:YsDaPfaO9Ub2XeSKdIy2DfwuiQlHQCauHJwSqtrkECI=
 github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
 github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
 github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
 github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
 github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/azure-sdk-for-go v54.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/azure-service-bus-go v0.10.11/go.mod h1:AWw9eTTWZVZyvgpPahD1ybz3a8/vT3GsJDS8KYex55U=
-github.com/Azure/azure-storage-blob-go v0.13.0/go.mod h1:pA9kNqtjUeQF2zOSu4s//nUdBD+e64lEuc4sVnuOfNs=
+github.com/Azure/azure-sdk-for-go v57.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-service-bus-go v0.10.16/go.mod h1:MlkLwGGf1ewcx5jZadn0gUEty+tTg0RaElr6bPf+QhI=
 github.com/Azure/azure-storage-blob-go v0.14.0 h1:1BCg74AmVdYwO3dlKwtFU1V0wU2PZdREkXvAmZJRUlM=
 github.com/Azure/azure-storage-blob-go v0.14.0 h1:1BCg74AmVdYwO3dlKwtFU1V0wU2PZdREkXvAmZJRUlM=
 github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck=
 github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck=
 github.com/Azure/go-amqp v0.13.0/go.mod h1:qj+o8xPCz9tMSbQ83Vp8boHahuRDl5mkNHyt1xlxUTs=
 github.com/Azure/go-amqp v0.13.0/go.mod h1:qj+o8xPCz9tMSbQ83Vp8boHahuRDl5mkNHyt1xlxUTs=
-github.com/Azure/go-amqp v0.13.4/go.mod h1:wbpCKA8tR5MLgRyIu+bb+S6ECdIDdYJ0NlpFE9xsBPI=
-github.com/Azure/go-amqp v0.13.7/go.mod h1:wbpCKA8tR5MLgRyIu+bb+S6ECdIDdYJ0NlpFE9xsBPI=
+github.com/Azure/go-amqp v0.13.11/go.mod h1:D5ZrjQqB1dyp1A+G73xeL/kNn7D5qHJIIsNNps7YNmk=
+github.com/Azure/go-amqp v0.13.12/go.mod h1:D5ZrjQqB1dyp1A+G73xeL/kNn7D5qHJIIsNNps7YNmk=
 github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
 github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
 github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest/autorest v0.11.3/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
 github.com/Azure/go-autorest/autorest v0.11.3/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
 github.com/Azure/go-autorest/autorest v0.11.17/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw=
 github.com/Azure/go-autorest/autorest v0.11.17/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw=
-github.com/Azure/go-autorest/autorest v0.11.18 h1:90Y4srNYrwOtAgVo3ndrQkTYn6kf1Eg/AjTFJ8Is2aM=
 github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA=
 github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA=
+github.com/Azure/go-autorest/autorest v0.11.20 h1:s8H1PbCZSqg/DH7JMlOz6YMig6htWLNPsjDdlLqCx3M=
+github.com/Azure/go-autorest/autorest v0.11.20/go.mod h1:o3tqFY+QR40VOlk+pV4d77mORO64jOXSgEnPQgLK6JY=
 github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
 github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
-github.com/Azure/go-autorest/autorest/adal v0.9.2/go.mod h1:/3SMAM86bP6wC9Ev35peQDUeqFZBMH07vvUOmg4z/fE=
 github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
 github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
 github.com/Azure/go-autorest/autorest/adal v0.9.11/go.mod h1:nBKAnTomx8gDtl+3ZCJv2v0KACFHWTB2drffI1B68Pk=
 github.com/Azure/go-autorest/autorest/adal v0.9.11/go.mod h1:nBKAnTomx8gDtl+3ZCJv2v0KACFHWTB2drffI1B68Pk=
-github.com/Azure/go-autorest/autorest/adal v0.9.13 h1:Mp5hbtOePIzM8pJVRa3YLrWWmZtoxRXqUEzCfJt3+/Q=
 github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
 github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
-github.com/Azure/go-autorest/autorest/azure/auth v0.5.7/go.mod h1:AkzUsqkrdmNhfP2i54HqINVQopw0CLDnvHpJ88Zz1eI=
+github.com/Azure/go-autorest/autorest/adal v0.9.14/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
+github.com/Azure/go-autorest/autorest/adal v0.9.15 h1:X+p2GF0GWyOiSmqohIaEeuNFNDY4I4EOlVuUQvFdWMk=
+github.com/Azure/go-autorest/autorest/adal v0.9.15/go.mod h1:tGMin8I49Yij6AQ+rvV+Xa/zwxYQB5hmsd6DkfAx2+A=
+github.com/Azure/go-autorest/autorest/azure/auth v0.5.8/go.mod h1:kxyKZTSfKh8OVFWPAgOgQ/frrJgeYQJPyR5fLFmXko4=
 github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM=
 github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM=
+github.com/Azure/go-autorest/autorest/azure/cli v0.4.3/go.mod h1:yAQ2b6eP/CmLPnmLvxtT1ALIY3OR1oFcCqVBi8vHiTc=
 github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
 github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
 github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
 github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
 github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
 github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
@@ -99,7 +109,7 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
-github.com/GoogleCloudPlatform/cloudsql-proxy v1.22.0/go.mod h1:mAm5O/zik2RFmcpigNjg6nMotDL8ZXJaxKzgGVcSMFA=
+github.com/GoogleCloudPlatform/cloudsql-proxy v1.24.0/go.mod h1:3tx938GhY4FC+E1KT/jNjDw7Z5qxAEtIiERJ2sXjnII=
 github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
 github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
 github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
@@ -121,20 +131,36 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
 github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
 github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
-github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
+github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
-github.com/aws/aws-sdk-go v1.40.25 h1:Depnx7O86HWgOCLD5nMto6F9Ju85Q1QuFDnbpZYQWno=
-github.com/aws/aws-sdk-go v1.40.25/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.40.37 h1:I+Q6cLctkFyMMrKukcDnj+i2kjrQ37LGiOM6xmsxC48=
+github.com/aws/aws-sdk-go v1.40.37/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
+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=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.5.0/go.mod h1:CpNzHK9VEFUCknu50kkB8z58AH2B5DvPP7ea1LHve/Y=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.2.2/go.mod h1:BQV0agm+JEhqR+2RT5e1XTFIDcAAV0eW6z2trp+iduw=
 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.5.0/go.mod h1:acH3+MQoiMzozT/ivU+DbRg7Ooo2298RdRaWcOv+4vM=
 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.5.0/go.mod h1:acH3+MQoiMzozT/ivU+DbRg7Ooo2298RdRaWcOv+4vM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.0/go.mod h1:R1KK+vY8AfalhG1AOu5e35pOD2SdoPKQCFLTvnxiohk=
+github.com/aws/aws-sdk-go-v2/service/kms v1.5.0/go.mod h1:w7JuP9Oq1IKMFQPkNe3V6s9rOssXzOVEMNEqK1L1bao=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.6.0/go.mod h1:B+7C5UKdVq1ylkI/A6O8wcurFtaux0R1njePNPtKwoA=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.10.0/go.mod h1:4dXS5YNqI3SNbetQ7X7vfsMlX6ZnboJA2dulBwJx7+g=
+github.com/aws/aws-sdk-go-v2/service/sso v1.4.0/go.mod h1:+1fpWnL96DL23aXPpMGbsmKe8jLTEfbjuQoA4WS1VaA=
+github.com/aws/aws-sdk-go-v2/service/sts v1.7.0/go.mod h1:0qcSMCyASQPN2sk/1KQLQ2Fh6yq8wm0HSDAimPhzCoM=
 github.com/aws/smithy-go v1.5.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
 github.com/aws/smithy-go v1.5.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
+github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
 github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
 github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
 github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
+github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/casbin/casbin/v2 v2.31.6/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
 github.com/casbin/casbin/v2 v2.31.6/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
 github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
 github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
 github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
 github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
@@ -142,8 +168,9 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
 github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -161,7 +188,6 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd/v22 v22.3.1/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
 github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -174,8 +200,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60=
 github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60=
 github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
 github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
-github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 h1:sgNeV1VRMDzs6rzyPpxyM0jp317hnwiq58Filgag2xw=
 github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0/go.mod h1:J70FGZSbzsjecRTiTzER+3f1KZLNaXkuv+yeFTKoxM8=
 github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0/go.mod h1:J70FGZSbzsjecRTiTzER+3f1KZLNaXkuv+yeFTKoxM8=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210901152745-8830d9c9cdba h1:53fWlu/0nYmjfGM7IXRobqSO7P5w4TRh3Gi/f9Bpua0=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=
 github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
 github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
 github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
 github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
@@ -210,22 +238,21 @@ github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc=
 github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
 github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
 github.com/fclairamb/go-log v0.1.0 h1:fNoqk8w62i4EDEuRzDgHdDVTqMYSyr3DS981R7F2x/Y=
 github.com/fclairamb/go-log v0.1.0 h1:fNoqk8w62i4EDEuRzDgHdDVTqMYSyr3DS981R7F2x/Y=
 github.com/fclairamb/go-log v0.1.0/go.mod h1:iqmym8aI6xBbZXnZSPjElrmQrlEwjwEemOmIzKaTBM8=
 github.com/fclairamb/go-log v0.1.0/go.mod h1:iqmym8aI6xBbZXnZSPjElrmQrlEwjwEemOmIzKaTBM8=
-github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
 github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
 github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
 github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/fsnotify/fsnotify v1.5.0 h1:NO5hkcB+srp1x6QmwvNZLeaOgbM8cmBTN32THzjvu2k=
-github.com/fsnotify/fsnotify v1.5.0/go.mod h1:BX0DCEr5pT4jm2CnQdVP1lFV521fcCNcyEeNp4DQQDk=
+github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
+github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
 github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
 github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
 github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
 github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
-github.com/go-chi/chi/v5 v5.0.4-0.20210817181946-13e9eff8bd29 h1:3j7epc78R1f8xr8JZ3FsF4h8SXcj71sn1vvFkW7zccA=
-github.com/go-chi/chi/v5 v5.0.4-0.20210817181946-13e9eff8bd29/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/chi/v5 v5.0.4 h1:5e494iHzsYBiyXQAHHuI4tyJS9M3V84OuX3ufIIGHFo=
+github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-chi/jwtauth/v5 v5.0.1 h1:eyJ6Yx5VphEfjkqpZ7+LJEWThzyIcF5aN2QVpgqSIu0=
 github.com/go-chi/jwtauth/v5 v5.0.1 h1:eyJ6Yx5VphEfjkqpZ7+LJEWThzyIcF5aN2QVpgqSIu0=
 github.com/go-chi/jwtauth/v5 v5.0.1/go.mod h1:+JtcRYGZsnA4+ur1LFlb4Bei3O9WeUzoMfDZWfUJuoY=
 github.com/go-chi/jwtauth/v5 v5.0.1/go.mod h1:+JtcRYGZsnA4+ur1LFlb4Bei3O9WeUzoMfDZWfUJuoY=
 github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
 github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
@@ -260,13 +287,15 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22
 github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
 github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
 github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/goccy/go-json v0.4.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/goccy/go-json v0.4.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
-github.com/goccy/go-json v0.7.4/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
-github.com/goccy/go-json v0.7.6 h1:H0wq4jppBQ+9222sk5+hPLL25abZQiRuQ6YPnjO9c+A=
 github.com/goccy/go-json v0.7.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/goccy/go-json v0.7.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-json v0.7.8 h1:CvMH7LotYymYuLGEohBM1lTZWX4g6jzWUUl2aLFuBoE=
+github.com/goccy/go-json v0.7.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o=
+github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -319,8 +348,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
 github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-replayers/grpcreplay v1.0.0/go.mod h1:8Ig2Idjpr6gifRd6pNVggX6TC1Zw6Jx74AKp7QNH2QE=
-github.com/google/go-replayers/httpreplay v0.1.2/go.mod h1:YKZViNhiGgqdBlUbI2MwGpq4pXxNmhJLPHQ7cv2b5no=
+github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
+github.com/google/go-replayers/httpreplay v1.0.0/go.mod h1:LJhKoTwS5Wy5Ld/peq8dFFG5OfJyHEz7ft+DsTUv25M=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:xmapqc1AyLoB+ddYT6r04bD9lIjlOqGaREovi0SzFaE=
 github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:xmapqc1AyLoB+ddYT6r04bD9lIjlOqGaREovi0SzFaE=
@@ -341,14 +370,15 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210506205249-923b5ab0fc1a/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210715191844-86eeefc3e471/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
 github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
-github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
@@ -356,8 +386,9 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
 github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
 github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
 github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
 github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/googleapis/gax-go/v2 v2.1.0 h1:6DWmvNpomjL1+3liNSZbVns3zsYzzCjm6pRBO1tLeso=
+github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
@@ -407,8 +438,8 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
 github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
 github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
 github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
-github.com/hashicorp/yamux v0.0.0-20210707203944-259a57b3608c h1:nqkErwUGfpZZMqj29WZ9U/wz2OpJVDuiokLhE/3Y7IQ=
-github.com/hashicorp/yamux v0.0.0-20210707203944-259a57b3608c/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
+github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 h1:brI5vBRUlAlM34VFmnLPwjnCL/FxAJp9XvOdX6Zt+XE=
+github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
 github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -462,7 +493,6 @@ github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyX
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
 github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
-github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -485,9 +515,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
-github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s=
-github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
+github.com/klauspost/compress v1.13.5 h1:9O69jUPDcsT9fEm74W92rZL9FQY7rCdaXVneq+yyzl4=
+github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
 github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -514,26 +543,26 @@ github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBB
 github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4=
 github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4=
 github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ=
 github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ=
 github.com/lestrrat-go/codegen v1.0.0/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
 github.com/lestrrat-go/codegen v1.0.0/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
+github.com/lestrrat-go/codegen v1.0.1/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
 github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkOpYc=
 github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkOpYc=
 github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
 github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
 github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
 github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/jwx v1.1.6/go.mod h1:c+R8G7qsaFNmTzYjU98A+sMh8Bo/MJqO9GnpqR+X024=
 github.com/lestrrat-go/jwx v1.1.6/go.mod h1:c+R8G7qsaFNmTzYjU98A+sMh8Bo/MJqO9GnpqR+X024=
-github.com/lestrrat-go/jwx v1.2.5 h1:0Akd9qTHrla8eqCV54Z4wRVv54WI54dUHN5D2+mIayc=
-github.com/lestrrat-go/jwx v1.2.5/go.mod h1:CAe9Z479rJwIYDR2DqWwMm9c+gCNoYB6+0wBxPkEh0Q=
+github.com/lestrrat-go/jwx v1.2.6 h1:XAgfuHaOB7fDZ/6WhVgl8K89af768dU+3Nx4DlTbLIk=
+github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
 github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
 github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
-github.com/lestrrat-go/pdebug/v3 v3.0.1 h1:3G5sX/aw/TbMTtVc9U7IHBWRZtMvwvBziF1e4HoQtv8=
 github.com/lestrrat-go/pdebug/v3 v3.0.1/go.mod h1:za+m+Ve24yCxTEhR59N7UlnJomWwCiIqbJRmKeiADU4=
 github.com/lestrrat-go/pdebug/v3 v3.0.1/go.mod h1:za+m+Ve24yCxTEhR59N7UlnJomWwCiIqbJRmKeiADU4=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
+github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
 github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
 github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
 github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
@@ -600,7 +629,6 @@ github.com/nats-io/nats.go v1.11.0/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/
 github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
 github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
 github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
 github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
 github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
 github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
 github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
 github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
 github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
 github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
 github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
@@ -636,6 +664,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
+github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
+github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
 github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
 github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
 github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
@@ -667,8 +697,8 @@ github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
 github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
-github.com/rs/zerolog v1.23.0 h1:UskrK+saS9P9Y789yNNulYKdARjPZuS35B8gJF2x60g=
-github.com/rs/zerolog v1.23.0/go.mod h1:6c7hFfxPOy7TacJc4Fcdi24/J0NKYGzjG8FWRI916Qo=
+github.com/rs/zerolog v1.24.0 h1:76ivFxmVSRs1u2wUwJVg5VZDYQgeH1JpoS6ndgr9Wy8=
+github.com/rs/zerolog v1.24.0/go.mod h1:7KHcEGe0QZPOm2IE4Kpb5rTh6n1h2hIgS5OOnu1rUaI=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -677,8 +707,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/shirou/gopsutil/v3 v3.21.7 h1:PnTqQamUjwEDSgn+nBGu0qSDV/CfvyiR/gwTH3i7HTU=
-github.com/shirou/gopsutil/v3 v3.21.7/go.mod h1:RGl11Y7XMTQPmHh8F0ayC6haKNBgH4PXMJuTAcMOlz4=
+github.com/shirou/gopsutil/v3 v3.21.8 h1:nKct+uP0TV8DjjNiHanKf8SAuub+GNsbrOtM9Nl9biA=
+github.com/shirou/gopsutil/v3 v3.21.8/go.mod h1:YWp/H8Qs5fVmf17v7JNZzA0mPJ+mS2e9JdiUF9LlKzQ=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@@ -724,10 +754,8 @@ github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8 h1:ipNUBPHSUmH
 github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
 github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
-github.com/tklauser/go-sysconf v0.3.7/go.mod h1:JZIdXh4RmBvZDBZ41ld2bGxRV3n4daiiqA3skYhAoQ4=
-github.com/tklauser/go-sysconf v0.3.8 h1:41Nq9J+pxKud4IQ830J5LlS5nl67dVQC7AuisUooaOU=
-github.com/tklauser/go-sysconf v0.3.8/go.mod h1:z4zYWRS+X53WUKtBcmDg1comV3fPhdQnzasnIHUoLDU=
-github.com/tklauser/numcpus v0.2.3/go.mod h1:vpEPS/JC+oZGGQ/My/vJnNsvMDQL6PwOqt8dsCw5j+E=
+github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo=
+github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs=
 github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ=
 github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ=
 github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8=
 github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8=
 github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
 github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
@@ -755,6 +783,7 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
 go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
+go.opencensus.io v0.22.6/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
 go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
 go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
 go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
 go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
@@ -762,18 +791,22 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/automaxprocs v1.4.0 h1:CpDZl6aOlLhReez+8S3eEotD7Jx0Os++lemPlMULQP0=
 go.uber.org/automaxprocs v1.4.0 h1:CpDZl6aOlLhReez+8S3eEotD7Jx0Os++lemPlMULQP0=
 go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q=
 go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q=
+go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
 go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
 go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
 go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
 go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
 go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
 go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
-gocloud.dev v0.23.0 h1:u/6F8slWwaZPgGpjpNp0jzH+1P/M2ri7qEP3lFgbqBE=
-gocloud.dev v0.23.0/go.mod h1:zklCCIIo1N9ELkU2S2E7tW8P8eeMU7oGLeQCXdDwx9Q=
+go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+gocloud.dev v0.24.0 h1:cNtHD07zQQiv02OiwwDyVMuHmR7iQt2RLkzoAgz7wBs=
+gocloud.dev v0.24.0/go.mod h1:uA+als++iBX5ShuG4upQo/3Zoz49iIPlYUWHV5mM8w8=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -809,6 +842,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -817,18 +851,17 @@ golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4Iltr
 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210126194326-f9ce19ea3013/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210615190721-d04028783cf1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210817223510-7df4dd6e12ab h1:llrcWN/wOwO+6gAyfBzxb5hZ+c3mriU/0+KNgYu6adA=
-golang.org/x/oauth2 v0.0.0-20210817223510-7df4dd6e12ab/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f h1:Qmd2pbz05z7z6lm0DrgQVVPuBm92jqujBKMHMOlOQEw=
+golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -901,12 +934,9 @@ golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210420205809-ac73e9fd8988/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -916,8 +946,10 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210819072135-bce67f096156 h1:f7XLk/QXGE6IM4HjJ4ttFFlPSwJ65A1apfDd+mmViR0=
-golang.org/x/sys v0.0.0-20210819072135-bce67f096156/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210903071746-97244b99971b h1:3Dq0eVHn0uaQJmPO+/aYPI/fRMqdrVDbu7MQcku54gg=
+golang.org/x/sys v0.0.0-20210903071746-97244b99971b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -934,7 +966,6 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -956,10 +987,10 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn
 golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191010075000-0337d82405ff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -1011,11 +1042,9 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.5.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
 google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
 google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.10.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
 google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
 google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
 google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
 google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
 google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
 google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
@@ -1030,24 +1059,25 @@ google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSr
 google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
 google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
 google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
 google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
 google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
 google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
+google.golang.org/api v0.37.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
 google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
 google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
 google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
 google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
 google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
 google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
 google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
 google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
-google.golang.org/api v0.45.0/go.mod h1:ISLIJCedJolbZvDfAk+Ctuq5hf+aJ33WgtUsfyFoLXA=
 google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4ylg3I=
 google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4ylg3I=
 google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
 google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
 google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
 google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
-google.golang.org/api v0.49.0/go.mod h1:BECiH72wsfwUvOVn3+btPD5WHi0LzavZReBndi42L18=
 google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
 google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
 google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
 google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
-google.golang.org/api v0.54.0 h1:ECJUVngj71QI6XEm7b1sAf8BljU5inEhMbKPR8Lxhhk=
+google.golang.org/api v0.52.0/go.mod h1:Him/adpjt0sxtkWViy0b6xyKW/SD71CwdJ7HqJo7SrU=
 google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
 google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
+google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
+google.golang.org/api v0.56.0 h1:08F9XVYTLOGeSQb3xI9C0gXMuQanhdGed0cWFhDozbI=
+google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
 google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
 google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@@ -1058,8 +1088,6 @@ google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRn
 google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
 google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
 google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
 google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
 google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
 google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190508193815-b515fa19cec8/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
 google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
 google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
@@ -1091,37 +1119,37 @@ google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
 google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210420162539-3c870d7478d2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210423144448-3a41ef94ed2b/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
+google.golang.org/genproto v0.0.0-20210517163617-5e0236093d7a/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
 google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
 google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
 google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
 google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
-google.golang.org/genproto v0.0.0-20210624174822-c5cf32407d0a/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
 google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
 google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
 google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
 google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
 google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
 google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
+google.golang.org/genproto v0.0.0-20210721163202-f1cecdd8b78a/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
+google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
 google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
 google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
 google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
 google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
 google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
 google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
-google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f h1:enWPderunHptc5pzJkSYGx0olpF8goXzG0rY3kL0eSg=
-google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210825212027-de86158e7fda/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83 h1:3V2dxSZpz4zozWWUq36vUxXEKnSYitEH2LdsAx+RUmg=
+google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
 google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
@@ -1163,7 +1191,6 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
@@ -1172,8 +1199,9 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
 gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
 gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
 gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
 gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
 gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
 gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
-gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
 gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.62.1 h1:Idt4Iidq1iKKmhakQtqAIvBBL53JTyuNIX+wR/rmkp4=
+gopkg.in/ini.v1 v1.62.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=

+ 23 - 0
httpd/api_admin.go

@@ -64,6 +64,25 @@ func addAdmin(w http.ResponseWriter, r *http.Request) {
 	renderAdmin(w, r, admin.Username, http.StatusCreated)
 	renderAdmin(w, r, admin.Username, http.StatusCreated)
 }
 }
 
 
+func disableAdmin2FA(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	username := getURLParam(r, "username")
+	admin, err := dataprovider.AdminExists(username)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	admin.Filters.RecoveryCodes = nil
+	admin.Filters.TOTPConfig = dataprovider.TOTPConfig{
+		Enabled: false,
+	}
+	if err := dataprovider.UpdateAdmin(&admin); err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, "2FA disabled", http.StatusOK)
+}
+
 func updateAdmin(w http.ResponseWriter, r *http.Request) {
 func updateAdmin(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	username := getURLParam(r, "username")
 	username := getURLParam(r, "username")
@@ -74,6 +93,8 @@ func updateAdmin(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	adminID := admin.ID
 	adminID := admin.ID
+	totpConfig := admin.Filters.TOTPConfig
+	recoveryCodes := admin.Filters.RecoveryCodes
 	err = render.DecodeJSON(r.Body, &admin)
 	err = render.DecodeJSON(r.Body, &admin)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
@@ -102,6 +123,8 @@ func updateAdmin(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	admin.ID = adminID
 	admin.ID = adminID
 	admin.Username = username
 	admin.Username = username
+	admin.Filters.TOTPConfig = totpConfig
+	admin.Filters.RecoveryCodes = recoveryCodes
 	if err := dataprovider.UpdateAdmin(&admin); err != nil {
 	if err := dataprovider.UpdateAdmin(&admin); err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return

+ 240 - 0
httpd/api_mfa.go

@@ -0,0 +1,240 @@
+package httpd
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/go-chi/render"
+	"github.com/lithammer/shortuuid/v3"
+
+	"github.com/drakkan/sftpgo/v2/dataprovider"
+	"github.com/drakkan/sftpgo/v2/kms"
+	"github.com/drakkan/sftpgo/v2/mfa"
+	"github.com/drakkan/sftpgo/v2/sdk"
+	"github.com/drakkan/sftpgo/v2/util"
+)
+
+type generateTOTPRequest struct {
+	ConfigName string `json:"config_name"`
+}
+
+type generateTOTPResponse struct {
+	ConfigName string `json:"config_name"`
+	Issuer     string `json:"issuer"`
+	Secret     string `json:"secret"`
+	QRCode     []byte `json:"qr_code"`
+}
+
+type validateTOTPRequest struct {
+	ConfigName string `json:"config_name"`
+	Passcode   string `json:"passcode"`
+	Secret     string `json:"secret"`
+}
+
+type recoveryCode struct {
+	Code string `json:"code"`
+	Used bool   `json:"used"`
+}
+
+func getTOTPConfigs(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	render.JSON(w, r, mfa.GetAvailableTOTPConfigs())
+}
+
+func generateTOTPSecret(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	var accountName string
+	if claims.hasUserAudience() {
+		accountName = fmt.Sprintf("User %#v", claims.Username)
+	} else {
+		accountName = fmt.Sprintf("Admin %#v", claims.Username)
+	}
+
+	var req generateTOTPRequest
+	err = render.DecodeJSON(r.Body, &req)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	configName, issuer, secret, 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,
+		QRCode:     qrCode,
+	})
+}
+
+func saveTOTPConfig(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	recoveryCodes := make([]sdk.RecoveryCode, 0, 12)
+	for i := 0; i < 12; i++ {
+		code := getNewRecoveryCode()
+		recoveryCodes = append(recoveryCodes, sdk.RecoveryCode{Secret: kms.NewPlainSecret(code)})
+	}
+	if claims.hasUserAudience() {
+		if err := saveUserTOTPConfig(claims.Username, r, recoveryCodes); err != nil {
+			sendAPIResponse(w, r, err, "", getRespStatus(err))
+			return
+		}
+	} else {
+		if err := saveAdminTOTPConfig(claims.Username, r, recoveryCodes); err != nil {
+			sendAPIResponse(w, r, err, "", getRespStatus(err))
+			return
+		}
+	}
+
+	sendAPIResponse(w, r, nil, "TOTP configuration saved", http.StatusOK)
+}
+
+func validateTOTPPasscode(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	var req validateTOTPRequest
+	err := render.DecodeJSON(r.Body, &req)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	match, err := mfa.ValidateTOTPPasscode(req.ConfigName, req.Passcode, req.Secret)
+	if !match || err != nil {
+		sendAPIResponse(w, r, err, "Invalid passcode", http.StatusBadRequest)
+		return
+	}
+	sendAPIResponse(w, r, nil, "Passcode successfully validated", http.StatusOK)
+}
+
+func getRecoveryCodes(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	recoveryCodes := make([]recoveryCode, 0, 12)
+	var accountRecoveryCodes []sdk.RecoveryCode
+	if claims.hasUserAudience() {
+		user, err := dataprovider.UserExists(claims.Username)
+		if err != nil {
+			sendAPIResponse(w, r, err, "", getRespStatus(err))
+			return
+		}
+		accountRecoveryCodes = user.Filters.RecoveryCodes
+	} else {
+		admin, err := dataprovider.AdminExists(claims.Username)
+		if err != nil {
+			sendAPIResponse(w, r, err, "", getRespStatus(err))
+			return
+		}
+		accountRecoveryCodes = admin.Filters.RecoveryCodes
+	}
+
+	for _, code := range accountRecoveryCodes {
+		if err := code.Secret.Decrypt(); err != nil {
+			sendAPIResponse(w, r, err, "Unable to decrypt recovery codes", getRespStatus(err))
+			return
+		}
+		recoveryCodes = append(recoveryCodes, recoveryCode{
+			Code: code.Secret.GetPayload(),
+			Used: code.Used,
+		})
+	}
+	render.JSON(w, r, recoveryCodes)
+}
+
+func generateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	recoveryCodes := make([]string, 0, 12)
+	accountRecoveryCodes := make([]sdk.RecoveryCode, 0, 12)
+	for i := 0; i < 12; i++ {
+		code := getNewRecoveryCode()
+		recoveryCodes = append(recoveryCodes, code)
+		accountRecoveryCodes = append(accountRecoveryCodes, sdk.RecoveryCode{Secret: kms.NewPlainSecret(code)})
+	}
+	if claims.hasUserAudience() {
+		user, err := dataprovider.UserExists(claims.Username)
+		if err != nil {
+			sendAPIResponse(w, r, err, "", getRespStatus(err))
+			return
+		}
+		user.Filters.RecoveryCodes = accountRecoveryCodes
+		if err := dataprovider.UpdateUser(&user); err != nil {
+			sendAPIResponse(w, r, err, "", getRespStatus(err))
+			return
+		}
+	} else {
+		admin, err := dataprovider.AdminExists(claims.Username)
+		if err != nil {
+			sendAPIResponse(w, r, err, "", getRespStatus(err))
+			return
+		}
+		admin.Filters.RecoveryCodes = accountRecoveryCodes
+		if err := dataprovider.UpdateAdmin(&admin); err != nil {
+			sendAPIResponse(w, r, err, "", getRespStatus(err))
+			return
+		}
+	}
+
+	render.JSON(w, r, recoveryCodes)
+}
+
+func getNewRecoveryCode() string {
+	return fmt.Sprintf("RC-%v", strings.ToUpper(shortuuid.New()))
+}
+
+func saveUserTOTPConfig(username string, r *http.Request, recoveryCodes []sdk.RecoveryCode) error {
+	user, err := dataprovider.UserExists(username)
+	if err != nil {
+		return err
+	}
+	currentTOTPSecret := user.Filters.TOTPConfig.Secret
+	err = render.DecodeJSON(r.Body, &user.Filters.TOTPConfig)
+	if err != nil {
+		return util.NewValidationError(fmt.Sprintf("unable to decode JSON body: %v", err))
+	}
+	if user.Filters.TOTPConfig.Secret != nil && !user.Filters.TOTPConfig.Secret.IsPlain() {
+		user.Filters.TOTPConfig.Secret = currentTOTPSecret
+	}
+	if user.CountUnusedRecoveryCodes() < 5 && user.Filters.TOTPConfig.Enabled {
+		user.Filters.RecoveryCodes = recoveryCodes
+	}
+	return dataprovider.UpdateUser(&user)
+}
+
+func saveAdminTOTPConfig(username string, r *http.Request, recoveryCodes []sdk.RecoveryCode) error {
+	admin, err := dataprovider.AdminExists(username)
+	if err != nil {
+		return err
+	}
+	currentTOTPSecret := admin.Filters.TOTPConfig.Secret
+	err = render.DecodeJSON(r.Body, &admin.Filters.TOTPConfig)
+	if err != nil {
+		return util.NewValidationError(fmt.Sprintf("unable to decode JSON body: %v", err))
+	}
+	if admin.CountUnusedRecoveryCodes() < 5 && admin.Filters.TOTPConfig.Enabled {
+		admin.Filters.RecoveryCodes = recoveryCodes
+	}
+	if admin.Filters.TOTPConfig.Secret != nil && !admin.Filters.TOTPConfig.Secret.IsPlain() {
+		admin.Filters.TOTPConfig.Secret = currentTOTPSecret
+	}
+	return dataprovider.UpdateAdmin(&admin)
+}

+ 23 - 0
httpd/api_user.go

@@ -104,6 +104,25 @@ func addUser(w http.ResponseWriter, r *http.Request) {
 	renderUser(w, r, user.Username, http.StatusCreated)
 	renderUser(w, r, user.Username, http.StatusCreated)
 }
 }
 
 
+func disableUser2FA(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	username := getURLParam(r, "username")
+	user, err := dataprovider.UserExists(username)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	user.Filters.RecoveryCodes = nil
+	user.Filters.TOTPConfig = sdk.TOTPConfig{
+		Enabled: false,
+	}
+	if err := dataprovider.UpdateUser(&user); err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, "2FA disabled", http.StatusOK)
+}
+
 func updateUser(w http.ResponseWriter, r *http.Request) {
 func updateUser(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	var err error
 	var err error
@@ -124,6 +143,8 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 	userID := user.ID
 	userID := user.ID
+	totpConfig := user.Filters.TOTPConfig
+	recoveryCodes := user.Filters.RecoveryCodes
 	currentPermissions := user.Permissions
 	currentPermissions := user.Permissions
 	currentS3AccessSecret := user.FsConfig.S3Config.AccessSecret
 	currentS3AccessSecret := user.FsConfig.S3Config.AccessSecret
 	currentAzAccountKey := user.FsConfig.AzBlobConfig.AccountKey
 	currentAzAccountKey := user.FsConfig.AzBlobConfig.AccountKey
@@ -147,6 +168,8 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	user.ID = userID
 	user.ID = userID
 	user.Username = username
 	user.Username = username
+	user.Filters.TOTPConfig = totpConfig
+	user.Filters.RecoveryCodes = recoveryCodes
 	user.SetEmptySecretsIfNil()
 	user.SetEmptySecretsIfNil()
 	// we use new Permissions if passed otherwise the old ones
 	// we use new Permissions if passed otherwise the old ones
 	if len(user.Permissions) == 0 {
 	if len(user.Permissions) == 0 {

+ 27 - 8
httpd/auth_utils.go

@@ -18,11 +18,13 @@ import (
 type tokenAudience = string
 type tokenAudience = string
 
 
 const (
 const (
-	tokenAudienceWebAdmin  tokenAudience = "WebAdmin"
-	tokenAudienceWebClient tokenAudience = "WebClient"
-	tokenAudienceAPI       tokenAudience = "API"
-	tokenAudienceAPIUser   tokenAudience = "APIUser"
-	tokenAudienceCSRF      tokenAudience = "CSRF"
+	tokenAudienceWebAdmin         tokenAudience = "WebAdmin"
+	tokenAudienceWebClient        tokenAudience = "WebClient"
+	tokenAudienceWebAdminPartial  tokenAudience = "WebAdminPartial"
+	tokenAudienceWebClientPartial tokenAudience = "WebClientPartial"
+	tokenAudienceAPI              tokenAudience = "API"
+	tokenAudienceAPIUser          tokenAudience = "APIUser"
+	tokenAudienceCSRF             tokenAudience = "CSRF"
 )
 )
 
 
 const (
 const (
@@ -44,9 +46,17 @@ type jwtTokenClaims struct {
 	Username    string
 	Username    string
 	Permissions []string
 	Permissions []string
 	Signature   string
 	Signature   string
+	Audience    string
 	APIKeyID    string
 	APIKeyID    string
 }
 }
 
 
+func (c *jwtTokenClaims) hasUserAudience() bool {
+	if c.Audience == tokenAudienceWebClient || c.Audience == tokenAudienceAPIUser {
+		return true
+	}
+	return false
+}
+
 func (c *jwtTokenClaims) asMap() map[string]interface{} {
 func (c *jwtTokenClaims) asMap() map[string]interface{} {
 	claims := make(map[string]interface{})
 	claims := make(map[string]interface{})
 
 
@@ -75,6 +85,15 @@ func (c *jwtTokenClaims) Decode(token map[string]interface{}) {
 		c.Signature = v
 		c.Signature = v
 	}
 	}
 
 
+	audience := token[jwt.AudienceKey]
+
+	switch v := audience.(type) {
+	case []string:
+		if len(v) > 0 {
+			c.Audience = v[0]
+		}
+	}
+
 	if val, ok := token[claimAPIKey]; ok {
 	if val, ok := token[claimAPIKey]; ok {
 		switch v := val.(type) {
 		switch v := val.(type) {
 		case string:
 		case string:
@@ -142,7 +161,7 @@ func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Reque
 		return err
 		return err
 	}
 	}
 	var basePath string
 	var basePath string
-	if audience == tokenAudienceWebAdmin {
+	if audience == tokenAudienceWebAdmin || audience == tokenAudienceWebAdminPartial {
 		basePath = webBaseAdminPath
 		basePath = webBaseAdminPath
 	} else {
 	} else {
 		basePath = webBaseClientPath
 		basePath = webBaseClientPath
@@ -207,11 +226,11 @@ func isTokenInvalidated(r *http.Request) bool {
 func invalidateToken(r *http.Request) {
 func invalidateToken(r *http.Request) {
 	tokenString := jwtauth.TokenFromHeader(r)
 	tokenString := jwtauth.TokenFromHeader(r)
 	if tokenString != "" {
 	if tokenString != "" {
-		invalidatedJWTTokens.Store(tokenString, time.Now().UTC().Add(tokenDuration))
+		invalidatedJWTTokens.Store(tokenString, time.Now().Add(tokenDuration).UTC())
 	}
 	}
 	tokenString = jwtauth.TokenFromCookie(r)
 	tokenString = jwtauth.TokenFromCookie(r)
 	if tokenString != "" {
 	if tokenString != "" {
-		invalidatedJWTTokens.Store(tokenString, time.Now().UTC().Add(tokenDuration))
+		invalidatedJWTTokens.Store(tokenString, time.Now().Add(tokenDuration).UTC())
 	}
 	}
 }
 }
 
 

+ 70 - 14
httpd/httpd.go

@@ -24,6 +24,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/ftpd"
 	"github.com/drakkan/sftpgo/v2/ftpd"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sftpd"
 	"github.com/drakkan/sftpgo/v2/sftpd"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/webdavd"
 	"github.com/drakkan/sftpgo/v2/webdavd"
@@ -62,6 +63,16 @@ const (
 	userFilesPath                          = "/api/v2/user/files"
 	userFilesPath                          = "/api/v2/user/files"
 	userStreamZipPath                      = "/api/v2/user/streamzip"
 	userStreamZipPath                      = "/api/v2/user/streamzip"
 	apiKeysPath                            = "/api/v2/apikeys"
 	apiKeysPath                            = "/api/v2/apikeys"
+	adminTOTPConfigsPath                   = "/api/v2/admin/totp/configs"
+	adminTOTPGeneratePath                  = "/api/v2/admin/totp/generate"
+	adminTOTPValidatePath                  = "/api/v2/admin/totp/validate"
+	adminTOTPSavePath                      = "/api/v2/admin/totp/save"
+	admin2FARecoveryCodesPath              = "/api/v2/admin/2fa/recoverycodes"
+	userTOTPConfigsPath                    = "/api/v2/user/totp/configs"
+	userTOTPGeneratePath                   = "/api/v2/user/totp/generate"
+	userTOTPValidatePath                   = "/api/v2/user/totp/validate"
+	userTOTPSavePath                       = "/api/v2/user/totp/save"
+	user2FARecoveryCodesPath               = "/api/v2/user/2fa/recoverycodes"
 	healthzPath                            = "/healthz"
 	healthzPath                            = "/healthz"
 	webRootPathDefault                     = "/"
 	webRootPathDefault                     = "/"
 	webBasePathDefault                     = "/web"
 	webBasePathDefault                     = "/web"
@@ -69,6 +80,8 @@ const (
 	webBasePathClientDefault               = "/web/client"
 	webBasePathClientDefault               = "/web/client"
 	webAdminSetupPathDefault               = "/web/admin/setup"
 	webAdminSetupPathDefault               = "/web/admin/setup"
 	webLoginPathDefault                    = "/web/admin/login"
 	webLoginPathDefault                    = "/web/admin/login"
+	webAdminTwoFactorPathDefault           = "/web/admin/twofactor"
+	webAdminTwoFactorRecoveryPathDefault   = "/web/admin/twofactor-recovery"
 	webLogoutPathDefault                   = "/web/admin/logout"
 	webLogoutPathDefault                   = "/web/admin/logout"
 	webUsersPathDefault                    = "/web/admin/users"
 	webUsersPathDefault                    = "/web/admin/users"
 	webUserPathDefault                     = "/web/admin/user"
 	webUserPathDefault                     = "/web/admin/user"
@@ -85,16 +98,28 @@ const (
 	webQuotaScanPathDefault                = "/web/admin/quotas/scanuser"
 	webQuotaScanPathDefault                = "/web/admin/quotas/scanuser"
 	webChangeAdminPwdPathDefault           = "/web/admin/changepwd"
 	webChangeAdminPwdPathDefault           = "/web/admin/changepwd"
 	webAdminCredentialsPathDefault         = "/web/admin/credentials"
 	webAdminCredentialsPathDefault         = "/web/admin/credentials"
+	webAdminMFAPathDefault                 = "/web/admin/mfa"
+	webAdminTOTPGeneratePathDefault        = "/web/admin/totp/generate"
+	webAdminTOTPValidatePathDefault        = "/web/admin/totp/validate"
+	webAdminTOTPSavePathDefault            = "/web/admin/totp/save"
+	webAdminRecoveryCodesPathDefault       = "/web/admin/recoverycodes"
 	webChangeAdminAPIKeyAccessPathDefault  = "/web/admin/apikeyaccess"
 	webChangeAdminAPIKeyAccessPathDefault  = "/web/admin/apikeyaccess"
 	webTemplateUserDefault                 = "/web/admin/template/user"
 	webTemplateUserDefault                 = "/web/admin/template/user"
 	webTemplateFolderDefault               = "/web/admin/template/folder"
 	webTemplateFolderDefault               = "/web/admin/template/folder"
 	webDefenderPathDefault                 = "/web/admin/defender"
 	webDefenderPathDefault                 = "/web/admin/defender"
 	webDefenderHostsPathDefault            = "/web/admin/defender/hosts"
 	webDefenderHostsPathDefault            = "/web/admin/defender/hosts"
 	webClientLoginPathDefault              = "/web/client/login"
 	webClientLoginPathDefault              = "/web/client/login"
+	webClientTwoFactorPathDefault          = "/web/client/twofactor"
+	webClientTwoFactorRecoveryPathDefault  = "/web/client/twofactor-recovery"
 	webClientFilesPathDefault              = "/web/client/files"
 	webClientFilesPathDefault              = "/web/client/files"
 	webClientDirsPathDefault               = "/web/client/dirs"
 	webClientDirsPathDefault               = "/web/client/dirs"
 	webClientDownloadZipPathDefault        = "/web/client/downloadzip"
 	webClientDownloadZipPathDefault        = "/web/client/downloadzip"
 	webClientCredentialsPathDefault        = "/web/client/credentials"
 	webClientCredentialsPathDefault        = "/web/client/credentials"
+	webClientMFAPathDefault                = "/web/client/mfa"
+	webClientTOTPGeneratePathDefault       = "/web/client/totp/generate"
+	webClientTOTPValidatePathDefault       = "/web/client/totp/validate"
+	webClientTOTPSavePathDefault           = "/web/client/totp/save"
+	webClientRecoveryCodesPathDefault      = "/web/client/recoverycodes"
 	webChangeClientPwdPathDefault          = "/web/client/changepwd"
 	webChangeClientPwdPathDefault          = "/web/client/changepwd"
 	webChangeClientKeysPathDefault         = "/web/client/managekeys"
 	webChangeClientKeysPathDefault         = "/web/client/managekeys"
 	webChangeClientAPIKeyAccessPathDefault = "/web/client/apikeyaccess"
 	webChangeClientAPIKeyAccessPathDefault = "/web/client/apikeyaccess"
@@ -106,13 +131,14 @@ const (
 	maxLoginBodySize = 262144   // 256 KB
 	maxLoginBodySize = 262144   // 256 KB
 	maxMultipartMem  = 8388608  // 8MB
 	maxMultipartMem  = 8388608  // 8MB
 	osWindows        = "windows"
 	osWindows        = "windows"
+	otpHeaderCode    = "X-SFTPGO-OTP"
 )
 )
 
 
 var (
 var (
 	backupsPath                     string
 	backupsPath                     string
 	certMgr                         *common.CertManager
 	certMgr                         *common.CertManager
-	jwtTokensCleanupTicker          *time.Ticker
-	jwtTokensCleanupDone            chan bool
+	cleanupTicker                   *time.Ticker
+	cleanupDone                     chan bool
 	invalidatedJWTTokens            sync.Map
 	invalidatedJWTTokens            sync.Map
 	csrfTokenAuth                   *jwtauth.JWTAuth
 	csrfTokenAuth                   *jwtauth.JWTAuth
 	webRootPath                     string
 	webRootPath                     string
@@ -121,6 +147,8 @@ var (
 	webBaseClientPath               string
 	webBaseClientPath               string
 	webAdminSetupPath               string
 	webAdminSetupPath               string
 	webLoginPath                    string
 	webLoginPath                    string
+	webAdminTwoFactorPath           string
+	webAdminTwoFactorRecoveryPath   string
 	webLogoutPath                   string
 	webLogoutPath                   string
 	webUsersPath                    string
 	webUsersPath                    string
 	webUserPath                     string
 	webUserPath                     string
@@ -136,6 +164,11 @@ var (
 	webScanVFolderPath              string
 	webScanVFolderPath              string
 	webQuotaScanPath                string
 	webQuotaScanPath                string
 	webAdminCredentialsPath         string
 	webAdminCredentialsPath         string
+	webAdminMFAPath                 string
+	webAdminTOTPGeneratePath        string
+	webAdminTOTPValidatePath        string
+	webAdminTOTPSavePath            string
+	webAdminRecoveryCodesPath       string
 	webChangeAdminAPIKeyAccessPath  string
 	webChangeAdminAPIKeyAccessPath  string
 	webChangeAdminPwdPath           string
 	webChangeAdminPwdPath           string
 	webTemplateUser                 string
 	webTemplateUser                 string
@@ -143,12 +176,19 @@ var (
 	webDefenderPath                 string
 	webDefenderPath                 string
 	webDefenderHostsPath            string
 	webDefenderHostsPath            string
 	webClientLoginPath              string
 	webClientLoginPath              string
+	webClientTwoFactorPath          string
+	webClientTwoFactorRecoveryPath  string
 	webClientFilesPath              string
 	webClientFilesPath              string
 	webClientDirsPath               string
 	webClientDirsPath               string
 	webClientDownloadZipPath        string
 	webClientDownloadZipPath        string
 	webClientCredentialsPath        string
 	webClientCredentialsPath        string
 	webChangeClientPwdPath          string
 	webChangeClientPwdPath          string
 	webChangeClientKeysPath         string
 	webChangeClientKeysPath         string
+	webClientMFAPath                string
+	webClientTOTPGeneratePath       string
+	webClientTOTPValidatePath       string
+	webClientTOTPSavePath           string
+	webClientRecoveryCodesPath      string
 	webChangeClientAPIKeyAccessPath string
 	webChangeClientAPIKeyAccessPath string
 	webClientLogoutPath             string
 	webClientLogoutPath             string
 	webStaticFilesPath              string
 	webStaticFilesPath              string
@@ -258,6 +298,7 @@ type ServicesStatus struct {
 	WebDAV       webdavd.ServiceStatus       `json:"webdav"`
 	WebDAV       webdavd.ServiceStatus       `json:"webdav"`
 	DataProvider dataprovider.ProviderStatus `json:"data_provider"`
 	DataProvider dataprovider.ProviderStatus `json:"data_provider"`
 	Defender     defenderStatus              `json:"defender"`
 	Defender     defenderStatus              `json:"defender"`
+	MFA          mfa.ServiceStatus           `json:"mfa"`
 }
 }
 
 
 // Conf httpd daemon configuration
 // Conf httpd daemon configuration
@@ -404,7 +445,7 @@ func (c *Conf) Initialize(configDir string) error {
 	}
 	}
 
 
 	maxUploadFileSize = c.MaxUploadFileSize
 	maxUploadFileSize = c.MaxUploadFileSize
-	startJWTTokensCleanupTicker(tokenDuration)
+	startCleanupTicker(tokenDuration)
 	return <-exitChannel
 	return <-exitChannel
 }
 }
 
 
@@ -443,6 +484,7 @@ func getServicesStatus() ServicesStatus {
 		Defender: defenderStatus{
 		Defender: defenderStatus{
 			IsActive: common.Config.DefenderConfig.Enabled,
 			IsActive: common.Config.DefenderConfig.Enabled,
 		},
 		},
+		MFA: mfa.GetStatus(),
 	}
 	}
 	return status
 	return status
 }
 }
@@ -479,6 +521,8 @@ func updateWebClientURLs(baseURL string) {
 	webBasePath = path.Join(baseURL, webBasePathDefault)
 	webBasePath = path.Join(baseURL, webBasePathDefault)
 	webBaseClientPath = path.Join(baseURL, webBasePathClientDefault)
 	webBaseClientPath = path.Join(baseURL, webBasePathClientDefault)
 	webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault)
 	webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault)
+	webClientTwoFactorPath = path.Join(baseURL, webClientTwoFactorPathDefault)
+	webClientTwoFactorRecoveryPath = path.Join(baseURL, webClientTwoFactorRecoveryPathDefault)
 	webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
 	webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
 	webClientDirsPath = path.Join(baseURL, webClientDirsPathDefault)
 	webClientDirsPath = path.Join(baseURL, webClientDirsPathDefault)
 	webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault)
 	webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault)
@@ -487,6 +531,11 @@ func updateWebClientURLs(baseURL string) {
 	webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault)
 	webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault)
 	webChangeClientAPIKeyAccessPath = path.Join(baseURL, webChangeClientAPIKeyAccessPathDefault)
 	webChangeClientAPIKeyAccessPath = path.Join(baseURL, webChangeClientAPIKeyAccessPathDefault)
 	webClientLogoutPath = path.Join(baseURL, webClientLogoutPathDefault)
 	webClientLogoutPath = path.Join(baseURL, webClientLogoutPathDefault)
+	webClientMFAPath = path.Join(baseURL, webClientMFAPathDefault)
+	webClientTOTPGeneratePath = path.Join(baseURL, webClientTOTPGeneratePathDefault)
+	webClientTOTPValidatePath = path.Join(baseURL, webClientTOTPValidatePathDefault)
+	webClientTOTPSavePath = path.Join(baseURL, webClientTOTPSavePathDefault)
+	webClientRecoveryCodesPath = path.Join(baseURL, webClientRecoveryCodesPathDefault)
 }
 }
 
 
 func updateWebAdminURLs(baseURL string) {
 func updateWebAdminURLs(baseURL string) {
@@ -498,6 +547,8 @@ func updateWebAdminURLs(baseURL string) {
 	webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault)
 	webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault)
 	webAdminSetupPath = path.Join(baseURL, webAdminSetupPathDefault)
 	webAdminSetupPath = path.Join(baseURL, webAdminSetupPathDefault)
 	webLoginPath = path.Join(baseURL, webLoginPathDefault)
 	webLoginPath = path.Join(baseURL, webLoginPathDefault)
+	webAdminTwoFactorPath = path.Join(baseURL, webAdminTwoFactorPathDefault)
+	webAdminTwoFactorRecoveryPath = path.Join(baseURL, webAdminTwoFactorRecoveryPathDefault)
 	webLogoutPath = path.Join(baseURL, webLogoutPathDefault)
 	webLogoutPath = path.Join(baseURL, webLogoutPathDefault)
 	webUsersPath = path.Join(baseURL, webUsersPathDefault)
 	webUsersPath = path.Join(baseURL, webUsersPathDefault)
 	webUserPath = path.Join(baseURL, webUserPathDefault)
 	webUserPath = path.Join(baseURL, webUserPathDefault)
@@ -514,6 +565,11 @@ func updateWebAdminURLs(baseURL string) {
 	webQuotaScanPath = path.Join(baseURL, webQuotaScanPathDefault)
 	webQuotaScanPath = path.Join(baseURL, webQuotaScanPathDefault)
 	webChangeAdminPwdPath = path.Join(baseURL, webChangeAdminPwdPathDefault)
 	webChangeAdminPwdPath = path.Join(baseURL, webChangeAdminPwdPathDefault)
 	webAdminCredentialsPath = path.Join(baseURL, webAdminCredentialsPathDefault)
 	webAdminCredentialsPath = path.Join(baseURL, webAdminCredentialsPathDefault)
+	webAdminMFAPath = path.Join(baseURL, webAdminMFAPathDefault)
+	webAdminTOTPGeneratePath = path.Join(baseURL, webAdminTOTPGeneratePathDefault)
+	webAdminTOTPValidatePath = path.Join(baseURL, webAdminTOTPValidatePathDefault)
+	webAdminTOTPSavePath = path.Join(baseURL, webAdminTOTPSavePathDefault)
+	webAdminRecoveryCodesPath = path.Join(baseURL, webAdminRecoveryCodesPathDefault)
 	webChangeAdminAPIKeyAccessPath = path.Join(baseURL, webChangeAdminAPIKeyAccessPathDefault)
 	webChangeAdminAPIKeyAccessPath = path.Join(baseURL, webChangeAdminAPIKeyAccessPathDefault)
 	webTemplateUser = path.Join(baseURL, webTemplateUserDefault)
 	webTemplateUser = path.Join(baseURL, webTemplateUserDefault)
 	webTemplateFolder = path.Join(baseURL, webTemplateFolderDefault)
 	webTemplateFolder = path.Join(baseURL, webTemplateFolderDefault)
@@ -536,28 +592,28 @@ func GetHTTPRouter() http.Handler {
 }
 }
 
 
 // the ticker cannot be started/stopped from multiple goroutines
 // the ticker cannot be started/stopped from multiple goroutines
-func startJWTTokensCleanupTicker(duration time.Duration) {
-	stopJWTTokensCleanupTicker()
-	jwtTokensCleanupTicker = time.NewTicker(duration)
-	jwtTokensCleanupDone = make(chan bool)
+func startCleanupTicker(duration time.Duration) {
+	stopCleanupTicker()
+	cleanupTicker = time.NewTicker(duration)
+	cleanupDone = make(chan bool)
 
 
 	go func() {
 	go func() {
 		for {
 		for {
 			select {
 			select {
-			case <-jwtTokensCleanupDone:
+			case <-cleanupDone:
 				return
 				return
-			case <-jwtTokensCleanupTicker.C:
+			case <-cleanupTicker.C:
 				cleanupExpiredJWTTokens()
 				cleanupExpiredJWTTokens()
 			}
 			}
 		}
 		}
 	}()
 	}()
 }
 }
 
 
-func stopJWTTokensCleanupTicker() {
-	if jwtTokensCleanupTicker != nil {
-		jwtTokensCleanupTicker.Stop()
-		jwtTokensCleanupDone <- true
-		jwtTokensCleanupTicker = nil
+func stopCleanupTicker() {
+	if cleanupTicker != nil {
+		cleanupTicker.Stop()
+		cleanupDone <- true
+		cleanupTicker = nil
 	}
 	}
 }
 }
 
 

File diff suppressed because it is too large
+ 1823 - 137
httpd/httpd_test.go


+ 72 - 3
httpd/internal_test.go

@@ -389,6 +389,44 @@ func TestInvalidToken(t *testing.T) {
 	setUserPublicKeys(rr, req)
 	setUserPublicKeys(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	generateTOTPSecret(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	saveTOTPConfig(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	getRecoveryCodes(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	generateRecoveryCodes(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	server := httpdServer{}
+	server.initializeRouter()
+	rr = httptest.NewRecorder()
+	server.handleWebClientTwoFactorRecoveryPost(rr, req)
+	assert.Equal(t, http.StatusNotFound, rr.Code)
+
+	rr = httptest.NewRecorder()
+	server.handleWebClientTwoFactorPost(rr, req)
+	assert.Equal(t, http.StatusNotFound, rr.Code)
+
+	rr = httptest.NewRecorder()
+	server.handleWebAdminTwoFactorRecoveryPost(rr, req)
+	assert.Equal(t, http.StatusNotFound, rr.Code)
+
+	rr = httptest.NewRecorder()
+	server.handleWebAdminTwoFactorPost(rr, req)
+	assert.Equal(t, http.StatusNotFound, rr.Code)
 }
 }
 
 
 func TestUpdateWebAdminInvalidClaims(t *testing.T) {
 func TestUpdateWebAdminInvalidClaims(t *testing.T) {
@@ -506,6 +544,9 @@ func TestCreateTokenError(t *testing.T) {
 	rr = httptest.NewRecorder()
 	rr = httptest.NewRecorder()
 	server.handleWebAdminLoginPost(rr, req)
 	server.handleWebAdminLoginPost(rr, req)
 	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
 	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	req, _ = http.NewRequest(http.MethodPost, webAdminSetupPath, nil)
+	rr = httptest.NewRecorder()
+	server.loginAdmin(rr, req, &admin, false, nil)
 	// req with no POST body
 	// req with no POST body
 	req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%AO%GG", nil)
 	req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%AO%GG", nil)
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -555,6 +596,34 @@ func TestCreateTokenError(t *testing.T) {
 	handleWebAdminManageAPIKeyPost(rr, req)
 	handleWebAdminManageAPIKeyPost(rr, req)
 	assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
 	assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
 
 
+	req, _ = http.NewRequest(http.MethodPost, webAdminTwoFactorPath+"?a=a%C3%AO%GC", bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = httptest.NewRecorder()
+	server.handleWebAdminTwoFactorPost(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
+
+	req, _ = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath+"?a=a%C3%AO%GD", bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = httptest.NewRecorder()
+	server.handleWebAdminTwoFactorRecoveryPost(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
+
+	req, _ = http.NewRequest(http.MethodPost, webClientTwoFactorPath+"?a=a%C3%AO%GC", bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = httptest.NewRecorder()
+	server.handleWebClientTwoFactorPost(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
+
+	req, _ = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath+"?a=a%C3%AO%GD", bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = httptest.NewRecorder()
+	server.handleWebClientTwoFactorRecoveryPost(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
+
 	username := "webclientuser"
 	username := "webclientuser"
 	user = dataprovider.User{
 	user = dataprovider.User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{
@@ -1096,11 +1165,11 @@ func TestJWTTokenCleanup(t *testing.T) {
 
 
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token))
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token))
 
 
-	invalidatedJWTTokens.Store(token, time.Now().UTC().Add(-tokenDuration))
+	invalidatedJWTTokens.Store(token, time.Now().Add(-tokenDuration).UTC())
 	require.True(t, isTokenInvalidated(req))
 	require.True(t, isTokenInvalidated(req))
-	startJWTTokensCleanupTicker(100 * time.Millisecond)
+	startCleanupTicker(100 * time.Millisecond)
 	assert.Eventually(t, func() bool { return !isTokenInvalidated(req) }, 1*time.Second, 200*time.Millisecond)
 	assert.Eventually(t, func() bool { return !isTokenInvalidated(req) }, 1*time.Second, 200*time.Millisecond)
-	stopJWTTokensCleanupTicker()
+	stopCleanupTicker()
 }
 }
 
 
 func TestProxyHeaders(t *testing.T) {
 func TestProxyHeaders(t *testing.T) {

+ 60 - 6
httpd/middleware.go

@@ -65,19 +65,23 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
 		}
 		}
 		return errInvalidToken
 		return errInvalidToken
 	}
 	}
-	if !util.IsStringInSlice(audience, token.Audience()) {
-		logger.Debug(logSender, "", "the token is not valid for audience %#v", audience)
+	if isTokenInvalidated(r) {
+		logger.Debug(logSender, "", "the token has been invalidated")
 		if isAPIToken {
 		if isAPIToken {
-			sendAPIResponse(w, r, nil, "Your token audience is not valid", http.StatusUnauthorized)
+			sendAPIResponse(w, r, nil, "Your token is no longer valid", http.StatusUnauthorized)
 		} else {
 		} else {
 			http.Redirect(w, r, redirectPath, http.StatusFound)
 			http.Redirect(w, r, redirectPath, http.StatusFound)
 		}
 		}
 		return errInvalidToken
 		return errInvalidToken
 	}
 	}
-	if isTokenInvalidated(r) {
-		logger.Debug(logSender, "", "the token has been invalidated")
+	// a user with a partial token will be always redirected to the appropriate two factor auth page
+	if err := checkPartialAuth(w, r, audience, token.Audience()); err != nil {
+		return err
+	}
+	if !util.IsStringInSlice(audience, token.Audience()) {
+		logger.Debug(logSender, "", "the token is not valid for audience %#v", audience)
 		if isAPIToken {
 		if isAPIToken {
-			sendAPIResponse(w, r, nil, "Your token is no longer valid", http.StatusUnauthorized)
+			sendAPIResponse(w, r, nil, "Your token audience is not valid", http.StatusUnauthorized)
 		} else {
 		} else {
 			http.Redirect(w, r, redirectPath, http.StatusFound)
 			http.Redirect(w, r, redirectPath, http.StatusFound)
 		}
 		}
@@ -86,6 +90,44 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
 	return nil
 	return nil
 }
 }
 
 
+func validateJWTPartialToken(w http.ResponseWriter, r *http.Request, audience tokenAudience) error {
+	token, _, err := jwtauth.FromContext(r.Context())
+	var notFoundFunc func(w http.ResponseWriter, r *http.Request, err error)
+	if audience == tokenAudienceWebAdminPartial {
+		notFoundFunc = renderNotFoundPage
+	} else {
+		notFoundFunc = renderClientNotFoundPage
+	}
+	if err != nil || token == nil || jwt.Validate(token) != nil {
+		notFoundFunc(w, r, nil)
+		return errInvalidToken
+	}
+	if isTokenInvalidated(r) {
+		notFoundFunc(w, r, nil)
+		return errInvalidToken
+	}
+	if !util.IsStringInSlice(audience, token.Audience()) {
+		logger.Debug(logSender, "", "the token is not valid for audience %#v", audience)
+		notFoundFunc(w, r, nil)
+		return errInvalidToken
+	}
+
+	return nil
+}
+
+func jwtAuthenticatorPartial(audience tokenAudience) func(next http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			if err := validateJWTPartialToken(w, r, audience); err != nil {
+				return
+			}
+
+			// Token is authenticated, pass it through
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
 func jwtAuthenticatorAPI(next http.Handler) http.Handler {
 func jwtAuthenticatorAPI(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if err := validateJWTToken(w, r, tokenAudienceAPI); err != nil {
 		if err := validateJWTToken(w, r, tokenAudienceAPI); err != nil {
@@ -402,3 +444,15 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu
 
 
 	return nil
 	return nil
 }
 }
+
+func checkPartialAuth(w http.ResponseWriter, r *http.Request, audience string, tokenAudience []string) error {
+	if audience == tokenAudienceWebAdmin && util.IsStringInSlice(tokenAudienceWebAdminPartial, tokenAudience) {
+		http.Redirect(w, r, webAdminTwoFactorPath, http.StatusFound)
+		return errInvalidToken
+	}
+	if audience == tokenAudienceWebClient && util.IsStringInSlice(tokenAudienceWebClientPartial, tokenAudience) {
+		http.Redirect(w, r, webClientTwoFactorPath, http.StatusFound)
+		return errInvalidToken
+	}
+	return nil
+}

+ 590 - 6
httpd/schema/openapi.yaml

@@ -58,6 +58,13 @@ paths:
       summary: Get a new admin access token
       summary: Get a new admin access token
       description: Returns an access token and its expiration
       description: Returns an access token and its expiration
       operationId: get_token
       operationId: get_token
+      parameters:
+        - in: header
+          name: X-SFTPGO-OTP
+          schema:
+            type: string
+          required: false
+          description: 'If you have 2FA configured for the admin attempting to log in you need to set the authentication code using this header parameter'
       responses:
       responses:
         '200':
         '200':
           description: successful operation
           description: successful operation
@@ -106,6 +113,13 @@ paths:
       summary: Get a new user access token
       summary: Get a new user access token
       description: Returns an access token and its expiration
       description: Returns an access token and its expiration
       operationId: get_user_token
       operationId: get_user_token
+      parameters:
+        - in: header
+          name: X-SFTPGO-OTP
+          schema:
+            type: string
+          required: false
+          description: 'If you have 2FA configured, for the HTTP protocol, for the user attempting to log in you need to set the authentication code using this header parameter'
       responses:
       responses:
         '200':
         '200':
           description: successful operation
           description: successful operation
@@ -228,6 +242,210 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  /admin/2fa/recoverycodes:
+    get:
+      security:
+        - BearerAuth: []
+      tags:
+        - admins
+      summary: Get recovery codes
+      description: 'Returns the recovery codes for the logged in admin. Recovery codes can be used if the admin loses access to their second factor auth device. Recovery codes are returned unencrypted'
+      operationId: get_admin_recovery_codes
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/RecoveryCode'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    post:
+      security:
+        - BearerAuth: []
+      tags:
+        - admins
+      summary: Generate recovery codes
+      description: 'Generates new recovery codes for the logged in admin. Generating new recovery codes you automatically invalidate old ones'
+      operationId: generate_admin_recovery_codes
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  type: string
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /admin/totp/configs:
+    get:
+      security:
+        - BearerAuth: []
+      tags:
+        - admins
+      summary: Get available TOTP configuration
+      description: Returns the available TOTP configurations for the logged in admin
+      operationId: get_admin_totp_configs
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/TOTPConfig'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /admin/totp/generate:
+    post:
+      security:
+        - BearerAuth: []
+      tags:
+        - admins
+      summary: Generate a new TOTP secret
+      description: 'Generates a new TOTP secret, including the QR code as png, using the specified configuration for the logged in admin'
+      operationId: generate_admin_totp_secret
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                config_name:
+                  type: string
+                  description: 'name of the configuration to use to generate the secret'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  config_name:
+                    type: string
+                  issuer:
+                    type: string
+                  secret:
+                    type: string
+                  qr_code:
+                    type: string
+                    format: byte
+                    description: 'QR code png encoded as BASE64'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /admin/totp/validate:
+    post:
+      security:
+        - BearerAuth: []
+      tags:
+        - admins
+      summary: Validate a one time authentication code
+      description: 'Checks if the given authentication code can be validated using the specified secret and config name'
+      operationId: validate_admin_totp_secret
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                config_name:
+                  type: string
+                  description: 'name of the configuration to use to validate the passcode'
+                passcode:
+                  type: string
+                  description: 'passcode to validate'
+                secret:
+                  type: string
+                  description: 'secret to use to validate the passcode'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: Passcode successfully validated
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /admin/totp/save:
+    post:
+      security:
+        - BearerAuth: []
+      tags:
+        - admins
+      summary: Save a TOTP config
+      description: 'Saves the specified TOTP config for the logged in admin'
+      operationId: save_admin_totp_config
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/AdminTOTPConfig'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: TOTP configuration saved
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /connections:
   /connections:
     get:
     get:
       tags:
       tags:
@@ -1391,7 +1609,7 @@ paths:
       tags:
       tags:
         - admins
         - admins
       summary: Add admin
       summary: Add admin
-      description: Adds a new admin
+      description: 'Adds a new admin. Recovery codes and TOTP configuration cannot be set using this API: each admin must use the specific APIs'
       operationId: add_admin
       operationId: add_admin
       requestBody:
       requestBody:
         required: true
         required: true
@@ -1444,7 +1662,7 @@ paths:
       tags:
       tags:
         - admins
         - admins
       summary: Find admins by username
       summary: Find admins by username
-      description: Returns the admin with the given username, if it exists. For security reasons the hashed password is omitted in the response
+      description: 'Returns the admin with the given username, if it exists. For security reasons the hashed password is omitted in the response'
       operationId: get_admin_by_username
       operationId: get_admin_by_username
       responses:
       responses:
         '200':
         '200':
@@ -1469,7 +1687,7 @@ paths:
       tags:
       tags:
         - admins
         - admins
       summary: Update admin
       summary: Update admin
-      description: Updates an existing admin. You are not allowed to update the admin impersonated using an API key
+      description: 'Updates an existing admin. Recovery codes and TOTP configuration cannot be set/updated using this API: each admin must use the specific APIs. You are not allowed to update the admin impersonated using an API key'
       operationId: update_admin
       operationId: update_admin
       requestBody:
       requestBody:
         required: true
         required: true
@@ -1525,6 +1743,41 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  '/admins/{username}/2fa/disable':
+    parameters:
+      - name: username
+        in: path
+        description: the admin username
+        required: true
+        schema:
+          type: string
+    put:
+      tags:
+        - admins
+      summary: Disable second factor authentication
+      description: 'Disables second factor authentication for the given admin. This API must be used if the admin loses access to their second factor auth device and has no recovery codes'
+      operationId: disable_admin_2fa
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: 2FA disabled
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /users:
   /users:
     get:
     get:
       tags:
       tags:
@@ -1582,7 +1835,7 @@ paths:
       tags:
       tags:
         - users
         - users
       summary: Add user
       summary: Add user
-      description: Adds a new user
+      description: 'Adds a new user.Recovery codes and TOTP configuration cannot be set using this API: each user must use the specific APIs'
       operationId: add_user
       operationId: add_user
       requestBody:
       requestBody:
         required: true
         required: true
@@ -1644,7 +1897,7 @@ paths:
       tags:
       tags:
         - users
         - users
       summary: Update user
       summary: Update user
-      description: 'Updates an existing user and optionally disconnects it, if connected, to apply the new settings'
+      description: 'Updates an existing user and optionally disconnects it, if connected, to apply the new settings. Recovery codes and TOTP configuration cannot be set/updated using this API: each user must use the specific APIs'
       operationId: update_user
       operationId: update_user
       parameters:
       parameters:
         - in: query
         - in: query
@@ -1712,6 +1965,41 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  '/users/{username}/2fa/disable':
+    parameters:
+      - name: username
+        in: path
+        description: the username
+        required: true
+        schema:
+          type: string
+    put:
+      tags:
+        - users
+      summary: Disable second factor authentication
+      description: 'Disables second factor authentication for the given user. This API must be used if the user loses access to their second factor auth device and has no recovery codes'
+      operationId: disable_user_2fa
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: 2FA disabled
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /status:
   /status:
     get:
     get:
       tags:
       tags:
@@ -1975,6 +2263,210 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  /user/2fa/recoverycodes:
+    get:
+      security:
+        - BearerAuth: []
+      tags:
+        - users API
+      summary: Get recovery codes
+      description: 'Returns the recovery codes for the logged in user. Recovery codes can be used if the user loses access to their second factor auth device. Recovery codes are returned unencrypted'
+      operationId: get_user_recovery_codes
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/RecoveryCode'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    post:
+      security:
+        - BearerAuth: []
+      tags:
+        - users API
+      summary: Generate recovery codes
+      description: 'Generates new recovery codes for the logged in user. Generating new recovery codes you automatically invalidate old ones'
+      operationId: generate_user_recovery_codes
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  type: string
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/totp/configs:
+    get:
+      security:
+        - BearerAuth: []
+      tags:
+        - users API
+      summary: Get available TOTP configuration
+      description: Returns the available TOTP configurations for the logged in user
+      operationId: get_user_totp_configs
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/TOTPConfig'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/totp/generate:
+    post:
+      security:
+        - BearerAuth: []
+      tags:
+        - users API
+      summary: Generate a new TOTP secret
+      description: 'Generates a new TOTP secret, including the QR code as png, using the specified configuration for the logged in user'
+      operationId: generate_user_totp_secret
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                config_name:
+                  type: string
+                  description: 'name of the configuration to use to generate the secret'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  config_name:
+                    type: string
+                  issuer:
+                    type: string
+                  secret:
+                    type: string
+                  qr_code:
+                    type: string
+                    format: byte
+                    description: 'QR code png encoded as BASE64'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/totp/validate:
+    post:
+      security:
+        - BearerAuth: []
+      tags:
+        - users API
+      summary: Validate a one time authentication code
+      description: 'Checks if the given authentication code can be validated using the specified secret and config name'
+      operationId: validate_user_totp_secret
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                config_name:
+                  type: string
+                  description: 'name of the configuration to use to validate the passcode'
+                passcode:
+                  type: string
+                  description: 'passcode to validate'
+                secret:
+                  type: string
+                  description: 'secret to use to validate the passcode'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: Passcode successfully validated
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  /user/totp/save:
+    post:
+      security:
+        - BearerAuth: []
+      tags:
+        - users API
+      summary: Save a TOTP config
+      description: 'Saves the specified TOTP config for the logged in user'
+      operationId: save_user_totp_config
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/UserTOTPConfig'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: TOTP configuration saved
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /user/folder:
   /user/folder:
     get:
     get:
       tags:
       tags:
@@ -2515,16 +3007,29 @@ components:
           * `SSH` - includes both SFTP and SSH commands
           * `SSH` - includes both SFTP and SSH commands
           * `FTP` - plain FTP and FTPES/FTPS
           * `FTP` - plain FTP and FTPES/FTPS
           * `DAV` - WebDAV over HTTP/HTTPS
           * `DAV` - WebDAV over HTTP/HTTPS
-          * `HTTP` - WebClient
+          * `HTTP` - WebClient/REST API
+    MFAProtocols:
+      type: string
+      enum:
+        - SSH
+        - FTP
+        - HTTP
+      description: |
+        Protocols:
+          * `SSH` - includes both SFTP and SSH commands
+          * `FTP` - plain FTP and FTPES/FTPS
+          * `HTTP` - WebClient/REST API
     WebClientOptions:
     WebClientOptions:
       type: string
       type: string
       enum:
       enum:
         - publickey-change-disabled
         - publickey-change-disabled
         - write-disabled
         - write-disabled
+        - mfa-disabled
       description: |
       description: |
         Options:
         Options:
           * `publickey-change-disabled` - changing SSH public keys is not allowed
           * `publickey-change-disabled` - changing SSH public keys is not allowed
           * `write-disabled` - upload, rename, delete are not allowed even if the user has permissions for these actions
           * `write-disabled` - upload, rename, delete are not allowed even if the user has permissions for these actions
+          * `mfa-disabled` - the user cannot enable multi-factor authentication. This option cannot be set if the user has MFA already enabled
     APIKeyScope:
     APIKeyScope:
       type: integer
       type: integer
       enum:
       enum:
@@ -2534,6 +3039,60 @@ components:
         Options:
         Options:
           * `1` - admin scope. The API key will be used to impersonate an SFTPGo admin
           * `1` - admin scope. The API key will be used to impersonate an SFTPGo admin
           * `2` - user scope. The API key will be used to impersonate an SFTPGo user
           * `2` - user scope. The API key will be used to impersonate an SFTPGo user
+    TOTPHMacAlgo:
+      type: string
+      enum:
+        - sha1
+        - sha256
+        - sha512
+      description: 'Supported HMAC algorithms for Time-based one time passwords'
+    UserType:
+      type: string
+      enum:
+        - ''
+        - LDAPUser
+        - OSUser
+      description: This is an hint for authentication plugins. It is ignored when using SFTPGo internal authentication
+    TOTPConfig:
+      type: object
+      properties:
+        name:
+          type: string
+        issuer:
+          type: string
+        algo:
+          $ref: '#/components/schemas/TOTPHMacAlgo'
+    RecoveryCode:
+      type: object
+      properties:
+        secret:
+          $ref: '#/components/schemas/Secret'
+        used:
+          type: boolean
+      description: 'Recovery codes to use if the user loses access to their second factor auth device. Each code can only be used once, you should use these codes to login and disable or reset 2FA for your account'
+    BaseTOTPConfig:
+      type: object
+      properties:
+        enabled:
+          type: boolean
+        config_name:
+          type: string
+          description: 'This name must be defined within the "totp" section of the SFTPGo configuration file. You will be unable to save a user/admin referencing a missing config_name'
+        secret:
+          $ref: '#/components/schemas/Secret'
+    AdminTOTPConfig:
+      allOf:
+        - $ref: '#/components/schemas/BaseTOTPConfig'
+    UserTOTPConfig:
+      allOf:
+        - $ref: '#/components/schemas/BaseTOTPConfig'
+        - type: object
+          properties:
+            protocols:
+              type: array
+              items:
+                $ref: '#/components/schemas/MFAProtocols'
+              description: 'TOTP will be required for the specified protocols. SSH protocol (SFTP/SCP/SSH commands) will ask for the TOTP passcode if the client uses keyboard interactive authentication. FTP has no standard way to support two factor authentication, if you enable the FTP support, you have to add the TOTP passcode after the password. For example if your password is "password" and your one time passcode is "123456" you have to use "password123456" as password. WebDAV is not supported since each single request must be authenticated and a passcode cannot be reused.'
     PatternsFilter:
     PatternsFilter:
       type: object
       type: object
       properties:
       properties:
@@ -2628,6 +3187,14 @@ components:
         allow_api_key_auth:
         allow_api_key_auth:
           type: boolean
           type: boolean
           description: 'API key authentication allows to impersonate this user with an API key'
           description: 'API key authentication allows to impersonate this user with an API key'
+        user_type:
+          $ref: '#/components/schemas/UserType'
+        totp_config:
+          $ref: '#/components/schemas/UserTOTPConfig'
+        recovery_codes:
+          type: array
+          items:
+            $ref: '#/components/schemas/RecoveryCode'
       description: Additional user options
       description: Additional user options
     Secret:
     Secret:
       type: object
       type: object
@@ -3002,6 +3569,12 @@ components:
         allow_api_key_auth:
         allow_api_key_auth:
           type: boolean
           type: boolean
           description: 'API key auth allows to impersonate this administrator with an API key'
           description: 'API key auth allows to impersonate this administrator with an API key'
+        totp_config:
+          $ref: '#/components/schemas/AdminTOTPConfig'
+        recovery_codes:
+          type: array
+          items:
+            $ref: '#/components/schemas/RecoveryCode'
     Admin:
     Admin:
       type: object
       type: object
       properties:
       properties:
@@ -3312,6 +3885,15 @@ components:
           type: string
           type: string
         error:
         error:
           type: string
           type: string
+    MFAStatus:
+      type: object
+      properties:
+        is_active:
+          type: boolean
+        totp_configs:
+          type: array
+          items:
+            $ref: '#/components/schemas/TOTPConfig'
     ServicesStatus:
     ServicesStatus:
       type: object
       type: object
       properties:
       properties:
@@ -3328,6 +3910,8 @@ components:
           properties:
           properties:
             is_active:
             is_active:
               type: boolean
               type: boolean
+        mfa:
+          $ref: '#/components/schemas/MFAStatus'
     BanStatus:
     BanStatus:
       type: object
       type: object
       properties:
       properties:

+ 373 - 18
httpd/server.go

@@ -21,6 +21,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/version"
 	"github.com/drakkan/sftpgo/v2/version"
@@ -182,23 +183,206 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 		s.renderClientLoginPage(w, err.Error())
 		s.renderClientLoginPage(w, err.Error())
 		return
 		return
 	}
 	}
+	s.loginUser(w, r, &user, connectionID, ipAddr, false, s.renderClientLoginPage)
+}
 
 
-	c := jwtTokenClaims{
-		Username:    user.Username,
-		Permissions: user.Filters.WebClient,
-		Signature:   user.GetSignature(),
+func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	claims, err := getTokenClaims(r)
+	if err != nil {
+		renderNotFoundPage(w, r, nil)
+		return
+	}
+	if err := r.ParseForm(); err != nil {
+		renderClientTwoFactorRecoveryPage(w, err.Error())
+		return
+	}
+	username := claims.Username
+	recoveryCode := r.Form.Get("recovery_code")
+	if username == "" || recoveryCode == "" {
+		renderClientTwoFactorRecoveryPage(w, "Invalid credentials")
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderClientTwoFactorRecoveryPage(w, err.Error())
+		return
 	}
 	}
+	user, err := dataprovider.UserExists(username)
+	if err != nil {
+		renderClientTwoFactorRecoveryPage(w, "Invalid credentials")
+		return
+	}
+	if !user.Filters.TOTPConfig.Enabled || !util.IsStringInSlice(common.ProtocolHTTP, user.Filters.TOTPConfig.Protocols) {
+		renderClientTwoFactorPage(w, "Two factory authentication is not enabled")
+		return
+	}
+	for idx, code := range user.Filters.RecoveryCodes {
+		if err := code.Secret.Decrypt(); err != nil {
+			renderClientInternalServerErrorPage(w, r, fmt.Errorf("unable to decrypt recovery code: %w", err))
+			return
+		}
+		if code.Secret.GetPayload() == recoveryCode {
+			if code.Used {
+				renderClientTwoFactorRecoveryPage(w, "This recovery code was already used")
+				return
+			}
+			user.Filters.RecoveryCodes[idx].Used = true
+			err = dataprovider.UpdateUser(&user)
+			if err != nil {
+				logger.Warn(logSender, "", "unable to set the recovery code %#v as used: %v", recoveryCode, err)
+				renderClientInternalServerErrorPage(w, r, errors.New("unable to set the recovery code as used"))
+				return
+			}
+			connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String())
+			s.loginUser(w, r, &user, connectionID, util.GetIPFromRemoteAddress(r.RemoteAddr), true,
+				renderClientTwoFactorRecoveryPage)
+			return
+		}
+	}
+	renderClientTwoFactorRecoveryPage(w, "Invalid recovery code")
+}
 
 
-	err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient)
+func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	claims, err := getTokenClaims(r)
 	if err != nil {
 	if err != nil {
-		logger.Warn(logSender, connectionID, "unable to set client login cookie %v", err)
-		updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
-		s.renderClientLoginPage(w, err.Error())
+		renderNotFoundPage(w, r, nil)
 		return
 		return
 	}
 	}
-	updateLoginMetrics(&user, ipAddr, err)
-	dataprovider.UpdateLastLogin(&user)
-	http.Redirect(w, r, webClientFilesPath, http.StatusFound)
+	if err := r.ParseForm(); err != nil {
+		renderClientTwoFactorPage(w, err.Error())
+		return
+	}
+	username := claims.Username
+	passcode := r.Form.Get("passcode")
+	if username == "" || passcode == "" {
+		renderClientTwoFactorPage(w, "Invalid credentials")
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderClientTwoFactorPage(w, err.Error())
+		return
+	}
+	user, err := dataprovider.UserExists(username)
+	if err != nil {
+		renderClientTwoFactorPage(w, "Invalid credentials")
+		return
+	}
+	if !user.Filters.TOTPConfig.Enabled || !util.IsStringInSlice(common.ProtocolHTTP, user.Filters.TOTPConfig.Protocols) {
+		renderClientTwoFactorPage(w, "Two factory authentication is not enabled")
+		return
+	}
+	err = user.Filters.TOTPConfig.Secret.Decrypt()
+	if err != nil {
+		renderClientInternalServerErrorPage(w, r, err)
+		return
+	}
+	match, err := mfa.ValidateTOTPPasscode(user.Filters.TOTPConfig.ConfigName, passcode,
+		user.Filters.TOTPConfig.Secret.GetPayload())
+	if !match || err != nil {
+		renderClientTwoFactorPage(w, "Invalid authentication code")
+		return
+	}
+	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String())
+	s.loginUser(w, r, &user, connectionID, util.GetIPFromRemoteAddress(r.RemoteAddr), true, renderClientTwoFactorPage)
+}
+
+func (s *httpdServer) handleWebAdminTwoFactorRecoveryPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	claims, err := getTokenClaims(r)
+	if err != nil {
+		renderNotFoundPage(w, r, nil)
+		return
+	}
+	if err := r.ParseForm(); err != nil {
+		renderTwoFactorRecoveryPage(w, err.Error())
+		return
+	}
+	username := claims.Username
+	recoveryCode := r.Form.Get("recovery_code")
+	if username == "" || recoveryCode == "" {
+		renderTwoFactorRecoveryPage(w, "Invalid credentials")
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderTwoFactorRecoveryPage(w, err.Error())
+		return
+	}
+	admin, err := dataprovider.AdminExists(username)
+	if err != nil {
+		renderTwoFactorRecoveryPage(w, "Invalid credentials")
+		return
+	}
+	if !admin.Filters.TOTPConfig.Enabled {
+		renderTwoFactorRecoveryPage(w, "Two factory authentication is not enabled")
+		return
+	}
+	for idx, code := range admin.Filters.RecoveryCodes {
+		if err := code.Secret.Decrypt(); err != nil {
+			renderInternalServerErrorPage(w, r, fmt.Errorf("unable to decrypt recovery code: %w", err))
+			return
+		}
+		if code.Secret.GetPayload() == recoveryCode {
+			if code.Used {
+				renderTwoFactorRecoveryPage(w, "This recovery code was already used")
+				return
+			}
+			admin.Filters.RecoveryCodes[idx].Used = true
+			err = dataprovider.UpdateAdmin(&admin)
+			if err != nil {
+				logger.Warn(logSender, "", "unable to set the recovery code %#v as used: %v", recoveryCode, err)
+				renderInternalServerErrorPage(w, r, errors.New("unable to set the recovery code as used"))
+				return
+			}
+			s.loginAdmin(w, r, &admin, true, renderTwoFactorRecoveryPage)
+			return
+		}
+	}
+	renderTwoFactorRecoveryPage(w, "Invalid recovery code")
+}
+
+func (s *httpdServer) handleWebAdminTwoFactorPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	claims, err := getTokenClaims(r)
+	if err != nil {
+		renderNotFoundPage(w, r, nil)
+		return
+	}
+	if err := r.ParseForm(); err != nil {
+		renderTwoFactorPage(w, err.Error())
+		return
+	}
+	username := claims.Username
+	passcode := r.Form.Get("passcode")
+	if username == "" || passcode == "" {
+		renderTwoFactorPage(w, "Invalid credentials")
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderTwoFactorPage(w, err.Error())
+		return
+	}
+	admin, err := dataprovider.AdminExists(username)
+	if err != nil {
+		renderTwoFactorPage(w, "Invalid credentials")
+		return
+	}
+	if !admin.Filters.TOTPConfig.Enabled {
+		renderTwoFactorPage(w, "Two factory authentication is not enabled")
+		return
+	}
+	err = admin.Filters.TOTPConfig.Secret.Decrypt()
+	if err != nil {
+		renderInternalServerErrorPage(w, r, err)
+		return
+	}
+	match, err := mfa.ValidateTOTPPasscode(admin.Filters.TOTPConfig.ConfigName, passcode,
+		admin.Filters.TOTPConfig.Secret.GetPayload())
+	if !match || err != nil {
+		renderTwoFactorPage(w, "Invalid authentication code")
+		return
+	}
+	s.loginAdmin(w, r, &admin, true, renderTwoFactorPage)
 }
 }
 
 
 func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Request) {
@@ -222,7 +406,7 @@ func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Req
 		s.renderAdminLoginPage(w, err.Error())
 		s.renderAdminLoginPage(w, err.Error())
 		return
 		return
 	}
 	}
-	s.loginAdmin(w, r, &admin)
+	s.loginAdmin(w, r, &admin, false, s.renderAdminLoginPage)
 }
 }
 
 
 func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error string) {
 func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error string) {
@@ -289,25 +473,78 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req
 		renderAdminSetupPage(w, r, username, err.Error())
 		renderAdminSetupPage(w, r, username, err.Error())
 		return
 		return
 	}
 	}
-	s.loginAdmin(w, r, &admin)
+	s.loginAdmin(w, r, &admin, false, nil)
 }
 }
 
 
-func (s *httpdServer) loginAdmin(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin) {
+func (s *httpdServer) loginUser(
+	w http.ResponseWriter, r *http.Request, user *dataprovider.User, connectionID, ipAddr string,
+	isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, error string),
+) {
+	c := jwtTokenClaims{
+		Username:    user.Username,
+		Permissions: user.Filters.WebClient,
+		Signature:   user.GetSignature(),
+	}
+
+	audience := tokenAudienceWebClient
+	if user.Filters.TOTPConfig.Enabled && util.IsStringInSlice(common.ProtocolHTTP, user.Filters.TOTPConfig.Protocols) &&
+		user.CanManageMFA() && !isSecondFactorAuth {
+		audience = tokenAudienceWebClientPartial
+	}
+
+	err := c.createAndSetCookie(w, r, s.tokenAuth, audience)
+	if err != nil {
+		logger.Warn(logSender, connectionID, "unable to set user login cookie %v", err)
+		updateLoginMetrics(user, ipAddr, common.ErrInternalFailure)
+		errorFunc(w, err.Error())
+		return
+	}
+	if isSecondFactorAuth {
+		invalidateToken(r)
+	}
+	if audience == tokenAudienceWebClientPartial {
+		http.Redirect(w, r, webClientTwoFactorPath, http.StatusFound)
+		return
+	}
+	updateLoginMetrics(user, ipAddr, err)
+	dataprovider.UpdateLastLogin(user)
+	http.Redirect(w, r, webClientFilesPath, http.StatusFound)
+}
+
+func (s *httpdServer) loginAdmin(
+	w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin,
+	isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, error string),
+) {
 	c := jwtTokenClaims{
 	c := jwtTokenClaims{
 		Username:    admin.Username,
 		Username:    admin.Username,
 		Permissions: admin.Permissions,
 		Permissions: admin.Permissions,
 		Signature:   admin.GetSignature(),
 		Signature:   admin.GetSignature(),
 	}
 	}
 
 
-	err := c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin)
+	audience := tokenAudienceWebAdmin
+	if admin.Filters.TOTPConfig.Enabled && admin.CanManageMFA() && !isSecondFactorAuth {
+		audience = tokenAudienceWebAdminPartial
+	}
+
+	err := c.createAndSetCookie(w, r, s.tokenAuth, audience)
 	if err != nil {
 	if err != nil {
 		logger.Warn(logSender, "", "unable to set admin login cookie %v", err)
 		logger.Warn(logSender, "", "unable to set admin login cookie %v", err)
-		s.renderAdminLoginPage(w, err.Error())
+		if errorFunc == nil {
+			renderAdminSetupPage(w, r, admin.Username, err.Error())
+			return
+		}
+		errorFunc(w, err.Error())
+		return
+	}
+	if isSecondFactorAuth {
+		invalidateToken(r)
+	}
+	if audience == tokenAudienceWebAdminPartial {
+		http.Redirect(w, r, webAdminTwoFactorPath, http.StatusFound)
 		return
 		return
 	}
 	}
-
-	http.Redirect(w, r, webUsersPath, http.StatusFound)
 	dataprovider.UpdateAdminLastLogin(admin)
 	dataprovider.UpdateAdminLastLogin(admin)
+	http.Redirect(w, r, webUsersPath, http.StatusFound)
 }
 }
 
 
 func (s *httpdServer) logout(w http.ResponseWriter, r *http.Request) {
 func (s *httpdServer) logout(w http.ResponseWriter, r *http.Request) {
@@ -351,6 +588,34 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
+	if user.Filters.TOTPConfig.Enabled && util.IsStringInSlice(common.ProtocolHTTP, user.Filters.TOTPConfig.Protocols) {
+		passcode := r.Header.Get(otpHeaderCode)
+		if passcode == "" {
+			logger.Debug(logSender, "", "TOTP enabled for user %#v and not passcode provided, authentication refused", user.Username)
+			w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+			updateLoginMetrics(&user, ipAddr, dataprovider.ErrInvalidCredentials)
+			sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized),
+				http.StatusUnauthorized)
+			return
+		}
+		err = user.Filters.TOTPConfig.Secret.Decrypt()
+		if err != nil {
+			updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
+			sendAPIResponse(w, r, fmt.Errorf("unable to decrypt TOTP secret: %w", err), http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+		match, err := mfa.ValidateTOTPPasscode(user.Filters.TOTPConfig.ConfigName, passcode,
+			user.Filters.TOTPConfig.Secret.GetPayload())
+		if !match || err != nil {
+			logger.Debug(logSender, "invalid passcode for user %#v, match? %v, err: %v", user.Username, match, err)
+			w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+			updateLoginMetrics(&user, ipAddr, dataprovider.ErrInvalidCredentials)
+			sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized),
+				http.StatusUnauthorized)
+			return
+		}
+	}
+
 	defer user.CloseFs() //nolint:errcheck
 	defer user.CloseFs() //nolint:errcheck
 	err = user.CheckFsRoot(connectionID)
 	err = user.CheckFsRoot(connectionID)
 	if err != nil {
 	if err != nil {
@@ -396,6 +661,30 @@ func (s *httpdServer) getToken(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 		sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 		return
 		return
 	}
 	}
+	if admin.Filters.TOTPConfig.Enabled {
+		passcode := r.Header.Get(otpHeaderCode)
+		if passcode == "" {
+			logger.Debug(logSender, "", "TOTP enabled for admin %#v and not passcode provided, authentication refused", admin.Username)
+			w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+			sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			return
+		}
+		err = admin.Filters.TOTPConfig.Secret.Decrypt()
+		if err != nil {
+			sendAPIResponse(w, r, fmt.Errorf("unable to decrypt TOTP secret: %w", err),
+				http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+		match, err := mfa.ValidateTOTPPasscode(admin.Filters.TOTPConfig.ConfigName, passcode,
+			admin.Filters.TOTPConfig.Secret.GetPayload())
+		if !match || err != nil {
+			logger.Debug(logSender, "invalid passcode for admin %#v, match? %v, err: %v", admin.Username, match, err)
+			w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+			sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized),
+				http.StatusUnauthorized)
+			return
+		}
+	}
 
 
 	s.generateAndSendToken(w, r, admin)
 	s.generateAndSendToken(w, r, admin)
 }
 }
@@ -619,6 +908,13 @@ func (s *httpdServer) initializeRouter() {
 		router.With(forbidAPIKeyAuthentication).Put(adminPwdPath, changeAdminPassword)
 		router.With(forbidAPIKeyAuthentication).Put(adminPwdPath, changeAdminPassword)
 		// compatibility layer to remove in v2.2
 		// compatibility layer to remove in v2.2
 		router.With(forbidAPIKeyAuthentication).Put(adminPwdCompatPath, changeAdminPassword)
 		router.With(forbidAPIKeyAuthentication).Put(adminPwdCompatPath, changeAdminPassword)
+		// admin TOTP APIs
+		router.With(forbidAPIKeyAuthentication).Get(adminTOTPConfigsPath, getTOTPConfigs)
+		router.With(forbidAPIKeyAuthentication).Post(adminTOTPGeneratePath, generateTOTPSecret)
+		router.With(forbidAPIKeyAuthentication).Post(adminTOTPValidatePath, validateTOTPPasscode)
+		router.With(forbidAPIKeyAuthentication).Post(adminTOTPSavePath, saveTOTPConfig)
+		router.With(forbidAPIKeyAuthentication).Get(admin2FARecoveryCodesPath, getRecoveryCodes)
+		router.With(forbidAPIKeyAuthentication).Post(admin2FARecoveryCodesPath, generateRecoveryCodes)
 
 
 		router.With(checkPerm(dataprovider.PermAdminViewServerStatus)).
 		router.With(checkPerm(dataprovider.PermAdminViewServerStatus)).
 			Get(serverStatusPath, func(w http.ResponseWriter, r *http.Request) {
 			Get(serverStatusPath, func(w http.ResponseWriter, r *http.Request) {
@@ -647,6 +943,7 @@ func (s *httpdServer) initializeRouter() {
 		router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(userPath+"/{username}", getUserByUsername)
 		router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(userPath+"/{username}", getUserByUsername)
 		router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(userPath+"/{username}", updateUser)
 		router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(userPath+"/{username}", updateUser)
 		router.With(checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(userPath+"/{username}", deleteUser)
 		router.With(checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(userPath+"/{username}", deleteUser)
+		router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(userPath+"/{username}/2fa/disable", disableUser2FA)
 		router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(folderPath, getFolders)
 		router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(folderPath, getFolders)
 		router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(folderPath+"/{name}", getFolderByName)
 		router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(folderPath+"/{name}", getFolderByName)
 		router.With(checkPerm(dataprovider.PermAdminAddUsers)).Post(folderPath, addFolder)
 		router.With(checkPerm(dataprovider.PermAdminAddUsers)).Post(folderPath, addFolder)
@@ -670,6 +967,7 @@ func (s *httpdServer) initializeRouter() {
 		router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Get(adminPath+"/{username}", getAdminByUsername)
 		router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Get(adminPath+"/{username}", getAdminByUsername)
 		router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Put(adminPath+"/{username}", updateAdmin)
 		router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Put(adminPath+"/{username}", updateAdmin)
 		router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Delete(adminPath+"/{username}", deleteAdmin)
 		router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Delete(adminPath+"/{username}", deleteAdmin)
+		router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Put(adminPath+"/{username}/2fa/disable", disableAdmin2FA)
 		router.With(forbidAPIKeyAuthentication, checkPerm(dataprovider.PermAdminManageAPIKeys)).
 		router.With(forbidAPIKeyAuthentication, checkPerm(dataprovider.PermAdminManageAPIKeys)).
 			Get(apiKeysPath, getAPIKeys)
 			Get(apiKeysPath, getAPIKeys)
 		router.With(forbidAPIKeyAuthentication, checkPerm(dataprovider.PermAdminManageAPIKeys)).
 		router.With(forbidAPIKeyAuthentication, checkPerm(dataprovider.PermAdminManageAPIKeys)).
@@ -695,6 +993,20 @@ func (s *httpdServer) initializeRouter() {
 			Get(userPublicKeysPath, getUserPublicKeys)
 			Get(userPublicKeysPath, getUserPublicKeys)
 		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).
 		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).
 			Put(userPublicKeysPath, setUserPublicKeys)
 			Put(userPublicKeysPath, setUserPublicKeys)
+		// user TOTP APIs
+		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
+			Get(userTOTPConfigsPath, getTOTPConfigs)
+		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
+			Post(userTOTPGeneratePath, generateTOTPSecret)
+		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
+			Post(userTOTPValidatePath, validateTOTPPasscode)
+		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
+			Post(userTOTPSavePath, saveTOTPConfig)
+		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
+			Get(user2FARecoveryCodesPath, getRecoveryCodes)
+		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
+			Post(user2FARecoveryCodesPath, generateRecoveryCodes)
+
 		// compatibility layer to remove in v2.3
 		// compatibility layer to remove in v2.3
 		router.With(compressor.Handler).Get(userFolderPath, readUserFolder)
 		router.With(compressor.Handler).Get(userFolderPath, readUserFolder)
 		router.Get(userFilePath, getUserFile)
 		router.Get(userFilePath, getUserFile)
@@ -743,6 +1055,18 @@ func (s *httpdServer) initializeRouter() {
 		})
 		})
 		s.router.Get(webClientLoginPath, s.handleClientWebLogin)
 		s.router.Get(webClientLoginPath, s.handleClientWebLogin)
 		s.router.Post(webClientLoginPath, s.handleWebClientLoginPost)
 		s.router.Post(webClientLoginPath, s.handleWebClientLoginPost)
+		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+			jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
+			Get(webClientTwoFactorPath, handleWebClientTwoFactor)
+		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+			jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
+			Post(webClientTwoFactorPath, s.handleWebClientTwoFactorPost)
+		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+			jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
+			Get(webClientTwoFactorRecoveryPath, handleWebClientTwoFactorRecovery)
+		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+			jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
+			Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost)
 
 
 		s.router.Group(func(router chi.Router) {
 		s.router.Group(func(router chi.Router) {
 			router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
 			router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
@@ -769,6 +1093,18 @@ func (s *httpdServer) initializeRouter() {
 			router.Post(webChangeClientAPIKeyAccessPath, handleWebClientManageAPIKeyPost)
 			router.Post(webChangeClientAPIKeyAccessPath, handleWebClientManageAPIKeyPost)
 			router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).
 			router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).
 				Post(webChangeClientKeysPath, handleWebClientManageKeysPost)
 				Post(webChangeClientKeysPath, handleWebClientManageKeysPost)
+			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie).
+				Get(webClientMFAPath, handleWebClientMFA)
+			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
+				Post(webClientTOTPGeneratePath, generateTOTPSecret)
+			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
+				Post(webClientTOTPValidatePath, validateTOTPPasscode)
+			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
+				Post(webClientTOTPSavePath, saveTOTPConfig)
+			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader, s.refreshCookie).
+				Get(webClientRecoveryCodesPath, getRecoveryCodes)
+			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
+				Post(webClientRecoveryCodesPath, generateRecoveryCodes)
 		})
 		})
 	}
 	}
 
 
@@ -781,6 +1117,18 @@ func (s *httpdServer) initializeRouter() {
 		s.router.Post(webLoginPath, s.handleWebAdminLoginPost)
 		s.router.Post(webLoginPath, s.handleWebAdminLoginPost)
 		s.router.Get(webAdminSetupPath, handleWebAdminSetupGet)
 		s.router.Get(webAdminSetupPath, handleWebAdminSetupGet)
 		s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)
 		s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)
+		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+			jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
+			Get(webAdminTwoFactorPath, handleWebAdminTwoFactor)
+		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+			jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
+			Post(webAdminTwoFactorPath, s.handleWebAdminTwoFactorPost)
+		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+			jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
+			Get(webAdminTwoFactorRecoveryPath, handleWebAdminTwoFactorRecovery)
+		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+			jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
+			Post(webAdminTwoFactorRecoveryPath, s.handleWebAdminTwoFactorRecoveryPost)
 
 
 		s.router.Group(func(router chi.Router) {
 		s.router.Group(func(router chi.Router) {
 			router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
 			router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
@@ -790,6 +1138,13 @@ func (s *httpdServer) initializeRouter() {
 			router.With(s.refreshCookie).Get(webAdminCredentialsPath, handleWebAdminCredentials)
 			router.With(s.refreshCookie).Get(webAdminCredentialsPath, handleWebAdminCredentials)
 			router.Post(webChangeAdminPwdPath, handleWebAdminChangePwdPost)
 			router.Post(webChangeAdminPwdPath, handleWebAdminChangePwdPost)
 			router.Post(webChangeAdminAPIKeyAccessPath, handleWebAdminManageAPIKeyPost)
 			router.Post(webChangeAdminAPIKeyAccessPath, handleWebAdminManageAPIKeyPost)
+			router.With(s.refreshCookie).Get(webAdminMFAPath, handleWebAdminMFA)
+			router.With(verifyCSRFHeader).Post(webAdminTOTPGeneratePath, generateTOTPSecret)
+			router.With(verifyCSRFHeader).Post(webAdminTOTPValidatePath, validateTOTPPasscode)
+			router.With(verifyCSRFHeader).Post(webAdminTOTPSavePath, saveTOTPConfig)
+			router.With(verifyCSRFHeader, s.refreshCookie).Get(webAdminRecoveryCodesPath, getRecoveryCodes)
+			router.With(verifyCSRFHeader).Post(webAdminRecoveryCodesPath, generateRecoveryCodes)
+
 			router.With(checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
 			router.With(checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
 				Get(webUsersPath, handleGetWebUsers)
 				Get(webUsersPath, handleGetWebUsers)
 			router.With(checkPerm(dataprovider.PermAdminAddUsers), s.refreshCookie).
 			router.With(checkPerm(dataprovider.PermAdminAddUsers), s.refreshCookie).

+ 22 - 10
httpd/web.go

@@ -5,16 +5,19 @@ import (
 )
 )
 
 
 const (
 const (
-	page400Title      = "Bad request"
-	page403Title      = "Forbidden"
-	page404Title      = "Not found"
-	page404Body       = "The page you are looking for does not exist."
-	page500Title      = "Internal Server Error"
-	page500Body       = "The server is unable to fulfill your request."
-	webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS
-	redactedSecret    = "[**redacted**]"
-	csrfFormToken     = "_form_token"
-	csrfHeaderToken   = "X-CSRF-TOKEN"
+	pageMFATitle              = "Two-factor authentication"
+	page400Title              = "Bad request"
+	page403Title              = "Forbidden"
+	page404Title              = "Not found"
+	page404Body               = "The page you are looking for does not exist."
+	page500Title              = "Internal Server Error"
+	page500Body               = "The server is unable to fulfill your request."
+	webDateTimeFormat         = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS
+	redactedSecret            = "[**redacted**]"
+	csrfFormToken             = "_form_token"
+	csrfHeaderToken           = "X-CSRF-TOKEN"
+	templateTwoFactor         = "twofactor.html"
+	templateTwoFactorRecovery = "twofactor-recovery.html"
 )
 )
 
 
 type loginPage struct {
 type loginPage struct {
@@ -26,6 +29,15 @@ type loginPage struct {
 	AltLoginURL string
 	AltLoginURL string
 }
 }
 
 
+type twoFactorPage struct {
+	CurrentURL  string
+	Version     string
+	Error       string
+	CSRFToken   string
+	StaticURL   string
+	RecoveryURL string
+}
+
 func getSliceFromDelimitedValues(values, delimiter string) []string {
 func getSliceFromDelimitedValues(values, delimiter string) []string {
 	result := []string{}
 	result := []string{}
 	for _, v := range strings.Split(values, delimiter) {
 	for _, v := range strings.Split(values, delimiter) {

+ 95 - 0
httpd/webadmin.go

@@ -17,6 +17,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/kms"
+	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/version"
 	"github.com/drakkan/sftpgo/v2/version"
@@ -42,6 +43,7 @@ const (
 const (
 const (
 	templateAdminDir     = "webadmin"
 	templateAdminDir     = "webadmin"
 	templateBase         = "base.html"
 	templateBase         = "base.html"
+	templateBaseLogin    = "baselogin.html"
 	templateFsConfig     = "fsconfig.html"
 	templateFsConfig     = "fsconfig.html"
 	templateUsers        = "users.html"
 	templateUsers        = "users.html"
 	templateUser         = "user.html"
 	templateUser         = "user.html"
@@ -56,6 +58,7 @@ const (
 	templateDefender     = "defender.html"
 	templateDefender     = "defender.html"
 	templateCredentials  = "credentials.html"
 	templateCredentials  = "credentials.html"
 	templateMaintenance  = "maintenance.html"
 	templateMaintenance  = "maintenance.html"
+	templateMFA          = "mfa.html"
 	templateSetup        = "adminsetup.html"
 	templateSetup        = "adminsetup.html"
 	pageUsersTitle       = "Users"
 	pageUsersTitle       = "Users"
 	pageAdminsTitle      = "Admins"
 	pageAdminsTitle      = "Admins"
@@ -89,6 +92,7 @@ type basePage struct {
 	DefenderURL        string
 	DefenderURL        string
 	LogoutURL          string
 	LogoutURL          string
 	CredentialsURL     string
 	CredentialsURL     string
+	MFAURL             string
 	FolderQuotaScanURL string
 	FolderQuotaScanURL string
 	StatusURL          string
 	StatusURL          string
 	MaintenanceURL     string
 	MaintenanceURL     string
@@ -162,6 +166,16 @@ type credentialsPage struct {
 	APIKeyError     string
 	APIKeyError     string
 }
 }
 
 
+type mfaPage struct {
+	basePage
+	TOTPConfigs     []string
+	TOTPConfig      dataprovider.TOTPConfig
+	GenerateTOTPURL string
+	ValidateTOTPURL string
+	SaveTOTPURL     string
+	RecCodesURL     string
+}
+
 type maintenancePage struct {
 type maintenancePage struct {
 	basePage
 	basePage
 	BackupPath  string
 	BackupPath  string
@@ -243,6 +257,7 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateStatus),
 		filepath.Join(templatesPath, templateAdminDir, templateStatus),
 	}
 	}
 	loginPath := []string{
 	loginPath := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBaseLogin),
 		filepath.Join(templatesPath, templateAdminDir, templateLogin),
 		filepath.Join(templatesPath, templateAdminDir, templateLogin),
 	}
 	}
 	maintenancePath := []string{
 	maintenancePath := []string{
@@ -253,7 +268,20 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateDefender),
 		filepath.Join(templatesPath, templateAdminDir, templateDefender),
 	}
 	}
+	mfaPath := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBase),
+		filepath.Join(templatesPath, templateAdminDir, templateMFA),
+	}
+	twoFactorPath := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBaseLogin),
+		filepath.Join(templatesPath, templateAdminDir, templateTwoFactor),
+	}
+	twoFactorRecoveryPath := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBaseLogin),
+		filepath.Join(templatesPath, templateAdminDir, templateTwoFactorRecovery),
+	}
 	setupPath := []string{
 	setupPath := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBaseLogin),
 		filepath.Join(templatesPath, templateAdminDir, templateSetup),
 		filepath.Join(templatesPath, templateAdminDir, templateSetup),
 	}
 	}
 
 
@@ -273,6 +301,9 @@ func loadAdminTemplates(templatesPath string) {
 	credentialsTmpl := util.LoadTemplate(rootTpl, credentialsPaths...)
 	credentialsTmpl := util.LoadTemplate(rootTpl, credentialsPaths...)
 	maintenanceTmpl := util.LoadTemplate(rootTpl, maintenancePath...)
 	maintenanceTmpl := util.LoadTemplate(rootTpl, maintenancePath...)
 	defenderTmpl := util.LoadTemplate(rootTpl, defenderPath...)
 	defenderTmpl := util.LoadTemplate(rootTpl, defenderPath...)
+	mfaTmpl := util.LoadTemplate(nil, mfaPath...)
+	twoFactorTmpl := util.LoadTemplate(nil, twoFactorPath...)
+	twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...)
 	setupTmpl := util.LoadTemplate(rootTpl, setupPath...)
 	setupTmpl := util.LoadTemplate(rootTpl, setupPath...)
 
 
 	adminTemplates[templateUsers] = usersTmpl
 	adminTemplates[templateUsers] = usersTmpl
@@ -288,6 +319,9 @@ func loadAdminTemplates(templatesPath string) {
 	adminTemplates[templateCredentials] = credentialsTmpl
 	adminTemplates[templateCredentials] = credentialsTmpl
 	adminTemplates[templateMaintenance] = maintenanceTmpl
 	adminTemplates[templateMaintenance] = maintenanceTmpl
 	adminTemplates[templateDefender] = defenderTmpl
 	adminTemplates[templateDefender] = defenderTmpl
+	adminTemplates[templateMFA] = mfaTmpl
+	adminTemplates[templateTwoFactor] = twoFactorTmpl
+	adminTemplates[templateTwoFactorRecovery] = twoFactorRecoveryTmpl
 	adminTemplates[templateSetup] = setupTmpl
 	adminTemplates[templateSetup] = setupTmpl
 }
 }
 
 
@@ -310,6 +344,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
 		DefenderURL:        webDefenderPath,
 		DefenderURL:        webDefenderPath,
 		LogoutURL:          webLogoutPath,
 		LogoutURL:          webLogoutPath,
 		CredentialsURL:     webAdminCredentialsPath,
 		CredentialsURL:     webAdminCredentialsPath,
+		MFAURL:             webAdminMFAPath,
 		QuotaScanURL:       webQuotaScanPath,
 		QuotaScanURL:       webQuotaScanPath,
 		ConnectionsURL:     webConnectionsPath,
 		ConnectionsURL:     webConnectionsPath,
 		StatusURL:          webStatusPath,
 		StatusURL:          webStatusPath,
@@ -370,6 +405,47 @@ func renderNotFoundPage(w http.ResponseWriter, r *http.Request, err error) {
 	renderMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "")
 	renderMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "")
 }
 }
 
 
+func renderTwoFactorPage(w http.ResponseWriter, error string) {
+	data := twoFactorPage{
+		CurrentURL:  webAdminTwoFactorPath,
+		Version:     version.Get().Version,
+		Error:       error,
+		CSRFToken:   createCSRFToken(),
+		StaticURL:   webStaticFilesPath,
+		RecoveryURL: webAdminTwoFactorRecoveryPath,
+	}
+	renderAdminTemplate(w, templateTwoFactor, data)
+}
+
+func renderTwoFactorRecoveryPage(w http.ResponseWriter, error string) {
+	data := twoFactorPage{
+		CurrentURL: webAdminTwoFactorRecoveryPath,
+		Version:    version.Get().Version,
+		Error:      error,
+		CSRFToken:  createCSRFToken(),
+		StaticURL:  webStaticFilesPath,
+	}
+	renderAdminTemplate(w, templateTwoFactorRecovery, data)
+}
+
+func renderMFAPage(w http.ResponseWriter, r *http.Request) {
+	data := mfaPage{
+		basePage:        getBasePageData(pageMFATitle, webAdminMFAPath, r),
+		TOTPConfigs:     mfa.GetAvailableTOTPConfigNames(),
+		GenerateTOTPURL: webAdminTOTPGeneratePath,
+		ValidateTOTPURL: webAdminTOTPValidatePath,
+		SaveTOTPURL:     webAdminTOTPSavePath,
+		RecCodesURL:     webAdminRecoveryCodesPath,
+	}
+	admin, err := dataprovider.AdminExists(data.LoggedAdmin.Username)
+	if err != nil {
+		renderInternalServerErrorPage(w, r, err)
+		return
+	}
+	data.TOTPConfig = admin.Filters.TOTPConfig
+	renderAdminTemplate(w, templateMFA, data)
+}
+
 func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError, apiKeyError string) {
 func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError, apiKeyError string) {
 	data := credentialsPage{
 	data := credentialsPage{
 		basePage:        getBasePageData(pageCredentialsTitle, webAdminCredentialsPath, r),
 		basePage:        getBasePageData(pageCredentialsTitle, webAdminCredentialsPath, r),
@@ -1033,6 +1109,21 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	return user, err
 	return user, err
 }
 }
 
 
+func handleWebAdminTwoFactor(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	renderTwoFactorPage(w, "")
+}
+
+func handleWebAdminTwoFactorRecovery(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	renderTwoFactorRecoveryPage(w, "")
+}
+
+func handleWebAdminMFA(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	renderMFAPage(w, r)
+}
+
 func handleWebAdminCredentials(w http.ResponseWriter, r *http.Request) {
 func handleWebAdminCredentials(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	renderCredentialsPage(w, r, "", "")
 	renderCredentialsPage(w, r, "", "")
@@ -1250,6 +1341,8 @@ func handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Request) {
 	if updatedAdmin.Password == "" {
 	if updatedAdmin.Password == "" {
 		updatedAdmin.Password = admin.Password
 		updatedAdmin.Password = admin.Password
 	}
 	}
+	updatedAdmin.Filters.TOTPConfig = admin.Filters.TOTPConfig
+	updatedAdmin.Filters.RecoveryCodes = admin.Filters.RecoveryCodes
 	claims, err := getTokenClaims(r)
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
 	if err != nil || claims.Username == "" {
 		renderAddUpdateAdminPage(w, r, &updatedAdmin, fmt.Sprintf("Invalid token claims: %v", err), false)
 		renderAddUpdateAdminPage(w, r, &updatedAdmin, fmt.Sprintf("Invalid token claims: %v", err), false)
@@ -1509,6 +1602,8 @@ func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	updatedUser.ID = user.ID
 	updatedUser.ID = user.ID
 	updatedUser.Username = user.Username
 	updatedUser.Username = user.Username
+	updatedUser.Filters.RecoveryCodes = user.Filters.RecoveryCodes
+	updatedUser.Filters.TOTPConfig = user.Filters.TOTPConfig
 	updatedUser.SetEmptySecretsIfNil()
 	updatedUser.SetEmptySecretsIfNil()
 	if updatedUser.Password == redactedSecret {
 	if updatedUser.Password == redactedSecret {
 		updatedUser.Password = user.Password
 		updatedUser.Password = user.Password

+ 105 - 8
httpd/webclient.go

@@ -16,20 +16,26 @@ import (
 
 
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/common"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
+	"github.com/drakkan/sftpgo/v2/mfa"
+	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/version"
 	"github.com/drakkan/sftpgo/v2/version"
 	"github.com/drakkan/sftpgo/v2/vfs"
 	"github.com/drakkan/sftpgo/v2/vfs"
 )
 )
 
 
 const (
 const (
-	templateClientDir          = "webclient"
-	templateClientBase         = "base.html"
-	templateClientLogin        = "login.html"
-	templateClientFiles        = "files.html"
-	templateClientMessage      = "message.html"
-	templateClientCredentials  = "credentials.html"
-	pageClientFilesTitle       = "My Files"
-	pageClientCredentialsTitle = "Credentials"
+	templateClientDir               = "webclient"
+	templateClientBase              = "base.html"
+	templateClientBaseLogin         = "baselogin.html"
+	templateClientLogin             = "login.html"
+	templateClientFiles             = "files.html"
+	templateClientMessage           = "message.html"
+	templateClientCredentials       = "credentials.html"
+	templateClientTwoFactor         = "twofactor.html"
+	templateClientTwoFactorRecovery = "twofactor-recovery.html"
+	templateClientMFA               = "mfa.html"
+	pageClientFilesTitle            = "My Files"
+	pageClientCredentialsTitle      = "Credentials"
 )
 )
 
 
 // condResult is the result of an HTTP request precondition check.
 // condResult is the result of an HTTP request precondition check.
@@ -59,6 +65,8 @@ type baseClientPage struct {
 	CredentialsURL   string
 	CredentialsURL   string
 	StaticURL        string
 	StaticURL        string
 	LogoutURL        string
 	LogoutURL        string
+	MFAURL           string
+	MFATitle         string
 	FilesTitle       string
 	FilesTitle       string
 	CredentialsTitle string
 	CredentialsTitle string
 	Version          string
 	Version          string
@@ -103,6 +111,17 @@ type clientCredentialsPage struct {
 	APIKeyError     string
 	APIKeyError     string
 }
 }
 
 
+type clientMFAPage struct {
+	baseClientPage
+	TOTPConfigs     []string
+	TOTPConfig      sdk.TOTPConfig
+	GenerateTOTPURL string
+	ValidateTOTPURL string
+	SaveTOTPURL     string
+	RecCodesURL     string
+	Protocols       []string
+}
+
 func getFileObjectURL(baseDir, name string) string {
 func getFileObjectURL(baseDir, name string) string {
 	return fmt.Sprintf("%v?path=%v&_=%v", webClientFilesPath, url.QueryEscape(path.Join(baseDir, name)), time.Now().UTC().Unix())
 	return fmt.Sprintf("%v?path=%v&_=%v", webClientFilesPath, url.QueryEscape(path.Join(baseDir, name)), time.Now().UTC().Unix())
 }
 }
@@ -124,22 +143,41 @@ func loadClientTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateClientDir, templateClientCredentials),
 		filepath.Join(templatesPath, templateClientDir, templateClientCredentials),
 	}
 	}
 	loginPath := []string{
 	loginPath := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBaseLogin),
 		filepath.Join(templatesPath, templateClientDir, templateClientLogin),
 		filepath.Join(templatesPath, templateClientDir, templateClientLogin),
 	}
 	}
 	messagePath := []string{
 	messagePath := []string{
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
 		filepath.Join(templatesPath, templateClientDir, templateClientMessage),
 		filepath.Join(templatesPath, templateClientDir, templateClientMessage),
 	}
 	}
+	mfaPath := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBase),
+		filepath.Join(templatesPath, templateClientDir, templateClientMFA),
+	}
+	twoFactorPath := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBaseLogin),
+		filepath.Join(templatesPath, templateClientDir, templateClientTwoFactor),
+	}
+	twoFactorRecoveryPath := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBaseLogin),
+		filepath.Join(templatesPath, templateClientDir, templateClientTwoFactorRecovery),
+	}
 
 
 	filesTmpl := util.LoadTemplate(nil, filesPaths...)
 	filesTmpl := util.LoadTemplate(nil, filesPaths...)
 	credentialsTmpl := util.LoadTemplate(nil, credentialsPaths...)
 	credentialsTmpl := util.LoadTemplate(nil, credentialsPaths...)
 	loginTmpl := util.LoadTemplate(nil, loginPath...)
 	loginTmpl := util.LoadTemplate(nil, loginPath...)
 	messageTmpl := util.LoadTemplate(nil, messagePath...)
 	messageTmpl := util.LoadTemplate(nil, messagePath...)
+	mfaTmpl := util.LoadTemplate(nil, mfaPath...)
+	twoFactorTmpl := util.LoadTemplate(nil, twoFactorPath...)
+	twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...)
 
 
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientCredentials] = credentialsTmpl
 	clientTemplates[templateClientCredentials] = credentialsTmpl
 	clientTemplates[templateClientLogin] = loginTmpl
 	clientTemplates[templateClientLogin] = loginTmpl
 	clientTemplates[templateClientMessage] = messageTmpl
 	clientTemplates[templateClientMessage] = messageTmpl
+	clientTemplates[templateClientMFA] = mfaTmpl
+	clientTemplates[templateClientTwoFactor] = twoFactorTmpl
+	clientTemplates[templateClientTwoFactorRecovery] = twoFactorRecoveryTmpl
 }
 }
 
 
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
@@ -156,6 +194,8 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient
 		CredentialsURL:   webClientCredentialsPath,
 		CredentialsURL:   webClientCredentialsPath,
 		StaticURL:        webStaticFilesPath,
 		StaticURL:        webStaticFilesPath,
 		LogoutURL:        webClientLogoutPath,
 		LogoutURL:        webClientLogoutPath,
+		MFAURL:           webClientMFAPath,
+		MFATitle:         "Two-factor auth",
 		FilesTitle:       pageClientFilesTitle,
 		FilesTitle:       pageClientFilesTitle,
 		CredentialsTitle: pageClientCredentialsTitle,
 		CredentialsTitle: pageClientCredentialsTitle,
 		Version:          fmt.Sprintf("%v-%v", v.Version, v.CommitHash),
 		Version:          fmt.Sprintf("%v-%v", v.Version, v.CommitHash),
@@ -204,6 +244,48 @@ func renderClientNotFoundPage(w http.ResponseWriter, r *http.Request, err error)
 	renderClientMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "")
 	renderClientMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "")
 }
 }
 
 
+func renderClientTwoFactorPage(w http.ResponseWriter, error string) {
+	data := twoFactorPage{
+		CurrentURL:  webClientTwoFactorPath,
+		Version:     version.Get().Version,
+		Error:       error,
+		CSRFToken:   createCSRFToken(),
+		StaticURL:   webStaticFilesPath,
+		RecoveryURL: webClientTwoFactorRecoveryPath,
+	}
+	renderClientTemplate(w, templateTwoFactor, data)
+}
+
+func renderClientTwoFactorRecoveryPage(w http.ResponseWriter, error string) {
+	data := twoFactorPage{
+		CurrentURL: webClientTwoFactorRecoveryPath,
+		Version:    version.Get().Version,
+		Error:      error,
+		CSRFToken:  createCSRFToken(),
+		StaticURL:  webStaticFilesPath,
+	}
+	renderClientTemplate(w, templateTwoFactorRecovery, data)
+}
+
+func renderClientMFAPage(w http.ResponseWriter, r *http.Request) {
+	data := clientMFAPage{
+		baseClientPage:  getBaseClientPageData(pageMFATitle, webClientMFAPath, r),
+		TOTPConfigs:     mfa.GetAvailableTOTPConfigNames(),
+		GenerateTOTPURL: webClientTOTPGeneratePath,
+		ValidateTOTPURL: webClientTOTPValidatePath,
+		SaveTOTPURL:     webClientTOTPSavePath,
+		RecCodesURL:     webClientRecoveryCodesPath,
+		Protocols:       dataprovider.MFAProtocols,
+	}
+	user, err := dataprovider.UserExists(data.LoggedUser.Username)
+	if err != nil {
+		renderInternalServerErrorPage(w, r, err)
+		return
+	}
+	data.TOTPConfig = user.Filters.TOTPConfig
+	renderClientTemplate(w, templateClientMFA, data)
+}
+
 func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User) {
 func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User) {
 	data := filesPage{
 	data := filesPage{
 		baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r),
 		baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r),
@@ -517,3 +599,18 @@ func handleWebClientManageAPIKeyPost(w http.ResponseWriter, r *http.Request) {
 	renderClientMessagePage(w, r, "API key authentication updated", "", http.StatusOK, nil,
 	renderClientMessagePage(w, r, "API key authentication updated", "", http.StatusOK, nil,
 		"Your API key access permission has been successfully updated")
 		"Your API key access permission has been successfully updated")
 }
 }
+
+func handleWebClientMFA(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	renderClientMFAPage(w, r)
+}
+
+func handleWebClientTwoFactor(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	renderClientTwoFactorPage(w, "")
+}
+
+func handleWebClientTwoFactorRecovery(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	renderClientTwoFactorRecoveryPage(w, "")
+}

+ 118 - 0
mfa/mfa.go

@@ -0,0 +1,118 @@
+// Package mfa provides supports for Multi-Factor authentication modules
+package mfa
+
+import (
+	"fmt"
+	"time"
+)
+
+var (
+	totpConfigs   []*TOTPConfig
+	serviceStatus ServiceStatus
+)
+
+// ServiceStatus defines the service status
+type ServiceStatus struct {
+	IsActive    bool         `json:"is_active"`
+	TOTPConfigs []TOTPConfig `json:"totp_configs"`
+}
+
+// GetStatus returns the service status
+func GetStatus() ServiceStatus {
+	return serviceStatus
+}
+
+// Config defines configuration parameters for Multi-Factor authentication modules
+type Config struct {
+	// Time-based one time passwords configurations
+	TOTP []TOTPConfig `json:"totp" mapstructure:"totp"`
+}
+
+// Initialize configures the MFA support
+func (c *Config) Initialize() error {
+	totpConfigs = nil
+	serviceStatus.IsActive = false
+	serviceStatus.TOTPConfigs = nil
+	totp := make(map[string]bool)
+	for _, totpConfig := range c.TOTP {
+		totpConfig := totpConfig //pin
+		if err := totpConfig.validate(); err != nil {
+			totpConfigs = nil
+			return fmt.Errorf("invalid TOTP config %+v: %v", totpConfig, err)
+		}
+		if _, ok := totp[totpConfig.Name]; ok {
+			totpConfigs = nil
+			return fmt.Errorf("totp: duplicate configuration name %#v", totpConfig.Name)
+		}
+		totp[totpConfig.Name] = true
+		totpConfigs = append(totpConfigs, &totpConfig)
+		serviceStatus.IsActive = true
+		serviceStatus.TOTPConfigs = append(serviceStatus.TOTPConfigs, totpConfig)
+	}
+	startCleanupTicker(2 * time.Minute)
+	return nil
+}
+
+// GetAvailableTOTPConfigs returns the available TOTP config names
+func GetAvailableTOTPConfigs() []*TOTPConfig {
+	return totpConfigs
+}
+
+// GetAvailableTOTPConfigNames returns the available TOTP config names
+func GetAvailableTOTPConfigNames() []string {
+	var result []string
+	for _, c := range totpConfigs {
+		result = append(result, c.Name)
+	}
+	return result
+}
+
+// ValidateTOTPPasscode validates a TOTP passcode using the given secret and configName
+func ValidateTOTPPasscode(configName, passcode, secret string) (bool, error) {
+	for _, config := range totpConfigs {
+		if config.Name == configName {
+			return config.validatePasscode(passcode, secret)
+		}
+	}
+
+	return false, fmt.Errorf("totp: no configuration %#v", configName)
+}
+
+// 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) {
+	for _, config := range totpConfigs {
+		if config.Name == configName {
+			issuer, secret, qrCode, err := config.generate(username, 200, 200)
+			return configName, issuer, secret, qrCode, err
+		}
+	}
+
+	return "", "", "", nil, fmt.Errorf("totp: no configuration %#v", configName)
+}
+
+// the ticker cannot be started/stopped from multiple goroutines
+func startCleanupTicker(duration time.Duration) {
+	stopCleanupTicker()
+	cleanupTicker = time.NewTicker(duration)
+	cleanupDone = make(chan bool)
+
+	go func() {
+		for {
+			select {
+			case <-cleanupDone:
+				return
+			case <-cleanupTicker.C:
+				cleanupUsedPasscodes()
+			}
+		}
+	}()
+}
+
+func stopCleanupTicker() {
+	if cleanupTicker != nil {
+		cleanupTicker.Stop()
+		cleanupDone <- true
+		cleanupTicker = nil
+	}
+}

+ 129 - 0
mfa/mfa_test.go

@@ -0,0 +1,129 @@
+package mfa
+
+import (
+	"testing"
+	"time"
+
+	"github.com/pquerna/otp"
+	"github.com/pquerna/otp/totp"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMFAConfig(t *testing.T) {
+	config := Config{
+		TOTP: []TOTPConfig{
+			{},
+		},
+	}
+	configName1 := "config1"
+	configName2 := "config2"
+	configName3 := "config3"
+	err := config.Initialize()
+	assert.Error(t, err)
+	config.TOTP[0].Name = configName1
+	err = config.Initialize()
+	assert.Error(t, err)
+	config.TOTP[0].Issuer = "issuer"
+	err = config.Initialize()
+	assert.Error(t, err)
+	config.TOTP[0].Algo = TOTPAlgoSHA1
+	err = config.Initialize()
+	assert.NoError(t, err)
+	config.TOTP = append(config.TOTP, TOTPConfig{
+		Name:   configName1,
+		Issuer: "SFTPGo",
+		Algo:   TOTPAlgoSHA512,
+	})
+	err = config.Initialize()
+	assert.Error(t, err)
+	config.TOTP[1].Name = configName2
+	err = config.Initialize()
+	assert.NoError(t, err)
+	assert.Len(t, GetAvailableTOTPConfigs(), 2)
+	assert.Len(t, GetAvailableTOTPConfigNames(), 2)
+	config.TOTP = append(config.TOTP, TOTPConfig{
+		Name:   configName3,
+		Issuer: "SFTPGo",
+		Algo:   TOTPAlgoSHA256,
+	})
+	err = config.Initialize()
+	assert.NoError(t, err)
+	assert.Len(t, GetAvailableTOTPConfigs(), 3)
+	if assert.Len(t, GetAvailableTOTPConfigNames(), 3) {
+		assert.Contains(t, GetAvailableTOTPConfigNames(), configName1)
+		assert.Contains(t, GetAvailableTOTPConfigNames(), configName2)
+		assert.Contains(t, GetAvailableTOTPConfigNames(), configName3)
+	}
+	status := GetStatus()
+	assert.True(t, status.IsActive)
+	if assert.Len(t, status.TOTPConfigs, 3) {
+		assert.Equal(t, configName1, status.TOTPConfigs[0].Name)
+		assert.Equal(t, configName2, status.TOTPConfigs[1].Name)
+		assert.Equal(t, configName3, status.TOTPConfigs[2].Name)
+	}
+	// now generate some secrets and validate some passcodes
+	_, _, _, _, 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")
+	assert.NoError(t, err)
+	assert.NotEmpty(t, secret)
+	assert.Equal(t, configName1, cfgName)
+	passcode, err := generatePasscode(secret, otp.AlgorithmSHA1)
+	assert.NoError(t, err)
+	match, err = ValidateTOTPPasscode(configName1, passcode, secret)
+	assert.NoError(t, err)
+	assert.True(t, match)
+	match, err = ValidateTOTPPasscode(configName1, passcode, secret)
+	assert.ErrorIs(t, err, errPasscodeUsed)
+	assert.False(t, match)
+
+	passcode, err = generatePasscode(secret, otp.AlgorithmSHA256)
+	assert.NoError(t, err)
+	// config1 uses sha1 algo
+	match, err = ValidateTOTPPasscode(configName1, passcode, secret)
+	assert.NoError(t, err)
+	assert.False(t, match)
+	// config3 use the expected algo
+	match, err = ValidateTOTPPasscode(configName3, passcode, secret)
+	assert.NoError(t, err)
+	assert.True(t, match)
+
+	stopCleanupTicker()
+}
+
+func TestCleanupPasscodes(t *testing.T) {
+	usedPasscodes.Store("key", time.Now().Add(-24*time.Hour).UTC())
+	startCleanupTicker(30 * time.Millisecond)
+	assert.Eventually(t, func() bool {
+		_, ok := usedPasscodes.Load("key")
+		return !ok
+	}, 300*time.Millisecond, 100*time.Millisecond)
+	stopCleanupTicker()
+}
+
+func TestTOTPGenerateErrors(t *testing.T) {
+	config := TOTPConfig{
+		Name:   "name",
+		Issuer: "",
+		algo:   otp.AlgorithmSHA1,
+	}
+	// issuer cannot be empty
+	_, _, _, 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
+	assert.Error(t, err)
+}
+
+func generatePasscode(secret string, algo otp.Algorithm) (string, error) {
+	return totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{
+		Period:    30,
+		Skew:      1,
+		Digits:    otp.DigitsSix,
+		Algorithm: algo,
+	})
+}

+ 106 - 0
mfa/totp.go

@@ -0,0 +1,106 @@
+package mfa
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"image/png"
+	"sync"
+	"time"
+
+	"github.com/pquerna/otp"
+	"github.com/pquerna/otp/totp"
+)
+
+// TOTPHMacAlgo is the enumerable for the possible HMAC algorithms for Time-based one time passwords
+type TOTPHMacAlgo = string
+
+// supported TOTP HMAC algorithms
+const (
+	TOTPAlgoSHA1   TOTPHMacAlgo = "sha1"
+	TOTPAlgoSHA256 TOTPHMacAlgo = "sha256"
+	TOTPAlgoSHA512 TOTPHMacAlgo = "sha512"
+)
+
+var (
+	cleanupTicker   *time.Ticker
+	cleanupDone     chan bool
+	usedPasscodes   sync.Map
+	errPasscodeUsed = errors.New("this passcode was already used")
+)
+
+// TOTPConfig defines the configuration for a Time-based one time password
+type TOTPConfig struct {
+	Name   string       `json:"name" mapstructure:"name"`
+	Issuer string       `json:"issuer" mapstructure:"issuer"`
+	Algo   TOTPHMacAlgo `json:"algo" mapstructure:"algo"`
+	algo   otp.Algorithm
+}
+
+func (c *TOTPConfig) validate() error {
+	if c.Name == "" {
+		return errors.New("totp: name is mandatory")
+	}
+	if c.Issuer == "" {
+		return errors.New("totp: issuer is mandatory")
+	}
+	switch c.Algo {
+	case TOTPAlgoSHA1:
+		c.algo = otp.AlgorithmSHA1
+	case TOTPAlgoSHA256:
+		c.algo = otp.AlgorithmSHA256
+	case TOTPAlgoSHA512:
+		c.algo = otp.AlgorithmSHA512
+	default:
+		return fmt.Errorf("unsupported totp algo %#v", c.Algo)
+	}
+	return nil
+}
+
+// validatePasscode validates a TOTP passcode
+func (c *TOTPConfig) validatePasscode(passcode, secret string) (bool, error) {
+	key := fmt.Sprintf("%v_%v", secret, passcode)
+	if _, ok := usedPasscodes.Load(key); ok {
+		return false, errPasscodeUsed
+	}
+	match, err := totp.ValidateCustom(passcode, secret, time.Now().UTC(), totp.ValidateOpts{
+		Period:    30,
+		Skew:      1,
+		Digits:    otp.DigitsSix,
+		Algorithm: c.algo,
+	})
+	if match && err == nil {
+		usedPasscodes.Store(key, time.Now().Add(1*time.Minute).UTC())
+	}
+	return match, err
+}
+
+// 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) {
+	key, err := totp.Generate(totp.GenerateOpts{
+		Issuer:      c.Issuer,
+		AccountName: username,
+		Digits:      otp.DigitsSix,
+		Algorithm:   c.algo,
+	})
+	if err != nil {
+		return "", "", nil, err
+	}
+	var buf bytes.Buffer
+	img, err := key.Image(qrCodeWidth, qrCodeHeight)
+	if err != nil {
+		return "", "", nil, err
+	}
+	err = png.Encode(&buf, img)
+	return key.Issuer(), key.Secret(), buf.Bytes(), err
+}
+
+func cleanupUsedPasscodes() {
+	usedPasscodes.Range(func(key, value interface{}) bool {
+		exp, ok := value.(time.Time)
+		if !ok || exp.Before(time.Now().UTC()) {
+			usedPasscodes.Delete(key)
+		}
+		return true
+	})
+}

+ 45 - 1
sdk/user.go

@@ -3,6 +3,7 @@ package sdk
 import (
 import (
 	"strings"
 	"strings"
 
 
+	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 )
 )
 
 
@@ -10,11 +11,14 @@ import (
 const (
 const (
 	WebClientPubKeyChangeDisabled = "publickey-change-disabled"
 	WebClientPubKeyChangeDisabled = "publickey-change-disabled"
 	WebClientWriteDisabled        = "write-disabled"
 	WebClientWriteDisabled        = "write-disabled"
+	WebClientMFADisabled          = "mfa-disabled"
 )
 )
 
 
 var (
 var (
 	// WebClientOptions defines the available options for the web client interface/user REST API
 	// WebClientOptions defines the available options for the web client interface/user REST API
-	WebClientOptions = []string{WebClientPubKeyChangeDisabled, WebClientWriteDisabled}
+	WebClientOptions = []string{WebClientPubKeyChangeDisabled, WebClientWriteDisabled, WebClientMFADisabled}
+	// UserTypes defines the supported user type hints for auth plugins
+	UserTypes = []string{string(UserTypeLDAP), string(UserTypeOS)}
 )
 )
 
 
 // TLSUsername defines the TLS certificate attribute to use as username
 // TLSUsername defines the TLS certificate attribute to use as username
@@ -26,6 +30,16 @@ const (
 	TLSUsernameCN   TLSUsername = "CommonName"
 	TLSUsernameCN   TLSUsername = "CommonName"
 )
 )
 
 
+// UserType defines the supported user types.
+// This is an hint for external auth plugins, is not used in SFTPGo directly
+type UserType string
+
+// User types, auth plugins could use this info to choose the correct authentication backend
+const (
+	UserTypeLDAP UserType = "LDAPUser"
+	UserTypeOS   UserType = "OSUser"
+)
+
 // DirectoryPermissions defines permissions for a directory virtual path
 // DirectoryPermissions defines permissions for a directory virtual path
 type DirectoryPermissions struct {
 type DirectoryPermissions struct {
 	Path        string
 	Path        string
@@ -83,6 +97,27 @@ type HooksFilter struct {
 	CheckPasswordDisabled bool `json:"check_password_disabled"`
 	CheckPasswordDisabled bool `json:"check_password_disabled"`
 }
 }
 
 
+// RecoveryCode defines a 2FA recovery code
+type RecoveryCode struct {
+	Secret *kms.Secret `json:"secret"`
+	Used   bool        `json:"used,omitempty"`
+}
+
+// TOTPConfig defines the time-based one time password configuration
+type TOTPConfig struct {
+	Enabled    bool        `json:"enabled,omitempty"`
+	ConfigName string      `json:"config_name,omitempty"`
+	Secret     *kms.Secret `json:"secret,omitempty"`
+	// TOTP will be required for the specified protocols.
+	// SSH protocol (SFTP/SCP/SSH commands) will ask for the TOTP passcode if the client uses keyboard interactive
+	// authentication.
+	// FTP have no standard way to support two factor authentication, if you
+	// enable the support for this protocol you have to add the TOTP passcode after the password.
+	// For example if your password is "password" and your one time passcode is
+	// "123456" you have to use "password123456" as password.
+	Protocols []string `json:"protocols,omitempty"`
+}
+
 // UserFilters defines additional restrictions for a user
 // UserFilters defines additional restrictions for a user
 // TODO: rename to UserOptions in v3
 // TODO: rename to UserOptions in v3
 type UserFilters struct {
 type UserFilters struct {
@@ -122,6 +157,15 @@ type UserFilters struct {
 	WebClient []string `json:"web_client,omitempty"`
 	WebClient []string `json:"web_client,omitempty"`
 	// API key auth allows to impersonate this user with an API key
 	// API key auth allows to impersonate this user with an API key
 	AllowAPIKeyAuth bool `json:"allow_api_key_auth,omitempty"`
 	AllowAPIKeyAuth bool `json:"allow_api_key_auth,omitempty"`
+	// Time-based one time passwords configuration
+	TOTPConfig TOTPConfig `json:"totp_config,omitempty"`
+	// Recovery codes to use if the user loses access to their second factor auth device.
+	// Each code can only be used once, you should use these codes to login and disable or
+	// reset 2FA for your account
+	RecoveryCodes []RecoveryCode `json:"recovery_codes,omitempty"`
+	// UserType is an hint for authentication plugins.
+	// It is ignored when using SFTPGo internal authentication
+	UserType string `json:"user_type,omitempty"`
 }
 }
 
 
 type BaseUser struct {
 type BaseUser struct {

+ 7 - 0
service/service.go

@@ -98,6 +98,13 @@ func (s *Service) Start() error {
 		logger.ErrorToConsole("unable to initialize KMS: %v", err)
 		logger.ErrorToConsole("unable to initialize KMS: %v", err)
 		os.Exit(1)
 		os.Exit(1)
 	}
 	}
+	mfaConfig := config.GetMFAConfig()
+	err = mfaConfig.Initialize()
+	if err != nil {
+		logger.Error(logSender, "", "unable to initialize MFA: %v", err)
+		logger.ErrorToConsole("unable to initialize MFA: %v", err)
+		os.Exit(1)
+	}
 	if err := plugin.Initialize(config.GetPluginsConfig(), s.LogVerbose); err != nil {
 	if err := plugin.Initialize(config.GetPluginsConfig(), s.LogVerbose); err != nil {
 		logger.Error(logSender, "", "unable to initialize plugin system: %v", err)
 		logger.Error(logSender, "", "unable to initialize plugin system: %v", err)
 		logger.ErrorToConsole("unable to initialize plugin system: %v", err)
 		logger.ErrorToConsole("unable to initialize plugin system: %v", err)

+ 5 - 2
sftpd/server.go

@@ -21,7 +21,6 @@ import (
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/metric"
 	"github.com/drakkan/sftpgo/v2/metric"
-	"github.com/drakkan/sftpgo/v2/sdk/plugin"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/vfs"
 	"github.com/drakkan/sftpgo/v2/vfs"
 )
 )
@@ -115,6 +114,10 @@ type Configuration struct {
 	// The following SSH commands are enabled by default: "md5sum", "sha1sum", "cd", "pwd".
 	// The following SSH commands are enabled by default: "md5sum", "sha1sum", "cd", "pwd".
 	// "*" enables all supported SSH commands.
 	// "*" enables all supported SSH commands.
 	EnabledSSHCommands []string `json:"enabled_ssh_commands" mapstructure:"enabled_ssh_commands"`
 	EnabledSSHCommands []string `json:"enabled_ssh_commands" mapstructure:"enabled_ssh_commands"`
+	// KeyboardInteractiveAuthentication specifies whether keyboard interactive authentication is allowed.
+	// If no keyboard interactive hook or auth plugin is defined the default is to prompt for the user password and then the
+	// one time authentication code, if defined.
+	KeyboardInteractiveAuthentication bool `json:"keyboard_interactive_authentication" mapstructure:"keyboard_interactive_authentication"`
 	// Absolute path to an external program or an HTTP URL to invoke for keyboard interactive authentication.
 	// Absolute path to an external program or an HTTP URL to invoke for keyboard interactive authentication.
 	// Leave empty to disable this authentication mode.
 	// Leave empty to disable this authentication mode.
 	KeyboardInteractiveHook string `json:"keyboard_interactive_auth_hook" mapstructure:"keyboard_interactive_auth_hook"`
 	KeyboardInteractiveHook string `json:"keyboard_interactive_auth_hook" mapstructure:"keyboard_interactive_auth_hook"`
@@ -307,7 +310,7 @@ func (c *Configuration) configureLoginBanner(serverConfig *ssh.ServerConfig, con
 }
 }
 
 
 func (c *Configuration) configureKeyboardInteractiveAuth(serverConfig *ssh.ServerConfig) {
 func (c *Configuration) configureKeyboardInteractiveAuth(serverConfig *ssh.ServerConfig) {
-	if c.KeyboardInteractiveHook == "" && !plugin.Handler.HasAuthScope(plugin.AuthScopeKeyboardInteractive) {
+	if !c.KeyboardInteractiveAuthentication {
 		return
 		return
 	}
 	}
 	if c.KeyboardInteractiveHook != "" {
 	if c.KeyboardInteractiveHook != "" {

+ 2 - 0
sftpd/sftpd_test.go

@@ -217,6 +217,7 @@ func TestMain(m *testing.M) {
 		logger.ErrorToConsole("error writing keyboard interactive script: %v", err)
 		logger.ErrorToConsole("error writing keyboard interactive script: %v", err)
 		os.Exit(1)
 		os.Exit(1)
 	}
 	}
+	sftpdConf.KeyboardInteractiveAuthentication = true
 	sftpdConf.KeyboardInteractiveHook = keyIntAuthPath
 	sftpdConf.KeyboardInteractiveHook = keyIntAuthPath
 
 
 	createInitialFiles(scriptArgs)
 	createInitialFiles(scriptArgs)
@@ -333,6 +334,7 @@ func TestInitialization(t *testing.T) {
 	sftpdConf.EnabledSSHCommands = append(sftpdConf.EnabledSSHCommands, "ls")
 	sftpdConf.EnabledSSHCommands = append(sftpdConf.EnabledSSHCommands, "ls")
 	err = sftpdConf.Initialize(configDir)
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
 	assert.Error(t, err)
+	sftpdConf.KeyboardInteractiveAuthentication = true
 	sftpdConf.KeyboardInteractiveHook = "invalid_file"
 	sftpdConf.KeyboardInteractiveHook = "invalid_file"
 	err = sftpdConf.Initialize(configDir)
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
 	assert.Error(t, err)

+ 10 - 0
sftpgo.json

@@ -70,6 +70,7 @@
       "pwd",
       "pwd",
       "scp"
       "scp"
     ],
     ],
+    "keyboard_interactive_authentication": false,
     "keyboard_interactive_auth_hook": "",
     "keyboard_interactive_auth_hook": "",
     "password_authentication": true,
     "password_authentication": true,
     "folder_prefix": ""
     "folder_prefix": ""
@@ -244,5 +245,14 @@
       "master_key_path": ""
       "master_key_path": ""
     }
     }
   },
   },
+  "mfa": {
+    "totp": [
+      {
+        "name": "Default",
+        "issuer": "SFTPGo",
+        "algo": "sha1"
+      }
+    ]
+  },
   "plugins": []
   "plugins": []
 }
 }

+ 3 - 3
templates/webadmin/adminsetup.html

@@ -99,15 +99,15 @@
                                         class="user-custom">
                                         class="user-custom">
                                         <div class="form-group">
                                         <div class="form-group">
                                             <input type="text" class="form-control form-control-user-custom" id="inputUsername"
                                             <input type="text" class="form-control form-control-user-custom" id="inputUsername"
-                                                name="username" placeholder="Username" value="{{.Username}}" maxlength="60" required>
+                                                name="username" placeholder="Username" value="{{.Username}}" required>
                                         </div>
                                         </div>
                                         <div class="form-group">
                                         <div class="form-group">
                                             <input type="password" class="form-control form-control-user-custom" id="inputPassword"
                                             <input type="password" class="form-control form-control-user-custom" id="inputPassword"
-                                                name="password" placeholder="Password" maxlength="60" required>
+                                                name="password" placeholder="Password" required>
                                         </div>
                                         </div>
                                         <div class="form-group">
                                         <div class="form-group">
                                             <input type="password" class="form-control form-control-user-custom" id="inputConfirmPassword"
                                             <input type="password" class="form-control form-control-user-custom" id="inputConfirmPassword"
-                                                name="confirm_password" placeholder="Repeat password" maxlength="60" required>
+                                                name="confirm_password" placeholder="Repeat password" required>
                                         </div>
                                         </div>
                                         <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
                                         <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
                                         <button type="submit" class="btn btn-primary btn-user-custom btn-block">
                                         <button type="submit" class="btn btn-primary btn-user-custom btn-block">

+ 6 - 0
templates/webadmin/base.html

@@ -171,6 +171,12 @@
                                     <i class="fas fa-key fa-sm fa-fw mr-2 text-gray-400"></i>
                                     <i class="fas fa-key fa-sm fa-fw mr-2 text-gray-400"></i>
                                     Credentials
                                     Credentials
                                 </a>
                                 </a>
+                                {{if .LoggedAdmin.CanManageMFA}}
+                                <a class="dropdown-item" href="{{.MFAURL}}">
+                                    <i class="fas fa-user-lock fa-sm fa-fw mr-2 text-gray-400"></i>
+                                    Two-Factor Auth
+                                </a>
+                                {{end}}
                                 <div class="dropdown-divider"></div>
                                 <div class="dropdown-divider"></div>
                                 <a class="dropdown-item" href="#" data-toggle="modal" data-target="#logoutModal">
                                 <a class="dropdown-item" href="#" data-toggle="modal" data-target="#logoutModal">
                                     <i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
                                     <i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>

+ 114 - 0
templates/webadmin/baselogin.html

@@ -0,0 +1,114 @@
+{{define "baselogin"}}
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="description" content="">
+    <meta name="author" content="">
+
+    <title>SFTPGo Admin - {{template "title" .}}</title>
+
+    <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
+
+    <!-- Custom styles for this template-->
+    <link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
+    <style>
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
+            font-weight: 700;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
+            font-weight: 400;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
+            font-weight: 300;
+            font-style: normal;
+        }
+
+        div.dt-buttons {
+            margin-bottom: 1em;
+        }
+
+        .text-form-error {
+            color: var(--red) !important;
+        }
+
+        div.dt-buttons {
+            margin-bottom: 1em;
+        }
+
+        .text-form-error {
+            color: var(--red) !important;
+        }
+
+        form.user-custom .custom-checkbox.small label {
+            line-height: 1.5rem;
+        }
+
+        form.user-custom .form-control-user-custom {
+            font-size: 0.9rem;
+            border-radius: 10rem;
+            padding: 1.5rem 1rem;
+        }
+
+        form.user-custom .btn-user-custom {
+            font-size: 0.9rem;
+            border-radius: 10rem;
+            padding: 0.75rem 1rem;
+        }
+    </style>
+
+</head>
+
+<body class="bg-gradient-primary">
+
+    <div class="container">
+
+        <!-- Outer Row -->
+        <div class="row justify-content-center">
+
+            <div class="col-xl-6 col-lg-7 col-md-9">
+
+                <div class="card o-hidden border-0 shadow-lg my-5">
+                    <div class="card-body p-0">
+                        <!-- Nested Row within Card Body -->
+                        <div class="row">
+                            <div class="col-lg-12">
+                                <div class="p-5">
+                                    {{template "content" .}}
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Bootstrap core JavaScript-->
+    <script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
+    <script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
+
+    <!-- Core plugin JavaScript-->
+    <script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
+
+    <!-- Custom scripts for all pages-->
+    <script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
+
+</body>
+
+</html>
+{{end}}

+ 4 - 109
templates/webadmin/login.html

@@ -1,92 +1,8 @@
-<!DOCTYPE html>
-<html lang="en">
+{{template "baselogin" .}}
 
 
-<head>
+{{define "title"}}Login{{end}}
 
 
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-    <meta name="description" content="">
-    <meta name="author" content="">
-
-    <title>SFTPGo Admin - Login</title>
-
-    <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
-
-    <!-- Custom styles for this template-->
-    <link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
-    <style>
-        @font-face {
-            font-family: 'Roboto';
-            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
-            font-weight: 700;
-            font-style: normal;
-        }
-
-        @font-face {
-            font-family: 'Roboto';
-            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
-            font-weight: 400;
-            font-style: normal;
-        }
-
-        @font-face {
-            font-family: 'Roboto';
-            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
-            font-weight: 300;
-            font-style: normal;
-        }
-
-        div.dt-buttons {
-            margin-bottom: 1em;
-        }
-
-        .text-form-error {
-            color: var(--red) !important;
-        }
-
-        div.dt-buttons {
-            margin-bottom: 1em;
-        }
-
-        .text-form-error {
-            color: var(--red) !important;
-        }
-
-        form.user-custom .custom-checkbox.small label {
-            line-height: 1.5rem;
-        }
-
-        form.user-custom .form-control-user-custom {
-            font-size: 0.9rem;
-            border-radius: 10rem;
-            padding: 1.5rem 1rem;
-        }
-
-        form.user-custom .btn-user-custom {
-            font-size: 0.9rem;
-            border-radius: 10rem;
-            padding: 0.75rem 1rem;
-        }
-    </style>
-
-</head>
-
-<body class="bg-gradient-primary">
-
-    <div class="container">
-
-        <!-- Outer Row -->
-        <div class="row justify-content-center">
-
-            <div class="col-xl-6 col-lg-7 col-md-9">
-
-                <div class="card o-hidden border-0 shadow-lg my-5">
-                    <div class="card-body p-0">
-                        <!-- Nested Row within Card Body -->
-                        <div class="row">
-                            <div class="col-lg-12">
-                                <div class="p-5">
+{{define "content"}}
                                     <div class="text-center">
                                     <div class="text-center">
                                         <h1 class="h4 text-gray-900 mb-4">SFTPGo Admin - {{.Version}}</h1>
                                         <h1 class="h4 text-gray-900 mb-4">SFTPGo Admin - {{.Version}}</h1>
                                     </div>
                                     </div>
@@ -116,25 +32,4 @@
                                         <a class="small" href="{{.AltLoginURL}}">Web Client</a>
                                         <a class="small" href="{{.AltLoginURL}}">Web Client</a>
                                     </div>
                                     </div>
                                     {{end}}
                                     {{end}}
-                                </div>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-
-    <!-- Bootstrap core JavaScript-->
-    <script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
-    <script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
-
-    <!-- Core plugin JavaScript-->
-    <script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
-
-    <!-- Custom scripts for all pages-->
-    <script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
-
-</body>
-
-</html>
+{{end}}

+ 401 - 0
templates/webadmin/mfa.html

@@ -0,0 +1,401 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "page_body"}}
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">TOTP (Authenticator app)</h6>
+    </div>
+    <div class="card-body">
+        <div id="successTOTPMsg" class="card mb-4 border-left-success" style="display: none;">
+            <div id="successTOTPTxt" class="card-body"></div>
+        </div>
+        <div id="errorTOTPMsg" class="card mb-4 border-left-warning" style="display: none;">
+            <div id="errorTOTPTxt" class="card-body text-form-error"></div>
+        </div>
+        <div>
+            <p>Status: {{if .TOTPConfig.Enabled }}"Enabled". Current configuration: "{{.TOTPConfig.ConfigName}}"{{else}}"Disabled"{{end}}</p>
+        </div>
+        <div class="form-group row totpDisable">
+            <div class="col-sm-12">
+                <a id="idTOTPDisable" class="btn btn-warning" href="#" onclick="totpDisableAsk()" role="button">Disable</a>
+            </div>
+        </div>
+        <div class="form-group row">
+            <label for="idConfig" class="col-sm-2 col-form-label">Configuration</label>
+            <div class="col-sm-10">
+                <select class="form-control" id="idConfig" name="config_name">
+                    <option value="">None</option>
+                    {{range .TOTPConfigs}}
+                    <option value="{{.}}" {{if eq . $.TOTPConfig.ConfigName}}selected{{end}}>{{.}}</option>
+                    {{end}}
+                </select>
+            </div>
+        </div>
+
+        <div class="form-group row totpGenerate">
+            <div class="col-sm-12">
+                <a id="idTOTPGenerate" class="btn btn-primary" href="#" onclick="totpGenerate()" role="button">Generate new secret</a>
+            </div>
+        </div>
+
+        <div id="idTOTPDetails" class="totpDetails">
+            <div>
+                <p>Your new TOTP secret is: <span id="idSecret"></span></p>
+                <p>For quick setup, scan this QR code with your TOTP app:</p>
+                <img id="idQRCode" src="data:image/gif;base64, R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="QR code" />
+            </div>
+            <br>
+            <div>
+                <p>After you configured your app, enter a test code below to ensure everything works correctly. Recovery codes are automatically generated if missing or most of them have already been used</p>
+            </div>
+
+            <div class="input-group">
+                <input type="text" class="form-control" id="idPasscode" name="passcode" value="" placeholder="Authentication code">
+                <span class="input-group-append">
+                    <a id="idTOTPSave" class="btn btn-primary" href="#" onclick="totpValidate()" role="button">Verify and save</a>
+                </span>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">Recovery codes</h6>
+    </div>
+    <div id="idRecoveryCodesCard" class="card-body">
+        <div id="successRecCodesMsg" class="card mb-4 border-left-success" style="display: none;">
+            <div id="successRecCodesTxt" class="card-body"></div>
+        </div>
+        <div id="errorRecCodesMsg" class="card mb-4 border-left-warning" style="display: none;">
+            <div id="errorRecCodesTxt" class="card-body text-form-error"></div>
+        </div>
+        <div>
+            <p>Recovery codes are a set of one time use codes that can be used in place of the TOTP to login to the web UI. You can use them if you lose access to your phone to login to your account and disable or regenerate TOTP configuration.</p>
+            <p>To keep your account secure, don't share or distribute your recovery codes. We recommend saving them with a secure password manager.</p>
+        </div>
+        <div class="form-group row viewRecoveryCodes">
+            <div class="col-sm-12">
+                <a class="btn btn-primary" href="#" onclick="getRecoveryCodes()" role="button">View</a>
+            </div>
+        </div>
+        <div id="idRecoveryCodes" style="display: none;">
+            <ul id="idRecoveryCodesList" class="list-group">
+            </ul>
+            <br>
+        </div>
+        <div>
+            <p>If you generate new recovery codes, you automatically invalidate old ones.</p>
+        </div>
+        <div class="form-group row">
+            <div class="col-sm-12">
+                <a class="btn btn-primary" href="#" onclick="generateRecoveryCodes()" role="button">Generate</a>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "dialog"}}
+<div class="modal fade" id="disableTOTPModal" tabindex="-1" role="dialog" aria-labelledby="disableTOTPModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="disableTOTPModalLabel">
+                    Confirmation required
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">Do you want to disable the TOTP configuration?</div>
+            <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">
+                    Cancel
+                </button>
+                <a class="btn btn-warning" href="#" onclick="totpDisable()">
+                    Disable
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+<script type="text/javascript">
+
+    function totpGenerate() {
+        var path = "{{.GenerateTOTPURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"config_name": $('#idConfig option:selected').val()}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('.totpDisable').hide();
+                $('.totpGenerate').hide();
+                $('#idSecret').text(result.secret);
+                $('#idQRCode').attr('src','data:image/png;base64, '+result.qr_code);
+                $('.totpDetails').show();
+                window.scrollTo(0, $("#idTOTPDetails").offset().top);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to generate a new TOTP secret";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function totpValidate() {
+        var passcode = $('#idPasscode').val();
+        if (passcode == "") {
+            $('#errorTOTPTxt').text("The verification code is required");
+            $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            return;
+        }
+        var path = "{{.ValidateTOTPURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"passcode": passcode, "config_name": $('#idConfig option:selected').val(), "secret": $('#idSecret').text()}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                totpSave();
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to validate the provided passcode";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function totpSave() {
+        var path = "{{.SaveTOTPURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"enabled": true, "config_name": $('#idConfig option:selected').val(), "secret": {"status": "Plain", "payload": $('#idSecret').text()}}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('#successTOTPTxt').text("Configuration saved");
+                $('#successTOTPMsg').show();
+                    setTimeout(function () {
+                        location.reload();
+                    }, 3000);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to save the new configuration";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function totpDisableAsk() {
+        $('#disableTOTPModal').modal('show');
+    }
+
+    function totpDisable() {
+        $('#disableTOTPModal').modal('hide');
+        var path = "{{.SaveTOTPURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"enabled": false}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                location.reload();
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to disable the current configuration";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function getRecoveryCodes() {
+        var path = "{{.RecCodesURL}}";
+        $.ajax({
+            url: path,
+            type: 'GET',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            timeout: 15000,
+            success: function (result) {
+                $('.viewRecoveryCodes').hide();
+                $('#idRecoveryCodesList').empty();
+                $.each(result, function(key, item) {
+                    if (item.used) {
+                        $('#idRecoveryCodesList').append(`<li class="list-group-item" style="text-decoration: line-through;">${item.code}</li>`);
+                    } else {
+                        $('#idRecoveryCodesList').append(`<li class="list-group-item">${item.code}</li>`);
+                    }
+                });
+                $('#idRecoveryCodes').show();
+                window.scrollTo(0, $("#idRecoveryCodesCard").offset().top);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to get your recovery codes";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorRecCodesTxt').text(txt);
+                $('#errorRecCodesMsg').show();
+                setTimeout(function () {
+                    $('#errorRecCodesMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function generateRecoveryCodes() {
+        var path = "{{.RecCodesURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('.viewRecoveryCodes').hide();
+                $('#idRecoveryCodesList').empty();
+                $.each(result, function(key, item) {
+                    $('#idRecoveryCodesList').append(`<li class="list-group-item">${item}</li>`);
+                });
+                $('#idRecoveryCodes').show();
+                $('#successRecCodesTxt').text('Recovery codes generated successfully');
+                $('#successRecCodesMsg').show();
+                window.scrollTo(0, $("#idRecoveryCodesCard").offset().top);
+                setTimeout(function () {
+                    $('#successRecCodesMsg').hide();
+                }, 5000);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to generate new recovery codes";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorRecCodesTxt').text(txt);
+                $('#errorRecCodesMsg').show();
+                setTimeout(function () {
+                    $('#errorRecCodesMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function handleConfigSelection() {
+        var selectedConfig = $('#idConfig option:selected').val();
+        if (selectedConfig == ""){
+            $('.totpGenerate').hide();
+        } else {
+            $('.totpGenerate').show();
+        }
+        $('.totpDetails').hide();
+        {{if .TOTPConfig.Enabled }}
+        $('.totpDisable').show();
+        {{end}}
+    }
+
+    $(document).ready(function () {
+        handleConfigSelection();
+        $('.totpDetails').hide();
+        {{if not .TOTPConfig.Enabled }}
+        $('.totpDisable').hide();
+        {{end}}
+
+        $('#idConfig').change(function() {
+            handleConfigSelection();
+        });
+    });
+</script>
+{{end}}

+ 19 - 0
templates/webadmin/status.html

@@ -88,6 +88,25 @@
             </div>
             </div>
         </div>
         </div>
 
 
+        <div class="card mb-4 {{ if .Status.MFA.IsActive}}border-left-success{{else}}border-left-info{{end}}">
+            <div class="card-body">
+                <h6 class="card-title font-weight-bold">Multi-factor authentication</h6>
+                <p class="card-text">
+                    Status: {{ if .Status.MFA.IsActive}}"Enabled"{{else}}"Disabled"{{end}}
+                    {{ if .Status.MFA.IsActive}}
+                    <br>
+                    Time-based one time passwords (RFC 6238) configurations:
+                    <br>
+                    <ul>
+                    {{range .Status.MFA.TOTPConfigs}}
+                    <li>Name: "{{.Name}}", issuer: "{{.Issuer}}", HMAC algorithm: "{{.Algo}}"</li>
+                    {{end}}
+                    </ul>
+                    {{end}}
+                </p>
+            </div>
+        </div>
+
         <div class="card mb-2 {{ if .Status.DataProvider.IsActive}}border-left-success{{else}}border-left-warning{{end}}">
         <div class="card mb-2 {{ if .Status.DataProvider.IsActive}}border-left-success{{else}}border-left-warning{{end}}">
             <div class="card-body">
             <div class="card-body">
                 <h6 class="card-title font-weight-bold">Data provider</h6>
                 <h6 class="card-title font-weight-bold">Data provider</h6>

+ 29 - 0
templates/webadmin/twofactor-recovery.html

@@ -0,0 +1,29 @@
+{{template "baselogin" .}}
+
+{{define "title"}}Two-Factor recovery{{end}}
+
+{{define "content"}}
+                                    <div class="text-center">
+                                        <h1 class="h4 text-gray-900 mb-4">SFTPGo Admin - {{.Version}}</h1>
+                                    </div>
+                                    {{if .Error}}
+                                    <div class="card mb-4 border-left-warning">
+                                        <div class="card-body text-form-error">{{.Error}}</div>
+                                    </div>
+                                    {{end}}
+                                    <form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
+                                        class="user-custom">
+                                        <div class="form-group">
+                                            <input type="text" class="form-control form-control-user-custom"
+                                                id="inputRecoveryCode" name="recovery_code" placeholder="Recovery code" required>
+                                        </div>
+                                        <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                        <button type="submit" class="btn btn-primary btn-user-custom btn-block">
+                                            Verify
+                                        </button>
+                                    </form>
+                                    <hr>
+                                    <div>
+                                        <p>You can enter one of your recovery codes in case you lost access to your mobile device.</p>
+                                    </div>
+{{end}}

+ 34 - 0
templates/webadmin/twofactor.html

@@ -0,0 +1,34 @@
+{{template "baselogin" .}}
+
+{{define "title"}}Two-Factor authentication{{end}}
+
+{{define "content"}}
+                                    <div class="text-center">
+                                        <h1 class="h4 text-gray-900 mb-4">SFTPGo Admin - {{.Version}}</h1>
+                                    </div>
+                                    {{if .Error}}
+                                    <div class="card mb-4 border-left-warning">
+                                        <div class="card-body text-form-error">{{.Error}}</div>
+                                    </div>
+                                    {{end}}
+                                    <form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
+                                        class="user-custom">
+                                        <div class="form-group">
+                                            <input type="text" class="form-control form-control-user-custom"
+                                                id="inputPasscode" name="passcode" placeholder="Authentication code" required>
+                                        </div>
+                                        <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                        <button type="submit" class="btn btn-primary btn-user-custom btn-block">
+                                            Verify
+                                        </button>
+                                    </form>
+                                    <hr>
+                                    <div>
+                                        <p>Open the two-factor authentication app on your device to view your authentication code and verify your identity.</p>
+                                    </div>
+                                    <hr>
+                                    <div>
+                                        <p><strong>Having problems?</strong></p>
+                                        <p><a href="{{.RecoveryURL}}">Enter a two-factor recovery code</a></p>
+                                    </div>
+{{end}}

+ 7 - 1
templates/webclient/base.html

@@ -84,7 +84,13 @@
                     <i class="fas fa-key"></i>
                     <i class="fas fa-key"></i>
                     <span>{{.CredentialsTitle}}</span></a>
                     <span>{{.CredentialsTitle}}</span></a>
             </li>
             </li>
-
+            {{if .LoggedUser.CanManageMFA}}
+            <li class="nav-item {{if eq .CurrentURL .MFAURL}}active{{end}}">
+                <a class="nav-link" href="{{.MFAURL}}">
+                    <i class="fas fa-user-lock"></i>
+                    <span>{{.MFATitle}}</span></a>
+            </li>
+            {{end}}
             <!-- Divider -->
             <!-- Divider -->
             <hr class="sidebar-divider d-none d-md-block">
             <hr class="sidebar-divider d-none d-md-block">
 
 

+ 117 - 0
templates/webclient/baselogin.html

@@ -0,0 +1,117 @@
+{{define "baselogin"}}
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="description" content="">
+    <meta name="author" content="">
+
+    <title>SFTPGo WebClient - {{template "title" .}}</title>
+
+    <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
+
+    <!-- Custom styles for this template-->
+    <link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
+    <style>
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
+            font-weight: 700;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
+            font-weight: 400;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
+            font-weight: 300;
+            font-style: normal;
+        }
+
+        div.dt-buttons {
+            margin-bottom: 1em;
+        }
+
+        .text-form-error {
+            color: var(--red) !important;
+        }
+
+        div.dt-buttons {
+            margin-bottom: 1em;
+        }
+
+        .text-form-error {
+            color: var(--red) !important;
+        }
+
+        form.user-custom .custom-checkbox.small label {
+            line-height: 1.5rem;
+        }
+
+        form.user-custom .form-control-user-custom {
+            font-size: 0.9rem;
+            border-radius: 10rem;
+            padding: 1.5rem 1rem;
+        }
+
+        form.user-custom .btn-user-custom {
+            font-size: 0.9rem;
+            border-radius: 10rem;
+            padding: 0.75rem 1rem;
+        }
+    </style>
+
+</head>
+
+<body class="bg-gradient-primary">
+
+    <div class="container">
+
+        <!-- Outer Row -->
+        <div class="row justify-content-center">
+
+            <div class="col-xl-6 col-lg-7 col-md-9">
+
+                <div class="card o-hidden border-0 shadow-lg my-5">
+                    <div class="card-body p-0">
+                        <!-- Nested Row within Card Body -->
+                        <div class="row">
+                            <div class="col-lg-12">
+                                <div class="p-5">
+                                    <div class="text-center">
+                                        <h1 class="h4 text-gray-900 mb-4">SFTPGo WebClient - {{.Version}}</h1>
+                                    </div>
+                                    {{template "content" .}}
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Bootstrap core JavaScript-->
+    <script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
+    <script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
+
+    <!-- Core plugin JavaScript-->
+    <script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
+
+    <!-- Custom scripts for all pages-->
+    <script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
+
+</body>
+
+</html>
+{{end}}

+ 4 - 112
templates/webclient/login.html

@@ -1,95 +1,8 @@
-<!DOCTYPE html>
-<html lang="en">
+{{template "baselogin" .}}
 
 
-<head>
+{{define "title"}}Login{{end}}
 
 
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-    <meta name="description" content="">
-    <meta name="author" content="">
-
-    <title>SFTPGo WebClient - Login</title>
-
-    <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
-
-    <!-- Custom styles for this template-->
-    <link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
-    <style>
-        @font-face {
-            font-family: 'Roboto';
-            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
-            font-weight: 700;
-            font-style: normal;
-        }
-
-        @font-face {
-            font-family: 'Roboto';
-            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
-            font-weight: 400;
-            font-style: normal;
-        }
-
-        @font-face {
-            font-family: 'Roboto';
-            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
-            font-weight: 300;
-            font-style: normal;
-        }
-
-        div.dt-buttons {
-            margin-bottom: 1em;
-        }
-
-        .text-form-error {
-            color: var(--red) !important;
-        }
-
-        div.dt-buttons {
-            margin-bottom: 1em;
-        }
-
-        .text-form-error {
-            color: var(--red) !important;
-        }
-
-        form.user-custom .custom-checkbox.small label {
-            line-height: 1.5rem;
-        }
-
-        form.user-custom .form-control-user-custom {
-            font-size: 0.9rem;
-            border-radius: 10rem;
-            padding: 1.5rem 1rem;
-        }
-
-        form.user-custom .btn-user-custom {
-            font-size: 0.9rem;
-            border-radius: 10rem;
-            padding: 0.75rem 1rem;
-        }
-    </style>
-
-</head>
-
-<body class="bg-gradient-primary">
-
-    <div class="container">
-
-        <!-- Outer Row -->
-        <div class="row justify-content-center">
-
-            <div class="col-xl-6 col-lg-7 col-md-9">
-
-                <div class="card o-hidden border-0 shadow-lg my-5">
-                    <div class="card-body p-0">
-                        <!-- Nested Row within Card Body -->
-                        <div class="row">
-                            <div class="col-lg-12">
-                                <div class="p-5">
-                                    <div class="text-center">
-                                        <h1 class="h4 text-gray-900 mb-4">SFTPGo WebClient - {{.Version}}</h1>
-                                    </div>
+{{define "content"}}
                                     {{if .Error}}
                                     {{if .Error}}
                                     <div class="card mb-4 border-left-warning">
                                     <div class="card mb-4 border-left-warning">
                                         <div class="card-body text-form-error">{{.Error}}</div>
                                         <div class="card-body text-form-error">{{.Error}}</div>
@@ -116,25 +29,4 @@
                                         <a class="small" href="{{.AltLoginURL}}">Web Admin</a>
                                         <a class="small" href="{{.AltLoginURL}}">Web Admin</a>
                                     </div>
                                     </div>
                                     {{end}}
                                     {{end}}
-                                </div>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-
-    <!-- Bootstrap core JavaScript-->
-    <script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
-    <script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
-
-    <!-- Core plugin JavaScript-->
-    <script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
-
-    <!-- Custom scripts for all pages-->
-    <script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
-
-</body>
-
-</html>
+{{end}}

+ 474 - 0
templates/webclient/mfa.html

@@ -0,0 +1,474 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "page_body"}}
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">TOTP (Authenticator app)</h6>
+    </div>
+    <div class="card-body">
+        <div id="successTOTPMsg" class="card mb-4 border-left-success" style="display: none;">
+            <div id="successTOTPTxt" class="card-body"></div>
+        </div>
+        <div id="errorTOTPMsg" class="card mb-4 border-left-warning" style="display: none;">
+            <div id="errorTOTPTxt" class="card-body text-form-error"></div>
+        </div>
+        <div>
+            <p>Status: {{if .TOTPConfig.Enabled }}"Enabled". Current configuration: "{{.TOTPConfig.ConfigName}}"{{else}}"Disabled"{{end}}</p>
+        </div>
+        <div class="form-group row totpDisable">
+            <div class="col-sm-12">
+                <a id="idTOTPDisable" class="btn btn-warning" href="#" onclick="totpDisableAsk()" role="button">Disable</a>
+            </div>
+        </div>
+
+        <div class="totpProtocols">
+            <p>SSH protocol (SFTP/SCP/SSH commands) will ask for the passcode if the client uses keyboard interactive authentication.</p>
+            <p>HTTP protocol means Web UI and REST APIs. Web UI will ask for the passcode using a specific page. For REST API you have to add the passcode using an HTTP header.</p>
+            <p>FTP has no standard way to support two factor authentication, if you enable the FTP support, you have to add the TOTP passcode after the password. For example if your password is "password" and your one time passcode is "123456" you have to use "password123456" as password.</p>
+            <p>WebDAV is not supported since each single request must be authenticated and a passcode cannot be reused.</p>
+        </div>
+        <div class="form-group row totpProtocols">
+            <label for="idProtocols" class="col-sm-3 col-form-label">Require two-factor auth for</label>
+            <div class="col-sm-9">
+                <select class="form-control" id="idProtocols" name="multi_factor_protocols" multiple>
+                    {{range $protocol := .Protocols}}
+                    <option value="{{$protocol}}" {{range $p :=$.TOTPConfig.Protocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
+                    </option>
+                    {{end}}
+                </select>
+            </div>
+        </div>
+
+        <div class="form-group row totpUpdateProtocols">
+            <div class="col-sm-12">
+                <a id="idTOTPUpdateProtocols" class="btn btn-primary" href="#" onclick="totpUpdateProtocols()" role="button">Update protocols</a>
+            </div>
+        </div>
+
+        <div class="form-group row">
+            <label for="idConfig" class="col-sm-3 col-form-label">Configuration</label>
+            <div class="col-sm-9">
+                <select class="form-control" id="idConfig" name="config_name">
+                    <option value="">None</option>
+                    {{range .TOTPConfigs}}
+                    <option value="{{.}}" {{if eq . $.TOTPConfig.ConfigName}}selected{{end}}>{{.}}</option>
+                    {{end}}
+                </select>
+            </div>
+        </div>
+
+        <div class="form-group row totpGenerate">
+            <div class="col-sm-12">
+                <a id="idTOTPGenerate" class="btn btn-primary" href="#" onclick="totpGenerate()" role="button">Generate new secret</a>
+            </div>
+        </div>
+
+        <div id="idTOTPDetails" class="totpDetails">
+            <div>
+                <p>Your new TOTP secret is: <span id="idSecret"></span></p>
+                <p>For quick setup, scan this QR code with your TOTP app:</p>
+                <img id="idQRCode" src="data:image/gif;base64, R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="QR code" />
+            </div>
+            <br>
+            <div>
+                <p>After you configured your app, enter a test code below to ensure everything works correctly. Recovery codes are automatically generated if missing or most of them have already been used</p>
+            </div>
+
+            <div class="input-group">
+                <input type="text" class="form-control" id="idPasscode" name="passcode" value="" placeholder="Authentication code">
+                <span class="input-group-append">
+                    <a id="idTOTPSave" class="btn btn-primary" href="#" onclick="totpValidate()" role="button">Verify and save</a>
+                </span>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">Recovery codes</h6>
+    </div>
+    <div id="idRecoveryCodesCard" class="card-body">
+        <div id="successRecCodesMsg" class="card mb-4 border-left-success" style="display: none;">
+            <div id="successRecCodesTxt" class="card-body"></div>
+        </div>
+        <div id="errorRecCodesMsg" class="card mb-4 border-left-warning" style="display: none;">
+            <div id="errorRecCodesTxt" class="card-body text-form-error"></div>
+        </div>
+        <div>
+            <p>Recovery codes are a set of one time use codes that can be used in place of the TOTP to login to the web UI. You can use them if you lose access to your phone to login to your account and disable or regenerate TOTP configuration.</p>
+            <p>To keep your account secure, don't share or distribute your recovery codes. We recommend saving them with a secure password manager.</p>
+        </div>
+        <div class="form-group row viewRecoveryCodes">
+            <div class="col-sm-12">
+                <a class="btn btn-primary" href="#" onclick="getRecoveryCodes()" role="button">View</a>
+            </div>
+        </div>
+        <div id="idRecoveryCodes" style="display: none;">
+            <ul id="idRecoveryCodesList" class="list-group">
+            </ul>
+            <br>
+        </div>
+        <div>
+            <p>If you generate new recovery codes, you automatically invalidate old ones.</p>
+        </div>
+        <div class="form-group row">
+            <div class="col-sm-12">
+                <a class="btn btn-primary" href="#" onclick="generateRecoveryCodes()" role="button">Generate</a>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "dialog"}}
+<div class="modal fade" id="disableTOTPModal" tabindex="-1" role="dialog" aria-labelledby="disableTOTPModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="disableTOTPModalLabel">
+                    Confirmation required
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">Do you want to disable the TOTP configuration?</div>
+            <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">
+                    Cancel
+                </button>
+                <a class="btn btn-warning" href="#" onclick="totpDisable()">
+                    Disable
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+<script type="text/javascript">
+
+    function totpGenerate() {
+        var path = "{{.GenerateTOTPURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"config_name": $('#idConfig option:selected').val()}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('.totpDisable').hide();
+                $('.totpGenerate').hide();
+                $('.totpUpdateProtocols').hide();
+                $('#idSecret').text(result.secret);
+                $('#idQRCode').attr('src','data:image/png;base64, '+result.qr_code);
+                $('.totpDetails').show();
+                window.scrollTo(0, $("#idTOTPDetails").offset().top);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to generate a new TOTP secret";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function totpValidate() {
+        var passcode = $('#idPasscode').val();
+        if (passcode == "") {
+            $('#errorTOTPTxt').text("The verification code is required");
+            $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            return;
+        }
+        var path = "{{.ValidateTOTPURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"passcode": passcode, "config_name": $('#idConfig option:selected').val(), "secret": $('#idSecret').text()}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                totpSave();
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to validate the provided passcode";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function totpSave() {
+        var path = "{{.SaveTOTPURL}}";
+        var protocolsArray = [];
+        $('#idProtocols').find('option:selected').each(function(){
+            protocolsArray.push($(this).val());
+        });
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"enabled": true, "config_name": $('#idConfig option:selected').val(), "protocols": protocolsArray, "secret": {"status": "Plain", "payload": $('#idSecret').text()}}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('#successTOTPTxt').text("Configuration saved");
+                $('#successTOTPMsg').show();
+                    setTimeout(function () {
+                        location.reload();
+                    }, 3000);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to save the new configuration";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function totpDisableAsk() {
+        $('#disableTOTPModal').modal('show');
+    }
+
+    function totpUpdateProtocols() {
+        var path = "{{.SaveTOTPURL}}";
+        var protocolsArray = [];
+        $('#idProtocols').find('option:selected').each(function(){
+            protocolsArray.push($(this).val());
+        });
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"protocols": protocolsArray}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('#successTOTPTxt').text("Protocols updated");
+                $('#successTOTPMsg').show();
+                    setTimeout(function () {
+                        location.reload();
+                    }, 3000);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to update protocols";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function totpDisable() {
+        $('#disableTOTPModal').modal('hide');
+        var path = "{{.SaveTOTPURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"enabled": false}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                location.reload();
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to disable the current configuration";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTOTPTxt').text(txt);
+                $('#errorTOTPMsg').show();
+                setTimeout(function () {
+                    $('#errorTOTPMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function getRecoveryCodes() {
+        var path = "{{.RecCodesURL}}";
+        $.ajax({
+            url: path,
+            type: 'GET',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            timeout: 15000,
+            success: function (result) {
+                $('.viewRecoveryCodes').hide();
+                $('#idRecoveryCodesList').empty();
+                $.each(result, function(key, item) {
+                    if (item.used) {
+                        $('#idRecoveryCodesList').append(`<li class="list-group-item" style="text-decoration: line-through;">${item.code}</li>`);
+                    } else {
+                        $('#idRecoveryCodesList').append(`<li class="list-group-item">${item.code}</li>`);
+                    }
+                });
+                $('#idRecoveryCodes').show();
+                window.scrollTo(0, $("#idRecoveryCodesCard").offset().top);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to get your recovery codes";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorRecCodesTxt').text(txt);
+                $('#errorRecCodesMsg').show();
+                setTimeout(function () {
+                    $('#errorRecCodesMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function generateRecoveryCodes() {
+        var path = "{{.RecCodesURL}}";
+        $.ajax({
+            url: path,
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('.viewRecoveryCodes').hide();
+                $('#idRecoveryCodesList').empty();
+                $.each(result, function(key, item) {
+                    $('#idRecoveryCodesList').append(`<li class="list-group-item">${item}</li>`);
+                });
+                $('#idRecoveryCodes').show();
+                $('#successRecCodesTxt').text('Recovery codes generated successfully');
+                $('#successRecCodesMsg').show();
+                window.scrollTo(0, $("#idRecoveryCodesCard").offset().top);
+                setTimeout(function () {
+                    $('#successRecCodesMsg').hide();
+                }, 5000);
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Failed to generate new recovery codes";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorRecCodesTxt').text(txt);
+                $('#errorRecCodesMsg').show();
+                setTimeout(function () {
+                    $('#errorRecCodesMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    function handleConfigSelection() {
+        var selectedConfig = $('#idConfig option:selected').val();
+        if (selectedConfig == ""){
+            $('.totpGenerate').hide();
+        } else {
+            $('.totpGenerate').show();
+        }
+        $('.totpDetails').hide();
+        {{if .TOTPConfig.Enabled }}
+        $('.totpDisable').show();
+        {{end}}
+    }
+
+    $(document).ready(function () {
+        handleConfigSelection();
+        $('.totpDetails').hide();
+        {{if not .TOTPConfig.Enabled }}
+        $('.totpDisable').hide();
+        $('.totpUpdateProtocols').hide();
+        {{end}}
+
+        $('#idConfig').change(function() {
+            handleConfigSelection();
+        });
+    });
+</script>
+{{end}}

+ 26 - 0
templates/webclient/twofactor-recovery.html

@@ -0,0 +1,26 @@
+{{template "baselogin" .}}
+
+{{define "title"}}Two-Factor recovery{{end}}
+
+{{define "content"}}
+                                    {{if .Error}}
+                                    <div class="card mb-4 border-left-warning">
+                                        <div class="card-body text-form-error">{{.Error}}</div>
+                                    </div>
+                                    {{end}}
+                                    <form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
+                                        class="user-custom">
+                                        <div class="form-group">
+                                            <input type="text" class="form-control form-control-user-custom"
+                                                id="inputRecoveryCode" name="recovery_code" placeholder="Recovery code" required>
+                                        </div>
+                                        <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                        <button type="submit" class="btn btn-primary btn-user-custom btn-block">
+                                            Verify
+                                        </button>
+                                    </form>
+                                    <hr>
+                                    <div>
+                                        <p>You can enter one of your recovery codes in case you lost access to your mobile device.</p>
+                                    </div>
+{{end}}

+ 31 - 0
templates/webclient/twofactor.html

@@ -0,0 +1,31 @@
+{{template "baselogin" .}}
+
+{{define "title"}}Two-Factor authentication{{end}}
+
+{{define "content"}}
+                                    {{if .Error}}
+                                    <div class="card mb-4 border-left-warning">
+                                        <div class="card-body text-form-error">{{.Error}}</div>
+                                    </div>
+                                    {{end}}
+                                    <form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
+                                        class="user-custom">
+                                        <div class="form-group">
+                                            <input type="text" class="form-control form-control-user-custom"
+                                                id="inputPasscode" name="passcode" placeholder="Authentication code" required>
+                                        </div>
+                                        <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                        <button type="submit" class="btn btn-primary btn-user-custom btn-block">
+                                            Verify
+                                        </button>
+                                    </form>
+                                    <hr>
+                                    <div>
+                                        <p>Open the two-factor authentication app on your device to view your authentication code and verify your identity.</p>
+                                    </div>
+                                    <hr>
+                                    <div>
+                                        <p><strong>Having problems?</strong></p>
+                                        <p><a href="{{.RecoveryURL}}">Enter a two-factor recovery code</a></p>
+                                    </div>
+{{end}}

Some files were not shown because too many files changed in this diff