From 8a4c21b64abd577a688227a911f018bf7c4615ec Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sat, 4 Sep 2021 12:11:04 +0200 Subject: [PATCH] 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. --- .github/FUNDING.yml | 2 +- .github/workflows/development.yml | 5 +- README.md | 1 + cmd/startsubsys.go | 6 + common/connection.go | 5 +- common/protocol_test.go | 102 ++ config/config.go | 79 +- config/config_test.go | 56 + dataprovider/admin.go | 149 +- dataprovider/dataprovider.go | 179 +- dataprovider/sqlcommon.go | 1 + dataprovider/user.go | 45 +- docs/full-configuration.md | 25 +- docs/rest-api.md | 2 +- ftpd/ftpd_test.go | 62 + go.mod | 55 +- go.sum | 206 ++- httpd/api_admin.go | 23 + httpd/api_mfa.go | 240 +++ httpd/api_user.go | 23 + httpd/auth_utils.go | 35 +- httpd/httpd.go | 84 +- httpd/httpd_test.go | 1774 ++++++++++++++++++- httpd/internal_test.go | 75 +- httpd/middleware.go | 72 +- httpd/schema/openapi.yaml | 596 ++++++- httpd/server.go | 399 ++++- httpd/web.go | 32 +- httpd/webadmin.go | 95 + httpd/webclient.go | 113 +- mfa/mfa.go | 118 ++ mfa/mfa_test.go | 129 ++ mfa/totp.go | 106 ++ sdk/user.go | 46 +- service/service.go | 7 + sftpd/server.go | 7 +- sftpd/sftpd_test.go | 2 + sftpgo.json | 10 + templates/webadmin/adminsetup.html | 6 +- templates/webadmin/base.html | 6 + templates/webadmin/baselogin.html | 114 ++ templates/webadmin/login.html | 113 +- templates/webadmin/mfa.html | 401 +++++ templates/webadmin/status.html | 19 + templates/webadmin/twofactor-recovery.html | 29 + templates/webadmin/twofactor.html | 34 + templates/webclient/base.html | 8 +- templates/webclient/baselogin.html | 117 ++ templates/webclient/login.html | 116 +- templates/webclient/mfa.html | 474 +++++ templates/webclient/twofactor-recovery.html | 26 + templates/webclient/twofactor.html | 31 + 52 files changed, 5985 insertions(+), 475 deletions(-) create mode 100644 httpd/api_mfa.go create mode 100644 mfa/mfa.go create mode 100644 mfa/mfa_test.go create mode 100644 mfa/totp.go create mode 100644 templates/webadmin/baselogin.html create mode 100644 templates/webadmin/mfa.html create mode 100644 templates/webadmin/twofactor-recovery.html create mode 100644 templates/webadmin/twofactor.html create mode 100644 templates/webclient/baselogin.html create mode 100644 templates/webclient/mfa.html create mode 100644 templates/webclient/twofactor-recovery.html create mode 100644 templates/webclient/twofactor.html diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a5b3b5e0..edfab1aa 100644 --- a/.github/FUNDING.yml +++ b/.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 issuehunt: # Replace with a single IssueHunt 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'] diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index c0e7a1ca..a1ad59cc 100644 --- a/.github/workflows/development.yml +++ b/.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 ./webdavd -covermode=atomic go test -v -p 1 -timeout 2m ./telemetry -covermode=atomic + go test -v -p 1 -timeout 2m ./mfa -covermode=atomic env: SFTPGO_DATA_PROVIDER__DRIVER: bolt SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db' @@ -107,7 +108,7 @@ jobs: path: output test-goarch-386: - name: Run test cases with GOARCH=386 + name: Run test cases on 32 bit arch runs-on: ubuntu-latest steps: @@ -123,7 +124,7 @@ jobs: env: 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 env: SFTPGO_DATA_PROVIDER__DRIVER: memory diff --git a/README.md b/README.md index 04f97376..662f177c 100644 --- a/README.md +++ b/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. - Partial authentication. You can configure multi-step authentication requiring, for example, the user password after successful public key authentication. - 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. - [Data At Rest Encryption](./docs/dare.md). - Dynamic user modification before login via external programs/HTTP API. diff --git a/cmd/startsubsys.go b/cmd/startsubsys.go index 1afe904b..d1d9f05b 100644 --- a/cmd/startsubsys.go +++ b/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) 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 { logger.Error(logSender, connectionID, "unable to initialize plugin system: %v", err) os.Exit(1) diff --git a/common/connection.go b/common/connection.go index 21f339a6..88affcbd 100644 --- a/common/connection.go +++ b/common/connection.go @@ -23,9 +23,10 @@ import ( // BaseConnection defines common fields for a connection using any supported protocol type BaseConnection struct { // 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 - // 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 // Unique identifier for the connection ID string diff --git a/common/protocol_test.go b/common/protocol_test.go index 9af55622..a76eb9c3 100644 --- a/common/protocol_test.go +++ b/common/protocol_test.go @@ -20,6 +20,8 @@ import ( _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" "github.com/pkg/sftp" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" @@ -33,6 +35,7 @@ import ( "github.com/drakkan/sftpgo/v2/httpdtest" "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sdk" "github.com/drakkan/sftpgo/v2/vfs" ) @@ -94,9 +97,16 @@ func TestMain(m *testing.M) { logger.ErrorToConsole("error initializing kms: %v", err) 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.Bindings[0].Port = 4022 + sftpdConf.KeyboardInteractiveAuthentication = true httpdConf := config.GetHTTPDConfig() httpdConf.Bindings[0].Port = 4080 @@ -2338,6 +2348,69 @@ func TestRenameDir(t *testing.T) { 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) { u := getTestUser() testDir := "/dir-no-create-links" @@ -2690,6 +2763,26 @@ func checkBasicSFTP(client *sftp.Client) error { 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) { var sftpClient *sftp.Client 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))...) 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, + }) +} diff --git a/config/config.go b/config/config.go index a32d208b..3f9fb37c 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,7 @@ import ( "github.com/drakkan/sftpgo/v2/httpd" "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sdk/plugin" "github.com/drakkan/sftpgo/v2/sftpd" "github.com/drakkan/sftpgo/v2/telemetry" @@ -87,6 +88,11 @@ var ( EntriesSoftLimit: 100, EntriesHardLimit: 150, } + defaultTOTP = mfa.TOTPConfig{ + Name: "Default", + Issuer: "SFTPGo", + Algo: mfa.TOTPAlgoSHA1, + } ) type globalConfig struct { @@ -98,6 +104,7 @@ type globalConfig struct { HTTPDConfig httpd.Conf `json:"httpd" mapstructure:"httpd"` HTTPConfig httpclient.Config `json:"http" mapstructure:"http"` KMSConfig kms.Configuration `json:"kms" mapstructure:"kms"` + MFAConfig mfa.Config `json:"mfa" mapstructure:"mfa"` TelemetryConfig telemetry.Conf `json:"telemetry" mapstructure:"telemetry"` PluginsConfig []plugin.Config `json:"plugins" mapstructure:"plugins"` } @@ -144,19 +151,20 @@ func Init() { RateLimitersConfig: []common.RateLimiterConfig{defaultRateLimiter}, }, 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{ Bindings: []ftpd.Binding{defaultFTPDBinding}, @@ -284,6 +292,9 @@ func Init() { MasterKeyPath: "", }, }, + MFAConfig: mfa.Config{ + TOTP: nil, + }, TelemetryConfig: telemetry.Conf{ BindPort: 10000, BindAddress: "127.0.0.1", @@ -395,6 +406,11 @@ func GetPluginsConfig() []plugin.Config { 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. // Supported services are SFTP, FTP and WebDAV func HasServicesToStart() bool { @@ -511,6 +527,7 @@ func LoadConfig(configDir, configFile string) error { func loadBindingsFromEnv() { for idx := 0; idx < 10; idx++ { + getTOTPFromEnv(idx) getRateLimitersFromEnv(idx) getPluginsFromEnv(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) { rtlConfig := defaultRateLimiter 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.login_banner_file", globalConf.SFTPD.LoginBannerFile) 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.password_authentication", globalConf.SFTPD.PasswordAuthentication) viper.SetDefault("sftpd.folder_prefix", globalConf.SFTPD.FolderPrefix) diff --git a/config/config_test.go b/config/config_test.go index 06da40db..46d39e1c 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -18,6 +18,7 @@ import ( "github.com/drakkan/sftpgo/v2/httpclient" "github.com/drakkan/sftpgo/v2/httpd" "github.com/drakkan/sftpgo/v2/kms" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sftpd" "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) { reset() diff --git a/dataprovider/admin.go b/dataprovider/admin.go index 8b83cc8c..b240cebe 100644 --- a/dataprovider/admin.go +++ b/dataprovider/admin.go @@ -14,6 +14,9 @@ import ( passwordvalidator "github.com/wagslane/go-password-validator" "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" ) @@ -43,6 +46,36 @@ var ( 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 // TODO: rename to AdminOptions in v3 type AdminFilters struct { @@ -52,6 +85,12 @@ type AdminFilters struct { AllowList []string `json:"allow_list,omitempty"` // API key auth allows to impersonate this administrator with an API key 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 @@ -76,6 +115,17 @@ type Admin struct { 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 { if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) { if config.PasswordValidation.Admins.MinEntropy > 0 { @@ -100,19 +150,26 @@ func (a *Admin) checkPassword() error { return nil } -func (a *Admin) validate() error { - if a.Username == "" { - return util.NewValidationError("username is mandatory") - } - if a.Password == "" { - return util.NewValidationError("please set a password") - } - 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)) - } - if err := a.checkPassword(); err != nil { - return err +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") @@ -125,6 +182,35 @@ func (a *Admin) validate() error { return util.NewValidationError(fmt.Sprintf("invalid permission: %#v", perm)) } } + return nil +} + +func (a *Admin) validate() error { + a.SetEmptySecretsIfNil() + if a.Username == "" { + return util.NewValidationError("username is mandatory") + } + if 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) { + 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 { + return err + } + if err := a.validatePermissions(); err != nil { + return err + } if a.Email != "" && !emailRegex.MatchString(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 func (a *Admin) HideConfidentialData() { 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 @@ -239,6 +345,11 @@ func (a *Admin) GetInfoString() string { 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. // It could change after an update func (a *Admin) GetSignature() string { @@ -249,12 +360,26 @@ func (a *Admin) GetSignature() string { } func (a *Admin) getACopy() Admin { + a.SetEmptySecretsIfNil() permissions := make([]string, len(a.Permissions)) copy(permissions, a.Permissions) filters := AdminFilters{} filters.AllowList = make([]string, len(a.Filters.AllowList)) 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) + 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{ ID: a.ID, diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index fd80ea33..1e4f1cfb 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -48,6 +48,7 @@ import ( "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/logger" "github.com/drakkan/sftpgo/v2/metric" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sdk" "github.com/drakkan/sftpgo/v2/sdk/plugin" "github.com/drakkan/sftpgo/v2/util" @@ -101,6 +102,13 @@ const ( OrderDESC = "DESC" ) +const ( + protocolSSH = "SSH" + protocolFTP = "FTP" + protocolWebDAV = "DAV" + protocolHTTP = "HTTP" +) + var ( // SupportedProviders defines the supported data providers SupportedProviders = []string{SQLiteDataProviderName, PGSQLDataProviderName, MySQLDataProviderName, @@ -117,7 +125,9 @@ var ( // ErrNoAuthTryed defines the error for connection closed before authentication ErrNoAuthTryed = errors.New("no auth tryed") // 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 = errors.New("the data provider is up to date") // 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 func AddAdmin(admin *Admin) error { + admin.Filters.RecoveryCodes = nil + admin.Filters.TOTPConfig = TOTPConfig{ + Enabled: false, + } err := provider.addAdmin(admin) if err == nil { atomic.StoreInt32(&isAdminCreated, 1) @@ -983,6 +997,10 @@ func UserExists(username string) (User, error) { // AddUser adds a new SFTPGo user. func AddUser(user *User) error { + user.Filters.RecoveryCodes = nil + user.Filters.TOTPConfig = sdk.TOTPConfig{ + Enabled: false, + } err := provider.addUser(user) if err == nil { executeAction(operationAdd, user) @@ -1321,6 +1339,54 @@ func validateUserVirtualFolders(user *User) error { 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 { if len(user.Permissions) == 0 { return util.NewValidationError("please grant some permissions to this user") @@ -1607,7 +1673,13 @@ func ValidateUser(user *User) error { return err } 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 { return err @@ -1627,6 +1699,9 @@ func ValidateUser(user *User) error { if err := validateFilters(user); err != nil { 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) } @@ -1674,7 +1749,7 @@ func checkUserAndTLSCertificate(user *User, protocol string, tlsCert *x509.Certi return *user, err } switch protocol { - case "FTP", "DAV": + case protocolFTP, protocolWebDAV: if user.Filters.TLSUsername == sdk.TLSUsernameCN { if user.Username == tlsCert.Subject.CommonName { return *user, nil @@ -1692,6 +1767,10 @@ func checkUserAndPass(user *User, password, ip, protocol string) (User, error) { if err != nil { return *user, err } + password, err = checkUserPasscode(user, password, protocol) + if err != nil { + return *user, ErrInvalidCredentials + } if user.Password == "" { 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 } +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) { err := user.CheckLoginConditions() if err != nil { @@ -1978,6 +2091,44 @@ func sendKeyboardAuthHTTPReq(url string, request *plugin.KeyboardAuthRequest) (* 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) { authResult := 0 requestID := xid.New().String() @@ -2061,7 +2212,8 @@ func executeKeyboardInteractiveHTTPHook(user *User, authHook string, client ssh. } 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 answers, err := client(user.Username, response.Instruction, questions, response.Echos) if err != nil { @@ -2086,7 +2238,8 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp } 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) if err != nil { return err @@ -2169,10 +2322,14 @@ func doKeyboardInteractiveAuth(user *User, authHook string, client ssh.KeyboardI var err error if plugin.Handler.HasAuthScope(plugin.AuthScopeKeyboardInteractive) { 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 { - authResult, err = executeKeyboardInteractiveProgram(user, authHook, client, ip, protocol) + authResult, err = doBuiltinKeyboardInteractiveAuth(user, client, ip, protocol) } if err != nil { return *user, err @@ -2195,11 +2352,11 @@ func isCheckPasswordHookDefined(protocol string) bool { return true } switch protocol { - case "SSH": + case protocolSSH: return config.CheckPasswordScope&1 != 0 - case "FTP": + case protocolFTP: return config.CheckPasswordScope&2 != 0 - case "DAV": + case protocolWebDAV: return config.CheckPasswordScope&4 != 0 default: return false diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index d936faaf..542ff0ed 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -806,6 +806,7 @@ func getAdminFromDbRow(row sqlScanner) (Admin, error) { admin.Description = description.String } + admin.SetEmptySecretsIfNil() return admin, nil } diff --git a/dataprovider/user.go b/dataprovider/user.go index a40c465e..6238aa76 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -17,6 +17,7 @@ import ( "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sdk" "github.com/drakkan/sftpgo/v2/util" "github.com/drakkan/sftpgo/v2/vfs" @@ -195,6 +196,7 @@ func (u *User) CheckLoginConditions() error { func (u *User) hideConfidentialData() { u.Password = "" u.FsConfig.HideConfidentialData() + u.Filters.TOTPConfig.Secret.Hide() } // 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 @@ -298,6 +300,7 @@ func (u *User) SetEmptySecrets() { folder := &u.VirtualFolders[idx] folder.FsConfig.SetEmptySecretsIfNil() } + u.Filters.TOTPConfig.Secret = kms.NewEmptySecret() } // GetPermissionsForPath returns the permissions for the given path. @@ -708,7 +711,15 @@ func (u *User) isFilePatternAllowed(virtualPath string) bool { 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 func (u *User) CanManagePublicKeys() bool { return !util.IsStringInSlice(sdk.WebClientPubKeyChangeDisabled, u.Filters.WebClient) @@ -968,6 +979,17 @@ func (u *User) GetDeniedIPAsString() string { 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 func (u *User) SetEmptySecretsIfNil() { u.FsConfig.SetEmptySecretsIfNil() @@ -975,6 +997,9 @@ func (u *User) SetEmptySecretsIfNil() { vfolder := &u.VirtualFolders[idx] vfolder.FsConfig.SetEmptySecretsIfNil() } + if u.Filters.TOTPConfig.Secret == nil { + u.Filters.TOTPConfig.Secret = kms.NewEmptySecret() + } } func (u *User) getACopy() User { @@ -995,6 +1020,12 @@ func (u *User) getACopy() User { filters := sdk.UserFilters{} filters.MaxUploadFileSize = u.Filters.MaxUploadFileSize 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)) copy(filters.AllowedIP, u.Filters.AllowedIP) filters.DeniedIP = make([]string, len(u.Filters.DeniedIP)) @@ -1012,6 +1043,16 @@ func (u *User) getACopy() User { filters.AllowAPIKeyAuth = u.Filters.AllowAPIKeyAuth filters.WebClient = make([]string, len(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{ BaseUser: sdk.BaseUser{ diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 67e699ba..8e5b4c15 100644 --- a/docs/full-configuration.md +++ b/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. - `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). + - `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. - - `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. - **"ftpd"**, the configuration for the FTP server - `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. - `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. +- **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: - `type`, string. Defines the plugin type. Supported types: `notifier`, `kms`, `auth`. - `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`. +## 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 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. diff --git a/docs/rest-api.md b/docs/rest-api.md index 5b002763..7411b74c 100644 --- a/docs/rest-api.md +++ b/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: - 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 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. diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index 8645637b..50cebee3 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -21,6 +21,8 @@ import ( ftpserver "github.com/fclairamb/ftpserverlib" "github.com/jlaffaye/ftp" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -32,6 +34,7 @@ import ( "github.com/drakkan/sftpgo/v2/httpdtest" "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sdk" "github.com/drakkan/sftpgo/v2/sftpd" "github.com/drakkan/sftpgo/v2/vfs" @@ -305,6 +308,12 @@ func TestMain(m *testing.M) { logger.ErrorToConsole("error initializing kms: %v", err) 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.Bindings[0].Port = 8079 @@ -587,6 +596,50 @@ func TestBasicFTPHandling(t *testing.T) { 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) { u := getTestUser() user, _, err := httpdtest.AddUser(u, http.StatusCreated) @@ -3096,3 +3149,12 @@ func writeCerts(certPath, keyPath, caCrtPath, caCRLPath string) error { } 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, + }) +} diff --git a/go.mod b/go.mod index 991feb9b..79ab0f16 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,17 @@ module github.com/drakkan/sftpgo/v2 go 1.17 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/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 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/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b github.com/fatih/color v1.12.0 // indirect github.com/fclairamb/ftpserverlib v0.15.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/render v1.0.1 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-plugin v1.4.2 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/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/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/mattn/go-isatty v0.0.13 // indirect github.com/mattn/go-sqlite3 v1.14.8 @@ -42,13 +41,14 @@ require ( github.com/otiai10/copy v1.6.0 github.com/pires/go-proxyproto v0.6.0 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/common v0.30.0 // indirect github.com/rs/cors v1.8.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/shirou/gopsutil/v3 v3.21.7 + github.com/shirou/gopsutil/v3 v3.21.8 github.com/spf13/afero v1.6.0 github.com/spf13/cobra v1.2.1 github.com/spf13/viper v1.8.1 @@ -58,46 +58,47 @@ require ( github.com/yl2chen/cidranger v1.0.2 go.etcd.io/bbolt v1.3.6 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 - 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/protobuf v1.27.1 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) 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/StackExchange/wmi v1.2.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/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/cpuguy83/go-md2man/v2 v2.0.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/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/protobuf v1.5.2 // indirect + github.com/google/go-cmp v0.5.6 // 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/inconshreveable/mousetrap v1.0.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/lestrrat-go/blackmagic 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/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/mattn/go-colorable v0.1.8 // 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/pflag v1.0.5 // 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 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/xerrors v0.0.0-20200804184101-5ec99f83aff1 // 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.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index 889d1882..ba316a2a 100644 --- a/go.sum +++ b/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.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.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.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= 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.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= 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.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= 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.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.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.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= 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/kms v0.1.0 h1:VXAb5OzejDcyhFzIDeZ5n5AUdlsFnCyexuascIwWMj0= 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.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.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.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.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.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/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= 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.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/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 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/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.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/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.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.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.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.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/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.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/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= 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/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/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/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 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 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.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.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.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/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.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 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/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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/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/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 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/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= 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.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/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 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-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/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/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/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/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/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/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= 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/fclairamb/go-log v0.1.0 h1:fNoqk8w62i4EDEuRzDgHdDVTqMYSyr3DS981R7F2x/Y= 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/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/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.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/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.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.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/go.mod h1:+JtcRYGZsnA4+ur1LFlb4Bei3O9WeUzoMfDZWfUJuoY= 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/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.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.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/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.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/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= @@ -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.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 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-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-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-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/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/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 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.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/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.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.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/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 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.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-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/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= @@ -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/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-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/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 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/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.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.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= 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/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= 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/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE= 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/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 v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= 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/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.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.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.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/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= 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.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= 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.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= 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/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/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 v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 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/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.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.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 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/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/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-20200227202807-02e2044944cc/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/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= 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/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8= 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.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= 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/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= 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.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 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/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.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 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/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.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 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.1/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-20190226205417-e64efc72b421/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-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-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-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-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-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-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-20181108010431-42b317875d0f/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-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-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-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-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-20210514084401-e8d321eab015/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-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-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/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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-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-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/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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-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-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-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-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-20191113191852-77e3bb0ad9e7/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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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.8.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.14.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.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= 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.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.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.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= 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.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.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.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.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.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 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-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-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-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 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-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-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-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-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-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-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-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-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-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-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-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-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-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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 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.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.26.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 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-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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/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/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.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/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/httpd/api_admin.go b/httpd/api_admin.go index 7b162e9e..c2c8746e 100644 --- a/httpd/api_admin.go +++ b/httpd/api_admin.go @@ -64,6 +64,25 @@ func addAdmin(w http.ResponseWriter, r *http.Request) { 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) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) username := getURLParam(r, "username") @@ -74,6 +93,8 @@ func updateAdmin(w http.ResponseWriter, r *http.Request) { } adminID := admin.ID + totpConfig := admin.Filters.TOTPConfig + recoveryCodes := admin.Filters.RecoveryCodes err = render.DecodeJSON(r.Body, &admin) if err != nil { sendAPIResponse(w, r, err, "", http.StatusBadRequest) @@ -102,6 +123,8 @@ func updateAdmin(w http.ResponseWriter, r *http.Request) { } admin.ID = adminID admin.Username = username + admin.Filters.TOTPConfig = totpConfig + admin.Filters.RecoveryCodes = recoveryCodes if err := dataprovider.UpdateAdmin(&admin); err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return diff --git a/httpd/api_mfa.go b/httpd/api_mfa.go new file mode 100644 index 00000000..8a96a97b --- /dev/null +++ b/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) +} diff --git a/httpd/api_user.go b/httpd/api_user.go index 8e69fb3c..0c98a058 100644 --- a/httpd/api_user.go +++ b/httpd/api_user.go @@ -104,6 +104,25 @@ func addUser(w http.ResponseWriter, r *http.Request) { 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) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) var err error @@ -124,6 +143,8 @@ func updateUser(w http.ResponseWriter, r *http.Request) { return } userID := user.ID + totpConfig := user.Filters.TOTPConfig + recoveryCodes := user.Filters.RecoveryCodes currentPermissions := user.Permissions currentS3AccessSecret := user.FsConfig.S3Config.AccessSecret currentAzAccountKey := user.FsConfig.AzBlobConfig.AccountKey @@ -147,6 +168,8 @@ func updateUser(w http.ResponseWriter, r *http.Request) { } user.ID = userID user.Username = username + user.Filters.TOTPConfig = totpConfig + user.Filters.RecoveryCodes = recoveryCodes user.SetEmptySecretsIfNil() // we use new Permissions if passed otherwise the old ones if len(user.Permissions) == 0 { diff --git a/httpd/auth_utils.go b/httpd/auth_utils.go index f1a5e064..e138982a 100644 --- a/httpd/auth_utils.go +++ b/httpd/auth_utils.go @@ -18,11 +18,13 @@ import ( type tokenAudience = string 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 ( @@ -44,9 +46,17 @@ type jwtTokenClaims struct { Username string Permissions []string Signature string + Audience 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{} { claims := make(map[string]interface{}) @@ -75,6 +85,15 @@ func (c *jwtTokenClaims) Decode(token map[string]interface{}) { 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 { switch v := val.(type) { case string: @@ -142,7 +161,7 @@ func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Reque return err } var basePath string - if audience == tokenAudienceWebAdmin { + if audience == tokenAudienceWebAdmin || audience == tokenAudienceWebAdminPartial { basePath = webBaseAdminPath } else { basePath = webBaseClientPath @@ -207,11 +226,11 @@ func isTokenInvalidated(r *http.Request) bool { func invalidateToken(r *http.Request) { tokenString := jwtauth.TokenFromHeader(r) if tokenString != "" { - invalidatedJWTTokens.Store(tokenString, time.Now().UTC().Add(tokenDuration)) + invalidatedJWTTokens.Store(tokenString, time.Now().Add(tokenDuration).UTC()) } tokenString = jwtauth.TokenFromCookie(r) if tokenString != "" { - invalidatedJWTTokens.Store(tokenString, time.Now().UTC().Add(tokenDuration)) + invalidatedJWTTokens.Store(tokenString, time.Now().Add(tokenDuration).UTC()) } } diff --git a/httpd/httpd.go b/httpd/httpd.go index f60dd76e..e14bc577 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -24,6 +24,7 @@ import ( "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/ftpd" "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sftpd" "github.com/drakkan/sftpgo/v2/util" "github.com/drakkan/sftpgo/v2/webdavd" @@ -62,6 +63,16 @@ const ( userFilesPath = "/api/v2/user/files" userStreamZipPath = "/api/v2/user/streamzip" 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" webRootPathDefault = "/" webBasePathDefault = "/web" @@ -69,6 +80,8 @@ const ( webBasePathClientDefault = "/web/client" webAdminSetupPathDefault = "/web/admin/setup" webLoginPathDefault = "/web/admin/login" + webAdminTwoFactorPathDefault = "/web/admin/twofactor" + webAdminTwoFactorRecoveryPathDefault = "/web/admin/twofactor-recovery" webLogoutPathDefault = "/web/admin/logout" webUsersPathDefault = "/web/admin/users" webUserPathDefault = "/web/admin/user" @@ -85,16 +98,28 @@ const ( webQuotaScanPathDefault = "/web/admin/quotas/scanuser" webChangeAdminPwdPathDefault = "/web/admin/changepwd" 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" webTemplateUserDefault = "/web/admin/template/user" webTemplateFolderDefault = "/web/admin/template/folder" webDefenderPathDefault = "/web/admin/defender" webDefenderHostsPathDefault = "/web/admin/defender/hosts" webClientLoginPathDefault = "/web/client/login" + webClientTwoFactorPathDefault = "/web/client/twofactor" + webClientTwoFactorRecoveryPathDefault = "/web/client/twofactor-recovery" webClientFilesPathDefault = "/web/client/files" webClientDirsPathDefault = "/web/client/dirs" webClientDownloadZipPathDefault = "/web/client/downloadzip" 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" webChangeClientKeysPathDefault = "/web/client/managekeys" webChangeClientAPIKeyAccessPathDefault = "/web/client/apikeyaccess" @@ -106,13 +131,14 @@ const ( maxLoginBodySize = 262144 // 256 KB maxMultipartMem = 8388608 // 8MB osWindows = "windows" + otpHeaderCode = "X-SFTPGO-OTP" ) var ( backupsPath string certMgr *common.CertManager - jwtTokensCleanupTicker *time.Ticker - jwtTokensCleanupDone chan bool + cleanupTicker *time.Ticker + cleanupDone chan bool invalidatedJWTTokens sync.Map csrfTokenAuth *jwtauth.JWTAuth webRootPath string @@ -121,6 +147,8 @@ var ( webBaseClientPath string webAdminSetupPath string webLoginPath string + webAdminTwoFactorPath string + webAdminTwoFactorRecoveryPath string webLogoutPath string webUsersPath string webUserPath string @@ -136,6 +164,11 @@ var ( webScanVFolderPath string webQuotaScanPath string webAdminCredentialsPath string + webAdminMFAPath string + webAdminTOTPGeneratePath string + webAdminTOTPValidatePath string + webAdminTOTPSavePath string + webAdminRecoveryCodesPath string webChangeAdminAPIKeyAccessPath string webChangeAdminPwdPath string webTemplateUser string @@ -143,12 +176,19 @@ var ( webDefenderPath string webDefenderHostsPath string webClientLoginPath string + webClientTwoFactorPath string + webClientTwoFactorRecoveryPath string webClientFilesPath string webClientDirsPath string webClientDownloadZipPath string webClientCredentialsPath string webChangeClientPwdPath string webChangeClientKeysPath string + webClientMFAPath string + webClientTOTPGeneratePath string + webClientTOTPValidatePath string + webClientTOTPSavePath string + webClientRecoveryCodesPath string webChangeClientAPIKeyAccessPath string webClientLogoutPath string webStaticFilesPath string @@ -258,6 +298,7 @@ type ServicesStatus struct { WebDAV webdavd.ServiceStatus `json:"webdav"` DataProvider dataprovider.ProviderStatus `json:"data_provider"` Defender defenderStatus `json:"defender"` + MFA mfa.ServiceStatus `json:"mfa"` } // Conf httpd daemon configuration @@ -404,7 +445,7 @@ func (c *Conf) Initialize(configDir string) error { } maxUploadFileSize = c.MaxUploadFileSize - startJWTTokensCleanupTicker(tokenDuration) + startCleanupTicker(tokenDuration) return <-exitChannel } @@ -443,6 +484,7 @@ func getServicesStatus() ServicesStatus { Defender: defenderStatus{ IsActive: common.Config.DefenderConfig.Enabled, }, + MFA: mfa.GetStatus(), } return status } @@ -479,6 +521,8 @@ func updateWebClientURLs(baseURL string) { webBasePath = path.Join(baseURL, webBasePathDefault) webBaseClientPath = path.Join(baseURL, webBasePathClientDefault) webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault) + webClientTwoFactorPath = path.Join(baseURL, webClientTwoFactorPathDefault) + webClientTwoFactorRecoveryPath = path.Join(baseURL, webClientTwoFactorRecoveryPathDefault) webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault) webClientDirsPath = path.Join(baseURL, webClientDirsPathDefault) webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault) @@ -487,6 +531,11 @@ func updateWebClientURLs(baseURL string) { webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault) webChangeClientAPIKeyAccessPath = path.Join(baseURL, webChangeClientAPIKeyAccessPathDefault) 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) { @@ -498,6 +547,8 @@ func updateWebAdminURLs(baseURL string) { webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault) webAdminSetupPath = path.Join(baseURL, webAdminSetupPathDefault) webLoginPath = path.Join(baseURL, webLoginPathDefault) + webAdminTwoFactorPath = path.Join(baseURL, webAdminTwoFactorPathDefault) + webAdminTwoFactorRecoveryPath = path.Join(baseURL, webAdminTwoFactorRecoveryPathDefault) webLogoutPath = path.Join(baseURL, webLogoutPathDefault) webUsersPath = path.Join(baseURL, webUsersPathDefault) webUserPath = path.Join(baseURL, webUserPathDefault) @@ -514,6 +565,11 @@ func updateWebAdminURLs(baseURL string) { webQuotaScanPath = path.Join(baseURL, webQuotaScanPathDefault) webChangeAdminPwdPath = path.Join(baseURL, webChangeAdminPwdPathDefault) 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) webTemplateUser = path.Join(baseURL, webTemplateUserDefault) webTemplateFolder = path.Join(baseURL, webTemplateFolderDefault) @@ -536,28 +592,28 @@ func GetHTTPRouter() http.Handler { } // 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() { for { select { - case <-jwtTokensCleanupDone: + case <-cleanupDone: return - case <-jwtTokensCleanupTicker.C: + case <-cleanupTicker.C: cleanupExpiredJWTTokens() } } }() } -func stopJWTTokensCleanupTicker() { - if jwtTokensCleanupTicker != nil { - jwtTokensCleanupTicker.Stop() - jwtTokensCleanupDone <- true - jwtTokensCleanupTicker = nil +func stopCleanupTicker() { + if cleanupTicker != nil { + cleanupTicker.Stop() + cleanupDone <- true + cleanupTicker = nil } } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index d73e2436..af374253 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -28,6 +28,9 @@ import ( _ "github.com/lib/pq" "github.com/lithammer/shortuuid/v3" _ "github.com/mattn/go-sqlite3" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "github.com/rs/xid" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -41,6 +44,7 @@ import ( "github.com/drakkan/sftpgo/v2/httpdtest" "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sdk" "github.com/drakkan/sftpgo/v2/sftpd" "github.com/drakkan/sftpgo/v2/util" @@ -83,6 +87,16 @@ const ( userFilesPath = "/api/v2/user/files" userStreamZipPath = "/api/v2/user/streamzip" 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" webBasePath = "/web" webBasePathAdmin = "/web/admin" @@ -105,6 +119,10 @@ const ( webTemplateFolder = "/web/admin/template/folder" webDefenderPath = "/web/admin/defender" webChangeAdminAPIKeyAccessPath = "/web/admin/apikeyaccess" + webAdminTwoFactorPath = "/web/admin/twofactor" + webAdminTwoFactorRecoveryPath = "/web/admin/twofactor-recovery" + webAdminMFAPath = "/web/admin/mfa" + webAdminTOTPSavePath = "/web/admin/totp/save" webBasePathClient = "/web/client" webClientLoginPath = "/web/client/login" webClientFilesPath = "/web/client/files" @@ -114,7 +132,11 @@ const ( webChangeClientPwdPath = "/web/client/changepwd" webChangeClientKeysPath = "/web/client/managekeys" webChangeClientAPIKeyAccessPath = "/web/client/apikeyaccess" + webClientTwoFactorPath = "/web/client/twofactor" + webClientTwoFactorRecoveryPath = "/web/client/twofactor-recovery" webClientLogoutPath = "/web/client/logout" + webClientMFAPath = "/web/client/mfa" + webClientTOTPSavePath = "/web/client/totp/save" httpBaseURL = "http://127.0.0.1:8081" sftpServerAddr = "127.0.0.1:8022" configDir = ".." @@ -190,6 +212,28 @@ func (c *fakeConnection) GetRemoteAddress() string { return "" } +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 TestMain(m *testing.M) { homeBasePath = os.TempDir() logfilePath := filepath.Join(configDir, "sftpgo_api_test.log") @@ -233,6 +277,12 @@ func TestMain(m *testing.M) { logger.ErrorToConsole("error initializing kms: %v", err) 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() @@ -611,6 +661,143 @@ func TestHTTPUserAuthentication(t *testing.T) { assert.NoError(t, err) } +func TestLoginUserAPITOTP(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + assert.NoError(t, err) + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + userTOTPConfig := sdk.TOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(secret), + Protocols: []string{common.ProtocolHTTP}, + } + asJSON, err := json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + resp, err := httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + passcode, err := generateTOTPPasscode(secret) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.Header.Set("X-SFTPGO-OTP", passcode) + req.SetBasicAuth(defaultUsername, defaultPassword) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + responseHolder := make(map[string]interface{}) + err = render.DecodeJSON(resp.Body, &responseHolder) + assert.NoError(t, err) + adminToken := responseHolder["access_token"].(string) + assert.NotEmpty(t, adminToken) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.Header.Set("X-SFTPGO-OTP", passcode) + req.SetBasicAuth(defaultUsername, defaultPassword) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestLoginAdminAPITOTP(t *testing.T) { + admin := getTestAdmin() + admin.Username = altAdminUsername + admin.Password = altAdminPassword + admin, _, err := httpdtest.AddAdmin(admin, http.StatusCreated) + assert.NoError(t, err) + + configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], admin.Username) + assert.NoError(t, err) + altToken, err := getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword) + assert.NoError(t, err) + adminTOTPConfig := dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(secret), + } + asJSON, err := json.Marshal(adminTOTPConfig) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, adminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil) + assert.NoError(t, err) + req.SetBasicAuth(altAdminUsername, altAdminPassword) + resp, err := httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil) + assert.NoError(t, err) + req.Header.Set("X-SFTPGO-OTP", "passcode") + req.SetBasicAuth(altAdminUsername, altAdminPassword) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + passcode, err := generateTOTPPasscode(secret) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil) + assert.NoError(t, err) + req.Header.Set("X-SFTPGO-OTP", passcode) + req.SetBasicAuth(altAdminUsername, altAdminPassword) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + responseHolder := make(map[string]interface{}) + err = render.DecodeJSON(resp.Body, &responseHolder) + assert.NoError(t, err) + adminToken := responseHolder["access_token"].(string) + assert.NotEmpty(t, adminToken) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, versionPath), nil) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) + assert.NoError(t, err) +} + func TestHTTPStreamZipError(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) @@ -1259,6 +1446,21 @@ func TestUserRedactedPassword(t *testing.T) { assert.NoError(t, err) } +func TestUserType(t *testing.T) { + u := getTestUser() + u.Filters.UserType = string(sdk.UserTypeLDAP) + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + assert.Equal(t, string(sdk.UserTypeLDAP), user.Filters.UserType) + user.Filters.UserType = string(sdk.UserTypeOS) + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + assert.Equal(t, string(sdk.UserTypeOS), user.Filters.UserType) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) +} + func TestAddUserInvalidVirtualFolders(t *testing.T) { u := getTestUser() folderName := "fname" @@ -3037,6 +3239,24 @@ func TestSkipNaturalKeysValidation(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), "the following characters are allowed") + adminAPIToken, err := getJWTAPITokenFromTestServer(admin.Username, defaultTokenAuthPass) + assert.NoError(t, err) + userAPIToken, err := getJWTAPIUserTokenFromTestServer(user.Username, defaultPassword) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, userPath+"/"+user.Username+"/2fa/disable", nil) + assert.NoError(t, err) + setBearerForReq(req, adminAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "the following characters are allowed") + + req, err = http.NewRequest(http.MethodPost, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, userAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "the following characters are allowed") + _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) @@ -3055,6 +3275,151 @@ func TestSkipNaturalKeysValidation(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), "the following characters are allowed") + req, err = http.NewRequest(http.MethodPut, adminPath+"/"+admin.Username+"/2fa/disable", nil) + assert.NoError(t, err) + setBearerForReq(req, adminAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "the following characters are allowed") + + req, err = http.NewRequest(http.MethodPost, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, adminAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "the following characters are allowed") + + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) + assert.NoError(t, err) +} + +func TestSaveErrors(t *testing.T) { + err := dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf := config.GetProviderConf() + providerConf.SkipNaturalKeysValidation = true + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + + recCode := "recovery code" + recoveryCodes := []sdk.RecoveryCode{ + { + Secret: kms.NewPlainSecret(recCode), + Used: false, + }, + } + + u := getTestUser() + u.Username = "user@example.com" + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + assert.NoError(t, err) + user.Password = u.Password + user.Filters.TOTPConfig = sdk.TOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(secret), + Protocols: []string{common.ProtocolSSH, common.ProtocolHTTP}, + } + user.Filters.RecoveryCodes = recoveryCodes + err = dataprovider.UpdateUser(&user) + assert.NoError(t, err) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.True(t, user.Filters.TOTPConfig.Enabled) + assert.Len(t, user.Filters.RecoveryCodes, 1) + + a := getTestAdmin() + a.Username = "admin@example.com" + admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated) + assert.NoError(t, err) + admin.Email = admin.Username + admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK) + assert.NoError(t, err) + admin.Password = a.Password + admin.Filters.TOTPConfig = dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(secret), + } + admin.Filters.RecoveryCodes = recoveryCodes + err = dataprovider.UpdateAdmin(&admin) + assert.NoError(t, err) + admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK) + assert.NoError(t, err) + assert.True(t, admin.Filters.TOTPConfig.Enabled) + assert.Len(t, admin.Filters.RecoveryCodes, 1) + + err = dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf = config.GetProviderConf() + providerConf.CredentialsPath = credentialsPath + err = os.RemoveAll(credentialsPath) + assert.NoError(t, err) + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + if config.GetProviderConf().Driver == dataprovider.MemoryDataProviderName { + return + } + + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) + assert.NoError(t, err) + form := getLoginForm(a.Username, a.Password, csrfToken) + req, err := http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location")) + cookie, err := getCookieFromResponse(rr) + assert.NoError(t, err) + + form = make(url.Values) + form.Set("recovery_code", recCode) + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Contains(t, rr.Body.String(), "unable to set the recovery code as used") + + csrfToken, err = getCSRFToken(httpBaseURL + webClientLoginPath) + assert.NoError(t, err) + form = getLoginForm(u.Username, u.Password, csrfToken) + req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location")) + cookie, err = getCookieFromResponse(rr) + assert.NoError(t, err) + + form = make(url.Values) + form.Set("recovery_code", recCode) + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Contains(t, rr.Body.String(), "unable to set the recovery code as used") + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) assert.NoError(t, err) } @@ -4032,6 +4397,511 @@ func TestAddAdminNoPasswordMock(t *testing.T) { assert.Contains(t, rr.Body.String(), "please set a password") } +func TestAdminTwoFactorLogin(t *testing.T) { + admin := getTestAdmin() + admin.Username = altAdminUsername + admin.Password = altAdminPassword + admin, _, err := httpdtest.AddAdmin(admin, http.StatusCreated) + assert.NoError(t, err) + // enable two factor authentication + configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], admin.Username) + assert.NoError(t, err) + altToken, err := getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword) + assert.NoError(t, err) + adminTOTPConfig := dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(secret), + } + asJSON, err := json.Marshal(adminTOTPConfig) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, adminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK) + assert.NoError(t, err) + assert.True(t, admin.Filters.TOTPConfig.Enabled) + + req, err = http.NewRequest(http.MethodGet, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var recCodes []recoveryCode + err = json.Unmarshal(rr.Body.Bytes(), &recCodes) + assert.NoError(t, err) + assert.Len(t, recCodes, 12) + + webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodGet, webAdminTwoFactorPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, webAdminTwoFactorRecoveryPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath) + assert.NoError(t, err) + form := getLoginForm(altAdminUsername, altAdminPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location")) + cookie, err := getCookieFromResponse(rr) + assert.NoError(t, err) + + // without a cookie + req, err = http.NewRequest(http.MethodGet, webAdminTwoFactorRecoveryPath, nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, webAdminTwoFactorPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, webAdminTwoFactorRecoveryPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + // any other page will be redirected to the two factor auth page + req, err = http.NewRequest(http.MethodGet, webUsersPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location")) + // a partial token cannot be used for user pages + req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) + + passcode, err := generateTOTPPasscode(secret) + assert.NoError(t, err) + form = make(url.Values) + form.Set("passcode", passcode) + // no csrf + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + form.Set("passcode", "invalid_passcode") + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid authentication code") + + form.Set("passcode", "") + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + form.Set("passcode", passcode) + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webUsersPath, rr.Header().Get("Location")) + // the same cookie cannot be reused + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusNotFound, rr.Code) + // get a new cookie and login using a recovery code + form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location")) + cookie, err = getCookieFromResponse(rr) + assert.NoError(t, err) + + form = make(url.Values) + recoveryCode := recCodes[0].Code + form.Set("recovery_code", recoveryCode) + // no csrf + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + form.Set("recovery_code", "") + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + form.Set("recovery_code", recoveryCode) + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webUsersPath, rr.Header().Get("Location")) + authenticatedCookie, err := getCookieFromResponse(rr) + assert.NoError(t, err) + //render MFA page + req, err = http.NewRequest(http.MethodGet, webAdminMFAPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, authenticatedCookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // check that the recovery code was marked as used + req, err = http.NewRequest(http.MethodGet, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + recCodes = nil + err = json.Unmarshal(rr.Body.Bytes(), &recCodes) + assert.NoError(t, err) + assert.Len(t, recCodes, 12) + found := false + for _, rc := range recCodes { + if rc.Code == recoveryCode { + found = true + assert.True(t, rc.Used) + } else { + assert.False(t, rc.Used) + } + } + assert.True(t, found) + // the same recovery code cannot be reused + form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location")) + cookie, err = getCookieFromResponse(rr) + assert.NoError(t, err) + form = make(url.Values) + form.Set("recovery_code", recoveryCode) + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "This recovery code was already used") + + form.Set("recovery_code", "invalid_recovery_code") + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid recovery code") + + form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location")) + cookie, err = getCookieFromResponse(rr) + assert.NoError(t, err) + + // disable TOTP + req, err = http.NewRequest(http.MethodPut, adminPath+"/"+altAdminUsername+"/2fa/disable", nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + form = make(url.Values) + form.Set("recovery_code", recoveryCode) + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Two factory authentication is not enabled") + + form.Set("passcode", passcode) + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Two factory authentication is not enabled") + + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + req, err = http.NewRequest(http.MethodGet, webAdminMFAPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, authenticatedCookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) +} + +func TestAdminTOTP(t *testing.T) { + token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + admin := getTestAdmin() + admin.Username = altAdminUsername + admin.Password = altAdminPassword + // TOTPConfig will be ignored on add + admin.Filters.TOTPConfig = dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: "config", + Secret: kms.NewEmptySecret(), + } + asJSON, err := json.Marshal(admin) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, adminPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + + admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK) + assert.NoError(t, err) + assert.False(t, admin.Filters.TOTPConfig.Enabled) + assert.Len(t, admin.Filters.RecoveryCodes, 0) + + altToken, err := getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, adminTOTPConfigsPath, nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var configs []mfa.TOTPConfig + err = json.Unmarshal(rr.Body.Bytes(), &configs) + assert.NoError(t, err, rr.Body.String()) + assert.Len(t, configs, len(mfa.GetAvailableTOTPConfigs())) + totpConfig := configs[0] + totpReq := generateTOTPRequest{ + ConfigName: totpConfig.Name, + } + asJSON, err = json.Marshal(totpReq) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, adminTOTPGeneratePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var totpGenResp generateTOTPResponse + err = json.Unmarshal(rr.Body.Bytes(), &totpGenResp) + assert.NoError(t, err) + assert.NotEmpty(t, totpGenResp.Secret) + assert.NotEmpty(t, totpGenResp.QRCode) + + passcode, err := generateTOTPPasscode(totpGenResp.Secret) + assert.NoError(t, err) + validateReq := validateTOTPRequest{ + ConfigName: totpGenResp.ConfigName, + Passcode: passcode, + Secret: totpGenResp.Secret, + } + asJSON, err = json.Marshal(validateReq) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, adminTOTPValidatePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // the same passcode cannot be reused + req, err = http.NewRequest(http.MethodPost, adminTOTPValidatePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "this passcode was already used") + + adminTOTPConfig := dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: totpGenResp.ConfigName, + Secret: kms.NewPlainSecret(totpGenResp.Secret), + } + asJSON, err = json.Marshal(adminTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, adminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK) + assert.NoError(t, err) + assert.True(t, admin.Filters.TOTPConfig.Enabled) + assert.Equal(t, totpGenResp.ConfigName, admin.Filters.TOTPConfig.ConfigName) + assert.Empty(t, admin.Filters.TOTPConfig.Secret.GetKey()) + assert.Empty(t, admin.Filters.TOTPConfig.Secret.GetAdditionalData()) + assert.NotEmpty(t, admin.Filters.TOTPConfig.Secret.GetPayload()) + assert.Equal(t, kms.SecretStatusSecretBox, admin.Filters.TOTPConfig.Secret.GetStatus()) + admin.Filters.TOTPConfig = dataprovider.TOTPConfig{ + Enabled: false, + } + admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK) + assert.NoError(t, err) + assert.True(t, admin.Filters.TOTPConfig.Enabled) + // if we use token we should get no recovery codes + req, err = http.NewRequest(http.MethodGet, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var recCodes []recoveryCode + err = json.Unmarshal(rr.Body.Bytes(), &recCodes) + assert.NoError(t, err) + assert.Len(t, recCodes, 0) + // now the same but with altToken + req, err = http.NewRequest(http.MethodGet, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + recCodes = nil + err = json.Unmarshal(rr.Body.Bytes(), &recCodes) + assert.NoError(t, err) + assert.Len(t, recCodes, 12) + // regenerate recovery codes + req, err = http.NewRequest(http.MethodPost, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // check that recovery codes are different + req, err = http.NewRequest(http.MethodGet, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var newRecCodes []recoveryCode + err = json.Unmarshal(rr.Body.Bytes(), &newRecCodes) + assert.NoError(t, err) + assert.Len(t, newRecCodes, 12) + assert.NotEqual(t, recCodes, newRecCodes) + // disable 2FA, the update admin API should not work + admin.Filters.TOTPConfig.Enabled = false + admin.Filters.RecoveryCodes = nil + admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, altAdminUsername, admin.Username) + assert.True(t, admin.Filters.TOTPConfig.Enabled) + assert.Len(t, admin.Filters.RecoveryCodes, 12) + // use the dedicated API + req, err = http.NewRequest(http.MethodPut, adminPath+"/"+altAdminUsername+"/2fa/disable", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK) + assert.NoError(t, err) + assert.False(t, admin.Filters.TOTPConfig.Enabled) + assert.Len(t, admin.Filters.RecoveryCodes, 0) + + req, _ = http.NewRequest(http.MethodDelete, path.Join(adminPath, altAdminUsername), nil) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodPut, adminPath+"/"+altAdminUsername+"/2fa/disable", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, admin2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, adminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) +} + func TestChangeAdminPwdInvalidJsonMock(t *testing.T) { token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) @@ -4041,6 +4911,822 @@ func TestChangeAdminPwdInvalidJsonMock(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr) } +func TestWebUserTwoFactorLogin(t *testing.T) { + u := getTestUser() + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + // enable two factor authentication + configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + assert.NoError(t, err) + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + adminToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + userTOTPConfig := sdk.TOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(secret), + Protocols: []string{common.ProtocolHTTP}, + } + asJSON, err := json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var recCodes []recoveryCode + err = json.Unmarshal(rr.Body.Bytes(), &recCodes) + assert.NoError(t, err) + assert.Len(t, recCodes, 12) + + req, err = http.NewRequest(http.MethodGet, webClientTwoFactorPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, webClientTwoFactorRecoveryPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath) + assert.NoError(t, err) + form := getLoginForm(defaultUsername, defaultPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location")) + cookie, err := getCookieFromResponse(rr) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, webClientTwoFactorPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + // without a cookie + req, err = http.NewRequest(http.MethodGet, webClientTwoFactorPath, nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, webClientTwoFactorRecoveryPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // any other page will be redirected to the two factor auth page + req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location")) + // a partial token cannot be used for admin pages + req, err = http.NewRequest(http.MethodGet, webUsersPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webLoginPath, rr.Header().Get("Location")) + + passcode, err := generateTOTPPasscode(secret) + assert.NoError(t, err) + form = make(url.Values) + form.Set("passcode", passcode) + + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + form.Set("passcode", "invalid_user_passcode") + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid authentication code") + + form.Set("passcode", "") + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + form.Set("passcode", passcode) + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientFilesPath, rr.Header().Get("Location")) + // the same cookie cannot be reused + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusNotFound, rr.Code) + // get a new cookie and login using a recovery code + form = getLoginForm(defaultUsername, defaultPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location")) + cookie, err = getCookieFromResponse(rr) + assert.NoError(t, err) + + form = make(url.Values) + recoveryCode := recCodes[0].Code + form.Set("recovery_code", recoveryCode) + // no csrf + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + form.Set("recovery_code", "") + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + form.Set("recovery_code", recoveryCode) + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientFilesPath, rr.Header().Get("Location")) + authenticatedCookie, err := getCookieFromResponse(rr) + assert.NoError(t, err) + //render MFA page + req, err = http.NewRequest(http.MethodGet, webClientMFAPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, authenticatedCookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + // check that the recovery code was marked as used + req, err = http.NewRequest(http.MethodGet, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + recCodes = nil + err = json.Unmarshal(rr.Body.Bytes(), &recCodes) + assert.NoError(t, err) + assert.Len(t, recCodes, 12) + found := false + for _, rc := range recCodes { + if rc.Code == recoveryCode { + found = true + assert.True(t, rc.Used) + } else { + assert.False(t, rc.Used) + } + } + assert.True(t, found) + // the same recovery code cannot be reused + form = getLoginForm(defaultUsername, defaultPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location")) + cookie, err = getCookieFromResponse(rr) + assert.NoError(t, err) + form = make(url.Values) + form.Set("recovery_code", recoveryCode) + form.Set(csrfFormToken, csrfToken) + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "This recovery code was already used") + + form.Set("recovery_code", "invalid_user_recovery_code") + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid recovery code") + + form = getLoginForm(defaultUsername, defaultPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location")) + cookie, err = getCookieFromResponse(rr) + assert.NoError(t, err) + + // disable TOTP + req, err = http.NewRequest(http.MethodPut, userPath+"/"+user.Username+"/2fa/disable", nil) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + form = make(url.Values) + form.Set("recovery_code", recoveryCode) + form.Set("passcode", passcode) + form.Set(csrfFormToken, csrfToken) + + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Two factory authentication is not enabled") + + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Two factory authentication is not enabled") + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid credentials") + + req, err = http.NewRequest(http.MethodGet, webClientMFAPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, authenticatedCookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) +} + +func TestMFAErrors(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + assert.False(t, user.Filters.TOTPConfig.Enabled) + userToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + adminToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + + // invalid config name + totpReq := generateTOTPRequest{ + ConfigName: "invalid config name", + } + asJSON, err := json.Marshal(totpReq) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, userTOTPGeneratePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + // invalid JSON + invalidJSON := []byte("not a JSON") + req, err = http.NewRequest(http.MethodPost, userTOTPGeneratePath, bytes.NewBuffer(invalidJSON)) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(invalidJSON)) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodPost, adminTOTPSavePath, bytes.NewBuffer(invalidJSON)) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodPost, adminTOTPValidatePath, bytes.NewBuffer(invalidJSON)) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + // invalid TOTP config name + userTOTPConfig := sdk.TOTPConfig{ + Enabled: true, + ConfigName: "missing name", + Secret: kms.NewPlainSecret(xid.New().String()), + Protocols: []string{common.ProtocolSSH}, + } + asJSON, err = json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "totp: config name") + // invalid TOTP secret + userTOTPConfig = sdk.TOTPConfig{ + Enabled: true, + ConfigName: mfa.GetAvailableTOTPConfigNames()[0], + Secret: nil, + Protocols: []string{common.ProtocolSSH}, + } + asJSON, err = json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "totp: secret is mandatory") + // no protocol + userTOTPConfig = sdk.TOTPConfig{ + Enabled: true, + ConfigName: mfa.GetAvailableTOTPConfigNames()[0], + Secret: kms.NewPlainSecret(xid.New().String()), + Protocols: nil, + } + asJSON, err = json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "totp: specify at least one protocol") + // invalid protocol + userTOTPConfig = sdk.TOTPConfig{ + Enabled: true, + ConfigName: mfa.GetAvailableTOTPConfigNames()[0], + Secret: kms.NewPlainSecret(xid.New().String()), + Protocols: []string{common.ProtocolWebDAV}, + } + asJSON, err = json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "totp: invalid protocol") + + adminTOTPConfig := dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: "", + Secret: kms.NewPlainSecret("secret"), + } + asJSON, err = json.Marshal(adminTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, adminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "totp: config name is mandatory") + + adminTOTPConfig = dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: mfa.GetAvailableTOTPConfigNames()[0], + Secret: nil, + } + asJSON, err = json.Marshal(adminTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, adminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "totp: secret is mandatory") + + // invalid TOTP secret status + userTOTPConfig = sdk.TOTPConfig{ + Enabled: true, + ConfigName: mfa.GetAvailableTOTPConfigNames()[0], + Secret: kms.NewSecret(kms.SecretStatusRedacted, "", "", ""), + Protocols: []string{common.ProtocolSSH}, + } + asJSON, err = json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "cannot save a user with a redacted secret") + + req, err = http.NewRequest(http.MethodPost, adminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "cannot save an admin with a redacted secret") + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestMFAInvalidSecret(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + userToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + user.Password = defaultPassword + user.Filters.TOTPConfig = sdk.TOTPConfig{ + Enabled: true, + ConfigName: mfa.GetAvailableTOTPConfigNames()[0], + Secret: kms.NewSecret(kms.SecretStatusSecretBox, "payload", "key", user.Username), + Protocols: []string{common.ProtocolSSH, common.ProtocolHTTP}, + } + user.Filters.RecoveryCodes = append(user.Filters.RecoveryCodes, sdk.RecoveryCode{ + Used: false, + Secret: kms.NewSecret(kms.SecretStatusSecretBox, "payload", "key", user.Username), + }) + err = dataprovider.UpdateUser(&user) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, userToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + assert.Contains(t, rr.Body.String(), "Unable to decrypt recovery codes") + + csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath) + assert.NoError(t, err) + form := getLoginForm(defaultUsername, defaultPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location")) + cookie, err := getCookieFromResponse(rr) + assert.NoError(t, err) + form = make(url.Values) + form.Set(csrfFormToken, csrfToken) + form.Set("passcode", "123456") + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + + form = make(url.Values) + form.Set(csrfFormToken, csrfToken) + form.Set("recovery_code", "RC-123456") + req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.Header.Set("X-SFTPGO-OTP", "authcode") + req.SetBasicAuth(defaultUsername, defaultPassword) + resp, err := httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + admin := getTestAdmin() + admin.Username = altAdminUsername + admin.Password = altAdminPassword + admin, _, err = httpdtest.AddAdmin(admin, http.StatusCreated) + assert.NoError(t, err) + + admin.Password = altAdminPassword + admin.Filters.TOTPConfig = dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: mfa.GetAvailableTOTPConfigNames()[0], + Secret: kms.NewSecret(kms.SecretStatusSecretBox, "payload", "key", user.Username), + } + admin.Filters.RecoveryCodes = append(user.Filters.RecoveryCodes, sdk.RecoveryCode{ + Used: false, + Secret: kms.NewSecret(kms.SecretStatusSecretBox, "payload", "key", user.Username), + }) + err = dataprovider.UpdateAdmin(&admin) + assert.NoError(t, err) + + csrfToken, err = getCSRFToken(httpBaseURL + webLoginPath) + assert.NoError(t, err) + form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken) + req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location")) + cookie, err = getCookieFromResponse(rr) + assert.NoError(t, err) + form = make(url.Values) + form.Set(csrfFormToken, csrfToken) + form.Set("passcode", "123456") + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + + form = make(url.Values) + form.Set(csrfFormToken, csrfToken) + form.Set("recovery_code", "RC-123456") + req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + setJWTCookieForReq(req, cookie) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil) + assert.NoError(t, err) + req.Header.Set("X-SFTPGO-OTP", "auth-code") + req.SetBasicAuth(altAdminUsername, altAdminPassword) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + _, err = httpdtest.RemoveAdmin(admin, http.StatusOK) + assert.NoError(t, err) +} + +func TestWebUserTOTP(t *testing.T) { + u := getTestUser() + // TOTPConfig will be ignored on add + u.Filters.TOTPConfig = sdk.TOTPConfig{ + Enabled: true, + ConfigName: "", + Secret: kms.NewEmptySecret(), + Protocols: []string{common.ProtocolSSH}, + } + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + assert.False(t, user.Filters.TOTPConfig.Enabled) + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, userTOTPConfigsPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var configs []mfa.TOTPConfig + err = json.Unmarshal(rr.Body.Bytes(), &configs) + assert.NoError(t, err, rr.Body.String()) + assert.Len(t, configs, len(mfa.GetAvailableTOTPConfigs())) + totpConfig := configs[0] + totpReq := generateTOTPRequest{ + ConfigName: totpConfig.Name, + } + asJSON, err := json.Marshal(totpReq) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPGeneratePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var totpGenResp generateTOTPResponse + err = json.Unmarshal(rr.Body.Bytes(), &totpGenResp) + assert.NoError(t, err) + assert.NotEmpty(t, totpGenResp.Secret) + assert.NotEmpty(t, totpGenResp.QRCode) + + passcode, err := generateTOTPPasscode(totpGenResp.Secret) + assert.NoError(t, err) + validateReq := validateTOTPRequest{ + ConfigName: totpGenResp.ConfigName, + Passcode: passcode, + Secret: totpGenResp.Secret, + } + asJSON, err = json.Marshal(validateReq) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPValidatePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // the same passcode cannot be reused + req, err = http.NewRequest(http.MethodPost, userTOTPValidatePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "this passcode was already used") + + userTOTPConfig := sdk.TOTPConfig{ + Enabled: true, + ConfigName: totpGenResp.ConfigName, + Secret: kms.NewPlainSecret(totpGenResp.Secret), + Protocols: []string{common.ProtocolSSH}, + } + asJSON, err = json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + totpCfg := user.Filters.TOTPConfig + assert.True(t, totpCfg.Enabled) + assert.Equal(t, totpGenResp.ConfigName, totpCfg.ConfigName) + assert.Empty(t, totpCfg.Secret.GetKey()) + assert.Empty(t, totpCfg.Secret.GetAdditionalData()) + assert.NotEmpty(t, totpCfg.Secret.GetPayload()) + assert.Equal(t, kms.SecretStatusSecretBox, totpCfg.Secret.GetStatus()) + assert.Len(t, totpCfg.Protocols, 1) + assert.Contains(t, totpCfg.Protocols, common.ProtocolSSH) + // update protocols only + userTOTPConfig = sdk.TOTPConfig{ + Protocols: []string{common.ProtocolSSH, common.ProtocolFTP}, + } + asJSON, err = json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + // update the user, TOTP should not be affected + user.Filters.TOTPConfig = sdk.TOTPConfig{ + Enabled: false, + } + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.True(t, user.Filters.TOTPConfig.Enabled) + assert.Equal(t, totpCfg.ConfigName, user.Filters.TOTPConfig.ConfigName) + assert.Empty(t, user.Filters.TOTPConfig.Secret.GetKey()) + assert.Empty(t, user.Filters.TOTPConfig.Secret.GetAdditionalData()) + assert.Equal(t, totpCfg.Secret.GetPayload(), user.Filters.TOTPConfig.Secret.GetPayload()) + assert.Equal(t, kms.SecretStatusSecretBox, user.Filters.TOTPConfig.Secret.GetStatus()) + assert.Len(t, user.Filters.TOTPConfig.Protocols, 2) + assert.Contains(t, user.Filters.TOTPConfig.Protocols, common.ProtocolSSH) + assert.Contains(t, user.Filters.TOTPConfig.Protocols, common.ProtocolFTP) + + req, err = http.NewRequest(http.MethodGet, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var recCodes []recoveryCode + err = json.Unmarshal(rr.Body.Bytes(), &recCodes) + assert.NoError(t, err) + assert.Len(t, recCodes, 12) + // regenerate recovery codes + req, err = http.NewRequest(http.MethodPost, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // check that recovery codes are different + req, err = http.NewRequest(http.MethodGet, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var newRecCodes []recoveryCode + err = json.Unmarshal(rr.Body.Bytes(), &newRecCodes) + assert.NoError(t, err) + assert.Len(t, newRecCodes, 12) + assert.NotEqual(t, recCodes, newRecCodes) + // disable 2FA, the update user API should not work + adminToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) + assert.NoError(t, err) + user.Filters.TOTPConfig.Enabled = false + user.Filters.RecoveryCodes = nil + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + assert.Equal(t, defaultUsername, user.Username) + assert.True(t, user.Filters.TOTPConfig.Enabled) + assert.Len(t, user.Filters.RecoveryCodes, 12) + // use the dedicated API + req, err = http.NewRequest(http.MethodPut, userPath+"/"+defaultUsername+"/2fa/disable", nil) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK) + assert.NoError(t, err) + assert.False(t, user.Filters.TOTPConfig.Enabled) + assert.Len(t, user.Filters.RecoveryCodes, 0) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodPut, userPath+"/"+defaultUsername+"/2fa/disable", nil) + assert.NoError(t, err) + setBearerForReq(req, adminToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, user2FARecoveryCodesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, userTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) +} + func TestWebAPIChangeUserPwdMock(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) @@ -8317,8 +10003,36 @@ func TestWebAdminBasicMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusSeeOther, rr) - _, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK) + // add TOTP config + configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], altAdminUsername) assert.NoError(t, err) + altToken, err := getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword) + assert.NoError(t, err) + adminTOTPConfig := dataprovider.TOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(secret), + } + asJSON, err := json.Marshal(adminTOTPConfig) + assert.NoError(t, err) + // no CSRF token + req, err = http.NewRequest(http.MethodPost, webAdminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setJWTCookieForReq(req, altToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Invalid token") + + req, err = http.NewRequest(http.MethodPost, webAdminTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setJWTCookieForReq(req, altToken) + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK) + assert.NoError(t, err) + assert.True(t, admin.Filters.TOTPConfig.Enabled) req, _ = http.NewRequest(http.MethodGet, webAdminsPath+"?qlimit=a", nil) setJWTCookieForReq(req, token) @@ -8373,6 +10087,12 @@ func TestWebAdminBasicMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusSeeOther, rr) + admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK) + assert.NoError(t, err) + assert.True(t, admin.Filters.TOTPConfig.Enabled) + assert.Equal(t, "admin@example.com", admin.Email) + assert.Equal(t, 0, admin.Status) + req, _ = http.NewRequest(http.MethodPost, path.Join(webAdminPath, altAdminUsername+"1"), bytes.NewBuffer([]byte(form.Encode()))) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") setJWTCookieForReq(req, token) @@ -8920,6 +10640,37 @@ func TestWebUserUpdateMock(t *testing.T) { setBearerForReq(req, apiToken) rr := executeRequest(req) checkResponseCode(t, http.StatusCreated, rr) + // add TOTP config + configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + assert.NoError(t, err) + userToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + userTOTPConfig := sdk.TOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(secret), + Protocols: []string{common.ProtocolSSH, common.ProtocolFTP}, + } + asJSON, err := json.Marshal(userTOTPConfig) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webClientTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setJWTCookieForReq(req, userToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Invalid token") + + req, err = http.NewRequest(http.MethodPost, webClientTOTPSavePath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setJWTCookieForReq(req, userToken) + setCSRFHeaderForReq(req, csrfToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.True(t, user.Filters.TOTPConfig.Enabled) + dbUser, err := dataprovider.UserExists(user.Username) assert.NoError(t, err) assert.NotEmpty(t, dbUser.Password) @@ -9008,6 +10759,7 @@ func TestWebUserUpdateMock(t *testing.T) { assert.NotEmpty(t, dbUser.Password) assert.True(t, dbUser.IsPasswordHashed()) assert.Equal(t, prevPwd, dbUser.Password) + assert.True(t, dbUser.Filters.TOTPConfig.Enabled) req, _ = http.NewRequest(http.MethodGet, path.Join(userPath, user.Username), nil) setBearerForReq(req, apiToken) @@ -9027,6 +10779,7 @@ func TestWebUserUpdateMock(t *testing.T) { assert.Equal(t, int64(100), updateUser.Filters.MaxUploadFileSize) assert.Equal(t, sdk.TLSUsernameCN, updateUser.Filters.TLSUsername) assert.True(t, updateUser.Filters.AllowAPIKeyAuth) + assert.True(t, updateUser.Filters.TOTPConfig.Enabled) if val, ok := updateUser.Permissions["/otherdir"]; ok { assert.True(t, util.IsStringInSlice(dataprovider.PermListItems, val)) @@ -10932,6 +12685,10 @@ func getJWTWebClientTokenFromTestServerWithAddr(username, password, remoteAddr s if rr.Code != http.StatusFound { return "", fmt.Errorf("unexpected status code %v", rr) } + return getCookieFromResponse(rr) +} + +func getCookieFromResponse(rr *httptest.ResponseRecorder) (string, error) { cookie := strings.Split(rr.Header().Get("Set-Cookie"), ";") if strings.HasPrefix(cookie[0], "jwt=") { return cookie[0][4:], nil @@ -10955,11 +12712,7 @@ func getJWTWebTokenFromTestServer(username, password string) (string, error) { if rr.Code != http.StatusFound { return "", fmt.Errorf("unexpected status code %v", rr) } - cookie := strings.Split(rr.Header().Get("Set-Cookie"), ";") - if strings.HasPrefix(cookie[0], "jwt=") { - return cookie[0][4:], nil - } - return "", errors.New("no cookie found") + return getCookieFromResponse(rr) } func executeRequest(req *http.Request) *httptest.ResponseRecorder { @@ -11024,6 +12777,15 @@ func getMultipartFormData(values url.Values, fileFieldName, filePath string) (by return b, w.FormDataContentType(), err } +func generateTOTPPasscode(secret string) (string, error) { + return totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{ + Period: 30, + Skew: 1, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) +} + func BenchmarkSecretDecryption(b *testing.B) { s := kms.NewPlainSecret("test data") s.SetAdditionalData("username") diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 0e1ce667..7cbdc8c7 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -389,6 +389,44 @@ func TestInvalidToken(t *testing.T) { setUserPublicKeys(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) 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) { @@ -506,6 +544,9 @@ func TestCreateTokenError(t *testing.T) { rr = httptest.NewRecorder() server.handleWebAdminLoginPost(rr, req) 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, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%AO%GG", nil) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -555,6 +596,34 @@ func TestCreateTokenError(t *testing.T) { handleWebAdminManageAPIKeyPost(rr, req) 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" user = dataprovider.User{ BaseUser: sdk.BaseUser{ @@ -1096,11 +1165,11 @@ func TestJWTTokenCleanup(t *testing.T) { 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)) - startJWTTokensCleanupTicker(100 * time.Millisecond) + startCleanupTicker(100 * time.Millisecond) assert.Eventually(t, func() bool { return !isTokenInvalidated(req) }, 1*time.Second, 200*time.Millisecond) - stopJWTTokensCleanupTicker() + stopCleanupTicker() } func TestProxyHeaders(t *testing.T) { diff --git a/httpd/middleware.go b/httpd/middleware.go index 4a6da512..c56872a4 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -65,15 +65,6 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi } return errInvalidToken } - if !util.IsStringInSlice(audience, token.Audience()) { - logger.Debug(logSender, "", "the token is not valid for audience %#v", audience) - if isAPIToken { - sendAPIResponse(w, r, nil, "Your token audience is not valid", http.StatusUnauthorized) - } else { - http.Redirect(w, r, redirectPath, http.StatusFound) - } - return errInvalidToken - } if isTokenInvalidated(r) { logger.Debug(logSender, "", "the token has been invalidated") if isAPIToken { @@ -83,9 +74,60 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi } return errInvalidToken } + // 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 { + sendAPIResponse(w, r, nil, "Your token audience is not valid", http.StatusUnauthorized) + } else { + http.Redirect(w, r, redirectPath, http.StatusFound) + } + return errInvalidToken + } 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 { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := validateJWTToken(w, r, tokenAudienceAPI); err != nil { @@ -402,3 +444,15 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu 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 +} diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index a359d16d..8051e719 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -58,6 +58,13 @@ paths: summary: Get a new admin access token description: Returns an access token and its expiration 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: '200': description: successful operation @@ -106,6 +113,13 @@ paths: summary: Get a new user access token description: Returns an access token and its expiration 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: '200': description: successful operation @@ -228,6 +242,210 @@ paths: $ref: '#/components/responses/InternalServerError' default: $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: get: tags: @@ -1391,7 +1609,7 @@ paths: tags: - admins 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 requestBody: required: true @@ -1444,7 +1662,7 @@ paths: tags: - admins 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 responses: '200': @@ -1469,7 +1687,7 @@ paths: tags: - admins 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 requestBody: required: true @@ -1525,6 +1743,41 @@ paths: $ref: '#/components/responses/InternalServerError' default: $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: get: tags: @@ -1582,7 +1835,7 @@ paths: tags: - users 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 requestBody: required: true @@ -1644,7 +1897,7 @@ paths: tags: - users 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 parameters: - in: query @@ -1712,6 +1965,41 @@ paths: $ref: '#/components/responses/InternalServerError' default: $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: get: tags: @@ -1975,6 +2263,210 @@ paths: $ref: '#/components/responses/InternalServerError' default: $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: get: tags: @@ -2515,16 +3007,29 @@ components: * `SSH` - includes both SFTP and SSH commands * `FTP` - plain FTP and FTPES/FTPS * `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: type: string enum: - publickey-change-disabled - write-disabled + - mfa-disabled description: | Options: * `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 + * `mfa-disabled` - the user cannot enable multi-factor authentication. This option cannot be set if the user has MFA already enabled APIKeyScope: type: integer enum: @@ -2534,6 +3039,60 @@ components: Options: * `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 + 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: type: object properties: @@ -2628,6 +3187,14 @@ components: allow_api_key_auth: type: boolean 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 Secret: type: object @@ -3002,6 +3569,12 @@ components: allow_api_key_auth: type: boolean 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: type: object properties: @@ -3312,6 +3885,15 @@ components: type: string error: type: string + MFAStatus: + type: object + properties: + is_active: + type: boolean + totp_configs: + type: array + items: + $ref: '#/components/schemas/TOTPConfig' ServicesStatus: type: object properties: @@ -3328,6 +3910,8 @@ components: properties: is_active: type: boolean + mfa: + $ref: '#/components/schemas/MFAStatus' BanStatus: type: object properties: diff --git a/httpd/server.go b/httpd/server.go index c061df2e..4e63dbb0 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -21,6 +21,7 @@ import ( "github.com/drakkan/sftpgo/v2/common" "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/mfa" "github.com/drakkan/sftpgo/v2/sdk" "github.com/drakkan/sftpgo/v2/util" "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()) return } + s.loginUser(w, r, &user, connectionID, ipAddr, false, s.renderClientLoginPage) +} - c := jwtTokenClaims{ - Username: user.Username, - Permissions: user.Filters.WebClient, - Signature: user.GetSignature(), - } - - err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient) +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 { - 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 } - updateLoginMetrics(&user, ipAddr, err) - dataprovider.UpdateLastLogin(&user) - http.Redirect(w, r, webClientFilesPath, http.StatusFound) + 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") +} + +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 { + renderNotFoundPage(w, r, nil) + return + } + 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) { @@ -222,7 +406,7 @@ func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Req s.renderAdminLoginPage(w, err.Error()) return } - s.loginAdmin(w, r, &admin) + s.loginAdmin(w, r, &admin, false, s.renderAdminLoginPage) } 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()) 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{ Username: admin.Username, Permissions: admin.Permissions, Signature: admin.GetSignature(), } - err := c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin) - if err != nil { - logger.Warn(logSender, "", "unable to set admin login cookie %v", err) - s.renderAdminLoginPage(w, err.Error()) - return + audience := tokenAudienceWebAdmin + if admin.Filters.TOTPConfig.Enabled && admin.CanManageMFA() && !isSecondFactorAuth { + audience = tokenAudienceWebAdminPartial } - http.Redirect(w, r, webUsersPath, http.StatusFound) + err := c.createAndSetCookie(w, r, s.tokenAuth, audience) + if err != nil { + logger.Warn(logSender, "", "unable to set admin login cookie %v", err) + 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 + } dataprovider.UpdateAdminLastLogin(admin) + http.Redirect(w, r, webUsersPath, http.StatusFound) } 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 } + 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 err = user.CheckFsRoot(connectionID) 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) 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) } @@ -619,6 +908,13 @@ func (s *httpdServer) initializeRouter() { router.With(forbidAPIKeyAuthentication).Put(adminPwdPath, changeAdminPassword) // compatibility layer to remove in v2.2 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)). 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.PermAdminChangeUsers)).Put(userPath+"/{username}", updateUser) 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+"/{name}", getFolderByName) 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)).Put(adminPath+"/{username}", updateAdmin) 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)). Get(apiKeysPath, getAPIKeys) router.With(forbidAPIKeyAuthentication, checkPerm(dataprovider.PermAdminManageAPIKeys)). @@ -695,6 +993,20 @@ func (s *httpdServer) initializeRouter() { Get(userPublicKeysPath, getUserPublicKeys) router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)). 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 router.With(compressor.Handler).Get(userFolderPath, readUserFolder) router.Get(userFilePath, getUserFile) @@ -743,6 +1055,18 @@ func (s *httpdServer) initializeRouter() { }) s.router.Get(webClientLoginPath, s.handleClientWebLogin) 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) { router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie)) @@ -769,6 +1093,18 @@ func (s *httpdServer) initializeRouter() { router.Post(webChangeClientAPIKeyAccessPath, handleWebClientManageAPIKeyPost) router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)). 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.Get(webAdminSetupPath, handleWebAdminSetupGet) 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) { router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie)) @@ -790,6 +1138,13 @@ func (s *httpdServer) initializeRouter() { router.With(s.refreshCookie).Get(webAdminCredentialsPath, handleWebAdminCredentials) router.Post(webChangeAdminPwdPath, handleWebAdminChangePwdPost) 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). Get(webUsersPath, handleGetWebUsers) router.With(checkPerm(dataprovider.PermAdminAddUsers), s.refreshCookie). diff --git a/httpd/web.go b/httpd/web.go index f53ea523..ed67a73e 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -5,16 +5,19 @@ import ( ) 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 { @@ -26,6 +29,15 @@ type loginPage struct { AltLoginURL string } +type twoFactorPage struct { + CurrentURL string + Version string + Error string + CSRFToken string + StaticURL string + RecoveryURL string +} + func getSliceFromDelimitedValues(values, delimiter string) []string { result := []string{} for _, v := range strings.Split(values, delimiter) { diff --git a/httpd/webadmin.go b/httpd/webadmin.go index 001ed733..a25acf30 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -17,6 +17,7 @@ import ( "github.com/drakkan/sftpgo/v2/common" "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" "github.com/drakkan/sftpgo/v2/version" @@ -42,6 +43,7 @@ const ( const ( templateAdminDir = "webadmin" templateBase = "base.html" + templateBaseLogin = "baselogin.html" templateFsConfig = "fsconfig.html" templateUsers = "users.html" templateUser = "user.html" @@ -56,6 +58,7 @@ const ( templateDefender = "defender.html" templateCredentials = "credentials.html" templateMaintenance = "maintenance.html" + templateMFA = "mfa.html" templateSetup = "adminsetup.html" pageUsersTitle = "Users" pageAdminsTitle = "Admins" @@ -89,6 +92,7 @@ type basePage struct { DefenderURL string LogoutURL string CredentialsURL string + MFAURL string FolderQuotaScanURL string StatusURL string MaintenanceURL string @@ -162,6 +166,16 @@ type credentialsPage struct { APIKeyError string } +type mfaPage struct { + basePage + TOTPConfigs []string + TOTPConfig dataprovider.TOTPConfig + GenerateTOTPURL string + ValidateTOTPURL string + SaveTOTPURL string + RecCodesURL string +} + type maintenancePage struct { basePage BackupPath string @@ -243,6 +257,7 @@ func loadAdminTemplates(templatesPath string) { filepath.Join(templatesPath, templateAdminDir, templateStatus), } loginPath := []string{ + filepath.Join(templatesPath, templateAdminDir, templateBaseLogin), filepath.Join(templatesPath, templateAdminDir, templateLogin), } maintenancePath := []string{ @@ -253,7 +268,20 @@ func loadAdminTemplates(templatesPath string) { filepath.Join(templatesPath, templateAdminDir, templateBase), 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{ + filepath.Join(templatesPath, templateAdminDir, templateBaseLogin), filepath.Join(templatesPath, templateAdminDir, templateSetup), } @@ -273,6 +301,9 @@ func loadAdminTemplates(templatesPath string) { credentialsTmpl := util.LoadTemplate(rootTpl, credentialsPaths...) maintenanceTmpl := util.LoadTemplate(rootTpl, maintenancePath...) 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...) adminTemplates[templateUsers] = usersTmpl @@ -288,6 +319,9 @@ func loadAdminTemplates(templatesPath string) { adminTemplates[templateCredentials] = credentialsTmpl adminTemplates[templateMaintenance] = maintenanceTmpl adminTemplates[templateDefender] = defenderTmpl + adminTemplates[templateMFA] = mfaTmpl + adminTemplates[templateTwoFactor] = twoFactorTmpl + adminTemplates[templateTwoFactorRecovery] = twoFactorRecoveryTmpl adminTemplates[templateSetup] = setupTmpl } @@ -310,6 +344,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage { DefenderURL: webDefenderPath, LogoutURL: webLogoutPath, CredentialsURL: webAdminCredentialsPath, + MFAURL: webAdminMFAPath, QuotaScanURL: webQuotaScanPath, ConnectionsURL: webConnectionsPath, StatusURL: webStatusPath, @@ -370,6 +405,47 @@ func renderNotFoundPage(w http.ResponseWriter, r *http.Request, err error) { 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) { data := credentialsPage{ basePage: getBasePageData(pageCredentialsTitle, webAdminCredentialsPath, r), @@ -1033,6 +1109,21 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { 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) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) renderCredentialsPage(w, r, "", "") @@ -1250,6 +1341,8 @@ func handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Request) { if updatedAdmin.Password == "" { updatedAdmin.Password = admin.Password } + updatedAdmin.Filters.TOTPConfig = admin.Filters.TOTPConfig + updatedAdmin.Filters.RecoveryCodes = admin.Filters.RecoveryCodes claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { 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.Username = user.Username + updatedUser.Filters.RecoveryCodes = user.Filters.RecoveryCodes + updatedUser.Filters.TOTPConfig = user.Filters.TOTPConfig updatedUser.SetEmptySecretsIfNil() if updatedUser.Password == redactedSecret { updatedUser.Password = user.Password diff --git a/httpd/webclient.go b/httpd/webclient.go index 9f3b210a..25c0233e 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -16,20 +16,26 @@ import ( "github.com/drakkan/sftpgo/v2/common" "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/version" "github.com/drakkan/sftpgo/v2/vfs" ) 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. @@ -59,6 +65,8 @@ type baseClientPage struct { CredentialsURL string StaticURL string LogoutURL string + MFAURL string + MFATitle string FilesTitle string CredentialsTitle string Version string @@ -103,6 +111,17 @@ type clientCredentialsPage struct { 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 { 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), } loginPath := []string{ + filepath.Join(templatesPath, templateClientDir, templateClientBaseLogin), filepath.Join(templatesPath, templateClientDir, templateClientLogin), } messagePath := []string{ filepath.Join(templatesPath, templateClientDir, templateClientBase), 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...) credentialsTmpl := util.LoadTemplate(nil, credentialsPaths...) loginTmpl := util.LoadTemplate(nil, loginPath...) messageTmpl := util.LoadTemplate(nil, messagePath...) + mfaTmpl := util.LoadTemplate(nil, mfaPath...) + twoFactorTmpl := util.LoadTemplate(nil, twoFactorPath...) + twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...) clientTemplates[templateClientFiles] = filesTmpl clientTemplates[templateClientCredentials] = credentialsTmpl clientTemplates[templateClientLogin] = loginTmpl clientTemplates[templateClientMessage] = messageTmpl + clientTemplates[templateClientMFA] = mfaTmpl + clientTemplates[templateClientTwoFactor] = twoFactorTmpl + clientTemplates[templateClientTwoFactorRecovery] = twoFactorRecoveryTmpl } func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage { @@ -156,6 +194,8 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient CredentialsURL: webClientCredentialsPath, StaticURL: webStaticFilesPath, LogoutURL: webClientLogoutPath, + MFAURL: webClientMFAPath, + MFATitle: "Two-factor auth", FilesTitle: pageClientFilesTitle, CredentialsTitle: pageClientCredentialsTitle, 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, "") } +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) { data := filesPage{ 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, "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, "") +} diff --git a/mfa/mfa.go b/mfa/mfa.go new file mode 100644 index 00000000..ca47bfaf --- /dev/null +++ b/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 + } +} diff --git a/mfa/mfa_test.go b/mfa/mfa_test.go new file mode 100644 index 00000000..9cada1f4 --- /dev/null +++ b/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, + }) +} diff --git a/mfa/totp.go b/mfa/totp.go new file mode 100644 index 00000000..f040ef5d --- /dev/null +++ b/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 + }) +} diff --git a/sdk/user.go b/sdk/user.go index cccabe9d..fd1b5a64 100644 --- a/sdk/user.go +++ b/sdk/user.go @@ -3,6 +3,7 @@ package sdk import ( "strings" + "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/util" ) @@ -10,11 +11,14 @@ import ( const ( WebClientPubKeyChangeDisabled = "publickey-change-disabled" WebClientWriteDisabled = "write-disabled" + WebClientMFADisabled = "mfa-disabled" ) var ( // 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 @@ -26,6 +30,16 @@ const ( 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 type DirectoryPermissions struct { Path string @@ -83,6 +97,27 @@ type HooksFilter struct { 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 // TODO: rename to UserOptions in v3 type UserFilters struct { @@ -122,6 +157,15 @@ type UserFilters struct { WebClient []string `json:"web_client,omitempty"` // API key auth allows to impersonate this user with an API key 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 { diff --git a/service/service.go b/service/service.go index f61a6d62..df2d7eee 100644 --- a/service/service.go +++ b/service/service.go @@ -98,6 +98,13 @@ func (s *Service) Start() error { logger.ErrorToConsole("unable to initialize KMS: %v", err) 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 { logger.Error(logSender, "", "unable to initialize plugin system: %v", err) logger.ErrorToConsole("unable to initialize plugin system: %v", err) diff --git a/sftpd/server.go b/sftpd/server.go index cf96ca56..6307ce96 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -21,7 +21,6 @@ import ( "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/logger" "github.com/drakkan/sftpgo/v2/metric" - "github.com/drakkan/sftpgo/v2/sdk/plugin" "github.com/drakkan/sftpgo/v2/util" "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". // "*" enables all supported 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. // Leave empty to disable this authentication mode. 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) { - if c.KeyboardInteractiveHook == "" && !plugin.Handler.HasAuthScope(plugin.AuthScopeKeyboardInteractive) { + if !c.KeyboardInteractiveAuthentication { return } if c.KeyboardInteractiveHook != "" { diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 8fe92242..d868d6ed 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -217,6 +217,7 @@ func TestMain(m *testing.M) { logger.ErrorToConsole("error writing keyboard interactive script: %v", err) os.Exit(1) } + sftpdConf.KeyboardInteractiveAuthentication = true sftpdConf.KeyboardInteractiveHook = keyIntAuthPath createInitialFiles(scriptArgs) @@ -333,6 +334,7 @@ func TestInitialization(t *testing.T) { sftpdConf.EnabledSSHCommands = append(sftpdConf.EnabledSSHCommands, "ls") err = sftpdConf.Initialize(configDir) assert.Error(t, err) + sftpdConf.KeyboardInteractiveAuthentication = true sftpdConf.KeyboardInteractiveHook = "invalid_file" err = sftpdConf.Initialize(configDir) assert.Error(t, err) diff --git a/sftpgo.json b/sftpgo.json index 347b01c5..0e52c08b 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -70,6 +70,7 @@ "pwd", "scp" ], + "keyboard_interactive_authentication": false, "keyboard_interactive_auth_hook": "", "password_authentication": true, "folder_prefix": "" @@ -244,5 +245,14 @@ "master_key_path": "" } }, + "mfa": { + "totp": [ + { + "name": "Default", + "issuer": "SFTPGo", + "algo": "sha1" + } + ] + }, "plugins": [] } \ No newline at end of file diff --git a/templates/webadmin/adminsetup.html b/templates/webadmin/adminsetup.html index 393534da..e7266667 100644 --- a/templates/webadmin/adminsetup.html +++ b/templates/webadmin/adminsetup.html @@ -99,15 +99,15 @@ class="user-custom">
+ name="username" placeholder="Username" value="{{.Username}}" required>
+ name="password" placeholder="Password" required>
+ name="confirm_password" placeholder="Repeat password" required>
+ + + + + + +{{end}} + +{{define "extra_js"}} + +{{end}} \ No newline at end of file diff --git a/templates/webadmin/status.html b/templates/webadmin/status.html index d59f9fa4..8667a50e 100644 --- a/templates/webadmin/status.html +++ b/templates/webadmin/status.html @@ -88,6 +88,25 @@ +
+
+
Multi-factor authentication
+

+ Status: {{ if .Status.MFA.IsActive}}"Enabled"{{else}}"Disabled"{{end}} + {{ if .Status.MFA.IsActive}} +
+ Time-based one time passwords (RFC 6238) configurations: +
+

    + {{range .Status.MFA.TOTPConfigs}} +
  • Name: "{{.Name}}", issuer: "{{.Issuer}}", HMAC algorithm: "{{.Algo}}"
  • + {{end}} +
+ {{end}} +

+
+
+
Data provider
diff --git a/templates/webadmin/twofactor-recovery.html b/templates/webadmin/twofactor-recovery.html new file mode 100644 index 00000000..3904a732 --- /dev/null +++ b/templates/webadmin/twofactor-recovery.html @@ -0,0 +1,29 @@ +{{template "baselogin" .}} + +{{define "title"}}Two-Factor recovery{{end}} + +{{define "content"}} +
+

SFTPGo Admin - {{.Version}}

+
+ {{if .Error}} +
+
{{.Error}}
+
+ {{end}} +
+
+ +
+ + +
+
+
+

You can enter one of your recovery codes in case you lost access to your mobile device.

+
+{{end}} \ No newline at end of file diff --git a/templates/webadmin/twofactor.html b/templates/webadmin/twofactor.html new file mode 100644 index 00000000..c2e900ee --- /dev/null +++ b/templates/webadmin/twofactor.html @@ -0,0 +1,34 @@ +{{template "baselogin" .}} + +{{define "title"}}Two-Factor authentication{{end}} + +{{define "content"}} +
+

SFTPGo Admin - {{.Version}}

+
+ {{if .Error}} +
+
{{.Error}}
+
+ {{end}} +
+
+ +
+ + +
+
+
+

Open the two-factor authentication app on your device to view your authentication code and verify your identity.

+
+
+
+

Having problems?

+

Enter a two-factor recovery code

+
+{{end}} \ No newline at end of file diff --git a/templates/webclient/base.html b/templates/webclient/base.html index aaeb0bd2..913bc4b5 100644 --- a/templates/webclient/base.html +++ b/templates/webclient/base.html @@ -84,7 +84,13 @@ {{.CredentialsTitle}} - + {{if .LoggedUser.CanManageMFA}} + + {{end}} diff --git a/templates/webclient/baselogin.html b/templates/webclient/baselogin.html new file mode 100644 index 00000000..f51345c4 --- /dev/null +++ b/templates/webclient/baselogin.html @@ -0,0 +1,117 @@ +{{define "baselogin"}} + + + + + + + + + + + + SFTPGo WebClient - {{template "title" .}} + + + + + + + + + + + +
+ + +
+ +
+ +
+
+ +
+
+
+
+

SFTPGo WebClient - {{.Version}}

+
+ {{template "content" .}} +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +{{end}} \ No newline at end of file diff --git a/templates/webclient/login.html b/templates/webclient/login.html index b6231df1..ede8c2b2 100644 --- a/templates/webclient/login.html +++ b/templates/webclient/login.html @@ -1,95 +1,8 @@ - - +{{template "baselogin" .}} - +{{define "title"}}Login{{end}} - - - - - - - SFTPGo WebClient - Login - - - - - - - - - - - -
- - -
- -
- -
-
- -
-
-
-
-

SFTPGo WebClient - {{.Version}}

-
+{{define "content"}} {{if .Error}}
{{.Error}}
@@ -116,25 +29,4 @@ Web Admin
{{end}} -
-
-
-
-
-
-
-
- - - - - - - - - - - - - - \ No newline at end of file +{{end}} \ No newline at end of file diff --git a/templates/webclient/mfa.html b/templates/webclient/mfa.html new file mode 100644 index 00000000..dfe28d41 --- /dev/null +++ b/templates/webclient/mfa.html @@ -0,0 +1,474 @@ +{{template "base" .}} + +{{define "title"}}{{.Title}}{{end}} + +{{define "page_body"}} + +
+
+
TOTP (Authenticator app)
+
+
+ + +
+

Status: {{if .TOTPConfig.Enabled }}"Enabled". Current configuration: "{{.TOTPConfig.ConfigName}}"{{else}}"Disabled"{{end}}

+
+
+
+ Disable +
+
+ +
+

SSH protocol (SFTP/SCP/SSH commands) will ask for the passcode if the client uses keyboard interactive authentication.

+

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.

+

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.

+
+
+ +
+ +
+
+ + + +
+ +
+ +
+
+ + + +
+
+

Your new TOTP secret is:

+

For quick setup, scan this QR code with your TOTP app:

+ QR code +
+
+
+

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

+
+ +
+ + + Verify and save + +
+
+
+
+ +
+
+
Recovery codes
+
+
+ + +
+

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.

+

To keep your account secure, don't share or distribute your recovery codes. We recommend saving them with a secure password manager.

+
+
+
+ View +
+
+ +
+

If you generate new recovery codes, you automatically invalidate old ones.

+
+
+
+ Generate +
+
+
+
+{{end}} + +{{define "dialog"}} + +{{end}} + +{{define "extra_js"}} + +{{end}} \ No newline at end of file diff --git a/templates/webclient/twofactor-recovery.html b/templates/webclient/twofactor-recovery.html new file mode 100644 index 00000000..5ab2be8e --- /dev/null +++ b/templates/webclient/twofactor-recovery.html @@ -0,0 +1,26 @@ +{{template "baselogin" .}} + +{{define "title"}}Two-Factor recovery{{end}} + +{{define "content"}} + {{if .Error}} +
+
{{.Error}}
+
+ {{end}} +
+
+ +
+ + +
+
+
+

You can enter one of your recovery codes in case you lost access to your mobile device.

+
+{{end}} \ No newline at end of file diff --git a/templates/webclient/twofactor.html b/templates/webclient/twofactor.html new file mode 100644 index 00000000..1772abc5 --- /dev/null +++ b/templates/webclient/twofactor.html @@ -0,0 +1,31 @@ +{{template "baselogin" .}} + +{{define "title"}}Two-Factor authentication{{end}} + +{{define "content"}} + {{if .Error}} +
+
{{.Error}}
+
+ {{end}} +
+
+ +
+ + +
+
+
+

Open the two-factor authentication app on your device to view your authentication code and verify your identity.

+
+
+
+

Having problems?

+

Enter a two-factor recovery code

+
+{{end}} \ No newline at end of file