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
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: #['https://www.paypal.com/donate?hosted_button_id=JQL6GBT8GXRKC'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View file

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

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.
- Partial authentication. You can configure multi-step authentication requiring, for example, the user password after successful public key authentication.
- Per user authentication methods.
- Two-factor authentication based on time-based one time passwords (RFC 6238) which works with Authy, Google Authenticator and other compatible apps.
- Custom authentication via external programs/HTTP API.
- [Data At Rest Encryption](./docs/dare.md).
- Dynamic user modification before login via external programs/HTTP API.

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)
os.Exit(1)
}
mfaConfig := config.GetMFAConfig()
err = mfaConfig.Initialize()
if err != nil {
logger.Error(logSender, "", "unable to initialize MFA: %v", err)
os.Exit(1)
}
if err := plugin.Initialize(config.GetPluginsConfig(), logVerbose); err != nil {
logger.Error(logSender, connectionID, "unable to initialize plugin system: %v", err)
os.Exit(1)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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:
- manage API keys itself. You cannot create, update, delete, enumerate API keys if you are logged in with an API key
- change password or public keys for the associated user
- change password, public keys or second factor authentication for the associated user
- update the impersonated admin
Please keep in mind that using an API key not associated with any administrator it is still possible to create a new administrator, with full permissions, and then impersonate it: be careful if you share unassociated API keys with third parties and with the `manage adminis` permission granted, they will basically allow full access, the only restriction is that the impersonated admin cannot be modified.

View file

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

55
go.mod
View file

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

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

View file

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

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -99,15 +99,15 @@
class="user-custom">
<div class="form-group">
<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 class="form-group">
<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 class="form-group">
<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>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<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>
Credentials
</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>
<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>

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

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

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