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.
This commit is contained in:
Nicola Murino 2021-09-04 12:11:04 +02:00
parent 16ba7ddb34
commit 8a4c21b64a
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
52 changed files with 5985 additions and 475 deletions

2
.github/FUNDING.yml vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

55
go.mod
View file

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

206
go.sum
View file

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

View file

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

240
httpd/api_mfa.go Normal file
View file

@ -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)
}

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -65,15 +65,6 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
} }
return errInvalidToken return errInvalidToken
} }
if !util.IsStringInSlice(audience, token.Audience()) {
logger.Debug(logSender, "", "the token is not valid for audience %#v", audience)
if 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) { if isTokenInvalidated(r) {
logger.Debug(logSender, "", "the token has been invalidated") logger.Debug(logSender, "", "the token has been invalidated")
if isAPIToken { if isAPIToken {
@ -83,9 +74,60 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
} }
return errInvalidToken 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 return nil
} }
func validateJWTPartialToken(w http.ResponseWriter, r *http.Request, audience tokenAudience) error {
token, _, err := jwtauth.FromContext(r.Context())
var notFoundFunc func(w http.ResponseWriter, r *http.Request, err error)
if audience == tokenAudienceWebAdminPartial {
notFoundFunc = renderNotFoundPage
} else {
notFoundFunc = renderClientNotFoundPage
}
if err != nil || token == nil || jwt.Validate(token) != nil {
notFoundFunc(w, r, nil)
return errInvalidToken
}
if isTokenInvalidated(r) {
notFoundFunc(w, r, nil)
return errInvalidToken
}
if !util.IsStringInSlice(audience, token.Audience()) {
logger.Debug(logSender, "", "the token is not valid for audience %#v", audience)
notFoundFunc(w, r, nil)
return errInvalidToken
}
return nil
}
func jwtAuthenticatorPartial(audience tokenAudience) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := validateJWTPartialToken(w, r, audience); err != nil {
return
}
// Token is authenticated, pass it through
next.ServeHTTP(w, r)
})
}
}
func jwtAuthenticatorAPI(next http.Handler) http.Handler { func jwtAuthenticatorAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := validateJWTToken(w, r, tokenAudienceAPI); err != nil { if err := validateJWTToken(w, r, tokenAudienceAPI); err != nil {
@ -402,3 +444,15 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu
return nil return nil
} }
func checkPartialAuth(w http.ResponseWriter, r *http.Request, audience string, tokenAudience []string) error {
if audience == tokenAudienceWebAdmin && util.IsStringInSlice(tokenAudienceWebAdminPartial, tokenAudience) {
http.Redirect(w, r, webAdminTwoFactorPath, http.StatusFound)
return errInvalidToken
}
if audience == tokenAudienceWebClient && util.IsStringInSlice(tokenAudienceWebClientPartial, tokenAudience) {
http.Redirect(w, r, webClientTwoFactorPath, http.StatusFound)
return errInvalidToken
}
return nil
}

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import (
) )
const ( const (
pageMFATitle = "Two-factor authentication"
page400Title = "Bad request" page400Title = "Bad request"
page403Title = "Forbidden" page403Title = "Forbidden"
page404Title = "Not found" page404Title = "Not found"
@ -15,6 +16,8 @@ const (
redactedSecret = "[**redacted**]" redactedSecret = "[**redacted**]"
csrfFormToken = "_form_token" csrfFormToken = "_form_token"
csrfHeaderToken = "X-CSRF-TOKEN" csrfHeaderToken = "X-CSRF-TOKEN"
templateTwoFactor = "twofactor.html"
templateTwoFactorRecovery = "twofactor-recovery.html"
) )
type loginPage struct { type loginPage struct {
@ -26,6 +29,15 @@ type loginPage struct {
AltLoginURL string AltLoginURL string
} }
type twoFactorPage struct {
CurrentURL string
Version string
Error string
CSRFToken string
StaticURL string
RecoveryURL string
}
func getSliceFromDelimitedValues(values, delimiter string) []string { func getSliceFromDelimitedValues(values, delimiter string) []string {
result := []string{} result := []string{}
for _, v := range strings.Split(values, delimiter) { for _, v := range strings.Split(values, delimiter) {

View file

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

View file

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

118
mfa/mfa.go Normal file
View file

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

129
mfa/mfa_test.go Normal file
View file

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

106
mfa/totp.go Normal file
View file

@ -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
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

401
templates/webadmin/mfa.html Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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