mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-22 07:30:25 +00:00
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:
parent
16ba7ddb34
commit
8a4c21b64a
52 changed files with 5985 additions and 475 deletions
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
@ -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']
|
||||
|
|
5
.github/workflows/development.yml
vendored
5
.github/workflows/development.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -806,6 +806,7 @@ func getAdminFromDbRow(row sqlScanner) (Admin, error) {
|
|||
admin.Description = description.String
|
||||
}
|
||||
|
||||
admin.SetEmptySecretsIfNil()
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
55
go.mod
|
@ -3,17 +3,17 @@ module github.com/drakkan/sftpgo/v2
|
|||
go 1.17
|
||||
|
||||
require (
|
||||
cloud.google.com/go/storage v1.16.0
|
||||
cloud.google.com/go/storage v1.16.1
|
||||
github.com/Azure/azure-storage-blob-go v0.14.0
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
|
||||
github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
|
||||
github.com/aws/aws-sdk-go v1.40.25
|
||||
github.com/aws/aws-sdk-go v1.40.37
|
||||
github.com/cockroachdb/cockroach-go/v2 v2.1.1
|
||||
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
|
||||
github.com/fatih/color v1.12.0 // indirect
|
||||
github.com/fclairamb/ftpserverlib v0.15.0
|
||||
github.com/fclairamb/go-log v0.1.0
|
||||
github.com/go-chi/chi/v5 v5.0.4-0.20210817181946-13e9eff8bd29
|
||||
github.com/go-chi/chi/v5 v5.0.4
|
||||
github.com/go-chi/jwtauth/v5 v5.0.1
|
||||
github.com/go-chi/render v1.0.1
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
|
@ -24,14 +24,13 @@ require (
|
|||
github.com/hashicorp/go-hclog v0.16.2
|
||||
github.com/hashicorp/go-plugin v1.4.2
|
||||
github.com/hashicorp/go-retryablehttp v0.7.0
|
||||
github.com/hashicorp/yamux v0.0.0-20210707203944-259a57b3608c // indirect
|
||||
github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 // indirect
|
||||
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
|
||||
github.com/klauspost/compress v1.13.4
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
|
||||
github.com/klauspost/compress v1.13.5
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
|
||||
github.com/lestrrat-go/jwx v1.2.5
|
||||
github.com/lib/pq v1.10.2
|
||||
github.com/lestrrat-go/jwx v1.2.6
|
||||
github.com/lib/pq v1.10.3
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
github.com/mattn/go-isatty v0.0.13 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.8
|
||||
|
@ -42,13 +41,14 @@ require (
|
|||
github.com/otiai10/copy v1.6.0
|
||||
github.com/pires/go-proxyproto v0.6.0
|
||||
github.com/pkg/sftp v1.13.2
|
||||
github.com/pquerna/otp v1.3.0
|
||||
github.com/prometheus/client_golang v1.11.0
|
||||
github.com/prometheus/common v0.30.0 // indirect
|
||||
github.com/rs/cors v1.8.0
|
||||
github.com/rs/xid v1.3.0
|
||||
github.com/rs/zerolog v1.23.0
|
||||
github.com/rs/zerolog v1.24.0
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.21.7
|
||||
github.com/shirou/gopsutil/v3 v3.21.8
|
||||
github.com/spf13/afero v1.6.0
|
||||
github.com/spf13/cobra v1.2.1
|
||||
github.com/spf13/viper v1.8.1
|
||||
|
@ -58,46 +58,47 @@ require (
|
|||
github.com/yl2chen/cidranger v1.0.2
|
||||
go.etcd.io/bbolt v1.3.6
|
||||
go.uber.org/automaxprocs v1.4.0
|
||||
gocloud.dev v0.23.0
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
|
||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d
|
||||
golang.org/x/sys v0.0.0-20210819072135-bce67f096156
|
||||
gocloud.dev v0.24.0
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
|
||||
golang.org/x/net v0.0.0-20210825183410-e898025ed96a
|
||||
golang.org/x/sys v0.0.0-20210903071746-97244b99971b
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
|
||||
google.golang.org/api v0.54.0
|
||||
google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f // indirect
|
||||
google.golang.org/api v0.56.0
|
||||
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83 // indirect
|
||||
google.golang.org/grpc v1.40.0
|
||||
google.golang.org/protobuf v1.27.1
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.93.3 // indirect
|
||||
cloud.google.com/go/kms v0.1.0 // indirect
|
||||
cloud.google.com/go v0.94.1 // indirect
|
||||
github.com/Azure/azure-pipeline-go v0.2.3 // indirect
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210901152745-8830d9c9cdba // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.1 // indirect
|
||||
github.com/go-ole/go-ole v1.2.5 // indirect
|
||||
github.com/goccy/go-json v0.7.6 // indirect
|
||||
github.com/goccy/go-json v0.7.8 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-cmp v0.5.6 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.0.5 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.0 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.1 // indirect
|
||||
github.com/lestrrat-go/option v1.0.0 // indirect
|
||||
github.com/lestrrat-go/pdebug/v3 v3.0.1 // indirect
|
||||
github.com/magiconair/properties v1.8.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.8 // indirect
|
||||
github.com/mattn/go-ieproxy v0.0.1 // indirect
|
||||
|
@ -113,14 +114,14 @@ require (
|
|||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.2.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.8 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.9 // indirect
|
||||
github.com/tklauser/numcpus v0.3.0 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20210817223510-7df4dd6e12ab // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||
gopkg.in/ini.v1 v1.62.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
)
|
||||
|
|
206
go.sum
206
go.sum
|
@ -2,7 +2,6 @@ bazil.org/fuse v0.0.0-20180421153158-65cc252bf669/go.mod h1:Xbm+BRKSBEpa4q4hTSxo
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.39.0/go.mod h1:rVLT6fkc8chs9sfPtFc1SBH6em7n+ZoXaG+87tDISts=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
|
@ -21,13 +20,19 @@ cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPT
|
|||
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
||||
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
||||
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
||||
cloud.google.com/go v0.82.0/go.mod h1:vlKccHJGuFBFufnAnuB08dfEH9Y3H7dzDzRECFdC2TA=
|
||||
cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
|
||||
cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
|
||||
cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
|
||||
cloud.google.com/go v0.88.0/go.mod h1:dnKwfYbP9hQhefiUvpbcAyoGSHUrOxR20JVElLiUvEY=
|
||||
cloud.google.com/go v0.89.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
|
||||
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
|
||||
cloud.google.com/go v0.92.2/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
||||
cloud.google.com/go v0.93.3 h1:wPBktZFzYBcCZVARvwVKqH1uEj+aLXofJEtrb4oOsio=
|
||||
cloud.google.com/go v0.92.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
||||
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
||||
cloud.google.com/go v0.94.0/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
||||
cloud.google.com/go v0.94.1 h1:DwuSvDZ1pTYGbXo8yOJevCTr3BoBlE+OVkHAKiYQUXc=
|
||||
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
|
@ -40,49 +45,54 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl
|
|||
cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo=
|
||||
cloud.google.com/go/kms v0.1.0 h1:VXAb5OzejDcyhFzIDeZ5n5AUdlsFnCyexuascIwWMj0=
|
||||
cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c=
|
||||
cloud.google.com/go/monitoring v0.1.0/go.mod h1:Hpm3XfzJv+UTiXzCG5Ffp0wijzHTC7Cv4eR7o3x/fEE=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/pubsub v1.10.3/go.mod h1:FUcc28GpGxxACoklPsE1sCtbkY4Ix+ro7yvw+h82Jn4=
|
||||
cloud.google.com/go/pubsub v1.16.0/go.mod h1:6A8EfoWZ/lUvCWStKGwAWauJZSiuV0Mkmu6WilK/TxQ=
|
||||
cloud.google.com/go/secretmanager v0.1.0/go.mod h1:3nGKHvnzDUVit7U0S9KAKJ4aOsO1xtwRG+7ey5LK1bM=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
cloud.google.com/go/storage v1.15.0/go.mod h1:mjjQMoxxyGH7Jr8K5qrx6N2O0AHsczI61sMNn03GIZI=
|
||||
cloud.google.com/go/storage v1.16.0 h1:1UwAux2OZP4310YXg5ohqBEpV16Y93uZG4+qOX7K2Kg=
|
||||
cloud.google.com/go/storage v1.16.0/go.mod h1:ieKBmUyzcftN5tbxwnXClMKH00CfcQ+xL6NN0r5QfmE=
|
||||
cloud.google.com/go/storage v1.16.1 h1:sMEIc4wxvoY3NXG7Rn9iP7jb/2buJgWR1vNXCR/UPfs=
|
||||
cloud.google.com/go/storage v1.16.1/go.mod h1:LaNorbty3ehnU3rEjXSNV/NRgQA0O8Y+uh6bPe5UOk4=
|
||||
cloud.google.com/go/trace v0.1.0/go.mod h1:wxEwsoeRVPbeSkt7ZC9nWCgmoKQRAoySN7XHW2AmI7g=
|
||||
contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA=
|
||||
contrib.go.opencensus.io/exporter/stackdriver v0.13.5/go.mod h1:aXENhDJ1Y4lIg4EUaVTwzvYETVNZk10Pu26tevFKLUc=
|
||||
contrib.go.opencensus.io/exporter/stackdriver v0.13.8/go.mod h1:huNtlWx75MwO7qMs0KrMxPZXzNNWebav1Sq/pm02JdQ=
|
||||
contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/Azure/azure-amqp-common-go/v3 v3.1.0/go.mod h1:PBIGdzcO1teYoufTKMcGibdKaYZv4avS+O6LNIp8bq0=
|
||||
github.com/Azure/azure-amqp-common-go/v3 v3.1.1/go.mod h1:YsDaPfaO9Ub2XeSKdIy2DfwuiQlHQCauHJwSqtrkECI=
|
||||
github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
|
||||
github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
|
||||
github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
|
||||
github.com/Azure/azure-sdk-for-go v54.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
|
||||
github.com/Azure/azure-service-bus-go v0.10.11/go.mod h1:AWw9eTTWZVZyvgpPahD1ybz3a8/vT3GsJDS8KYex55U=
|
||||
github.com/Azure/azure-storage-blob-go v0.13.0/go.mod h1:pA9kNqtjUeQF2zOSu4s//nUdBD+e64lEuc4sVnuOfNs=
|
||||
github.com/Azure/azure-sdk-for-go v57.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
|
||||
github.com/Azure/azure-service-bus-go v0.10.16/go.mod h1:MlkLwGGf1ewcx5jZadn0gUEty+tTg0RaElr6bPf+QhI=
|
||||
github.com/Azure/azure-storage-blob-go v0.14.0 h1:1BCg74AmVdYwO3dlKwtFU1V0wU2PZdREkXvAmZJRUlM=
|
||||
github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck=
|
||||
github.com/Azure/go-amqp v0.13.0/go.mod h1:qj+o8xPCz9tMSbQ83Vp8boHahuRDl5mkNHyt1xlxUTs=
|
||||
github.com/Azure/go-amqp v0.13.4/go.mod h1:wbpCKA8tR5MLgRyIu+bb+S6ECdIDdYJ0NlpFE9xsBPI=
|
||||
github.com/Azure/go-amqp v0.13.7/go.mod h1:wbpCKA8tR5MLgRyIu+bb+S6ECdIDdYJ0NlpFE9xsBPI=
|
||||
github.com/Azure/go-amqp v0.13.11/go.mod h1:D5ZrjQqB1dyp1A+G73xeL/kNn7D5qHJIIsNNps7YNmk=
|
||||
github.com/Azure/go-amqp v0.13.12/go.mod h1:D5ZrjQqB1dyp1A+G73xeL/kNn7D5qHJIIsNNps7YNmk=
|
||||
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
|
||||
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
|
||||
github.com/Azure/go-autorest/autorest v0.11.3/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
|
||||
github.com/Azure/go-autorest/autorest v0.11.17/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw=
|
||||
github.com/Azure/go-autorest/autorest v0.11.18 h1:90Y4srNYrwOtAgVo3ndrQkTYn6kf1Eg/AjTFJ8Is2aM=
|
||||
github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA=
|
||||
github.com/Azure/go-autorest/autorest v0.11.20 h1:s8H1PbCZSqg/DH7JMlOz6YMig6htWLNPsjDdlLqCx3M=
|
||||
github.com/Azure/go-autorest/autorest v0.11.20/go.mod h1:o3tqFY+QR40VOlk+pV4d77mORO64jOXSgEnPQgLK6JY=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.2/go.mod h1:/3SMAM86bP6wC9Ev35peQDUeqFZBMH07vvUOmg4z/fE=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.11/go.mod h1:nBKAnTomx8gDtl+3ZCJv2v0KACFHWTB2drffI1B68Pk=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.13 h1:Mp5hbtOePIzM8pJVRa3YLrWWmZtoxRXqUEzCfJt3+/Q=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
|
||||
github.com/Azure/go-autorest/autorest/azure/auth v0.5.7/go.mod h1:AkzUsqkrdmNhfP2i54HqINVQopw0CLDnvHpJ88Zz1eI=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.14/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.15 h1:X+p2GF0GWyOiSmqohIaEeuNFNDY4I4EOlVuUQvFdWMk=
|
||||
github.com/Azure/go-autorest/autorest/adal v0.9.15/go.mod h1:tGMin8I49Yij6AQ+rvV+Xa/zwxYQB5hmsd6DkfAx2+A=
|
||||
github.com/Azure/go-autorest/autorest/azure/auth v0.5.8/go.mod h1:kxyKZTSfKh8OVFWPAgOgQ/frrJgeYQJPyR5fLFmXko4=
|
||||
github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM=
|
||||
github.com/Azure/go-autorest/autorest/azure/cli v0.4.3/go.mod h1:yAQ2b6eP/CmLPnmLvxtT1ALIY3OR1oFcCqVBi8vHiTc=
|
||||
github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
|
||||
github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
|
||||
github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
|
||||
|
@ -99,7 +109,7 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
|||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
|
||||
github.com/GoogleCloudPlatform/cloudsql-proxy v1.22.0/go.mod h1:mAm5O/zik2RFmcpigNjg6nMotDL8ZXJaxKzgGVcSMFA=
|
||||
github.com/GoogleCloudPlatform/cloudsql-proxy v1.24.0/go.mod h1:3tx938GhY4FC+E1KT/jNjDw7Z5qxAEtIiERJ2sXjnII=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
|
@ -121,20 +131,36 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
|
|||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
|
||||
github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.40.25 h1:Depnx7O86HWgOCLD5nMto6F9Ju85Q1QuFDnbpZYQWno=
|
||||
github.com/aws/aws-sdk-go v1.40.25/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
|
||||
github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
|
||||
github.com/aws/aws-sdk-go v1.40.37 h1:I+Q6cLctkFyMMrKukcDnj+i2kjrQ37LGiOM6xmsxC48=
|
||||
github.com/aws/aws-sdk-go v1.40.37/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
|
||||
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.5.0/go.mod h1:CpNzHK9VEFUCknu50kkB8z58AH2B5DvPP7ea1LHve/Y=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.2.2/go.mod h1:BQV0agm+JEhqR+2RT5e1XTFIDcAAV0eW6z2trp+iduw=
|
||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.5.0/go.mod h1:acH3+MQoiMzozT/ivU+DbRg7Ooo2298RdRaWcOv+4vM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.0/go.mod h1:R1KK+vY8AfalhG1AOu5e35pOD2SdoPKQCFLTvnxiohk=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.5.0/go.mod h1:w7JuP9Oq1IKMFQPkNe3V6s9rOssXzOVEMNEqK1L1bao=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.6.0/go.mod h1:B+7C5UKdVq1ylkI/A6O8wcurFtaux0R1njePNPtKwoA=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.10.0/go.mod h1:4dXS5YNqI3SNbetQ7X7vfsMlX6ZnboJA2dulBwJx7+g=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.4.0/go.mod h1:+1fpWnL96DL23aXPpMGbsmKe8jLTEfbjuQoA4WS1VaA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.7.0/go.mod h1:0qcSMCyASQPN2sk/1KQLQ2Fh6yq8wm0HSDAimPhzCoM=
|
||||
github.com/aws/smithy-go v1.5.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
|
||||
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/casbin/casbin/v2 v2.31.6/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
|
@ -142,8 +168,9 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
|
|||
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
|
@ -161,7 +188,6 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
|
|||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd/v22 v22.3.1/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
|
@ -174,8 +200,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 h1:sgNeV1VRMDzs6rzyPpxyM0jp317hnwiq58Filgag2xw=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0/go.mod h1:J70FGZSbzsjecRTiTzER+3f1KZLNaXkuv+yeFTKoxM8=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210901152745-8830d9c9cdba h1:53fWlu/0nYmjfGM7IXRobqSO7P5w4TRh3Gi/f9Bpua0=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=
|
||||
github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
|
@ -210,22 +238,21 @@ github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc=
|
|||
github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
||||
github.com/fclairamb/go-log v0.1.0 h1:fNoqk8w62i4EDEuRzDgHdDVTqMYSyr3DS981R7F2x/Y=
|
||||
github.com/fclairamb/go-log v0.1.0/go.mod h1:iqmym8aI6xBbZXnZSPjElrmQrlEwjwEemOmIzKaTBM8=
|
||||
github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
|
||||
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.5.0 h1:NO5hkcB+srp1x6QmwvNZLeaOgbM8cmBTN32THzjvu2k=
|
||||
github.com/fsnotify/fsnotify v1.5.0/go.mod h1:BX0DCEr5pT4jm2CnQdVP1lFV521fcCNcyEeNp4DQQDk=
|
||||
github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
|
||||
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
|
||||
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
||||
github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
|
||||
github.com/go-chi/chi/v5 v5.0.4-0.20210817181946-13e9eff8bd29 h1:3j7epc78R1f8xr8JZ3FsF4h8SXcj71sn1vvFkW7zccA=
|
||||
github.com/go-chi/chi/v5 v5.0.4-0.20210817181946-13e9eff8bd29/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.0.4 h1:5e494iHzsYBiyXQAHHuI4tyJS9M3V84OuX3ufIIGHFo=
|
||||
github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/jwtauth/v5 v5.0.1 h1:eyJ6Yx5VphEfjkqpZ7+LJEWThzyIcF5aN2QVpgqSIu0=
|
||||
github.com/go-chi/jwtauth/v5 v5.0.1/go.mod h1:+JtcRYGZsnA4+ur1LFlb4Bei3O9WeUzoMfDZWfUJuoY=
|
||||
github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
|
||||
|
@ -260,13 +287,15 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22
|
|||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/goccy/go-json v0.4.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.7.4/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.7.6 h1:H0wq4jppBQ+9222sk5+hPLL25abZQiRuQ6YPnjO9c+A=
|
||||
github.com/goccy/go-json v0.7.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.7.8 h1:CvMH7LotYymYuLGEohBM1lTZWX4g6jzWUUl2aLFuBoE=
|
||||
github.com/goccy/go-json v0.7.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o=
|
||||
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
|
@ -319,8 +348,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-replayers/grpcreplay v1.0.0/go.mod h1:8Ig2Idjpr6gifRd6pNVggX6TC1Zw6Jx74AKp7QNH2QE=
|
||||
github.com/google/go-replayers/httpreplay v0.1.2/go.mod h1:YKZViNhiGgqdBlUbI2MwGpq4pXxNmhJLPHQ7cv2b5no=
|
||||
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
|
||||
github.com/google/go-replayers/httpreplay v1.0.0/go.mod h1:LJhKoTwS5Wy5Ld/peq8dFFG5OfJyHEz7ft+DsTUv25M=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:xmapqc1AyLoB+ddYT6r04bD9lIjlOqGaREovi0SzFaE=
|
||||
|
@ -341,14 +370,15 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe
|
|||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210506205249-923b5ab0fc1a/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210715191844-86eeefc3e471/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
|
@ -356,8 +386,9 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
|||
github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
|
||||
github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/gax-go/v2 v2.1.0 h1:6DWmvNpomjL1+3liNSZbVns3zsYzzCjm6pRBO1tLeso=
|
||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
|
@ -407,8 +438,8 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
|
|||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
|
||||
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
|
||||
github.com/hashicorp/yamux v0.0.0-20210707203944-259a57b3608c h1:nqkErwUGfpZZMqj29WZ9U/wz2OpJVDuiokLhE/3Y7IQ=
|
||||
github.com/hashicorp/yamux v0.0.0-20210707203944-259a57b3608c/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||
github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 h1:brI5vBRUlAlM34VFmnLPwjnCL/FxAJp9XvOdX6Zt+XE=
|
||||
github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
|
@ -462,7 +493,6 @@ github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyX
|
|||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
|
@ -485,9 +515,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.11.12/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s=
|
||||
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.13.5 h1:9O69jUPDcsT9fEm74W92rZL9FQY7rCdaXVneq+yyzl4=
|
||||
github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
|
@ -514,26 +543,26 @@ github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBB
|
|||
github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4=
|
||||
github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ=
|
||||
github.com/lestrrat-go/codegen v1.0.0/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
|
||||
github.com/lestrrat-go/codegen v1.0.1/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
|
||||
github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkOpYc=
|
||||
github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
|
||||
github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
|
||||
github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
|
||||
github.com/lestrrat-go/jwx v1.1.6/go.mod h1:c+R8G7qsaFNmTzYjU98A+sMh8Bo/MJqO9GnpqR+X024=
|
||||
github.com/lestrrat-go/jwx v1.2.5 h1:0Akd9qTHrla8eqCV54Z4wRVv54WI54dUHN5D2+mIayc=
|
||||
github.com/lestrrat-go/jwx v1.2.5/go.mod h1:CAe9Z479rJwIYDR2DqWwMm9c+gCNoYB6+0wBxPkEh0Q=
|
||||
github.com/lestrrat-go/jwx v1.2.6 h1:XAgfuHaOB7fDZ/6WhVgl8K89af768dU+3Nx4DlTbLIk=
|
||||
github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
|
||||
github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
|
||||
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/lestrrat-go/pdebug/v3 v3.0.1 h1:3G5sX/aw/TbMTtVc9U7IHBWRZtMvwvBziF1e4HoQtv8=
|
||||
github.com/lestrrat-go/pdebug/v3 v3.0.1/go.mod h1:za+m+Ve24yCxTEhR59N7UlnJomWwCiIqbJRmKeiADU4=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
|
||||
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
|
||||
github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
|
||||
|
@ -600,7 +629,6 @@ github.com/nats-io/nats.go v1.11.0/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/
|
|||
github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
|
||||
github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||
|
@ -636,6 +664,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
|
||||
github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
|
||||
github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
|
@ -667,8 +697,8 @@ github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
|
|||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/rs/zerolog v1.23.0 h1:UskrK+saS9P9Y789yNNulYKdARjPZuS35B8gJF2x60g=
|
||||
github.com/rs/zerolog v1.23.0/go.mod h1:6c7hFfxPOy7TacJc4Fcdi24/J0NKYGzjG8FWRI916Qo=
|
||||
github.com/rs/zerolog v1.24.0 h1:76ivFxmVSRs1u2wUwJVg5VZDYQgeH1JpoS6ndgr9Wy8=
|
||||
github.com/rs/zerolog v1.24.0/go.mod h1:7KHcEGe0QZPOm2IE4Kpb5rTh6n1h2hIgS5OOnu1rUaI=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
@ -677,8 +707,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
|
|||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
|
||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
|
||||
github.com/shirou/gopsutil/v3 v3.21.7 h1:PnTqQamUjwEDSgn+nBGu0qSDV/CfvyiR/gwTH3i7HTU=
|
||||
github.com/shirou/gopsutil/v3 v3.21.7/go.mod h1:RGl11Y7XMTQPmHh8F0ayC6haKNBgH4PXMJuTAcMOlz4=
|
||||
github.com/shirou/gopsutil/v3 v3.21.8 h1:nKct+uP0TV8DjjNiHanKf8SAuub+GNsbrOtM9Nl9biA=
|
||||
github.com/shirou/gopsutil/v3 v3.21.8/go.mod h1:YWp/H8Qs5fVmf17v7JNZzA0mPJ+mS2e9JdiUF9LlKzQ=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
|
@ -724,10 +754,8 @@ github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8 h1:ipNUBPHSUmH
|
|||
github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tklauser/go-sysconf v0.3.7/go.mod h1:JZIdXh4RmBvZDBZ41ld2bGxRV3n4daiiqA3skYhAoQ4=
|
||||
github.com/tklauser/go-sysconf v0.3.8 h1:41Nq9J+pxKud4IQ830J5LlS5nl67dVQC7AuisUooaOU=
|
||||
github.com/tklauser/go-sysconf v0.3.8/go.mod h1:z4zYWRS+X53WUKtBcmDg1comV3fPhdQnzasnIHUoLDU=
|
||||
github.com/tklauser/numcpus v0.2.3/go.mod h1:vpEPS/JC+oZGGQ/My/vJnNsvMDQL6PwOqt8dsCw5j+E=
|
||||
github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo=
|
||||
github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs=
|
||||
github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ=
|
||||
github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
|
@ -755,6 +783,7 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.22.6/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
|
@ -762,18 +791,22 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
|||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/automaxprocs v1.4.0 h1:CpDZl6aOlLhReez+8S3eEotD7Jx0Os++lemPlMULQP0=
|
||||
go.uber.org/automaxprocs v1.4.0/go.mod h1:/mTEdr7LvHhs0v7mjdxDreTz1OG5zdZGqgOnhWiR/+Q=
|
||||
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
|
||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||
gocloud.dev v0.23.0 h1:u/6F8slWwaZPgGpjpNp0jzH+1P/M2ri7qEP3lFgbqBE=
|
||||
gocloud.dev v0.23.0/go.mod h1:zklCCIIo1N9ELkU2S2E7tW8P8eeMU7oGLeQCXdDwx9Q=
|
||||
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
gocloud.dev v0.24.0 h1:cNtHD07zQQiv02OiwwDyVMuHmR7iQt2RLkzoAgz7wBs=
|
||||
gocloud.dev v0.24.0/go.mod h1:uA+als++iBX5ShuG4upQo/3Zoz49iIPlYUWHV5mM8w8=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
|
@ -809,6 +842,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
@ -817,18 +851,17 @@ golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4Iltr
|
|||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210126194326-f9ce19ea3013/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210615190721-d04028783cf1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210817223510-7df4dd6e12ab h1:llrcWN/wOwO+6gAyfBzxb5hZ+c3mriU/0+KNgYu6adA=
|
||||
golang.org/x/oauth2 v0.0.0-20210817223510-7df4dd6e12ab/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f h1:Qmd2pbz05z7z6lm0DrgQVVPuBm92jqujBKMHMOlOQEw=
|
||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -901,12 +934,9 @@ golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420205809-ac73e9fd8988/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
@ -916,8 +946,10 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210819072135-bce67f096156 h1:f7XLk/QXGE6IM4HjJ4ttFFlPSwJ65A1apfDd+mmViR0=
|
||||
golang.org/x/sys v0.0.0-20210819072135-bce67f096156/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210903071746-97244b99971b h1:3Dq0eVHn0uaQJmPO+/aYPI/fRMqdrVDbu7MQcku54gg=
|
||||
golang.org/x/sys v0.0.0-20210903071746-97244b99971b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
@ -934,7 +966,6 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
|
|||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs=
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
@ -956,10 +987,10 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn
|
|||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191010075000-0337d82405ff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
|
@ -1011,11 +1042,9 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
|||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.5.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.10.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
|
@ -1030,24 +1059,25 @@ google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSr
|
|||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||
google.golang.org/api v0.37.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
|
||||
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
|
||||
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
|
||||
google.golang.org/api v0.45.0/go.mod h1:ISLIJCedJolbZvDfAk+Ctuq5hf+aJ33WgtUsfyFoLXA=
|
||||
google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4ylg3I=
|
||||
google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
|
||||
google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
|
||||
google.golang.org/api v0.49.0/go.mod h1:BECiH72wsfwUvOVn3+btPD5WHi0LzavZReBndi42L18=
|
||||
google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
|
||||
google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
|
||||
google.golang.org/api v0.54.0 h1:ECJUVngj71QI6XEm7b1sAf8BljU5inEhMbKPR8Lxhhk=
|
||||
google.golang.org/api v0.52.0/go.mod h1:Him/adpjt0sxtkWViy0b6xyKW/SD71CwdJ7HqJo7SrU=
|
||||
google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
|
||||
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
||||
google.golang.org/api v0.56.0 h1:08F9XVYTLOGeSQb3xI9C0gXMuQanhdGed0cWFhDozbI=
|
||||
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
|
@ -1058,8 +1088,6 @@ google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRn
|
|||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190508193815-b515fa19cec8/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
|
@ -1091,37 +1119,37 @@ google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6D
|
|||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||
google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210420162539-3c870d7478d2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210423144448-3a41ef94ed2b/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210517163617-5e0236093d7a/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||
google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||
google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||
google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
|
||||
google.golang.org/genproto v0.0.0-20210624174822-c5cf32407d0a/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
|
||||
google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
|
||||
google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
|
||||
google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
|
||||
google.golang.org/genproto v0.0.0-20210721163202-f1cecdd8b78a/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
|
||||
google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
|
||||
google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
|
||||
google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
|
||||
google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
|
||||
google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f h1:enWPderunHptc5pzJkSYGx0olpF8goXzG0rY3kL0eSg=
|
||||
google.golang.org/genproto v0.0.0-20210818220304-27ea9cc85d9f/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210825212027-de86158e7fda/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83 h1:3V2dxSZpz4zozWWUq36vUxXEKnSYitEH2LdsAx+RUmg=
|
||||
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
|
@ -1163,7 +1191,6 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
|
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
|
@ -1172,8 +1199,9 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
|
|||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.62.1 h1:Idt4Iidq1iKKmhakQtqAIvBBL53JTyuNIX+wR/rmkp4=
|
||||
gopkg.in/ini.v1 v1.62.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
|
|
|
@ -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
240
httpd/api_mfa.go
Normal 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)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
1774
httpd/httpd_test.go
1774
httpd/httpd_test.go
File diff suppressed because it is too large
Load diff
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
397
httpd/server.go
397
httpd/server.go
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/v2/common"
|
||||
"github.com/drakkan/sftpgo/v2/dataprovider"
|
||||
"github.com/drakkan/sftpgo/v2/logger"
|
||||
"github.com/drakkan/sftpgo/v2/mfa"
|
||||
"github.com/drakkan/sftpgo/v2/sdk"
|
||||
"github.com/drakkan/sftpgo/v2/util"
|
||||
"github.com/drakkan/sftpgo/v2/version"
|
||||
|
@ -182,23 +183,206 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
|
|||
s.renderClientLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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).
|
||||
|
|
12
httpd/web.go
12
httpd/web.go
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
118
mfa/mfa.go
Normal 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
129
mfa/mfa_test.go
Normal 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
106
mfa/totp.go
Normal 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
|
||||
})
|
||||
}
|
46
sdk/user.go
46
sdk/user.go
|
@ -3,6 +3,7 @@ package sdk
|
|||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/drakkan/sftpgo/v2/kms"
|
||||
"github.com/drakkan/sftpgo/v2/util"
|
||||
)
|
||||
|
||||
|
@ -10,11 +11,14 @@ import (
|
|||
const (
|
||||
WebClientPubKeyChangeDisabled = "publickey-change-disabled"
|
||||
WebClientWriteDisabled = "write-disabled"
|
||||
WebClientMFADisabled = "mfa-disabled"
|
||||
)
|
||||
|
||||
var (
|
||||
// WebClientOptions defines the available options for the web client interface/user REST API
|
||||
WebClientOptions = []string{WebClientPubKeyChangeDisabled, WebClientWriteDisabled}
|
||||
WebClientOptions = []string{WebClientPubKeyChangeDisabled, WebClientWriteDisabled, WebClientMFADisabled}
|
||||
// UserTypes defines the supported user type hints for auth plugins
|
||||
UserTypes = []string{string(UserTypeLDAP), string(UserTypeOS)}
|
||||
)
|
||||
|
||||
// TLSUsername defines the TLS certificate attribute to use as username
|
||||
|
@ -26,6 +30,16 @@ const (
|
|||
TLSUsernameCN TLSUsername = "CommonName"
|
||||
)
|
||||
|
||||
// UserType defines the supported user types.
|
||||
// This is an hint for external auth plugins, is not used in SFTPGo directly
|
||||
type UserType string
|
||||
|
||||
// User types, auth plugins could use this info to choose the correct authentication backend
|
||||
const (
|
||||
UserTypeLDAP UserType = "LDAPUser"
|
||||
UserTypeOS UserType = "OSUser"
|
||||
)
|
||||
|
||||
// DirectoryPermissions defines permissions for a directory virtual path
|
||||
type DirectoryPermissions struct {
|
||||
Path string
|
||||
|
@ -83,6 +97,27 @@ type HooksFilter struct {
|
|||
CheckPasswordDisabled bool `json:"check_password_disabled"`
|
||||
}
|
||||
|
||||
// RecoveryCode defines a 2FA recovery code
|
||||
type RecoveryCode struct {
|
||||
Secret *kms.Secret `json:"secret"`
|
||||
Used bool `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// TOTPConfig defines the time-based one time password configuration
|
||||
type TOTPConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
ConfigName string `json:"config_name,omitempty"`
|
||||
Secret *kms.Secret `json:"secret,omitempty"`
|
||||
// TOTP will be required for the specified protocols.
|
||||
// SSH protocol (SFTP/SCP/SSH commands) will ask for the TOTP passcode if the client uses keyboard interactive
|
||||
// authentication.
|
||||
// FTP have no standard way to support two factor authentication, if you
|
||||
// enable the support for this protocol you have to add the TOTP passcode after the password.
|
||||
// For example if your password is "password" and your one time passcode is
|
||||
// "123456" you have to use "password123456" as password.
|
||||
Protocols []string `json:"protocols,omitempty"`
|
||||
}
|
||||
|
||||
// UserFilters defines additional restrictions for a user
|
||||
// TODO: rename to UserOptions in v3
|
||||
type UserFilters struct {
|
||||
|
@ -122,6 +157,15 @@ type UserFilters struct {
|
|||
WebClient []string `json:"web_client,omitempty"`
|
||||
// API key auth allows to impersonate this user with an API key
|
||||
AllowAPIKeyAuth bool `json:"allow_api_key_auth,omitempty"`
|
||||
// Time-based one time passwords configuration
|
||||
TOTPConfig TOTPConfig `json:"totp_config,omitempty"`
|
||||
// Recovery codes to use if the user loses access to their second factor auth device.
|
||||
// Each code can only be used once, you should use these codes to login and disable or
|
||||
// reset 2FA for your account
|
||||
RecoveryCodes []RecoveryCode `json:"recovery_codes,omitempty"`
|
||||
// UserType is an hint for authentication plugins.
|
||||
// It is ignored when using SFTPGo internal authentication
|
||||
UserType string `json:"user_type,omitempty"`
|
||||
}
|
||||
|
||||
type BaseUser struct {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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)
|
||||
|
|
10
sftpgo.json
10
sftpgo.json
|
@ -70,6 +70,7 @@
|
|||
"pwd",
|
||||
"scp"
|
||||
],
|
||||
"keyboard_interactive_authentication": false,
|
||||
"keyboard_interactive_auth_hook": "",
|
||||
"password_authentication": true,
|
||||
"folder_prefix": ""
|
||||
|
@ -244,5 +245,14 @@
|
|||
"master_key_path": ""
|
||||
}
|
||||
},
|
||||
"mfa": {
|
||||
"totp": [
|
||||
{
|
||||
"name": "Default",
|
||||
"issuer": "SFTPGo",
|
||||
"algo": "sha1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": []
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
114
templates/webadmin/baselogin.html
Normal file
114
templates/webadmin/baselogin.html
Normal 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}}
|
|
@ -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
401
templates/webadmin/mfa.html
Normal 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">×</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}}
|
|
@ -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>
|
||||
|
|
29
templates/webadmin/twofactor-recovery.html
Normal file
29
templates/webadmin/twofactor-recovery.html
Normal 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}}
|
34
templates/webadmin/twofactor.html
Normal file
34
templates/webadmin/twofactor.html
Normal 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}}
|
|
@ -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">
|
||||
|
||||
|
|
117
templates/webclient/baselogin.html
Normal file
117
templates/webclient/baselogin.html
Normal 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}}
|
|
@ -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}}
|
474
templates/webclient/mfa.html
Normal file
474
templates/webclient/mfa.html
Normal 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">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">Do you want to disable the TOTP configuration?</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" type="button" data-dismiss="modal">
|
||||
Cancel
|
||||
</button>
|
||||
<a class="btn btn-warning" href="#" onclick="totpDisable()">
|
||||
Disable
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "extra_js"}}
|
||||
<script type="text/javascript">
|
||||
|
||||
function totpGenerate() {
|
||||
var path = "{{.GenerateTOTPURL}}";
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'POST',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
data: JSON.stringify({"config_name": $('#idConfig option:selected').val()}),
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
$('.totpDisable').hide();
|
||||
$('.totpGenerate').hide();
|
||||
$('.totpUpdateProtocols').hide();
|
||||
$('#idSecret').text(result.secret);
|
||||
$('#idQRCode').attr('src','data:image/png;base64, '+result.qr_code);
|
||||
$('.totpDetails').show();
|
||||
window.scrollTo(0, $("#idTOTPDetails").offset().top);
|
||||
},
|
||||
error: function ($xhr, textStatus, errorThrown) {
|
||||
var txt = "Failed to generate a new TOTP secret";
|
||||
if ($xhr) {
|
||||
var json = $xhr.responseJSON;
|
||||
if (json) {
|
||||
if (json.message){
|
||||
txt += ": " + json.message;
|
||||
} else {
|
||||
txt += ": " + json.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#errorTOTPTxt').text(txt);
|
||||
$('#errorTOTPMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorTOTPMsg').hide();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function totpValidate() {
|
||||
var passcode = $('#idPasscode').val();
|
||||
if (passcode == "") {
|
||||
$('#errorTOTPTxt').text("The verification code is required");
|
||||
$('#errorTOTPMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorTOTPMsg').hide();
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
var path = "{{.ValidateTOTPURL}}";
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'POST',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
data: JSON.stringify({"passcode": passcode, "config_name": $('#idConfig option:selected').val(), "secret": $('#idSecret').text()}),
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
totpSave();
|
||||
},
|
||||
error: function ($xhr, textStatus, errorThrown) {
|
||||
var txt = "Failed to validate the provided passcode";
|
||||
if ($xhr) {
|
||||
var json = $xhr.responseJSON;
|
||||
if (json) {
|
||||
if (json.message){
|
||||
txt += ": " + json.message;
|
||||
} else {
|
||||
txt += ": " + json.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#errorTOTPTxt').text(txt);
|
||||
$('#errorTOTPMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorTOTPMsg').hide();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function totpSave() {
|
||||
var path = "{{.SaveTOTPURL}}";
|
||||
var protocolsArray = [];
|
||||
$('#idProtocols').find('option:selected').each(function(){
|
||||
protocolsArray.push($(this).val());
|
||||
});
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'POST',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
data: JSON.stringify({"enabled": true, "config_name": $('#idConfig option:selected').val(), "protocols": protocolsArray, "secret": {"status": "Plain", "payload": $('#idSecret').text()}}),
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
$('#successTOTPTxt').text("Configuration saved");
|
||||
$('#successTOTPMsg').show();
|
||||
setTimeout(function () {
|
||||
location.reload();
|
||||
}, 3000);
|
||||
},
|
||||
error: function ($xhr, textStatus, errorThrown) {
|
||||
var txt = "Failed to save the new configuration";
|
||||
if ($xhr) {
|
||||
var json = $xhr.responseJSON;
|
||||
if (json) {
|
||||
if (json.message){
|
||||
txt += ": " + json.message;
|
||||
} else {
|
||||
txt += ": " + json.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#errorTOTPTxt').text(txt);
|
||||
$('#errorTOTPMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorTOTPMsg').hide();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function totpDisableAsk() {
|
||||
$('#disableTOTPModal').modal('show');
|
||||
}
|
||||
|
||||
function totpUpdateProtocols() {
|
||||
var path = "{{.SaveTOTPURL}}";
|
||||
var protocolsArray = [];
|
||||
$('#idProtocols').find('option:selected').each(function(){
|
||||
protocolsArray.push($(this).val());
|
||||
});
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'POST',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
data: JSON.stringify({"protocols": protocolsArray}),
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
$('#successTOTPTxt').text("Protocols updated");
|
||||
$('#successTOTPMsg').show();
|
||||
setTimeout(function () {
|
||||
location.reload();
|
||||
}, 3000);
|
||||
},
|
||||
error: function ($xhr, textStatus, errorThrown) {
|
||||
var txt = "Failed to update protocols";
|
||||
if ($xhr) {
|
||||
var json = $xhr.responseJSON;
|
||||
if (json) {
|
||||
if (json.message){
|
||||
txt += ": " + json.message;
|
||||
} else {
|
||||
txt += ": " + json.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#errorTOTPTxt').text(txt);
|
||||
$('#errorTOTPMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorTOTPMsg').hide();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function totpDisable() {
|
||||
$('#disableTOTPModal').modal('hide');
|
||||
var path = "{{.SaveTOTPURL}}";
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'POST',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
data: JSON.stringify({"enabled": false}),
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
location.reload();
|
||||
},
|
||||
error: function ($xhr, textStatus, errorThrown) {
|
||||
var txt = "Failed to disable the current configuration";
|
||||
if ($xhr) {
|
||||
var json = $xhr.responseJSON;
|
||||
if (json) {
|
||||
if (json.message){
|
||||
txt += ": " + json.message;
|
||||
} else {
|
||||
txt += ": " + json.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#errorTOTPTxt').text(txt);
|
||||
$('#errorTOTPMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorTOTPMsg').hide();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getRecoveryCodes() {
|
||||
var path = "{{.RecCodesURL}}";
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'GET',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
$('.viewRecoveryCodes').hide();
|
||||
$('#idRecoveryCodesList').empty();
|
||||
$.each(result, function(key, item) {
|
||||
if (item.used) {
|
||||
$('#idRecoveryCodesList').append(`<li class="list-group-item" style="text-decoration: line-through;">${item.code}</li>`);
|
||||
} else {
|
||||
$('#idRecoveryCodesList').append(`<li class="list-group-item">${item.code}</li>`);
|
||||
}
|
||||
});
|
||||
$('#idRecoveryCodes').show();
|
||||
window.scrollTo(0, $("#idRecoveryCodesCard").offset().top);
|
||||
},
|
||||
error: function ($xhr, textStatus, errorThrown) {
|
||||
var txt = "Failed to get your recovery codes";
|
||||
if ($xhr) {
|
||||
var json = $xhr.responseJSON;
|
||||
if (json) {
|
||||
if (json.message){
|
||||
txt += ": " + json.message;
|
||||
} else {
|
||||
txt += ": " + json.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#errorRecCodesTxt').text(txt);
|
||||
$('#errorRecCodesMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorRecCodesMsg').hide();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateRecoveryCodes() {
|
||||
var path = "{{.RecCodesURL}}";
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'POST',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
dataType: 'json',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
$('.viewRecoveryCodes').hide();
|
||||
$('#idRecoveryCodesList').empty();
|
||||
$.each(result, function(key, item) {
|
||||
$('#idRecoveryCodesList').append(`<li class="list-group-item">${item}</li>`);
|
||||
});
|
||||
$('#idRecoveryCodes').show();
|
||||
$('#successRecCodesTxt').text('Recovery codes generated successfully');
|
||||
$('#successRecCodesMsg').show();
|
||||
window.scrollTo(0, $("#idRecoveryCodesCard").offset().top);
|
||||
setTimeout(function () {
|
||||
$('#successRecCodesMsg').hide();
|
||||
}, 5000);
|
||||
},
|
||||
error: function ($xhr, textStatus, errorThrown) {
|
||||
var txt = "Failed to generate new recovery codes";
|
||||
if ($xhr) {
|
||||
var json = $xhr.responseJSON;
|
||||
if (json) {
|
||||
if (json.message){
|
||||
txt += ": " + json.message;
|
||||
} else {
|
||||
txt += ": " + json.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#errorRecCodesTxt').text(txt);
|
||||
$('#errorRecCodesMsg').show();
|
||||
setTimeout(function () {
|
||||
$('#errorRecCodesMsg').hide();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfigSelection() {
|
||||
var selectedConfig = $('#idConfig option:selected').val();
|
||||
if (selectedConfig == ""){
|
||||
$('.totpGenerate').hide();
|
||||
} else {
|
||||
$('.totpGenerate').show();
|
||||
}
|
||||
$('.totpDetails').hide();
|
||||
{{if .TOTPConfig.Enabled }}
|
||||
$('.totpDisable').show();
|
||||
{{end}}
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
handleConfigSelection();
|
||||
$('.totpDetails').hide();
|
||||
{{if not .TOTPConfig.Enabled }}
|
||||
$('.totpDisable').hide();
|
||||
$('.totpUpdateProtocols').hide();
|
||||
{{end}}
|
||||
|
||||
$('#idConfig').change(function() {
|
||||
handleConfigSelection();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
26
templates/webclient/twofactor-recovery.html
Normal file
26
templates/webclient/twofactor-recovery.html
Normal 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}}
|
31
templates/webclient/twofactor.html
Normal file
31
templates/webclient/twofactor.html
Normal 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}}
|
Loading…
Reference in a new issue