From a6e36e7cad298ab4db0dfc03c0eb90e491e3d197 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 28 Feb 2021 12:10:40 +0100 Subject: [PATCH] FTP: improve TLS certificate authentication For each user you can now configure: - TLS certificate auth - TLS certificate auth and password - Password auth For TLS auth, the certificate common name must match the name provided using the "USER" FTP command --- config/config.go | 2 +- config/config_test.go | 6 +- dataprovider/bolt.go | 14 + dataprovider/dataprovider.go | 103 +++++- dataprovider/memory.go | 14 + dataprovider/mysql.go | 5 + dataprovider/pgsql.go | 5 + dataprovider/sqlcommon.go | 14 + dataprovider/sqlite.go | 5 + dataprovider/user.go | 30 +- docs/external-auth.md | 5 +- docs/full-configuration.md | 4 +- ftpd/cryptfs_test.go | 6 +- ftpd/ftpd.go | 16 +- ftpd/ftpd_test.go | 617 +++++++++++++++++++++++++++++++---- ftpd/internal_test.go | 19 +- ftpd/server.go | 139 ++++++-- go.mod | 2 +- go.sum | 4 +- httpd/httpd_test.go | 22 +- httpd/schema/openapi.yaml | 14 +- httpd/web.go | 37 ++- httpdtest/httpdtest.go | 42 ++- metrics/metrics.go | 64 +++- pkgs/build.sh | 2 +- sftpd/sftpd_test.go | 4 +- templates/user.html | 16 +- utils/utils.go | 13 + 28 files changed, 1051 insertions(+), 173 deletions(-) diff --git a/config/config.go b/config/config.go index 44a35be4..481f7c38 100644 --- a/config/config.go +++ b/config/config.go @@ -427,7 +427,7 @@ func LoadConfig(configDir, configFile string) error { logger.Warn(logSender, "", "Configuration error: %v", warn) logger.WarnToConsole("Configuration error: %v", warn) } - if globalConf.ProviderConf.ExternalAuthScope < 0 || globalConf.ProviderConf.ExternalAuthScope > 7 { + if globalConf.ProviderConf.ExternalAuthScope < 0 || globalConf.ProviderConf.ExternalAuthScope > 15 { warn := fmt.Sprintf("invalid external_auth_scope: %v reset to 0", globalConf.ProviderConf.ExternalAuthScope) globalConf.ProviderConf.ExternalAuthScope = 0 logger.Warn(logSender, "", "Configuration error: %v", warn) diff --git a/config/config_test.go b/config/config_test.go index 8fe8a5e8..e9fe5a04 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -133,7 +133,7 @@ func TestInvalidExternalAuthScope(t *testing.T) { err := config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - providerConf.ExternalAuthScope = 10 + providerConf.ExternalAuthScope = 100 c := make(map[string]dataprovider.Config) c["data_provider"] = providerConf jsonConf, err := json.Marshal(c) @@ -472,7 +472,7 @@ func TestFTPDBindingsFromEnv(t *testing.T) { os.Setenv("SFTPGO_FTPD__BINDINGS__9__APPLY_PROXY_CONFIG", "t") os.Setenv("SFTPGO_FTPD__BINDINGS__9__TLS_MODE", "1") os.Setenv("SFTPGO_FTPD__BINDINGS__9__FORCE_PASSIVE_IP", "127.0.1.1") - os.Setenv("SFTPGO_FTPD__BINDINGS__9__CLIENT_AUTH_TYPE", "1") + os.Setenv("SFTPGO_FTPD__BINDINGS__9__CLIENT_AUTH_TYPE", "2") t.Cleanup(func() { os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__ADDRESS") @@ -508,7 +508,7 @@ func TestFTPDBindingsFromEnv(t *testing.T) { require.True(t, bindings[1].ApplyProxyConfig) require.Equal(t, 1, bindings[1].TLSMode) require.Equal(t, "127.0.1.1", bindings[1].ForcePassiveIP) - require.Equal(t, 1, bindings[1].ClientAuthType) + require.Equal(t, 2, bindings[1].ClientAuthType) require.Nil(t, bindings[1].TLSCipherSuites) } diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index 88d1af41..7b20cea3 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -3,6 +3,7 @@ package dataprovider import ( + "crypto/x509" "encoding/json" "errors" "fmt" @@ -102,6 +103,19 @@ func (p *BoltProvider) checkAvailability() error { return err } +func (p *BoltProvider) validateUserAndTLSCert(username, protocol string, tlsCert *x509.Certificate) (User, error) { + var user User + if tlsCert == nil { + return user, errors.New("TLS certificate cannot be null or empty") + } + user, err := p.userExists(username) + if err != nil { + providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) + return user, err + } + return checkUserAndTLSCertificate(&user, protocol, tlsCert) +} + func (p *BoltProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) { var user User if password == "" { diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index eb3eb11a..a409e207 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -10,6 +10,7 @@ import ( "crypto/sha256" "crypto/sha512" "crypto/subtle" + "crypto/x509" "encoding/base64" "encoding/json" "errors" @@ -93,9 +94,10 @@ var ( // ValidPerms defines all the valid permissions for a user ValidPerms = []string{PermAny, PermListItems, PermDownload, PermUpload, PermOverwrite, PermRename, PermDelete, PermCreateDirs, PermCreateSymlinks, PermChmod, PermChown, PermChtimes} - // ValidSSHLoginMethods defines all the valid SSH login methods - ValidSSHLoginMethods = []string{SSHLoginMethodPublicKey, LoginMethodPassword, SSHLoginMethodKeyboardInteractive, - SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt} + // ValidLoginMethods defines all the valid login methods + ValidLoginMethods = []string{SSHLoginMethodPublicKey, LoginMethodPassword, SSHLoginMethodKeyboardInteractive, + SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt, LoginMethodTLSCertificate, + LoginMethodTLSCertificateAndPwd} // SSHMultiStepsLoginMethods defines the supported Multi-Step Authentications SSHMultiStepsLoginMethods = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt} // ErrNoAuthTryed defines the error for connection closed before authentication @@ -106,6 +108,7 @@ var ( ErrNoInitRequired = errors.New("The data provider is up to date") // ErrInvalidCredentials defines the error to return if the supplied credentials are invalid ErrInvalidCredentials = errors.New("invalid credentials") + validTLSUsernames = []string{string(TLSUsernameNone), string(TLSUsernameCN)} webDAVUsersCache sync.Map config Config provider Provider @@ -214,10 +217,11 @@ type Config struct { ExternalAuthHook string `json:"external_auth_hook" mapstructure:"external_auth_hook"` // ExternalAuthScope defines the scope for the external authentication hook. // - 0 means all supported authentication scopes, the external hook will be executed for password, - // public key and keyboard interactive authentication + // public key, keyboard interactive authentication and TLS certificates // - 1 means passwords only // - 2 means public keys only // - 4 means keyboard interactive only + // - 8 means TLS certificates only // you can combine the scopes, for example 3 means password and public key, 5 password and keyboard // interactive and so on ExternalAuthScope int `json:"external_auth_scope" mapstructure:"external_auth_scope"` @@ -369,6 +373,7 @@ func GetQuotaTracking() int { type Provider interface { validateUserAndPass(username, password, ip, protocol string) (User, error) validateUserAndPubKey(username string, pubKey []byte) (User, string, error) + validateUserAndTLSCert(username, protocol string, tlsCert *x509.Certificate) (User, error) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error getUsedQuota(username string) (int, int64, error) userExists(username string) (User, error) @@ -565,10 +570,41 @@ func CheckAdminAndPass(username, password, ip string) (Admin, error) { return provider.validateAdminAndPass(username, password, ip) } -// CheckUserAndPass retrieves the SFTP user with the given username and password if a match is found or an error +// CheckUserBeforeTLSAuth checks if a user exits before trying mutual TLS +func CheckUserBeforeTLSAuth(username, ip, protocol string, tlsCert *x509.Certificate) (User, error) { + if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&8 != 0) { + return doExternalAuth(username, "", nil, "", ip, protocol, tlsCert) + } + if config.PreLoginHook != "" { + return executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol) + } + return UserExists(username) +} + +// CheckUserAndTLSCert returns the SFTPGo user with the given username and check if the +// given TLS certificate allow authentication without password +func CheckUserAndTLSCert(username, ip, protocol string, tlsCert *x509.Certificate) (User, error) { + if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&8 != 0) { + user, err := doExternalAuth(username, "", nil, "", ip, protocol, tlsCert) + if err != nil { + return user, err + } + return checkUserAndTLSCertificate(&user, protocol, tlsCert) + } + if config.PreLoginHook != "" { + user, err := executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol) + if err != nil { + return user, err + } + return checkUserAndTLSCertificate(&user, protocol, tlsCert) + } + return provider.validateUserAndTLSCert(username, protocol, tlsCert) +} + +// CheckUserAndPass retrieves the SFTPGo user with the given username and password if a match is found or an error func CheckUserAndPass(username, password, ip, protocol string) (User, error) { if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) { - user, err := doExternalAuth(username, password, nil, "", ip, protocol) + user, err := doExternalAuth(username, password, nil, "", ip, protocol, nil) if err != nil { return user, err } @@ -587,7 +623,7 @@ func CheckUserAndPass(username, password, ip, protocol string) (User, error) { // CheckUserAndPubKey retrieves the SFTP user with the given username and public key if a match is found or an error func CheckUserAndPubKey(username string, pubKey []byte, ip, protocol string) (User, string, error) { if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&2 != 0) { - user, err := doExternalAuth(username, "", pubKey, "", ip, protocol) + user, err := doExternalAuth(username, "", pubKey, "", ip, protocol, nil) if err != nil { return user, "", err } @@ -609,7 +645,7 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard var user User var err error if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) { - user, err = doExternalAuth(username, "", nil, "1", ip, protocol) + user, err = doExternalAuth(username, "", nil, "1", ip, protocol, nil) } else if config.PreLoginHook != "" { user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol) } else { @@ -1130,7 +1166,7 @@ func validateFileFilters(user *User) error { return validateFiltersPatternExtensions(user) } -func validateFilters(user *User) error { +func checkEmptyFiltersStruct(user *User) { if len(user.Filters.AllowedIP) == 0 { user.Filters.AllowedIP = []string{} } @@ -1143,6 +1179,10 @@ func validateFilters(user *User) error { if len(user.Filters.DeniedProtocols) == 0 { user.Filters.DeniedProtocols = []string{} } +} + +func validateFilters(user *User) error { + checkEmptyFiltersStruct(user) for _, IPMask := range user.Filters.DeniedIP { _, _, err := net.ParseCIDR(IPMask) if err != nil { @@ -1155,11 +1195,11 @@ func validateFilters(user *User) error { return &ValidationError{err: fmt.Sprintf("could not parse allowed IP/Mask %#v : %v", IPMask, err)} } } - if len(user.Filters.DeniedLoginMethods) >= len(ValidSSHLoginMethods) { + if len(user.Filters.DeniedLoginMethods) >= len(ValidLoginMethods) { return &ValidationError{err: "invalid denied_login_methods"} } for _, loginMethod := range user.Filters.DeniedLoginMethods { - if !utils.IsStringInSlice(loginMethod, ValidSSHLoginMethods) { + if !utils.IsStringInSlice(loginMethod, ValidLoginMethods) { return &ValidationError{err: fmt.Sprintf("invalid login method: %#v", loginMethod)} } } @@ -1171,6 +1211,11 @@ func validateFilters(user *User) error { return &ValidationError{err: fmt.Sprintf("invalid protocol: %#v", p)} } } + if user.Filters.TLSUsername != "" { + if !utils.IsStringInSlice(string(user.Filters.TLSUsername), validTLSUsernames) { + return &ValidationError{err: fmt.Sprintf("invalid TLS username: %#v", user.Filters.TLSUsername)} + } + } return validateFileFilters(user) } @@ -1407,6 +1452,25 @@ func isPasswordOK(user *User, password string) (bool, error) { return match, err } +func checkUserAndTLSCertificate(user *User, protocol string, tlsCert *x509.Certificate) (User, error) { + err := checkLoginConditions(user) + if err != nil { + return *user, err + } + switch protocol { + case "FTP": + if user.Filters.TLSUsername == TLSUsernameCN { + if user.Username == tlsCert.Subject.CommonName { + return *user, nil + } + return *user, fmt.Errorf("CN %#v does not match username %#v", tlsCert.Subject.CommonName, user.Username) + } + return *user, errors.New("TLS certificate is not valid") + default: + return *user, fmt.Errorf("certificate authentication is not supported for protocol %v", protocol) + } +} + func checkUserAndPass(user *User, password, ip, protocol string) (User, error) { err := checkLoginConditions(user) if err != nil { @@ -2043,7 +2107,7 @@ func ExecutePostLoginHook(user *User, loginMethod, ip, protocol string, err erro }() } -func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, protocol string) ([]byte, error) { +func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, protocol, tlsCert string) ([]byte, error) { if strings.HasPrefix(config.ExternalAuthHook, "http") { var url *url.URL var result []byte @@ -2060,6 +2124,7 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, authRequest["public_key"] = pkey authRequest["protocol"] = protocol authRequest["keyboard_interactive"] = keyboardInteractive + authRequest["tls_cert"] = tlsCert authRequestAsJSON, err := json.Marshal(authRequest) if err != nil { providerLog(logger.LevelWarn, "error serializing external auth request: %v", err) @@ -2085,13 +2150,14 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", password), fmt.Sprintf("SFTPGO_AUTHD_PUBLIC_KEY=%v", pkey), fmt.Sprintf("SFTPGO_AUTHD_PROTOCOL=%v", protocol), + fmt.Sprintf("SFTPGO_AUTHD_TLS_CERT=%v", strings.ReplaceAll(tlsCert, "\n", "\\n")), fmt.Sprintf("SFTPGO_AUTHD_KEYBOARD_INTERACTIVE=%v", keyboardInteractive)) return cmd.Output() } -func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive, ip, protocol string) (User, error) { +func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive, ip, protocol string, tlsCert *x509.Certificate) (User, error) { var user User - pkey := "" + var pkey, cert string if len(pubKey) > 0 { k, err := ssh.ParsePublicKey(pubKey) if err != nil { @@ -2099,7 +2165,14 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv } pkey = string(ssh.MarshalAuthorizedKey(k)) } - out, err := getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, protocol) + if tlsCert != nil { + var err error + cert, err = utils.EncodeTLSCertToPem(tlsCert) + if err != nil { + return user, err + } + } + out, err := getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, protocol, cert) if err != nil { return user, fmt.Errorf("External auth error: %v", err) } diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 91809f02..42d722b8 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -1,6 +1,7 @@ package dataprovider import ( + "crypto/x509" "errors" "fmt" "os" @@ -88,6 +89,19 @@ func (p *MemoryProvider) close() error { return nil } +func (p *MemoryProvider) validateUserAndTLSCert(username, protocol string, tlsCert *x509.Certificate) (User, error) { + var user User + if tlsCert == nil { + return user, errors.New("TLS certificate cannot be null or empty") + } + user, err := p.userExists(username) + if err != nil { + providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) + return user, err + } + return checkUserAndTLSCertificate(&user, protocol, tlsCert) +} + func (p *MemoryProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) { var user User if password == "" { diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index de621f8c..9d4cee10 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -4,6 +4,7 @@ package dataprovider import ( "context" + "crypto/x509" "database/sql" "errors" "fmt" @@ -102,6 +103,10 @@ func (p *MySQLProvider) validateUserAndPass(username, password, ip, protocol str return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle) } +func (p *MySQLProvider) validateUserAndTLSCert(username, protocol string, tlsCert *x509.Certificate) (User, error) { + return sqlCommonValidateUserAndTLSCertificate(username, protocol, tlsCert, p.dbHandle) +} + func (p *MySQLProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) { return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle) } diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 997f93c1..b79c5d78 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -4,6 +4,7 @@ package dataprovider import ( "context" + "crypto/x509" "database/sql" "errors" "fmt" @@ -110,6 +111,10 @@ func (p *PGSQLProvider) validateUserAndPass(username, password, ip, protocol str return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle) } +func (p *PGSQLProvider) validateUserAndTLSCert(username, protocol string, tlsCert *x509.Certificate) (User, error) { + return sqlCommonValidateUserAndTLSCertificate(username, protocol, tlsCert, p.dbHandle) +} + func (p *PGSQLProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) { return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle) } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index 92936124..c63516c9 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -2,6 +2,7 @@ package dataprovider import ( "context" + "crypto/x509" "database/sql" "encoding/json" "errors" @@ -226,6 +227,19 @@ func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHan return checkUserAndPass(&user, password, ip, protocol) } +func sqlCommonValidateUserAndTLSCertificate(username, protocol string, tlsCert *x509.Certificate, dbHandle *sql.DB) (User, error) { + var user User + if tlsCert == nil { + return user, errors.New("TLS certificate cannot be null or empty") + } + user, err := sqlCommonGetUserByUsername(username, dbHandle) + if err != nil { + providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err) + return user, err + } + return checkUserAndTLSCertificate(&user, protocol, tlsCert) +} + func sqlCommonValidateUserAndPubKey(username string, pubKey []byte, dbHandle *sql.DB) (User, string, error) { var user User if len(pubKey) == 0 { diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 87292398..066a3d71 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -4,6 +4,7 @@ package dataprovider import ( "context" + "crypto/x509" "database/sql" "errors" "fmt" @@ -123,6 +124,10 @@ func (p *SQLiteProvider) validateUserAndPass(username, password, ip, protocol st return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle) } +func (p *SQLiteProvider) validateUserAndTLSCert(username, protocol string, tlsCert *x509.Certificate) (User, error) { + return sqlCommonValidateUserAndTLSCertificate(username, protocol, tlsCert, p.dbHandle) +} + func (p *SQLiteProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) { return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle) } diff --git a/dataprovider/user.go b/dataprovider/user.go index 14c675b8..8c40e119 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -57,6 +57,17 @@ const ( SSHLoginMethodKeyboardInteractive = "keyboard-interactive" SSHLoginMethodKeyAndPassword = "publickey+password" SSHLoginMethodKeyAndKeyboardInt = "publickey+keyboard-interactive" + LoginMethodTLSCertificate = "TLSCertificate" + LoginMethodTLSCertificateAndPwd = "TLSCertificate+password" +) + +// TLSUsername defines the TLS certificate attribute to use as username +type TLSUsername string + +// Supported certificate attributes to use as username +const ( + TLSUsernameNone TLSUsername = "None" + TLSUsernameCN TLSUsername = "CommonName" ) var ( @@ -144,6 +155,10 @@ type UserFilters struct { FilePatterns []PatternsFilter `json:"file_patterns,omitempty"` // max size allowed for a single upload, 0 means unlimited MaxUploadFileSize int64 `json:"max_upload_file_size,omitempty"` + // TLS certificate attribute to use as username. + // For FTP clients it must match the name provided using the + // "USER" command + TLSUsername TLSUsername `json:"tls_username,omitempty"` } // FilesystemProvider defines the supported storages @@ -268,6 +283,15 @@ func (u *User) IsPasswordHashed() bool { return utils.IsStringPrefixInSlice(u.Password, hashPwdPrefixes) } +// IsTLSUsernameVerificationEnabled returns true if we need to extract the username +// from the client TLS certificate +func (u *User) IsTLSUsernameVerificationEnabled() bool { + if u.Filters.TLSUsername != "" { + return u.Filters.TLSUsername != TLSUsernameNone + } + return false +} + // SetEmptySecrets sets to empty any user secret func (u *User) SetEmptySecrets() { u.FsConfig.S3Config.AccessSecret = kms.NewEmptySecret() @@ -531,6 +555,9 @@ func (u *User) IsPartialAuth(loginMethod string) bool { return false } for _, method := range u.GetAllowedLoginMethods() { + if method == LoginMethodTLSCertificate || method == LoginMethodTLSCertificateAndPwd { + continue + } if !utils.IsStringInSlice(method, SSHMultiStepsLoginMethods) { return false } @@ -541,7 +568,7 @@ func (u *User) IsPartialAuth(loginMethod string) bool { // GetAllowedLoginMethods returns the allowed login methods func (u *User) GetAllowedLoginMethods() []string { var allowedMethods []string - for _, method := range ValidSSHLoginMethods { + for _, method := range ValidLoginMethods { if !utils.IsStringInSlice(method, u.Filters.DeniedLoginMethods) { allowedMethods = append(allowedMethods, method) } @@ -857,6 +884,7 @@ func (u *User) getACopy() User { } filters := UserFilters{} filters.MaxUploadFileSize = u.Filters.MaxUploadFileSize + filters.TLSUsername = u.Filters.TLSUsername filters.AllowedIP = make([]string, len(u.Filters.AllowedIP)) copy(filters.AllowedIP, u.Filters.AllowedIP) filters.DeniedIP = make([]string, len(u.Filters.DeniedIP)) diff --git a/docs/external-auth.md b/docs/external-auth.md index 22682ca6..b2acb21e 100644 --- a/docs/external-auth.md +++ b/docs/external-auth.md @@ -10,6 +10,7 @@ The external program can read the following environment variables to get info ab - `SFTPGO_AUTHD_PASSWORD`, not empty for password authentication - `SFTPGO_AUTHD_PUBLIC_KEY`, not empty for public key authentication - `SFTPGO_AUTHD_KEYBOARD_INTERACTIVE`, not empty for keyboard interactive authentication +- `SFTPGO_AUTHD_TLS_CERT`, TLS client certificate PEM encoded. Not empty for TLS certificate authentication Previous global environment variables aren't cleared when the script is called. The content of these variables is _not_ quoted. They may contain special characters. They are under the control of a possibly malicious remote user. The program must write, on its standard output, a valid SFTPGo user serialized as JSON if the authentication succeeds or a user with an empty username if the authentication fails. @@ -22,6 +23,7 @@ If the hook is an HTTP URL then it will be invoked as HTTP POST. The request bod - `password`, not empty for password authentication - `public_key`, not empty for public key authentication - `keyboard_interactive`, not empty for keyboard interactive authentication +- `tls_cert`, TLS client certificate PEM encoded. Not empty for TLS certificate authentication If authentication succeeds the HTTP response code must be 200 and the response body a valid SFTPGo user serialized as JSON. If the authentication fails the HTTP response code must be != 200 or the response body must be empty. @@ -32,10 +34,11 @@ The program hook must finish within 30 seconds, the HTTP hook timeout will use t This method is slower than built-in authentication, but it's very flexible as anyone can easily write his own authentication hooks. You can also restrict the authentication scope for the hook using the `external_auth_scope` configuration key: -- `0` means all supported authentication scopes. The external hook will be used for password, public key and keyboard interactive authentication +- `0` means all supported authentication scopes. The external hook will be used for password, public key, keyboard interactive and TLS certificate authentication - `1` means passwords only - `2` means public keys only - `4` means keyboard interactive only +- `8` means TLS certificate only You can combine the scopes. For example, 3 means password and public key, 5 means password and keyboard interactive, and so on. diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 7c2a6e34..2bd06bf2 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -109,7 +109,7 @@ The configuration file contains the following sections: - `apply_proxy_config`, boolean. If enabled the common proxy configuration, if any, will be applied. Default `true`. - `tls_mode`, integer. 0 means accept both cleartext and encrypted sessions. 1 means TLS is required for both control and data connection. 2 means implicit TLS. Do not enable this blindly, please check that a proper TLS config is in place if you set `tls_mode` is different from 0. - `force_passive_ip`, ip address. External IP address to expose for passive connections. Leavy empty to autodetect. Defaut: "". - - `client_auth_type`, integer. Set to `1` to require client certificate authentication in addition to FTP authentication. You need to define at least a certificate authority for this to work. Default: 0. + - `client_auth_type`, integer. Set to `1` to require a client certificate and verify it. Set to `2` to request a client certificate during the TLS handshake and verify it if given, in this mode the client is allowed not to send a certificate. At least one certification authority must be defined in order to verify client certificates. If no certification authority is defined, this setting is ignored. Default: 0. - `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: empty. - `bind_port`, integer. Deprecated, please use `bindings` - `bind_address`, string. Deprecated, please use `bindings` @@ -173,7 +173,7 @@ The configuration file contains the following sections: - `hook`, string. Absolute path to the command to execute or HTTP URL to notify. - `external_auth_program`, string. Deprecated, please use `external_auth_hook`. - `external_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for users authentication. See [External Authentication](./external-auth.md) for more details. Leave empty to disable. - - `external_auth_scope`, integer. 0 means all supported authentication scopes (passwords, public keys and keyboard interactive). 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. The flags can be combined, for example 6 means public keys and keyboard interactive + - `external_auth_scope`, integer. 0 means all supported authentication scopes (passwords, public keys and keyboard interactive). 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. 8 means TLS certificate. The flags can be combined, for example 6 means public keys and keyboard interactive - `credentials_path`, string. It defines the directory for storing user provided credential files such as Google Cloud Storage credentials. This can be an absolute path or a path relative to the config dir - `prefer_database_credentials`, boolean. When true, users' Google Cloud Storage credentials will be written to the data provider instead of disk, though pre-existing credentials on disk will be used as a fallback. When false, they will be written to the directory specified by `credentials_path`. - `pre_login_program`, string. Deprecated, please use `pre_login_hook`. diff --git a/ftpd/cryptfs_test.go b/ftpd/cryptfs_test.go index 3ba1e9e3..f760139b 100644 --- a/ftpd/cryptfs_test.go +++ b/ftpd/cryptfs_test.go @@ -22,7 +22,7 @@ func TestBasicFTPHandlingCryptFs(t *testing.T) { u.QuotaSize = 6553600 user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { assert.Len(t, common.Connections.GetStats(), 1) testFilePath := filepath.Join(homeBasePath, testFileName) @@ -118,7 +118,7 @@ func TestZeroBytesTransfersCryptFs(t *testing.T) { u := getTestUserWithCryptFs() user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { testFileName := "testfilename" err = checkBasicFTP(client) @@ -155,7 +155,7 @@ func TestResumeCryptFs(t *testing.T) { u := getTestUserWithCryptFs() user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { testFilePath := filepath.Join(homeBasePath, testFileName) data := []byte("test data") diff --git a/ftpd/ftpd.go b/ftpd/ftpd.go index b6f04f97..8e4a35b2 100644 --- a/ftpd/ftpd.go +++ b/ftpd/ftpd.go @@ -34,7 +34,9 @@ type Binding struct { TLSMode int `json:"tls_mode" mapstructure:"tls_mode"` // External IP address to expose for passive connections. ForcePassiveIP string `json:"force_passive_ip" mapstructure:"force_passive_ip"` - // set to 1 to require client certificate authentication in addition to FTP auth. + // Set to 1 to require client certificate authentication. + // Set to 2 to require a client certificate and verfify it if given. In this mode + // the client is allowed not to send a certificate. // You need to define at least a certificate authority for this to work ClientAuthType int `json:"client_auth_type" mapstructure:"client_auth_type"` // TLSCipherSuites is a list of supported cipher suites for TLS version 1.2. @@ -48,6 +50,18 @@ type Binding struct { // any invalid name will be silently ignored. // The order matters, the ciphers listed first will be the preferred ones. TLSCipherSuites []string `json:"tls_cipher_suites" mapstructure:"tls_cipher_suites"` + ciphers []uint16 +} + +func (b *Binding) setCiphers() { + b.ciphers = utils.GetTLSCiphersFromNames(b.TLSCipherSuites) + if len(b.ciphers) == 0 { + b.ciphers = nil + } +} + +func (b *Binding) isMutualTLSEnabled() bool { + return b.ClientAuthType == 1 || b.ClientAuthType == 2 } // GetAddress returns the binding address diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index e7670f3a..005961bc 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -67,8 +67,161 @@ UM2lmBLIXpGgBwYFK4EEACKhZANiAARCjRMqJ85rzMC998X5z761nJ+xL3bkmGVq WvrJ51t5OxV0v25NsOgR82CANXUgvhVYs7vNFN+jxtb2aj6Xg+/2G/BNxkaFspIV CzgWkxiz7XE4lgUwX44FCXZM3+JeUbI= -----END EC PRIVATE KEY-----` - testFileName = "test_file_ftp.dat" - testDLFileName = "test_download_ftp.dat" + caCRT = `-----BEGIN CERTIFICATE----- +MIIE5jCCAs6gAwIBAgIBATANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDEwhDZXJ0 +QXV0aDAeFw0yMTAxMDIyMTIwNTVaFw0yMjA3MDIyMTMwNTJaMBMxETAPBgNVBAMT +CENlcnRBdXRoMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4Tiho5xW +AC15JRkMwfp3/TJwI2As7MY5dele5cmdr5bHAE+sRKqC+Ti88OJWCV5saoyax/1S +CjxJlQMZMl169P1QYJskKjdG2sdv6RLWLMgwSNRRjxp/Bw9dHdiEb9MjLgu28Jro +9peQkHcRHeMf5hM9WvlIJGrdzbC4hUehmqggcqgARainBkYjf0SwuWxHeu4nMqkp +Ak5tcSTLCjHfEFHZ9Te0TIPG5YkWocQKyeLgu4lvuU+DD2W2lym+YVUtRMGs1Env +k7p+N0DcGU26qfzZ2sF5ZXkqm7dBsGQB9pIxwc2Q8T1dCIyP9OQCKVILdc5aVFf1 +cryQFHYzYNNZXFlIBims5VV5Mgfp8ESHQSue+v6n6ykecLEyKt1F1Y/MWY/nWUSI +8zdq83jdBAZVjo9MSthxVn57/06s/hQca65IpcTZV2gX0a+eRlAVqaRbAhL3LaZe +bYsW3WHKoUOftwemuep3nL51TzlXZVL7Oz/ClGaEOsnGG9KFO6jh+W768qC0zLQI +CdE7v2Zex98sZteHCg9fGJHIaYoF0aJG5P3WI5oZf2fy7UIYN9ADLFZiorCXAZEh +CSU6mDoRViZ4RGR9GZxbDZ9KYn7O8M/KCR72bkQg73TlMsk1zSXEw0MKLUjtsw6c +rZ0Jt8t3sRatHO3JrYHALMt9vZfyNCZp0IsCAwEAAaNFMEMwDgYDVR0PAQH/BAQD +AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO1yCNAGr/zQTJIi8lw3 +w5OiuBvMMA0GCSqGSIb3DQEBCwUAA4ICAQA6gCNuM7r8mnx674dm31GxBjQy5ZwB +7CxDzYEvL/oiZ3Tv3HlPfN2LAAsJUfGnghh9DOytenL2CTZWjl/emP5eijzmlP+9 +zva5I6CIMCf/eDDVsRdO244t0o4uG7+At0IgSDM3bpVaVb4RHZNjEziYChsEYY8d +HK6iwuRSvFniV6yhR/Vj1Ymi9yZ5xclqseLXiQnUB0PkfIk23+7s42cXB16653fH +O/FsPyKBLiKJArizLYQc12aP3QOrYoYD9+fAzIIzew7A5C0aanZCGzkuFpO6TRlD +Tb7ry9Gf0DfPpCgxraH8tOcmnqp/ka3hjqo/SRnnTk0IFrmmLdarJvjD46rKwBo4 +MjyAIR1mQ5j8GTlSFBmSgETOQ/EYvO3FPLmra1Fh7L+DvaVzTpqI9fG3TuyyY+Ri +Fby4ycTOGSZOe5Fh8lqkX5Y47mCUJ3zHzOA1vUJy2eTlMRGpu47Eb1++Vm6EzPUP +2EF5aD+zwcssh+atZvQbwxpgVqVcyLt91RSkKkmZQslh0rnlTb68yxvUnD3zw7So +o6TAf9UvwVMEvdLT9NnFd6hwi2jcNte/h538GJwXeBb8EkfpqLKpTKyicnOdkamZ +7E9zY8SHNRYMwB9coQ/W8NvufbCgkvOoLyMXk5edbXofXl3PhNGOlraWbghBnzf5 +r3rwjFsQOoZotA== +-----END CERTIFICATE-----` + caCRL = `-----BEGIN X509 CRL----- +MIICpzCBkAIBATANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDEwhDZXJ0QXV0aBcN +MjEwMTAyMjEzNDA1WhcNMjMwMTAyMjEzNDA1WjAkMCICEQC+l04DbHWMyC3fG09k +VXf+Fw0yMTAxMDIyMTM0MDVaoCMwITAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJc +N8OTorgbzDANBgkqhkiG9w0BAQsFAAOCAgEAEJ7z+uNc8sqtxlOhSdTGDzX/xput +E857kFQkSlMnU2whQ8c+XpYrBLA5vIZJNSSwohTpM4+zVBX/bJpmu3wqqaArRO9/ +YcW5mQk9Anvb4WjQW1cHmtNapMTzoC9AiYt/OWPfy+P6JCgCr4Hy6LgQyIRL6bM9 +VYTalolOm1qa4Y5cIeT7iHq/91mfaqo8/6MYRjLl8DOTROpmw8OS9bCXkzGKdCat +AbAzwkQUSauyoCQ10rpX+Y64w9ng3g4Dr20aCqPf5osaqplEJ2HTK8ljDTidlslv +9anQj8ax3Su89vI8+hK+YbfVQwrThabgdSjQsn+veyx8GlP8WwHLAQ379KjZjWg+ +OlOSwBeU1vTdP0QcB8X5C2gVujAyuQekbaV86xzIBOj7vZdfHZ6ee30TZ2FKiMyg +7/N2OqW0w77ChsjB4MSHJCfuTgIeg62GzuZXLM+Q2Z9LBdtm4Byg+sm/P52adOEg +gVb2Zf4KSvsAmA0PIBlu449/QXUFcMxzLFy7mwTeZj2B4Ln0Hm0szV9f9R8MwMtB +SyLYxVH+mgqaR6Jkk22Q/yYyLPaELfafX5gp/AIXG8n0zxfVaTvK3auSgb1Q6ZLS +5QH9dSIsmZHlPq7GoSXmKpMdjUL8eaky/IMteioyXgsBiATzl5L2dsw6MTX3MDF0 +QbDK+MzhmbKfDxs= +-----END X509 CRL-----` + client1Crt = `-----BEGIN CERTIFICATE----- +MIIEITCCAgmgAwIBAgIRAIppZHoj1hM80D7WzTEKLuAwDQYJKoZIhvcNAQELBQAw +EzERMA8GA1UEAxMIQ2VydEF1dGgwHhcNMjEwMTAyMjEyMzEwWhcNMjIwNzAyMjEz +MDUxWjASMRAwDgYDVQQDEwdjbGllbnQxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAoKbYY9MdF2kF/nhBESIiZTdVYtA8XL9xrIZyDj9EnCiTxHiVbJtH +XVwszqSl5TRrotPmnmAQcX3r8OCk+z+RQZ0QQj257P3kG6q4rNnOcWCS5xEd20jP +yhQ3m+hMGfZsotNTQze1ochuQgLUN6IPyPxZkH22ia3jX4iu1eo/QxeLYHj1UHw4 +3Cii9yE+j5kPUC21xmnrGKdUrB55NYLXHx6yTIqYR5znSOVB8oJi18/hwdZmH859 +DHhm0Hx1HrS+jbjI3+CMorZJ3WUyNf+CkiVLD3xYutPbxzEpwiqkG/XYzLH0habT +cDcILo18n+o3jvem2KWBrDhyairjIDscwQIDAQABo3EwbzAOBgNVHQ8BAf8EBAMC +A7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBSJ5GIv +zIrE4ZSQt2+CGblKTDswizAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJcN8OTorgb +zDANBgkqhkiG9w0BAQsFAAOCAgEALh4f5GhvNYNou0Ab04iQBbLEdOu2RlbK1B5n +K9P/umYenBHMY/z6HT3+6tpcHsDuqE8UVdq3f3Gh4S2Gu9m8PRitT+cJ3gdo9Plm +3rD4ufn/s6rGg3ppydXcedm17492tbccUDWOBZw3IO/ASVq13WPgT0/Kev7cPq0k +sSdSNhVeXqx8Myc2/d+8GYyzbul2Kpfa7h9i24sK49E9ftnSmsIvngONo08eT1T0 +3wAOyK2981LIsHaAWcneShKFLDB6LeXIT9oitOYhiykhFlBZ4M1GNlSNfhQ8IIQP +xbqMNXCLkW4/BtLhGEEcg0QVso6Kudl9rzgTfQknrdF7pHp6rS46wYUjoSyIY6dl +oLmnoAVJX36J3QPWelePI9e07X2wrTfiZWewwgw3KNRWjd6/zfPLe7GoqXnK1S2z +PT8qMfCaTwKTtUkzXuTFvQ8bAo2My/mS8FOcpkt2oQWeOsADHAUX7fz5BCoa2DL3 +k/7Mh4gVT+JYZEoTwCFuYHgMWFWe98naqHi9lB4yR981p1QgXgxO7qBeipagKY1F +LlH1iwXUqZ3MZnkNA+4e1Fglsw3sa/rC+L98HnznJ/YbTfQbCP6aQ1qcOymrjMud +7MrFwqZjtd/SK4Qx1VpK6jGEAtPgWBTUS3p9ayg6lqjMBjsmySWfvRsDQbq6P5Ct +O/e3EH8= +-----END CERTIFICATE-----` + client1Key = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAoKbYY9MdF2kF/nhBESIiZTdVYtA8XL9xrIZyDj9EnCiTxHiV +bJtHXVwszqSl5TRrotPmnmAQcX3r8OCk+z+RQZ0QQj257P3kG6q4rNnOcWCS5xEd +20jPyhQ3m+hMGfZsotNTQze1ochuQgLUN6IPyPxZkH22ia3jX4iu1eo/QxeLYHj1 +UHw43Cii9yE+j5kPUC21xmnrGKdUrB55NYLXHx6yTIqYR5znSOVB8oJi18/hwdZm +H859DHhm0Hx1HrS+jbjI3+CMorZJ3WUyNf+CkiVLD3xYutPbxzEpwiqkG/XYzLH0 +habTcDcILo18n+o3jvem2KWBrDhyairjIDscwQIDAQABAoIBAEBSjVFqtbsp0byR +aXvyrtLX1Ng7h++at2jca85Ihq//jyqbHTje8zPuNAKI6eNbmb0YGr5OuEa4pD9N +ssDmMsKSoG/lRwwcm7h4InkSvBWpFShvMgUaohfHAHzsBYxfnh+TfULsi0y7c2n6 +t/2OZcOTRkkUDIITnXYiw93ibHHv2Mv2bBDu35kGrcK+c2dN5IL5ZjTjMRpbJTe2 +44RBJbdTxHBVSgoGBnugF+s2aEma6Ehsj70oyfoVpM6Aed5kGge0A5zA1JO7WCn9 +Ay/DzlULRXHjJIoRWd2NKvx5n3FNppUc9vJh2plRHalRooZ2+MjSf8HmXlvG2Hpb +ScvmWgECgYEA1G+A/2KnxWsr/7uWIJ7ClcGCiNLdk17Pv3DZ3G4qUsU2ITftfIbb +tU0Q/b19na1IY8Pjy9ptP7t74/hF5kky97cf1FA8F+nMj/k4+wO8QDI8OJfzVzh9 +PwielA5vbE+xmvis5Hdp8/od1Yrc/rPSy2TKtPFhvsqXjqoUmOAjDP8CgYEAwZjH +9dt1sc2lx/rMxihlWEzQ3JPswKW9/LJAmbRBoSWF9FGNjbX7uhWtXRKJkzb8ZAwa +88azluNo2oftbDD/+jw8b2cDgaJHlLAkSD4O1D1RthW7/LKD15qZ/oFsRb13NV85 +ZNKtwslXGbfVNyGKUVFm7fVA8vBAOUey+LKDFj8CgYEAg8WWstOzVdYguMTXXuyb +ruEV42FJaDyLiSirOvxq7GTAKuLSQUg1yMRBIeQEo2X1XU0JZE3dLodRVhuO4EXP +g7Dn4X7Th9HSvgvNuIacowWGLWSz4Qp9RjhGhXhezUSx2nseY6le46PmFavJYYSR +4PBofMyt4PcyA6Cknh+KHmkCgYEAnTriG7ETE0a7v4DXUpB4TpCEiMCy5Xs2o8Z5 +ZNva+W+qLVUWq+MDAIyechqeFSvxK6gRM69LJ96lx+XhU58wJiFJzAhT9rK/g+jS +bsHH9WOfu0xHkuHA5hgvvV2Le9B2wqgFyva4HJy82qxMxCu/VG/SMqyfBS9OWbb7 +ibQhdq0CgYAl53LUWZsFSZIth1vux2LVOsI8C3X1oiXDGpnrdlQ+K7z57hq5EsRq +GC+INxwXbvKNqp5h0z2MvmKYPDlGVTgw8f8JjM7TkN17ERLcydhdRrMONUryZpo8 +1xTob+8blyJgfxZUIAKbMbMbIiU0WAF0rfD/eJJwS4htOW/Hfv4TGA== +-----END RSA PRIVATE KEY-----` + // client 2 crt is revoked + client2Crt = `-----BEGIN CERTIFICATE----- +MIIEITCCAgmgAwIBAgIRAL6XTgNsdYzILd8bT2RVd/4wDQYJKoZIhvcNAQELBQAw +EzERMA8GA1UEAxMIQ2VydEF1dGgwHhcNMjEwMTAyMjEyMzIwWhcNMjIwNzAyMjEz +MDUxWjASMRAwDgYDVQQDEwdjbGllbnQyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA6xjW5KQR3/OFQtV5M75WINqQ4AzXSu6DhSz/yumaaQZP/UxY+6hi +jcrFzGo9MMie/Sza8DhkXOFAl2BelUubrOeB2cl+/Gr8OCyRi2Gv6j3zCsuN/4jQ +tNaoez/IbkDvI3l/ZpzBtnuNY2RiemGgHuORXHRVf3qVlsw+npBIRW5rM2HkO/xG +oZjeBErWVu390Lyn+Gvk2TqQDnkutWnxUC60/zPlHhXZ4BwaFAekbSnjsSDB1YFM +s8HwW4oBryoxdj3/+/qLrBHt75IdLw3T7/V1UDJQM3EvSQOr12w4egpldhtsC871 +nnBQZeY6qA5feffIwwg/6lJm70o6S6OX6wIDAQABo3EwbzAOBgNVHQ8BAf8EBAMC +A7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBTB84v5 +t9HqhLhMODbn6oYkEQt3KzAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJcN8OTorgb +zDANBgkqhkiG9w0BAQsFAAOCAgEALGtBCve5k8tToL3oLuXp/oSik6ovIB/zq4I/ +4zNMYPU31+ZWz6aahysgx1JL1yqTa3Qm8o2tu52MbnV10dM7CIw7c/cYa+c+OPcG +5LF97kp13X+r2axy+CmwM86b4ILaDGs2Qyai6VB6k7oFUve+av5o7aUrNFpqGCJz +HWdtHZSVA3JMATzy0TfWanwkzreqfdw7qH0yZ9bDURlBKAVWrqnCstva9jRuv+AI +eqxr/4Ro986TFjJdoAP3Vr16CPg7/B6GA/KmsBWJrpeJdPWq4i2gpLKvYZoy89qD +mUZf34RbzcCtV4NvV1DadGnt4us0nvLrvS5rL2+2uWD09kZYq9RbLkvgzF/cY0fz +i7I1bi5XQ+alWe0uAk5ZZL/D+GTRYUX1AWwCqwJxmHrMxcskMyO9pXvLyuSWRDLo +YNBrbX9nLcfJzVCp+X+9sntTHjs4l6Cw+fLepJIgtgqdCHtbhTiv68vSM6cgb4br +6n2xrXRKuioiWFOrTSRr+oalZh8dGJ/xvwY8IbWknZAvml9mf1VvfE7Ma5P777QM +fsbYVTq0Y3R/5hIWsC3HA5z6MIM8L1oRe/YyhP3CTmrCHkVKyDOosGXpGz+JVcyo +cfYkY5A3yFKB2HaCwZSfwFmRhxkrYWGEbHv3Cd9YkZs1J3hNhGFZyVMC9Uh0S85a +6zdDidU= +-----END CERTIFICATE-----` + client2Key = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA6xjW5KQR3/OFQtV5M75WINqQ4AzXSu6DhSz/yumaaQZP/UxY ++6hijcrFzGo9MMie/Sza8DhkXOFAl2BelUubrOeB2cl+/Gr8OCyRi2Gv6j3zCsuN +/4jQtNaoez/IbkDvI3l/ZpzBtnuNY2RiemGgHuORXHRVf3qVlsw+npBIRW5rM2Hk +O/xGoZjeBErWVu390Lyn+Gvk2TqQDnkutWnxUC60/zPlHhXZ4BwaFAekbSnjsSDB +1YFMs8HwW4oBryoxdj3/+/qLrBHt75IdLw3T7/V1UDJQM3EvSQOr12w4egpldhts +C871nnBQZeY6qA5feffIwwg/6lJm70o6S6OX6wIDAQABAoIBAFatstVb1KdQXsq0 +cFpui8zTKOUiduJOrDkWzTygAmlEhYtrccdfXu7OWz0x0lvBLDVGK3a0I/TGrAzj +4BuFY+FM/egxTVt9in6fmA3et4BS1OAfCryzUdfK6RV//8L+t+zJZ/qKQzWnugpy +QYjDo8ifuMFwtvEoXizaIyBNLAhEp9hnrv+Tyi2O2gahPvCHsD48zkyZRCHYRstD +NH5cIrwz9/RJgPO1KI+QsJE7Nh7stR0sbr+5TPU4fnsL2mNhMUF2TJrwIPrc1yp+ +YIUjdnh3SO88j4TQT3CIrWi8i4pOy6N0dcVn3gpCRGaqAKyS2ZYUj+yVtLO4KwxZ +SZ1lNvECgYEA78BrF7f4ETfWSLcBQ3qxfLs7ibB6IYo2x25685FhZjD+zLXM1AKb +FJHEXUm3mUYrFJK6AFEyOQnyGKBOLs3S6oTAswMPbTkkZeD1Y9O6uv0AHASLZnK6 +pC6ub0eSRF5LUyTQ55Jj8D7QsjXJueO8v+G5ihWhNSN9tB2UA+8NBmkCgYEA+weq +cvoeMIEMBQHnNNLy35bwfqrceGyPIRBcUIvzQfY1vk7KW6DYOUzC7u+WUzy/hA52 +DjXVVhua2eMQ9qqtOav7djcMc2W9RbLowxvno7K5qiCss013MeWk64TCWy+WMp5A +AVAtOliC3hMkIKqvR2poqn+IBTh1449agUJQqTMCgYEAu06IHGq1GraV6g9XpGF5 +wqoAlMzUTdnOfDabRilBf/YtSr+J++ThRcuwLvXFw7CnPZZ4TIEjDJ7xjj3HdxeE +fYYjineMmNd40UNUU556F1ZLvJfsVKizmkuCKhwvcMx+asGrmA+tlmds4p3VMS50 +KzDtpKzLWlmU/p/RINWlRmkCgYBy0pHTn7aZZx2xWKqCDg+L2EXPGqZX6wgZDpu7 +OBifzlfM4ctL2CmvI/5yPmLbVgkgBWFYpKUdiujsyyEiQvWTUKhn7UwjqKDHtcsk +G6p7xS+JswJrzX4885bZJ9Oi1AR2yM3sC9l0O7I4lDbNPmWIXBLeEhGMmcPKv/Kc +91Ff4wKBgQCF3ur+Vt0PSU0ucrPVHjCe7tqazm0LJaWbPXL1Aw0pzdM2EcNcW/MA +w0kqpr7MgJ94qhXCBcVcfPuFN9fBOadM3UBj1B45Cz3pptoK+ScI8XKno6jvVK/p +xr5cb9VBRBtB9aOKVfuRhpatAfS2Pzm2Htae9lFn7slGPUmu2hkjDw== +-----END RSA PRIVATE KEY-----` + testFileName = "test_file_ftp.dat" + testDLFileName = "test_download_ftp.dat" + tlsClient1Username = "client1" + tlsClient2Username = "client2" ) var ( @@ -79,6 +232,8 @@ var ( preLoginPath string postConnectPath string logFilePath string + caCrtPath string + caCRLPath string ) func TestMain(m *testing.M) { @@ -116,14 +271,10 @@ func TestMain(m *testing.M) { certPath := filepath.Join(os.TempDir(), "test_ftpd.crt") keyPath := filepath.Join(os.TempDir(), "test_ftpd.key") - err = os.WriteFile(certPath, []byte(ftpsCert), os.ModePerm) + caCrtPath = filepath.Join(os.TempDir(), "test_ftpd_ca.crt") + caCRLPath = filepath.Join(os.TempDir(), "test_ftpd_crl.crt") + err = writeCerts(certPath, keyPath, caCrtPath, caCRLPath) if err != nil { - logger.ErrorToConsole("error writing FTPS certificate: %v", err) - os.Exit(1) - } - err = os.WriteFile(keyPath, []byte(ftpsKey), os.ModePerm) - if err != nil { - logger.ErrorToConsole("error writing FTPS private key: %v", err) os.Exit(1) } @@ -155,7 +306,8 @@ func TestMain(m *testing.M) { ftpdConf := config.GetFTPDConfig() ftpdConf.Bindings = []ftpd.Binding{ { - Port: 2121, + Port: 2121, + ClientAuthType: 2, }, } ftpdConf.PassivePortRange.Start = 0 @@ -163,6 +315,8 @@ func TestMain(m *testing.M) { ftpdConf.BannerFile = bannerFileName ftpdConf.CertificateFile = certPath ftpdConf.CertificateKeyFile = keyPath + ftpdConf.CACertificates = []string{caCrtPath} + ftpdConf.CARevocationLists = []string{caCRLPath} ftpdConf.EnableSite = true // required to test sftpfs @@ -222,6 +376,8 @@ func TestMain(m *testing.M) { } ftpdConf.CertificateFile = certPath ftpdConf.CertificateKeyFile = keyPath + ftpdConf.CACertificates = []string{caCrtPath} + ftpdConf.CARevocationLists = []string{caCRLPath} ftpdConf.EnableSite = false ftpdConf.DisableActiveMode = true ftpdConf.CombineSupport = 1 @@ -246,6 +402,8 @@ func TestMain(m *testing.M) { os.Remove(postConnectPath) os.Remove(certPath) os.Remove(keyPath) + os.Remove(caCrtPath) + os.Remove(caCRLPath) os.Remove(hostKeyPath) os.Remove(hostKeyPath + ".pub") os.Exit(exitCode) @@ -293,6 +451,11 @@ func TestInitializationFailure(t *testing.T) { ftpdConf.CARevocationLists = []string{""} err = ftpdConf.Initialize(configDir) require.Error(t, err) + + ftpdConf.CACertificates = []string{caCrtPath} + ftpdConf.CARevocationLists = []string{caCRLPath} + err = ftpdConf.Initialize(configDir) + require.Error(t, err) } func TestBasicFTPHandling(t *testing.T) { @@ -306,7 +469,7 @@ func TestBasicFTPHandling(t *testing.T) { assert.NoError(t, err) for _, user := range []dataprovider.User{localUser, sftpUser} { - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { if user.Username == defaultUsername { assert.Len(t, common.Connections.GetStats(), 1) @@ -399,7 +562,7 @@ func TestLoginInvalidPwd(t *testing.T) { user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) user.Password = "wrong" - _, err = getFTPClient(user, false) + _, err = getFTPClient(user, false, nil) assert.Error(t, err) _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) @@ -407,7 +570,7 @@ func TestLoginInvalidPwd(t *testing.T) { func TestLoginNonExistentUser(t *testing.T) { user := getTestUser() - _, err := getFTPClient(user, false) + _, err := getFTPClient(user, false, nil) assert.Error(t, err) } @@ -427,7 +590,7 @@ func TestLoginExternalAuth(t *testing.T) { providerConf.ExternalAuthScope = 0 err = dataprovider.Initialize(providerConf, configDir, true) assert.NoError(t, err) - client, err := getFTPClient(u, true) + client, err := getFTPClient(u, true, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -435,7 +598,7 @@ func TestLoginExternalAuth(t *testing.T) { assert.NoError(t, err) } u.Username = defaultUsername + "1" - client, err = getFTPClient(u, true) + client, err = getFTPClient(u, true, nil) if !assert.Error(t, err) { err := client.Quit() assert.NoError(t, err) @@ -477,7 +640,7 @@ func TestPreLoginHook(t *testing.T) { assert.NoError(t, err) _, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusNotFound) assert.NoError(t, err) - client, err := getFTPClient(u, false) + client, err := getFTPClient(u, false, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -489,7 +652,7 @@ func TestPreLoginHook(t *testing.T) { assert.NoError(t, err) // test login with an existing user - client, err = getFTPClient(user, true) + client, err = getFTPClient(user, true, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -499,7 +662,7 @@ func TestPreLoginHook(t *testing.T) { err = os.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), os.ModePerm) assert.NoError(t, err) - client, err = getFTPClient(u, false) + client, err = getFTPClient(u, false, nil) if !assert.Error(t, err) { err := client.Quit() assert.NoError(t, err) @@ -507,7 +670,7 @@ func TestPreLoginHook(t *testing.T) { user.Status = 0 err = os.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm) assert.NoError(t, err) - client, err = getFTPClient(u, false) + client, err = getFTPClient(u, false, nil) if !assert.Error(t, err, "pre-login script returned a disabled user, login must fail") { err := client.Quit() assert.NoError(t, err) @@ -539,7 +702,7 @@ func TestPostConnectHook(t *testing.T) { assert.NoError(t, err) err = os.WriteFile(postConnectPath, getPostConnectScriptContent(0), os.ModePerm) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -548,7 +711,7 @@ func TestPostConnectHook(t *testing.T) { } err = os.WriteFile(postConnectPath, getPostConnectScriptContent(1), os.ModePerm) assert.NoError(t, err) - client, err = getFTPClient(user, true) + client, err = getFTPClient(user, true, nil) if !assert.Error(t, err) { err := client.Quit() assert.NoError(t, err) @@ -556,7 +719,7 @@ func TestPostConnectHook(t *testing.T) { common.Config.PostConnectHook = "http://127.0.0.1:8079/healthz" - client, err = getFTPClient(user, false) + client, err = getFTPClient(user, false, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -566,7 +729,7 @@ func TestPostConnectHook(t *testing.T) { common.Config.PostConnectHook = "http://127.0.0.1:8079/notfound" - client, err = getFTPClient(user, true) + client, err = getFTPClient(user, true, nil) if !assert.Error(t, err) { err := client.Quit() assert.NoError(t, err) @@ -586,11 +749,11 @@ func TestMaxConnections(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) - _, err = getFTPClient(user, false) + _, err = getFTPClient(user, false, nil) assert.Error(t, err) err = client.Quit() assert.NoError(t, err) @@ -615,7 +778,7 @@ func TestDefender(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -625,12 +788,12 @@ func TestDefender(t *testing.T) { for i := 0; i < 3; i++ { user.Password = "wrong_pwd" - _, err = getFTPClient(user, false) + _, err = getFTPClient(user, false, nil) assert.Error(t, err) } user.Password = defaultPassword - _, err = getFTPClient(user, false) + _, err = getFTPClient(user, false, nil) if assert.Error(t, err) { assert.Contains(t, err.Error(), "Access denied, banned client IP") } @@ -649,11 +812,11 @@ func TestMaxSessions(t *testing.T) { u.MaxSessions = 1 user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) - _, err = getFTPClient(user, false) + _, err = getFTPClient(user, false, nil) assert.Error(t, err) err = client.Quit() assert.NoError(t, err) @@ -669,7 +832,7 @@ func TestZeroBytesTransfers(t *testing.T) { user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) for _, useTLS := range []bool{true, false} { - client, err := getFTPClient(user, useTLS) + client, err := getFTPClient(user, useTLS, nil) if assert.NoError(t, err) { testFileName := "testfilename" err = checkBasicFTP(client) @@ -724,7 +887,7 @@ func TestDownloadErrors(t *testing.T) { } user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { testFilePath1 := filepath.Join(user.HomeDir, subDir1, "file.zip") testFilePath2 := filepath.Join(user.HomeDir, subDir2, "file.zip") @@ -776,7 +939,7 @@ func TestUploadErrors(t *testing.T) { } user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { testFilePath := filepath.Join(homeBasePath, testFileName) testFileSize := user.QuotaSize @@ -831,7 +994,7 @@ func TestResume(t *testing.T) { sftpUser, _, err := httpdtest.AddUser(getTestSFTPUser(), http.StatusCreated) assert.NoError(t, err) for _, user := range []dataprovider.User{localUser, sftpUser} { - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { testFilePath := filepath.Join(homeBasePath, testFileName) data := []byte("test data") @@ -897,12 +1060,12 @@ func TestDeniedLoginMethod(t *testing.T) { u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - _, err = getFTPClient(user, false) + _, err = getFTPClient(user, false, nil) assert.Error(t, err) user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyAndPassword} user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { assert.NoError(t, checkBasicFTP(client)) err = client.Quit() @@ -920,12 +1083,12 @@ func TestDeniedProtocols(t *testing.T) { u.Filters.DeniedProtocols = []string{common.ProtocolFTP} user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - _, err = getFTPClient(user, false) + _, err = getFTPClient(user, false, nil) assert.Error(t, err) user.Filters.DeniedProtocols = []string{common.ProtocolSSH, common.ProtocolWebDAV} user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { assert.NoError(t, checkBasicFTP(client)) err = client.Quit() @@ -962,7 +1125,7 @@ func TestQuotaLimits(t *testing.T) { err = createTestFile(testFilePath2, testFileSize2) assert.NoError(t, err) // test quota files - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { err = ftpUploadFile(testFilePath, testFileName+".quota", testFileSize, client, 0) assert.NoError(t, err) @@ -978,7 +1141,7 @@ func TestQuotaLimits(t *testing.T) { user.QuotaFiles = 0 user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err = getFTPClient(user, true) + client, err = getFTPClient(user, true, nil) if assert.NoError(t, err) { err = ftpUploadFile(testFilePath, testFileName+".quota", testFileSize, client, 0) assert.Error(t, err) @@ -992,7 +1155,7 @@ func TestQuotaLimits(t *testing.T) { user.QuotaFiles = 0 user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err = getFTPClient(user, false) + client, err = getFTPClient(user, false, nil) if assert.NoError(t, err) { err = ftpUploadFile(testFilePath1, testFileName1, testFileSize1, client, 0) assert.Error(t, err) @@ -1057,7 +1220,7 @@ func TestUploadMaxSize(t *testing.T) { testFilePath1 := filepath.Join(homeBasePath, testFileName1) err = createTestFile(testFilePath1, testFileSize1) assert.NoError(t, err) - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { err = ftpUploadFile(testFilePath1, testFileName1, testFileSize1, client, 0) assert.Error(t, err) @@ -1097,7 +1260,7 @@ func TestLoginWithIPilters(t *testing.T) { u.Filters.AllowedIP = []string{"172.19.0.0/16"} user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if !assert.Error(t, err) { err = client.Quit() assert.NoError(t, err) @@ -1141,7 +1304,7 @@ func TestLoginWithDatabaseCredentials(t *testing.T) { assert.NoFileExists(t, credentialsFile) - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { err = client.Quit() assert.NoError(t, err) @@ -1176,7 +1339,7 @@ func TestLoginInvalidFs(t *testing.T) { err = os.Remove(credentialsFile) assert.NoError(t, err) - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if !assert.Error(t, err) { err = client.Quit() assert.NoError(t, err) @@ -1191,7 +1354,7 @@ func TestClientClose(t *testing.T) { u := getTestUser() user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -1220,7 +1383,7 @@ func TestRename(t *testing.T) { testFileSize := int64(65535) err = createTestFile(testFilePath, testFileSize) assert.NoError(t, err) - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -1259,7 +1422,7 @@ func TestRename(t *testing.T) { user.Permissions[path.Join("/", testDir)] = []string{dataprovider.PermListItems} user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err = getFTPClient(user, false) + client, err = getFTPClient(user, false, nil) if assert.NoError(t, err) { err = client.Rename(path.Join(testDir, testFileName), testFileName) assert.Error(t, err) @@ -1297,7 +1460,7 @@ func TestSymlink(t *testing.T) { for _, user := range []dataprovider.User{localUser, sftpUser} { err = createTestFile(testFilePath, testFileSize) assert.NoError(t, err) - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -1354,7 +1517,7 @@ func TestStat(t *testing.T) { assert.NoError(t, err) for _, user := range []dataprovider.User{localUser, sftpUser} { - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { subDir := "subdir" testFilePath := filepath.Join(homeBasePath, testFileName) @@ -1412,7 +1575,7 @@ func TestUploadOverwriteVfolder(t *testing.T) { assert.NoError(t, err) user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { testFilePath := filepath.Join(homeBasePath, testFileName) testFileSize := int64(65535) @@ -1461,7 +1624,7 @@ func TestAllocateAvailable(t *testing.T) { assert.NoError(t, err) user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { code, response, err := client.SendCustomCommand("allo 2000000") assert.NoError(t, err) @@ -1483,7 +1646,7 @@ func TestAllocateAvailable(t *testing.T) { user.QuotaSize = 100 user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err = getFTPClient(user, false) + client, err = getFTPClient(user, false, nil) if assert.NoError(t, err) { testFilePath := filepath.Join(homeBasePath, testFileName) testFileSize := user.QuotaSize - 1 @@ -1532,7 +1695,7 @@ func TestAllocateAvailable(t *testing.T) { user.QuotaSize = 0 user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err = getFTPClient(user, false) + client, err = getFTPClient(user, false, nil) if assert.NoError(t, err) { code, response, err := client.SendCustomCommand("allo 99") assert.NoError(t, err) @@ -1555,7 +1718,7 @@ func TestAllocateAvailable(t *testing.T) { user.QuotaSize = 50 user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err = getFTPClient(user, false) + client, err = getFTPClient(user, false, nil) if assert.NoError(t, err) { code, response, err := client.SendCustomCommand("AVBL") assert.NoError(t, err) @@ -1567,7 +1730,7 @@ func TestAllocateAvailable(t *testing.T) { user.Filters.MaxUploadFileSize = 1 user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) - client, err = getFTPClient(user, false) + client, err = getFTPClient(user, false, nil) if assert.NoError(t, err) { code, response, err := client.SendCustomCommand("AVBL") assert.NoError(t, err) @@ -1591,7 +1754,7 @@ func TestAvailableSFTPFs(t *testing.T) { assert.NoError(t, err) sftpUser, _, err := httpdtest.AddUser(getTestSFTPUser(), http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(sftpUser, false) + client, err := getFTPClient(sftpUser, false, nil) if assert.NoError(t, err) { code, response, err := client.SendCustomCommand("AVBL /") assert.NoError(t, err) @@ -1619,7 +1782,7 @@ func TestChtimes(t *testing.T) { assert.NoError(t, err) for _, user := range []dataprovider.User{localUser, sftpUser} { - client, err := getFTPClient(user, false) + client, err := getFTPClient(user, false, nil) if assert.NoError(t, err) { testFilePath := filepath.Join(homeBasePath, testFileName) testFileSize := int64(65535) @@ -1660,7 +1823,7 @@ func TestChown(t *testing.T) { } user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { testFilePath := filepath.Join(homeBasePath, testFileName) testFileSize := int64(131072) @@ -1697,7 +1860,7 @@ func TestChmod(t *testing.T) { sftpUser, _, err := httpdtest.AddUser(getTestSFTPUser(), http.StatusCreated) assert.NoError(t, err) for _, user := range []dataprovider.User{localUser, sftpUser} { - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { testFilePath := filepath.Join(homeBasePath, testFileName) testFileSize := int64(131072) @@ -1743,7 +1906,7 @@ func TestCombineDisabled(t *testing.T) { sftpUser, _, err := httpdtest.AddUser(getTestSFTPUser(), http.StatusCreated) assert.NoError(t, err) for _, user := range []dataprovider.User{localUser, sftpUser} { - client, err := getFTPClient(user, true) + client, err := getFTPClient(user, true, nil) if assert.NoError(t, err) { err = checkBasicFTP(client) assert.NoError(t, err) @@ -1788,7 +1951,7 @@ func TestActiveModeDisabled(t *testing.T) { assert.NoError(t, err) } - client, err = getFTPClient(user, false) + client, err = getFTPClient(user, false, nil) if assert.NoError(t, err) { code, response, err := client.SendCustomCommand("PORT 10,2,0,2,4,31") assert.NoError(t, err) @@ -1948,6 +2111,300 @@ func TestCombine(t *testing.T) { assert.NoError(t, err) } +func TestClientCertificateAuthRevokedCert(t *testing.T) { + u := getTestUser() + u.Username = tlsClient2Username + u.Filters.TLSUsername = dataprovider.TLSUsernameCN + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + tlsConfig := &tls.Config{ + ServerName: "localhost", + InsecureSkipVerify: true, // use this for tests only + MinVersion: tls.VersionTLS12, + } + tlsCert, err := tls.X509KeyPair([]byte(client2Crt), []byte(client2Key)) + assert.NoError(t, err) + tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert) + _, err = getFTPClient(user, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "bad certificate") + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestClientCertificateAuth(t *testing.T) { + u := getTestUser() + u.Username = tlsClient1Username + u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + tlsConfig := &tls.Config{ + ServerName: "localhost", + InsecureSkipVerify: true, // use this for tests only + MinVersion: tls.VersionTLS12, + } + tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key)) + assert.NoError(t, err) + tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert) + // TLS username is not enabled, mutual TLS should fail + _, err = getFTPClient(user, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "Login method password is not allowed") + } + + user.Filters.TLSUsername = dataprovider.TLSUsernameCN + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + client, err := getFTPClient(user, true, tlsConfig) + if assert.NoError(t, err) { + err = checkBasicFTP(client) + assert.NoError(t, err) + err = client.Quit() + assert.NoError(t, err) + } + + // now use a valid certificate with a CN different from username + u = getTestUser() + u.Username = tlsClient2Username + u.Filters.TLSUsername = dataprovider.TLSUsernameCN + u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} + user2, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + _, err = getFTPClient(user2, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "does not match username") + } + + // now disable certificate authentication + user.Filters.DeniedLoginMethods = append(user.Filters.DeniedLoginMethods, dataprovider.LoginMethodTLSCertificate, + dataprovider.LoginMethodTLSCertificateAndPwd) + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + _, err = getFTPClient(user, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "Login method TLSCertificate+password is not allowed") + } + + // disable FTP protocol + user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} + user.Filters.DeniedProtocols = append(user.Filters.DeniedProtocols, common.ProtocolFTP) + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + _, err = getFTPClient(user, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "Protocol FTP is not allowed") + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + _, err = httpdtest.RemoveUser(user2, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user2.GetHomeDir()) + assert.NoError(t, err) + + _, err = getFTPClient(user, true, tlsConfig) + assert.Error(t, err) +} + +func TestClientCertificateAndPwdAuth(t *testing.T) { + u := getTestUser() + u.Username = tlsClient1Username + u.Filters.TLSUsername = dataprovider.TLSUsernameCN + u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword, dataprovider.LoginMethodTLSCertificate} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + tlsConfig := &tls.Config{ + ServerName: "localhost", + InsecureSkipVerify: true, // use this for tests only + MinVersion: tls.VersionTLS12, + } + tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key)) + assert.NoError(t, err) + tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert) + client, err := getFTPClient(user, true, tlsConfig) + if assert.NoError(t, err) { + err = checkBasicFTP(client) + assert.NoError(t, err) + err = client.Quit() + assert.NoError(t, err) + } + + _, err = getFTPClient(user, true, nil) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "Login method password is not allowed") + } + user.Password = defaultPassword + "1" + _, err = getFTPClient(user, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid credentials") + } + + tlsCert, err = tls.X509KeyPair([]byte(client2Crt), []byte(client2Key)) + assert.NoError(t, err) + tlsConfig.Certificates = []tls.Certificate{tlsCert} + _, err = getFTPClient(user, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "bad certificate") + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestExternatAuthWithClientCert(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } + u := getTestUser() + u.Username = tlsClient1Username + u.Filters.DeniedLoginMethods = append(u.Filters.DeniedLoginMethods, dataprovider.LoginMethodPassword) + u.Filters.TLSUsername = dataprovider.TLSUsernameCN + err := dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf := config.GetProviderConf() + err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) + assert.NoError(t, err) + providerConf.ExternalAuthHook = extAuthPath + providerConf.ExternalAuthScope = 8 + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + + // external auth not called, auth scope is 8 + _, err = getFTPClient(u, true, nil) + assert.Error(t, err) + _, _, err = httpdtest.GetUserByUsername(u.Username, http.StatusNotFound) + assert.NoError(t, err) + + tlsConfig := &tls.Config{ + ServerName: "localhost", + InsecureSkipVerify: true, // use this for tests only + MinVersion: tls.VersionTLS12, + } + tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key)) + assert.NoError(t, err) + tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert) + client, err := getFTPClient(u, true, tlsConfig) + if assert.NoError(t, err) { + err = checkBasicFTP(client) + assert.NoError(t, err) + err := client.Quit() + assert.NoError(t, err) + } + + user, _, err := httpdtest.GetUserByUsername(u.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, u.Username, user.Username) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + u.Username = tlsClient2Username + _, err = getFTPClient(u, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid credentials") + } + + err = dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf = config.GetProviderConf() + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + err = os.Remove(extAuthPath) + assert.NoError(t, err) +} + +func TestPreLoginHookWithClientCert(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } + u := getTestUser() + u.Username = tlsClient1Username + u.Filters.DeniedLoginMethods = append(u.Filters.DeniedLoginMethods, dataprovider.LoginMethodPassword) + u.Filters.TLSUsername = dataprovider.TLSUsernameCN + err := dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf := config.GetProviderConf() + err = os.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm) + assert.NoError(t, err) + providerConf.PreLoginHook = preLoginPath + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + _, _, err = httpdtest.GetUserByUsername(tlsClient1Username, http.StatusNotFound) + assert.NoError(t, err) + tlsConfig := &tls.Config{ + ServerName: "localhost", + InsecureSkipVerify: true, // use this for tests only + MinVersion: tls.VersionTLS12, + } + tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key)) + assert.NoError(t, err) + tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert) + client, err := getFTPClient(u, true, tlsConfig) + if assert.NoError(t, err) { + err = checkBasicFTP(client) + assert.NoError(t, err) + err := client.Quit() + assert.NoError(t, err) + } + + user, _, err := httpdtest.GetUserByUsername(tlsClient1Username, http.StatusOK) + assert.NoError(t, err) + + // test login with an existing user + client, err = getFTPClient(user, true, tlsConfig) + if assert.NoError(t, err) { + err = checkBasicFTP(client) + assert.NoError(t, err) + err := client.Quit() + assert.NoError(t, err) + } + + u.Username = tlsClient2Username + err = os.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm) + assert.NoError(t, err) + _, err = getFTPClient(u, true, tlsConfig) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "does not match username") + } + + user2, _, err := httpdtest.GetUserByUsername(tlsClient2Username, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user2, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user2.GetHomeDir()) + assert.NoError(t, err) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf = config.GetProviderConf() + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + err = os.Remove(preLoginPath) + assert.NoError(t, err) +} + func checkBasicFTP(client *ftp.ServerConn) error { _, err := client.CurrentDir() if err != nil { @@ -2041,13 +2498,15 @@ func getFTPClientImplicitTLS(user dataprovider.User) (*ftp.ServerConn, error) { return client, err } -func getFTPClient(user dataprovider.User, useTLS bool) (*ftp.ServerConn, error) { +func getFTPClient(user dataprovider.User, useTLS bool, tlsConfig *tls.Config) (*ftp.ServerConn, error) { ftpOptions := []ftp.DialOption{ftp.DialWithTimeout(5 * time.Second)} if useTLS { - tlsConfig := &tls.Config{ - ServerName: "localhost", - InsecureSkipVerify: true, // use this for tests only - MinVersion: tls.VersionTLS12, + if tlsConfig == nil { + tlsConfig = &tls.Config{ + ServerName: "localhost", + InsecureSkipVerify: true, // use this for tests only + MinVersion: tls.VersionTLS12, + } } ftpOptions = append(ftpOptions, ftp.DialWithExplicitTLS(tlsConfig)) } @@ -2166,3 +2625,27 @@ func createTestFile(path string, size int64) error { } return os.WriteFile(path, content, os.ModePerm) } + +func writeCerts(certPath, keyPath, caCrtPath, caCRLPath string) error { + err := os.WriteFile(certPath, []byte(ftpsCert), os.ModePerm) + if err != nil { + logger.ErrorToConsole("error writing FTPS certificate: %v", err) + return err + } + err = os.WriteFile(keyPath, []byte(ftpsKey), os.ModePerm) + if err != nil { + logger.ErrorToConsole("error writing FTPS private key: %v", err) + return err + } + err = os.WriteFile(caCrtPath, []byte(caCRT), os.ModePerm) + if err != nil { + logger.ErrorToConsole("error writing FTPS CA crt: %v", err) + return err + } + err = os.WriteFile(caCRLPath, []byte(caCRL), os.ModePerm) + if err != nil { + logger.ErrorToConsole("error writing FTPS CRL: %v", err) + return err + } + return nil +} diff --git a/ftpd/internal_test.go b/ftpd/internal_test.go index a927fef0..fb97f664 100644 --- a/ftpd/internal_test.go +++ b/ftpd/internal_test.go @@ -13,6 +13,7 @@ import ( "github.com/eikenb/pipeat" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/drakkan/sftpgo/common" "github.com/drakkan/sftpgo/dataprovider" @@ -457,7 +458,7 @@ func TestUserInvalidParams(t *testing.T) { }, } server := NewServer(c, configDir, binding, 3) - _, err := server.validateUser(u, mockFTPClientContext{}) + _, err := server.validateUser(u, mockFTPClientContext{}, dataprovider.LoginMethodPassword) assert.Error(t, err) u.Username = "a" @@ -479,10 +480,10 @@ func TestUserInvalidParams(t *testing.T) { }, VirtualPath: vdirPath2, }) - _, err = server.validateUser(u, mockFTPClientContext{}) + _, err = server.validateUser(u, mockFTPClientContext{}, dataprovider.LoginMethodPassword) assert.Error(t, err) u.VirtualFolders = nil - _, err = server.validateUser(u, mockFTPClientContext{}) + _, err = server.validateUser(u, mockFTPClientContext{}, dataprovider.LoginMethodPassword) assert.Error(t, err) } @@ -817,3 +818,15 @@ func TestVerifyTLSConnection(t *testing.T) { certMgr = oldCertMgr } + +func TestCiphers(t *testing.T) { + b := Binding{ + TLSCipherSuites: []string{}, + } + b.setCiphers() + require.Nil(t, b.ciphers) + b.TLSCipherSuites = []string{"TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384"} + b.setCiphers() + require.Len(t, b.ciphers, 2) + require.Equal(t, []uint16{tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384}, b.ciphers) +} diff --git a/ftpd/server.go b/ftpd/server.go index 0ec68d86..19bceff5 100644 --- a/ftpd/server.go +++ b/ftpd/server.go @@ -8,6 +8,7 @@ import ( "net" "os" "path/filepath" + "sync" ftpserver "github.com/fclairamb/ftpserverlib" @@ -21,21 +22,25 @@ import ( // Server implements the ftpserverlib MainDriver interface type Server struct { - ID int - config *Configuration - initialMsg string - statusBanner string - binding Binding + ID int + config *Configuration + initialMsg string + statusBanner string + binding Binding + mu sync.RWMutex + verifiedTLSConns map[uint32]bool } // NewServer returns a new FTP server driver func NewServer(config *Configuration, configDir string, binding Binding, id int) *Server { + binding.setCiphers() server := &Server{ - config: config, - initialMsg: config.Banner, - statusBanner: fmt.Sprintf("SFTPGo %v FTP Server", version.Get().Version), - binding: binding, - ID: id, + config: config, + initialMsg: config.Banner, + statusBanner: fmt.Sprintf("SFTPGo %v FTP Server", version.Get().Version), + binding: binding, + ID: id, + verifiedTLSConns: make(map[uint32]bool), } if config.BannerFile != "" { bannerFilePath := config.BannerFile @@ -53,6 +58,27 @@ func NewServer(config *Configuration, configDir string, binding Binding, id int) return server } +func (s *Server) isTLSConnVerified(id uint32) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.verifiedTLSConns[id] +} + +func (s *Server) setTLSConnVerified(id uint32, value bool) { + s.mu.Lock() + defer s.mu.Unlock() + + s.verifiedTLSConns[id] = value +} + +func (s *Server) cleanTLSConnVerification(id uint32) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.verifiedTLSConns, id) +} + // GetSettings returns FTP server settings func (s *Server) GetSettings() (*ftpserver.Settings, error) { var portRange *ftpserver.PortRange @@ -128,23 +154,28 @@ func (s *Server) ClientConnected(cc ftpserver.ClientContext) (string, error) { // ClientDisconnected is called when the user disconnects, even if he never authenticated func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) { + s.cleanTLSConnVerification(cc.ID()) connID := fmt.Sprintf("%v_%v_%v", common.ProtocolFTP, s.ID, cc.ID()) common.Connections.Remove(connID) } // AuthUser authenticates the user and selects an handling driver func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) { + loginMethod := dataprovider.LoginMethodPassword + if s.isTLSConnVerified(cc.ID()) { + loginMethod = dataprovider.LoginMethodTLSCertificateAndPwd + } ipAddr := utils.GetIPFromRemoteAddress(cc.RemoteAddr().String()) user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolFTP) if err != nil { user.Username = username - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, ipAddr, loginMethod, err) return nil, err } - connection, err := s.validateUser(user, cc) + connection, err := s.validateUser(user, cc, loginMethod) - defer updateLoginMetrics(&user, ipAddr, err) + defer updateLoginMetrics(&user, ipAddr, loginMethod, err) if err != nil { return nil, err @@ -156,18 +187,69 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) return connection, nil } +// VerifyConnection checks whether a user should be authenticated using a client certificate without prompting for a password +func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsConn *tls.Conn) (ftpserver.ClientDriver, error) { + if !s.binding.isMutualTLSEnabled() { + return nil, nil + } + s.setTLSConnVerified(cc.ID(), false) + if tlsConn != nil { + state := tlsConn.ConnectionState() + if len(state.PeerCertificates) > 0 { + ipAddr := utils.GetIPFromRemoteAddress(cc.RemoteAddr().String()) + dbUser, err := dataprovider.CheckUserBeforeTLSAuth(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0]) + if err != nil { + dbUser.Username = user + updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err) + return nil, err + } + if dbUser.IsTLSUsernameVerificationEnabled() { + dbUser, err = dataprovider.CheckUserAndTLSCert(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0]) + if err != nil { + return nil, err + } + + s.setTLSConnVerified(cc.ID(), true) + + if dbUser.IsLoginMethodAllowed(dataprovider.LoginMethodTLSCertificate, nil) { + connection, err := s.validateUser(dbUser, cc, dataprovider.LoginMethodTLSCertificate) + + defer updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err) + + if err != nil { + return nil, err + } + connection.Fs.CheckRootPath(connection.GetUsername(), dbUser.GetUID(), dbUser.GetGID()) + connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP using a TLS certificate, username: %#v, home_dir: %#v remote addr: %#v", + dbUser.ID, dbUser.Username, dbUser.HomeDir, ipAddr) + dataprovider.UpdateLastLogin(&dbUser) //nolint:errcheck + return connection, nil + } + } + } + } + + return nil, nil +} + // GetTLSConfig returns a TLS Certificate to use func (s *Server) GetTLSConfig() (*tls.Config, error) { if certMgr != nil { tlsConfig := &tls.Config{ - GetCertificate: certMgr.GetCertificateFunc(), - MinVersion: tls.VersionTLS12, - CipherSuites: utils.GetTLSCiphersFromNames(s.binding.TLSCipherSuites), + GetCertificate: certMgr.GetCertificateFunc(), + MinVersion: tls.VersionTLS12, + CipherSuites: s.binding.ciphers, + PreferServerCipherSuites: true, } - if s.binding.ClientAuthType == 1 { + if s.binding.isMutualTLSEnabled() { tlsConfig.ClientCAs = certMgr.GetRootCAs() - tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert tlsConfig.VerifyConnection = s.verifyTLSConnection + switch s.binding.ClientAuthType { + case 1: + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + case 2: + tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven + } } return tlsConfig, nil } @@ -183,6 +265,9 @@ func (s *Server) verifyTLSConnection(state tls.ConnectionState) error { clientCrtName = clientCrt.Subject.String() } if len(state.VerifiedChains) == 0 { + if s.binding.ClientAuthType == 2 { + return nil + } logger.Warn(logSender, "", "TLS connection cannot be verified: unable to get verification chain") return errors.New("TLS connection cannot be verified: unable to get verification chain") } @@ -201,7 +286,7 @@ func (s *Server) verifyTLSConnection(state tls.ConnectionState) error { return nil } -func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext) (*Connection, error) { +func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext, loginMethod string) (*Connection, error) { connectionID := fmt.Sprintf("%v_%v_%v", common.ProtocolFTP, s.ID, cc.ID()) if !filepath.IsAbs(user.HomeDir) { logger.Warn(logSender, connectionID, "user %#v has an invalid home dir: %#v. Home dir must be an absolute path, login not allowed", @@ -212,9 +297,9 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext logger.Debug(logSender, connectionID, "cannot login user %#v, protocol FTP is not allowed", user.Username) return nil, fmt.Errorf("Protocol FTP is not allowed for user %#v", user.Username) } - if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) { - logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username) - return nil, fmt.Errorf("Password login method is not allowed for user %#v", user.Username) + if !user.IsLoginMethodAllowed(loginMethod, nil) { + logger.Debug(logSender, connectionID, "cannot login user %#v, %v login method is not allowed", user.Username, loginMethod) + return nil, fmt.Errorf("Login method %v is not allowed for user %#v", loginMethod, user.Username) } if user.MaxSessions > 0 { activeSessions := common.Connections.GetActiveSessions(user.Username) @@ -249,10 +334,10 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext return connection, nil } -func updateLoginMetrics(user *dataprovider.User, ip string, err error) { - metrics.AddLoginAttempt(dataprovider.LoginMethodPassword) +func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) { + metrics.AddLoginAttempt(loginMethod) if err != nil { - logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword, + logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolFTP, err.Error()) event := common.HostEventLoginFailed if _, ok := err.(*dataprovider.RecordNotFoundError); ok { @@ -260,6 +345,6 @@ func updateLoginMetrics(user *dataprovider.User, ip string, err error) { } common.AddDefenderEvent(ip, event) } - metrics.AddLoginResult(dataprovider.LoginMethodPassword, err) - dataprovider.ExecutePostLoginHook(user, dataprovider.LoginMethodPassword, ip, common.ProtocolFTP, err) + metrics.AddLoginResult(loginMethod, err) + dataprovider.ExecutePostLoginHook(user, loginMethod, ip, common.ProtocolFTP, err) } diff --git a/go.mod b/go.mod index 9e817d27..21e6b1a9 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.6.8 github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 - github.com/lestrrat-go/jwx v1.1.3 + github.com/lestrrat-go/jwx v1.1.4-0.20210228091017-d69abec6f5b4 github.com/lib/pq v1.9.0 github.com/magiconair/properties v1.8.4 // indirect github.com/mattn/go-sqlite3 v1.14.6 diff --git a/go.sum b/go.sum index 9956a66a..16e5f2d1 100644 --- a/go.sum +++ b/go.sum @@ -463,8 +463,8 @@ github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++ github.com/lestrrat-go/iter v1.0.0 h1:QD+hHQPDSHC4rCJkZYY/yXChYr/vjfBopKekTc+7l4Q= github.com/lestrrat-go/iter v1.0.0/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= github.com/lestrrat-go/jwx v1.1.0/go.mod h1:vn9FzD6gJtKkgYs7RTKV7CjWtEka8F/voUollhnn4QE= -github.com/lestrrat-go/jwx v1.1.3 h1:a3yw3TjcsIUAmZefqTJh8S522MTSkRm2k90pWTJTa0E= -github.com/lestrrat-go/jwx v1.1.3/go.mod h1:Q+ncWBOZmzkVfTN0SWsKuvjkXeJau0BTTNTifDFvfr0= +github.com/lestrrat-go/jwx v1.1.4-0.20210228091017-d69abec6f5b4 h1:ErJhdIoGuh7iImHSCAWezJgkeD4UgA1q+xBjIauSH4s= +github.com/lestrrat-go/jwx v1.1.4-0.20210228091017-d69abec6f5b4/go.mod h1:Q+ncWBOZmzkVfTN0SWsKuvjkXeJau0BTTNTifDFvfr0= 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= diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 755224ec..c43e37c6 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -299,6 +299,7 @@ func TestBasicUserHandling(t *testing.T) { user.DownloadBandwidth = 64 user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now()) user.AdditionalInfo = "some free text" + user.Filters.TLSUsername = dataprovider.TLSUsernameCN originalUser := user user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") assert.NoError(t, err) @@ -486,7 +487,7 @@ func TestAddUserInvalidFilters(t *testing.T) { u.Filters.DeniedLoginMethods = []string{"invalid"} _, _, err = httpdtest.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) - u.Filters.DeniedLoginMethods = dataprovider.ValidSSHLoginMethods + u.Filters.DeniedLoginMethods = dataprovider.ValidLoginMethods _, _, err = httpdtest.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.Filters.DeniedLoginMethods = []string{} @@ -568,6 +569,10 @@ func TestAddUserInvalidFilters(t *testing.T) { u.Filters.DeniedProtocols = dataprovider.ValidProtocols _, _, err = httpdtest.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) + u.Filters.DeniedProtocols = nil + u.Filters.TLSUsername = "not a supported attribute" + _, _, err = httpdtest.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) } func TestAddUserInvalidFsConfig(t *testing.T) { @@ -963,6 +968,7 @@ func TestUpdateUser(t *testing.T) { u := getTestUser() u.UsedQuotaFiles = 1 u.UsedQuotaSize = 2 + u.Filters.TLSUsername = dataprovider.TLSUsernameCN user, _, err := httpdtest.AddUser(u, http.StatusCreated) assert.NoError(t, err) assert.Equal(t, 0, user.UsedQuotaFiles) @@ -979,6 +985,7 @@ func TestUpdateUser(t *testing.T) { user.Filters.DeniedIP = []string{"192.168.3.0/24", "192.168.4.0/24"} user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} user.Filters.DeniedProtocols = []string{common.ProtocolWebDAV} + user.Filters.TLSUsername = dataprovider.TLSUsernameNone user.Filters.FileExtensions = append(user.Filters.FileExtensions, dataprovider.ExtensionsFilter{ Path: "/subdir", AllowedExtensions: []string{".zip", ".rar"}, @@ -4672,6 +4679,16 @@ func TestWebUserAddMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) form.Set("max_upload_file_size", "1000") + // test invalid tls username + form.Set("tls_username", "username") + b, contentType, _ = getMultipartFormData(form, "", "") + req, _ = http.NewRequest(http.MethodPost, webUserPath, &b) + setJWTCookieForReq(req, webToken) + req.Header.Set("Content-Type", contentType) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Validation error: invalid TLS username") + form.Set("tls_username", string(dataprovider.TLSUsernameNone)) form.Set(csrfFormToken, "invalid form token") b, contentType, _ = getMultipartFormData(form, "", "") req, _ = http.NewRequest(http.MethodPost, webUserPath, &b) @@ -4767,6 +4784,7 @@ func TestWebUserAddMock(t *testing.T) { assert.True(t, utils.IsStringInSlice("*.rar", filter.DeniedPatterns)) } } + assert.Equal(t, dataprovider.TLSUsernameNone, newUser.Filters.TLSUsername) req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, newUser.Username), nil) setBearerForReq(req, apiToken) rr = executeRequest(req) @@ -4826,6 +4844,7 @@ func TestWebUserUpdateMock(t *testing.T) { form.Set("disconnect", "1") form.Set("additional_info", user.AdditionalInfo) form.Set("description", user.Description) + form.Set("tls_username", string(dataprovider.TLSUsernameCN)) b, contentType, _ := getMultipartFormData(form, "", "") req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b) setJWTCookieForReq(req, webToken) @@ -4888,6 +4907,7 @@ func TestWebUserUpdateMock(t *testing.T) { assert.Equal(t, user.AdditionalInfo, updateUser.AdditionalInfo) assert.Equal(t, user.Description, updateUser.Description) assert.Equal(t, int64(100), updateUser.Filters.MaxUploadFileSize) + assert.Equal(t, dataprovider.TLSUsernameCN, updateUser.Filters.TLSUsername) if val, ok := updateUser.Permissions["/otherdir"]; ok { assert.True(t, utils.IsStringInSlice(dataprovider.PermListItems, val)) diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 5293b300..20546f73 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: SFTPGo description: SFTPGo REST API - version: 2.4.5 + version: 2.0.3 servers: - url: /api/v2 @@ -1288,8 +1288,10 @@ components: - 'keyboard-interactive' - 'publickey+password' - 'publickey+keyboard-interactive' + - 'TLSCertificate' + - 'TLSCertificate+password' description: > - To enable multi-step authentication you have to allow only multi-step login methods. If password login method is denied or no password is set then FTP and WebDAV users cannot login + To enable multi-step authentication you have to allow only multi-step login methods SupportedProtocols: type: string enum: @@ -1371,7 +1373,13 @@ components: type: integer format: int64 description: maximum allowed size, as bytes, for a single file upload. The upload will be aborted if/when the size of the file being sent exceeds this limit. 0 means unlimited. This restriction does not apply for SSH system commands such as `git` and `rsync` - description: Additional restrictions + tls_username: + type: string + enum: + - None + - CommonName + description: defines the TLS certificate field to use as username. For FTP clients it must match the name provided using the "USER" command. Ignored if mutual TLS is disabled + description: Additional user restrictions Secret: type: object properties: diff --git a/httpd/web.go b/httpd/web.go index 9ba6c7b9..65877cd9 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -133,15 +133,15 @@ type statusPage struct { type userPage struct { basePage - User *dataprovider.User - RootPerms []string - Error string - ValidPerms []string - ValidSSHLoginMethods []string - ValidProtocols []string - RootDirPerms []string - RedactedSecret string - Mode userPageMode + User *dataprovider.User + RootPerms []string + Error string + ValidPerms []string + ValidLoginMethods []string + ValidProtocols []string + RootDirPerms []string + RedactedSecret string + Mode userPageMode } type adminPage struct { @@ -393,15 +393,15 @@ func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.U user.Password = redactedSecret } data := userPage{ - basePage: getBasePageData(title, currentURL, r), - Mode: mode, - Error: error, - User: user, - ValidPerms: dataprovider.ValidPerms, - ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods, - ValidProtocols: dataprovider.ValidProtocols, - RootDirPerms: user.GetPermissionsForPath("/"), - RedactedSecret: redactedSecret, + basePage: getBasePageData(title, currentURL, r), + Mode: mode, + Error: error, + User: user, + ValidPerms: dataprovider.ValidPerms, + ValidLoginMethods: dataprovider.ValidLoginMethods, + ValidProtocols: dataprovider.ValidProtocols, + RootDirPerms: user.GetPermissionsForPath("/"), + RedactedSecret: redactedSecret, } renderTemplate(w, templateUser, data) } @@ -655,6 +655,7 @@ func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters { filters.DeniedProtocols = r.Form["denied_protocols"] filters.FileExtensions = getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), r.Form.Get("denied_extensions")) filters.FilePatterns = getFilePatternsFromPostField(r.Form.Get("allowed_patterns"), r.Form.Get("denied_patterns")) + filters.TLSUsername = dataprovider.TLSUsername(r.Form.Get("tls_username")) return filters } diff --git a/httpdtest/httpdtest.go b/httpdtest/httpdtest.go index a90412e3..713f95a1 100644 --- a/httpdtest/httpdtest.go +++ b/httpdtest/httpdtest.go @@ -1148,22 +1148,7 @@ func checkEncryptedSecret(expected, actual *kms.Secret) error { return nil } -func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) error { - if len(expected.Filters.AllowedIP) != len(actual.Filters.AllowedIP) { - return errors.New("AllowedIP mismatch") - } - if len(expected.Filters.DeniedIP) != len(actual.Filters.DeniedIP) { - return errors.New("DeniedIP mismatch") - } - if len(expected.Filters.DeniedLoginMethods) != len(actual.Filters.DeniedLoginMethods) { - return errors.New("Denied login methods mismatch") - } - if len(expected.Filters.DeniedProtocols) != len(actual.Filters.DeniedProtocols) { - return errors.New("Denied protocols mismatch") - } - if expected.Filters.MaxUploadFileSize != actual.Filters.MaxUploadFileSize { - return errors.New("Max upload file size mismatch") - } +func compareUserFilterSubStructs(expected *dataprovider.User, actual *dataprovider.User) error { for _, IPMask := range expected.Filters.AllowedIP { if !utils.IsStringInSlice(IPMask, actual.Filters.AllowedIP) { return errors.New("AllowedIP contents mismatch") @@ -1184,6 +1169,31 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) return errors.New("Denied protocols contents mismatch") } } + return nil +} + +func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) error { + if len(expected.Filters.AllowedIP) != len(actual.Filters.AllowedIP) { + return errors.New("AllowedIP mismatch") + } + if len(expected.Filters.DeniedIP) != len(actual.Filters.DeniedIP) { + return errors.New("DeniedIP mismatch") + } + if len(expected.Filters.DeniedLoginMethods) != len(actual.Filters.DeniedLoginMethods) { + return errors.New("Denied login methods mismatch") + } + if len(expected.Filters.DeniedProtocols) != len(actual.Filters.DeniedProtocols) { + return errors.New("Denied protocols mismatch") + } + if expected.Filters.MaxUploadFileSize != actual.Filters.MaxUploadFileSize { + return errors.New("Max upload file size mismatch") + } + if expected.Filters.TLSUsername != actual.Filters.TLSUsername { + return errors.New("TLSUsername mismatch") + } + if err := compareUserFilterSubStructs(expected, actual); err != nil { + return err + } if err := compareUserFileExtensionsFilters(expected, actual); err != nil { return err } diff --git a/metrics/metrics.go b/metrics/metrics.go index 0c62b34f..64a3a753 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -13,10 +13,12 @@ import ( ) const ( - loginMethodPublicKey = "publickey" - loginMethodKeyboardInteractive = "keyboard-interactive" - loginMethodKeyAndPassword = "publickey+password" - loginMethodKeyAndKeyboardInt = "publickey+keyboard-interactive" + loginMethodPublicKey = "publickey" + loginMethodKeyboardInteractive = "keyboard-interactive" + loginMethodKeyAndPassword = "publickey+password" + loginMethodKeyAndKeyboardInt = "publickey+keyboard-interactive" + loginMethodTLSCertificate = "TLSCertificate" + loginMethodTLSCertificateAndPwd = "TLSCertificate+password" ) func init() { @@ -151,6 +153,48 @@ var ( Help: "The total number of failed logins using a public key", }) + // totalTLSCertLoginAttempts is the metric that reports the total number of login attempts + // using a TLS certificate + totalTLSCertLoginAttempts = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_tls_cert_login_attempts_total", + Help: "The total number of login attempts using a TLS certificate", + }) + + // totalTLSCertLoginOK is the metric that reports the total number of successful logins + // using a TLS certificate + totalTLSCertLoginOK = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_tls_cert_login_ok_total", + Help: "The total number of successful logins using a TLS certificate", + }) + + // totalTLSCertLoginFailed is the metric that reports the total number of failed logins + // using a TLS certificate + totalTLSCertLoginFailed = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_tls_cert_login_ko_total", + Help: "The total number of failed logins using a TLS certificate", + }) + + // totalTLSCertAndPwdLoginAttempts is the metric that reports the total number of login attempts + // using a TLS certificate+password + totalTLSCertAndPwdLoginAttempts = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_tls_cert_and_pwd_login_attempts_total", + Help: "The total number of login attempts using a TLS certificate+password", + }) + + // totalTLSCertLoginOK is the metric that reports the total number of successful logins + // using a TLS certificate+password + totalTLSCertAndPwdLoginOK = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_tls_cert_and_pwd_login_ok_total", + Help: "The total number of successful logins using a TLS certificate+password", + }) + + // totalTLSCertAndPwdLoginFailed is the metric that reports the total number of failed logins + // using a TLS certificate+password + totalTLSCertAndPwdLoginFailed = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_tls_cert_and_pwd_login_ko_total", + Help: "The total number of failed logins using a TLS certificate+password", + }) + // totalInteractiveLoginAttempts is the metric that reports the total number of login attempts // using keyboard interactive authentication totalInteractiveLoginAttempts = promauto.NewCounter(prometheus.CounterOpts{ @@ -777,6 +821,10 @@ func AddLoginAttempt(authMethod string) { totalKeyAndPasswordLoginAttempts.Inc() case loginMethodKeyAndKeyboardInt: totalKeyAndKeyIntLoginAttempts.Inc() + case loginMethodTLSCertificate: + totalTLSCertLoginAttempts.Inc() + case loginMethodTLSCertificateAndPwd: + totalTLSCertAndPwdLoginAttempts.Inc() default: totalPasswordLoginAttempts.Inc() } @@ -795,6 +843,10 @@ func AddLoginResult(authMethod string, err error) { totalKeyAndPasswordLoginOK.Inc() case loginMethodKeyAndKeyboardInt: totalKeyAndKeyIntLoginOK.Inc() + case loginMethodTLSCertificate: + totalTLSCertLoginOK.Inc() + case loginMethodTLSCertificateAndPwd: + totalTLSCertAndPwdLoginOK.Inc() default: totalPasswordLoginOK.Inc() } @@ -809,6 +861,10 @@ func AddLoginResult(authMethod string, err error) { totalKeyAndPasswordLoginFailed.Inc() case loginMethodKeyAndKeyboardInt: totalKeyAndKeyIntLoginFailed.Inc() + case loginMethodTLSCertificate: + totalTLSCertLoginFailed.Inc() + case loginMethodTLSCertificateAndPwd: + totalTLSCertAndPwdLoginFailed.Inc() default: totalPasswordLoginFailed.Inc() } diff --git a/pkgs/build.sh b/pkgs/build.sh index c966d0e6..71115015 100755 --- a/pkgs/build.sh +++ b/pkgs/build.sh @@ -1,6 +1,6 @@ #!/bin/bash -NFPM_VERSION=2.2.4 +NFPM_VERSION=2.2.5 NFPM_ARCH=${NFPM_ARCH:-amd64} if [ -z ${SFTPGO_VERSION} ] then diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 0f8796fe..158d767b 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -6181,7 +6181,7 @@ func TestFilterFileExtensions(t *testing.T) { func TestUserAllowedLoginMethods(t *testing.T) { user := getTestUser(true) - user.Filters.DeniedLoginMethods = dataprovider.ValidSSHLoginMethods + user.Filters.DeniedLoginMethods = dataprovider.ValidLoginMethods allowedMethods := user.GetAllowedLoginMethods() assert.Equal(t, 0, len(allowedMethods)) @@ -6191,7 +6191,7 @@ func TestUserAllowedLoginMethods(t *testing.T) { dataprovider.SSHLoginMethodKeyboardInteractive, } allowedMethods = user.GetAllowedLoginMethods() - assert.Equal(t, 2, len(allowedMethods)) + assert.Equal(t, 4, len(allowedMethods)) assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyAndKeyboardInt, allowedMethods)) assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyAndPassword, allowedMethods)) diff --git a/templates/user.html b/templates/user.html index cb042dfe..06e80443 100644 --- a/templates/user.html +++ b/templates/user.html @@ -111,6 +111,20 @@ {{end}} + +
+ +
+ + + Defines the TLS certificate field to use as username. Ignored if mutual TLS is disabled + +
+
+
@@ -127,7 +141,7 @@