Browse Source

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
Nicola Murino 4 years ago
parent
commit
a6e36e7cad

+ 1 - 1
config/config.go

@@ -427,7 +427,7 @@ func LoadConfig(configDir, configFile string) error {
 		logger.Warn(logSender, "", "Configuration error: %v", warn)
 		logger.Warn(logSender, "", "Configuration error: %v", warn)
 		logger.WarnToConsole("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)
 		warn := fmt.Sprintf("invalid external_auth_scope: %v reset to 0", globalConf.ProviderConf.ExternalAuthScope)
 		globalConf.ProviderConf.ExternalAuthScope = 0
 		globalConf.ProviderConf.ExternalAuthScope = 0
 		logger.Warn(logSender, "", "Configuration error: %v", warn)
 		logger.Warn(logSender, "", "Configuration error: %v", warn)

+ 3 - 3
config/config_test.go

@@ -133,7 +133,7 @@ func TestInvalidExternalAuthScope(t *testing.T) {
 	err := config.LoadConfig(configDir, "")
 	err := config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf := config.GetProviderConf()
 	providerConf := config.GetProviderConf()
-	providerConf.ExternalAuthScope = 10
+	providerConf.ExternalAuthScope = 100
 	c := make(map[string]dataprovider.Config)
 	c := make(map[string]dataprovider.Config)
 	c["data_provider"] = providerConf
 	c["data_provider"] = providerConf
 	jsonConf, err := json.Marshal(c)
 	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__APPLY_PROXY_CONFIG", "t")
 	os.Setenv("SFTPGO_FTPD__BINDINGS__9__TLS_MODE", "1")
 	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__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() {
 	t.Cleanup(func() {
 		os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__ADDRESS")
 		os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__ADDRESS")
@@ -508,7 +508,7 @@ func TestFTPDBindingsFromEnv(t *testing.T) {
 	require.True(t, bindings[1].ApplyProxyConfig)
 	require.True(t, bindings[1].ApplyProxyConfig)
 	require.Equal(t, 1, bindings[1].TLSMode)
 	require.Equal(t, 1, bindings[1].TLSMode)
 	require.Equal(t, "127.0.1.1", bindings[1].ForcePassiveIP)
 	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)
 	require.Nil(t, bindings[1].TLSCipherSuites)
 }
 }
 
 

+ 14 - 0
dataprovider/bolt.go

@@ -3,6 +3,7 @@
 package dataprovider
 package dataprovider
 
 
 import (
 import (
+	"crypto/x509"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
@@ -102,6 +103,19 @@ func (p *BoltProvider) checkAvailability() error {
 	return err
 	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) {
 func (p *BoltProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
 	var user User
 	var user User
 	if password == "" {
 	if password == "" {

+ 88 - 15
dataprovider/dataprovider.go

@@ -10,6 +10,7 @@ import (
 	"crypto/sha256"
 	"crypto/sha256"
 	"crypto/sha512"
 	"crypto/sha512"
 	"crypto/subtle"
 	"crypto/subtle"
+	"crypto/x509"
 	"encoding/base64"
 	"encoding/base64"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
@@ -93,9 +94,10 @@ var (
 	// ValidPerms defines all the valid permissions for a user
 	// ValidPerms defines all the valid permissions for a user
 	ValidPerms = []string{PermAny, PermListItems, PermDownload, PermUpload, PermOverwrite, PermRename, PermDelete,
 	ValidPerms = []string{PermAny, PermListItems, PermDownload, PermUpload, PermOverwrite, PermRename, PermDelete,
 		PermCreateDirs, PermCreateSymlinks, PermChmod, PermChown, PermChtimes}
 		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 defines the supported Multi-Step Authentications
 	SSHMultiStepsLoginMethods = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt}
 	SSHMultiStepsLoginMethods = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt}
 	// ErrNoAuthTryed defines the error for connection closed before authentication
 	// ErrNoAuthTryed defines the error for connection closed before authentication
@@ -106,6 +108,7 @@ var (
 	ErrNoInitRequired = errors.New("The data provider is up to date")
 	ErrNoInitRequired = errors.New("The data provider is up to date")
 	// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
 	// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
 	ErrInvalidCredentials = errors.New("invalid credentials")
 	ErrInvalidCredentials = errors.New("invalid credentials")
+	validTLSUsernames     = []string{string(TLSUsernameNone), string(TLSUsernameCN)}
 	webDAVUsersCache      sync.Map
 	webDAVUsersCache      sync.Map
 	config                Config
 	config                Config
 	provider              Provider
 	provider              Provider
@@ -214,10 +217,11 @@ type Config struct {
 	ExternalAuthHook string `json:"external_auth_hook" mapstructure:"external_auth_hook"`
 	ExternalAuthHook string `json:"external_auth_hook" mapstructure:"external_auth_hook"`
 	// ExternalAuthScope defines the scope for the external authentication hook.
 	// ExternalAuthScope defines the scope for the external authentication hook.
 	// - 0 means all supported authentication scopes, the external hook will be executed for password,
 	// - 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
 	// - 1 means passwords only
 	// - 2 means public keys only
 	// - 2 means public keys only
 	// - 4 means keyboard interactive 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
 	// you can combine the scopes, for example 3 means password and public key, 5 password and keyboard
 	// interactive and so on
 	// interactive and so on
 	ExternalAuthScope int `json:"external_auth_scope" mapstructure:"external_auth_scope"`
 	ExternalAuthScope int `json:"external_auth_scope" mapstructure:"external_auth_scope"`
@@ -369,6 +373,7 @@ func GetQuotaTracking() int {
 type Provider interface {
 type Provider interface {
 	validateUserAndPass(username, password, ip, protocol string) (User, error)
 	validateUserAndPass(username, password, ip, protocol string) (User, error)
 	validateUserAndPubKey(username string, pubKey []byte) (User, string, 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
 	updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error
 	getUsedQuota(username string) (int, int64, error)
 	getUsedQuota(username string) (int, int64, error)
 	userExists(username string) (User, error)
 	userExists(username string) (User, error)
@@ -565,10 +570,41 @@ func CheckAdminAndPass(username, password, ip string) (Admin, error) {
 	return provider.validateAdminAndPass(username, password, ip)
 	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) {
 func CheckUserAndPass(username, password, ip, protocol string) (User, error) {
 	if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
 	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 {
 		if err != nil {
 			return user, err
 			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
 // 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) {
 func CheckUserAndPubKey(username string, pubKey []byte, ip, protocol string) (User, string, error) {
 	if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&2 != 0) {
 	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 {
 		if err != nil {
 			return user, "", err
 			return user, "", err
 		}
 		}
@@ -609,7 +645,7 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
 	var user User
 	var user User
 	var err error
 	var err error
 	if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) {
 	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 != "" {
 	} else if config.PreLoginHook != "" {
 		user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol)
 		user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol)
 	} else {
 	} else {
@@ -1130,7 +1166,7 @@ func validateFileFilters(user *User) error {
 	return validateFiltersPatternExtensions(user)
 	return validateFiltersPatternExtensions(user)
 }
 }
 
 
-func validateFilters(user *User) error {
+func checkEmptyFiltersStruct(user *User) {
 	if len(user.Filters.AllowedIP) == 0 {
 	if len(user.Filters.AllowedIP) == 0 {
 		user.Filters.AllowedIP = []string{}
 		user.Filters.AllowedIP = []string{}
 	}
 	}
@@ -1143,6 +1179,10 @@ func validateFilters(user *User) error {
 	if len(user.Filters.DeniedProtocols) == 0 {
 	if len(user.Filters.DeniedProtocols) == 0 {
 		user.Filters.DeniedProtocols = []string{}
 		user.Filters.DeniedProtocols = []string{}
 	}
 	}
+}
+
+func validateFilters(user *User) error {
+	checkEmptyFiltersStruct(user)
 	for _, IPMask := range user.Filters.DeniedIP {
 	for _, IPMask := range user.Filters.DeniedIP {
 		_, _, err := net.ParseCIDR(IPMask)
 		_, _, err := net.ParseCIDR(IPMask)
 		if err != nil {
 		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)}
 			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"}
 		return &ValidationError{err: "invalid denied_login_methods"}
 	}
 	}
 	for _, loginMethod := range user.Filters.DeniedLoginMethods {
 	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)}
 			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)}
 			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)
 	return validateFileFilters(user)
 }
 }
 
 
@@ -1407,6 +1452,25 @@ func isPasswordOK(user *User, password string) (bool, error) {
 	return match, err
 	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) {
 func checkUserAndPass(user *User, password, ip, protocol string) (User, error) {
 	err := checkLoginConditions(user)
 	err := checkLoginConditions(user)
 	if err != nil {
 	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") {
 	if strings.HasPrefix(config.ExternalAuthHook, "http") {
 		var url *url.URL
 		var url *url.URL
 		var result []byte
 		var result []byte
@@ -2060,6 +2124,7 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip,
 		authRequest["public_key"] = pkey
 		authRequest["public_key"] = pkey
 		authRequest["protocol"] = protocol
 		authRequest["protocol"] = protocol
 		authRequest["keyboard_interactive"] = keyboardInteractive
 		authRequest["keyboard_interactive"] = keyboardInteractive
+		authRequest["tls_cert"] = tlsCert
 		authRequestAsJSON, err := json.Marshal(authRequest)
 		authRequestAsJSON, err := json.Marshal(authRequest)
 		if err != nil {
 		if err != nil {
 			providerLog(logger.LevelWarn, "error serializing external auth request: %v", err)
 			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_PASSWORD=%v", password),
 		fmt.Sprintf("SFTPGO_AUTHD_PUBLIC_KEY=%v", pkey),
 		fmt.Sprintf("SFTPGO_AUTHD_PUBLIC_KEY=%v", pkey),
 		fmt.Sprintf("SFTPGO_AUTHD_PROTOCOL=%v", protocol),
 		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))
 		fmt.Sprintf("SFTPGO_AUTHD_KEYBOARD_INTERACTIVE=%v", keyboardInteractive))
 	return cmd.Output()
 	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
 	var user User
-	pkey := ""
+	var pkey, cert string
 	if len(pubKey) > 0 {
 	if len(pubKey) > 0 {
 		k, err := ssh.ParsePublicKey(pubKey)
 		k, err := ssh.ParsePublicKey(pubKey)
 		if err != nil {
 		if err != nil {
@@ -2099,7 +2165,14 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
 		}
 		}
 		pkey = string(ssh.MarshalAuthorizedKey(k))
 		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 {
 	if err != nil {
 		return user, fmt.Errorf("External auth error: %v", err)
 		return user, fmt.Errorf("External auth error: %v", err)
 	}
 	}

+ 14 - 0
dataprovider/memory.go

@@ -1,6 +1,7 @@
 package dataprovider
 package dataprovider
 
 
 import (
 import (
+	"crypto/x509"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"os"
 	"os"
@@ -88,6 +89,19 @@ func (p *MemoryProvider) close() error {
 	return nil
 	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) {
 func (p *MemoryProvider) validateUserAndPass(username, password, ip, protocol string) (User, error) {
 	var user User
 	var user User
 	if password == "" {
 	if password == "" {

+ 5 - 0
dataprovider/mysql.go

@@ -4,6 +4,7 @@ package dataprovider
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/x509"
 	"database/sql"
 	"database/sql"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
@@ -102,6 +103,10 @@ func (p *MySQLProvider) validateUserAndPass(username, password, ip, protocol str
 	return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
 	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) {
 func (p *MySQLProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {
 	return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
 	return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
 }
 }

+ 5 - 0
dataprovider/pgsql.go

@@ -4,6 +4,7 @@ package dataprovider
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/x509"
 	"database/sql"
 	"database/sql"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
@@ -110,6 +111,10 @@ func (p *PGSQLProvider) validateUserAndPass(username, password, ip, protocol str
 	return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
 	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) {
 func (p *PGSQLProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {
 	return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
 	return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
 }
 }

+ 14 - 0
dataprovider/sqlcommon.go

@@ -2,6 +2,7 @@ package dataprovider
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/x509"
 	"database/sql"
 	"database/sql"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
@@ -226,6 +227,19 @@ func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHan
 	return checkUserAndPass(&user, password, ip, protocol)
 	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) {
 func sqlCommonValidateUserAndPubKey(username string, pubKey []byte, dbHandle *sql.DB) (User, string, error) {
 	var user User
 	var user User
 	if len(pubKey) == 0 {
 	if len(pubKey) == 0 {

+ 5 - 0
dataprovider/sqlite.go

@@ -4,6 +4,7 @@ package dataprovider
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/x509"
 	"database/sql"
 	"database/sql"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
@@ -123,6 +124,10 @@ func (p *SQLiteProvider) validateUserAndPass(username, password, ip, protocol st
 	return sqlCommonValidateUserAndPass(username, password, ip, protocol, p.dbHandle)
 	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) {
 func (p *SQLiteProvider) validateUserAndPubKey(username string, publicKey []byte) (User, string, error) {
 	return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
 	return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
 }
 }

+ 29 - 1
dataprovider/user.go

@@ -57,6 +57,17 @@ const (
 	SSHLoginMethodKeyboardInteractive = "keyboard-interactive"
 	SSHLoginMethodKeyboardInteractive = "keyboard-interactive"
 	SSHLoginMethodKeyAndPassword      = "publickey+password"
 	SSHLoginMethodKeyAndPassword      = "publickey+password"
 	SSHLoginMethodKeyAndKeyboardInt   = "publickey+keyboard-interactive"
 	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 (
 var (
@@ -144,6 +155,10 @@ type UserFilters struct {
 	FilePatterns []PatternsFilter `json:"file_patterns,omitempty"`
 	FilePatterns []PatternsFilter `json:"file_patterns,omitempty"`
 	// max size allowed for a single upload, 0 means unlimited
 	// max size allowed for a single upload, 0 means unlimited
 	MaxUploadFileSize int64 `json:"max_upload_file_size,omitempty"`
 	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
 // FilesystemProvider defines the supported storages
@@ -268,6 +283,15 @@ func (u *User) IsPasswordHashed() bool {
 	return utils.IsStringPrefixInSlice(u.Password, hashPwdPrefixes)
 	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
 // SetEmptySecrets sets to empty any user secret
 func (u *User) SetEmptySecrets() {
 func (u *User) SetEmptySecrets() {
 	u.FsConfig.S3Config.AccessSecret = kms.NewEmptySecret()
 	u.FsConfig.S3Config.AccessSecret = kms.NewEmptySecret()
@@ -531,6 +555,9 @@ func (u *User) IsPartialAuth(loginMethod string) bool {
 		return false
 		return false
 	}
 	}
 	for _, method := range u.GetAllowedLoginMethods() {
 	for _, method := range u.GetAllowedLoginMethods() {
+		if method == LoginMethodTLSCertificate || method == LoginMethodTLSCertificateAndPwd {
+			continue
+		}
 		if !utils.IsStringInSlice(method, SSHMultiStepsLoginMethods) {
 		if !utils.IsStringInSlice(method, SSHMultiStepsLoginMethods) {
 			return false
 			return false
 		}
 		}
@@ -541,7 +568,7 @@ func (u *User) IsPartialAuth(loginMethod string) bool {
 // GetAllowedLoginMethods returns the allowed login methods
 // GetAllowedLoginMethods returns the allowed login methods
 func (u *User) GetAllowedLoginMethods() []string {
 func (u *User) GetAllowedLoginMethods() []string {
 	var allowedMethods []string
 	var allowedMethods []string
-	for _, method := range ValidSSHLoginMethods {
+	for _, method := range ValidLoginMethods {
 		if !utils.IsStringInSlice(method, u.Filters.DeniedLoginMethods) {
 		if !utils.IsStringInSlice(method, u.Filters.DeniedLoginMethods) {
 			allowedMethods = append(allowedMethods, method)
 			allowedMethods = append(allowedMethods, method)
 		}
 		}
@@ -857,6 +884,7 @@ func (u *User) getACopy() User {
 	}
 	}
 	filters := UserFilters{}
 	filters := UserFilters{}
 	filters.MaxUploadFileSize = u.Filters.MaxUploadFileSize
 	filters.MaxUploadFileSize = u.Filters.MaxUploadFileSize
+	filters.TLSUsername = u.Filters.TLSUsername
 	filters.AllowedIP = make([]string, len(u.Filters.AllowedIP))
 	filters.AllowedIP = make([]string, len(u.Filters.AllowedIP))
 	copy(filters.AllowedIP, u.Filters.AllowedIP)
 	copy(filters.AllowedIP, u.Filters.AllowedIP)
 	filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))
 	filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))

+ 4 - 1
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_PASSWORD`, not empty for password authentication
 - `SFTPGO_AUTHD_PUBLIC_KEY`, not empty for public key authentication
 - `SFTPGO_AUTHD_PUBLIC_KEY`, not empty for public key authentication
 - `SFTPGO_AUTHD_KEYBOARD_INTERACTIVE`, not empty for keyboard interactive 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.
 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.
 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
 - `password`, not empty for password authentication
 - `public_key`, not empty for public key authentication
 - `public_key`, not empty for public key authentication
 - `keyboard_interactive`, not empty for keyboard interactive 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.
 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.
 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:
 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
 - `1` means passwords only
 - `2` means public keys only
 - `2` means public keys only
 - `4` means keyboard interactive 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.
 You can combine the scopes. For example, 3 means password and public key, 5 means password and keyboard interactive, and so on.
 
 

+ 2 - 2
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`.
     - `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.
     - `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: "".
     - `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.
     - `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_port`, integer. Deprecated, please use `bindings`
   - `bind_address`, string. 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.
     - `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_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_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
   - `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`.
   - `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`.
   - `pre_login_program`, string. Deprecated, please use `pre_login_hook`.

+ 3 - 3
ftpd/cryptfs_test.go

@@ -22,7 +22,7 @@ func TestBasicFTPHandlingCryptFs(t *testing.T) {
 	u.QuotaSize = 6553600
 	u.QuotaSize = 6553600
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	client, err := getFTPClient(user, true)
+	client, err := getFTPClient(user, true, nil)
 	if assert.NoError(t, err) {
 	if assert.NoError(t, err) {
 		assert.Len(t, common.Connections.GetStats(), 1)
 		assert.Len(t, common.Connections.GetStats(), 1)
 		testFilePath := filepath.Join(homeBasePath, testFileName)
 		testFilePath := filepath.Join(homeBasePath, testFileName)
@@ -118,7 +118,7 @@ func TestZeroBytesTransfersCryptFs(t *testing.T) {
 	u := getTestUserWithCryptFs()
 	u := getTestUserWithCryptFs()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	client, err := getFTPClient(user, true)
+	client, err := getFTPClient(user, true, nil)
 	if assert.NoError(t, err) {
 	if assert.NoError(t, err) {
 		testFileName := "testfilename"
 		testFileName := "testfilename"
 		err = checkBasicFTP(client)
 		err = checkBasicFTP(client)
@@ -155,7 +155,7 @@ func TestResumeCryptFs(t *testing.T) {
 	u := getTestUserWithCryptFs()
 	u := getTestUserWithCryptFs()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	client, err := getFTPClient(user, true)
+	client, err := getFTPClient(user, true, nil)
 	if assert.NoError(t, err) {
 	if assert.NoError(t, err) {
 		testFilePath := filepath.Join(homeBasePath, testFileName)
 		testFilePath := filepath.Join(homeBasePath, testFileName)
 		data := []byte("test data")
 		data := []byte("test data")

+ 15 - 1
ftpd/ftpd.go

@@ -34,7 +34,9 @@ type Binding struct {
 	TLSMode int `json:"tls_mode" mapstructure:"tls_mode"`
 	TLSMode int `json:"tls_mode" mapstructure:"tls_mode"`
 	// External IP address to expose for passive connections.
 	// External IP address to expose for passive connections.
 	ForcePassiveIP string `json:"force_passive_ip" mapstructure:"force_passive_ip"`
 	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
 	// You need to define at least a certificate authority for this to work
 	ClientAuthType int `json:"client_auth_type" mapstructure:"client_auth_type"`
 	ClientAuthType int `json:"client_auth_type" mapstructure:"client_auth_type"`
 	// TLSCipherSuites is a list of supported cipher suites for TLS version 1.2.
 	// 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.
 	// any invalid name will be silently ignored.
 	// The order matters, the ciphers listed first will be the preferred ones.
 	// The order matters, the ciphers listed first will be the preferred ones.
 	TLSCipherSuites []string `json:"tls_cipher_suites" mapstructure:"tls_cipher_suites"`
 	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
 // GetAddress returns the binding address

File diff suppressed because it is too large
+ 526 - 67
ftpd/ftpd_test.go


+ 16 - 3
ftpd/internal_test.go

@@ -13,6 +13,7 @@ import (
 
 
 	"github.com/eikenb/pipeat"
 	"github.com/eikenb/pipeat"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/dataprovider"
@@ -457,7 +458,7 @@ func TestUserInvalidParams(t *testing.T) {
 		},
 		},
 	}
 	}
 	server := NewServer(c, configDir, binding, 3)
 	server := NewServer(c, configDir, binding, 3)
-	_, err := server.validateUser(u, mockFTPClientContext{})
+	_, err := server.validateUser(u, mockFTPClientContext{}, dataprovider.LoginMethodPassword)
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
 	u.Username = "a"
 	u.Username = "a"
@@ -479,10 +480,10 @@ func TestUserInvalidParams(t *testing.T) {
 		},
 		},
 		VirtualPath: vdirPath2,
 		VirtualPath: vdirPath2,
 	})
 	})
-	_, err = server.validateUser(u, mockFTPClientContext{})
+	_, err = server.validateUser(u, mockFTPClientContext{}, dataprovider.LoginMethodPassword)
 	assert.Error(t, err)
 	assert.Error(t, err)
 	u.VirtualFolders = nil
 	u.VirtualFolders = nil
-	_, err = server.validateUser(u, mockFTPClientContext{})
+	_, err = server.validateUser(u, mockFTPClientContext{}, dataprovider.LoginMethodPassword)
 	assert.Error(t, err)
 	assert.Error(t, err)
 }
 }
 
 
@@ -817,3 +818,15 @@ func TestVerifyTLSConnection(t *testing.T) {
 
 
 	certMgr = oldCertMgr
 	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)
+}

+ 112 - 27
ftpd/server.go

@@ -8,6 +8,7 @@ import (
 	"net"
 	"net"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
+	"sync"
 
 
 	ftpserver "github.com/fclairamb/ftpserverlib"
 	ftpserver "github.com/fclairamb/ftpserverlib"
 
 
@@ -21,21 +22,25 @@ import (
 
 
 // Server implements the ftpserverlib MainDriver interface
 // Server implements the ftpserverlib MainDriver interface
 type Server struct {
 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
 // NewServer returns a new FTP server driver
 func NewServer(config *Configuration, configDir string, binding Binding, id int) *Server {
 func NewServer(config *Configuration, configDir string, binding Binding, id int) *Server {
+	binding.setCiphers()
 	server := &Server{
 	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 != "" {
 	if config.BannerFile != "" {
 		bannerFilePath := config.BannerFile
 		bannerFilePath := config.BannerFile
@@ -53,6 +58,27 @@ func NewServer(config *Configuration, configDir string, binding Binding, id int)
 	return server
 	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
 // GetSettings returns FTP server settings
 func (s *Server) GetSettings() (*ftpserver.Settings, error) {
 func (s *Server) GetSettings() (*ftpserver.Settings, error) {
 	var portRange *ftpserver.PortRange
 	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
 // ClientDisconnected is called when the user disconnects, even if he never authenticated
 func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) {
 func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) {
+	s.cleanTLSConnVerification(cc.ID())
 	connID := fmt.Sprintf("%v_%v_%v", common.ProtocolFTP, s.ID, cc.ID())
 	connID := fmt.Sprintf("%v_%v_%v", common.ProtocolFTP, s.ID, cc.ID())
 	common.Connections.Remove(connID)
 	common.Connections.Remove(connID)
 }
 }
 
 
 // AuthUser authenticates the user and selects an handling driver
 // AuthUser authenticates the user and selects an handling driver
 func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) {
 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())
 	ipAddr := utils.GetIPFromRemoteAddress(cc.RemoteAddr().String())
 	user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolFTP)
 	user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolFTP)
 	if err != nil {
 	if err != nil {
 		user.Username = username
 		user.Username = username
-		updateLoginMetrics(&user, ipAddr, err)
+		updateLoginMetrics(&user, ipAddr, loginMethod, err)
 		return nil, 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 {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -156,18 +187,69 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string)
 	return connection, nil
 	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
 // GetTLSConfig returns a TLS Certificate to use
 func (s *Server) GetTLSConfig() (*tls.Config, error) {
 func (s *Server) GetTLSConfig() (*tls.Config, error) {
 	if certMgr != nil {
 	if certMgr != nil {
 		tlsConfig := &tls.Config{
 		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.ClientCAs = certMgr.GetRootCAs()
-			tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
 			tlsConfig.VerifyConnection = s.verifyTLSConnection
 			tlsConfig.VerifyConnection = s.verifyTLSConnection
+			switch s.binding.ClientAuthType {
+			case 1:
+				tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
+			case 2:
+				tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven
+			}
 		}
 		}
 		return tlsConfig, nil
 		return tlsConfig, nil
 	}
 	}
@@ -183,6 +265,9 @@ func (s *Server) verifyTLSConnection(state tls.ConnectionState) error {
 			clientCrtName = clientCrt.Subject.String()
 			clientCrtName = clientCrt.Subject.String()
 		}
 		}
 		if len(state.VerifiedChains) == 0 {
 		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")
 			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")
 			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
 	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())
 	connectionID := fmt.Sprintf("%v_%v_%v", common.ProtocolFTP, s.ID, cc.ID())
 	if !filepath.IsAbs(user.HomeDir) {
 	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",
 		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)
 		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)
 		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 {
 	if user.MaxSessions > 0 {
 		activeSessions := common.Connections.GetActiveSessions(user.Username)
 		activeSessions := common.Connections.GetActiveSessions(user.Username)
@@ -249,10 +334,10 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
 	return connection, nil
 	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 {
 	if err != nil {
-		logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword,
+		logger.ConnectionFailedLog(user.Username, ip, loginMethod,
 			common.ProtocolFTP, err.Error())
 			common.ProtocolFTP, err.Error())
 		event := common.HostEventLoginFailed
 		event := common.HostEventLoginFailed
 		if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
 		if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
@@ -260,6 +345,6 @@ func updateLoginMetrics(user *dataprovider.User, ip string, err error) {
 		}
 		}
 		common.AddDefenderEvent(ip, event)
 		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)
 }
 }

+ 1 - 1
go.mod

@@ -26,7 +26,7 @@ require (
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-retryablehttp v0.6.8
 	github.com/hashicorp/go-retryablehttp v0.6.8
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	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/lib/pq v1.9.0
 	github.com/magiconair/properties v1.8.4 // indirect
 	github.com/magiconair/properties v1.8.4 // indirect
 	github.com/mattn/go-sqlite3 v1.14.6
 	github.com/mattn/go-sqlite3 v1.14.6

+ 2 - 2
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 h1:QD+hHQPDSHC4rCJkZYY/yXChYr/vjfBopKekTc+7l4Q=
 github.com/lestrrat-go/iter v1.0.0/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 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.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 v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
 github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=

+ 21 - 1
httpd/httpd_test.go

@@ -299,6 +299,7 @@ func TestBasicUserHandling(t *testing.T) {
 	user.DownloadBandwidth = 64
 	user.DownloadBandwidth = 64
 	user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now())
 	user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now())
 	user.AdditionalInfo = "some free text"
 	user.AdditionalInfo = "some free text"
+	user.Filters.TLSUsername = dataprovider.TLSUsernameCN
 	originalUser := user
 	originalUser := user
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -486,7 +487,7 @@ func TestAddUserInvalidFilters(t *testing.T) {
 	u.Filters.DeniedLoginMethods = []string{"invalid"}
 	u.Filters.DeniedLoginMethods = []string{"invalid"}
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	u.Filters.DeniedLoginMethods = dataprovider.ValidSSHLoginMethods
+	u.Filters.DeniedLoginMethods = dataprovider.ValidLoginMethods
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	u.Filters.DeniedLoginMethods = []string{}
 	u.Filters.DeniedLoginMethods = []string{}
@@ -568,6 +569,10 @@ func TestAddUserInvalidFilters(t *testing.T) {
 	u.Filters.DeniedProtocols = dataprovider.ValidProtocols
 	u.Filters.DeniedProtocols = dataprovider.ValidProtocols
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
 	assert.NoError(t, err)
 	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) {
 func TestAddUserInvalidFsConfig(t *testing.T) {
@@ -963,6 +968,7 @@ func TestUpdateUser(t *testing.T) {
 	u := getTestUser()
 	u := getTestUser()
 	u.UsedQuotaFiles = 1
 	u.UsedQuotaFiles = 1
 	u.UsedQuotaSize = 2
 	u.UsedQuotaSize = 2
+	u.Filters.TLSUsername = dataprovider.TLSUsernameCN
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, 0, user.UsedQuotaFiles)
 	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.DeniedIP = []string{"192.168.3.0/24", "192.168.4.0/24"}
 	user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
 	user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
 	user.Filters.DeniedProtocols = []string{common.ProtocolWebDAV}
 	user.Filters.DeniedProtocols = []string{common.ProtocolWebDAV}
+	user.Filters.TLSUsername = dataprovider.TLSUsernameNone
 	user.Filters.FileExtensions = append(user.Filters.FileExtensions, dataprovider.ExtensionsFilter{
 	user.Filters.FileExtensions = append(user.Filters.FileExtensions, dataprovider.ExtensionsFilter{
 		Path:              "/subdir",
 		Path:              "/subdir",
 		AllowedExtensions: []string{".zip", ".rar"},
 		AllowedExtensions: []string{".zip", ".rar"},
@@ -4672,6 +4679,16 @@ func TestWebUserAddMock(t *testing.T) {
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
 	form.Set("max_upload_file_size", "1000")
 	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")
 	form.Set(csrfFormToken, "invalid form token")
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	b, contentType, _ = getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
 	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.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)
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, newUser.Username), nil)
 	setBearerForReq(req, apiToken)
 	setBearerForReq(req, apiToken)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
@@ -4826,6 +4844,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	form.Set("disconnect", "1")
 	form.Set("disconnect", "1")
 	form.Set("additional_info", user.AdditionalInfo)
 	form.Set("additional_info", user.AdditionalInfo)
 	form.Set("description", user.Description)
 	form.Set("description", user.Description)
+	form.Set("tls_username", string(dataprovider.TLSUsernameCN))
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	b, contentType, _ := getMultipartFormData(form, "", "")
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
 	req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
 	setJWTCookieForReq(req, webToken)
 	setJWTCookieForReq(req, webToken)
@@ -4888,6 +4907,7 @@ func TestWebUserUpdateMock(t *testing.T) {
 	assert.Equal(t, user.AdditionalInfo, updateUser.AdditionalInfo)
 	assert.Equal(t, user.AdditionalInfo, updateUser.AdditionalInfo)
 	assert.Equal(t, user.Description, updateUser.Description)
 	assert.Equal(t, user.Description, updateUser.Description)
 	assert.Equal(t, int64(100), updateUser.Filters.MaxUploadFileSize)
 	assert.Equal(t, int64(100), updateUser.Filters.MaxUploadFileSize)
+	assert.Equal(t, dataprovider.TLSUsernameCN, updateUser.Filters.TLSUsername)
 
 
 	if val, ok := updateUser.Permissions["/otherdir"]; ok {
 	if val, ok := updateUser.Permissions["/otherdir"]; ok {
 		assert.True(t, utils.IsStringInSlice(dataprovider.PermListItems, val))
 		assert.True(t, utils.IsStringInSlice(dataprovider.PermListItems, val))

+ 11 - 3
httpd/schema/openapi.yaml

@@ -2,7 +2,7 @@ openapi: 3.0.3
 info:
 info:
   title: SFTPGo
   title: SFTPGo
   description: SFTPGo REST API
   description: SFTPGo REST API
-  version: 2.4.5
+  version: 2.0.3
 
 
 servers:
 servers:
   - url: /api/v2
   - url: /api/v2
@@ -1288,8 +1288,10 @@ components:
         - 'keyboard-interactive'
         - 'keyboard-interactive'
         - 'publickey+password'
         - 'publickey+password'
         - 'publickey+keyboard-interactive'
         - 'publickey+keyboard-interactive'
+        - 'TLSCertificate'
+        - 'TLSCertificate+password'
       description: >
       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:
     SupportedProtocols:
       type: string
       type: string
       enum:
       enum:
@@ -1371,7 +1373,13 @@ components:
           type: integer
           type: integer
           format: int64
           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: 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:
     Secret:
       type: object
       type: object
       properties:
       properties:

+ 19 - 18
httpd/web.go

@@ -133,15 +133,15 @@ type statusPage struct {
 
 
 type userPage struct {
 type userPage struct {
 	basePage
 	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 {
 type adminPage struct {
@@ -393,15 +393,15 @@ func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.U
 		user.Password = redactedSecret
 		user.Password = redactedSecret
 	}
 	}
 	data := userPage{
 	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)
 	renderTemplate(w, templateUser, data)
 }
 }
@@ -655,6 +655,7 @@ func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters {
 	filters.DeniedProtocols = r.Form["denied_protocols"]
 	filters.DeniedProtocols = r.Form["denied_protocols"]
 	filters.FileExtensions = getFileExtensionsFromPostField(r.Form.Get("allowed_extensions"), r.Form.Get("denied_extensions"))
 	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.FilePatterns = getFilePatternsFromPostField(r.Form.Get("allowed_patterns"), r.Form.Get("denied_patterns"))
+	filters.TLSUsername = dataprovider.TLSUsername(r.Form.Get("tls_username"))
 	return filters
 	return filters
 }
 }
 
 

+ 26 - 16
httpdtest/httpdtest.go

@@ -1148,22 +1148,7 @@ func checkEncryptedSecret(expected, actual *kms.Secret) error {
 	return nil
 	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 {
 	for _, IPMask := range expected.Filters.AllowedIP {
 		if !utils.IsStringInSlice(IPMask, actual.Filters.AllowedIP) {
 		if !utils.IsStringInSlice(IPMask, actual.Filters.AllowedIP) {
 			return errors.New("AllowedIP contents mismatch")
 			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 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 {
 	if err := compareUserFileExtensionsFilters(expected, actual); err != nil {
 		return err
 		return err
 	}
 	}

+ 60 - 4
metrics/metrics.go

@@ -13,10 +13,12 @@ import (
 )
 )
 
 
 const (
 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() {
 func init() {
@@ -151,6 +153,48 @@ var (
 		Help: "The total number of failed logins using a public key",
 		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
 	// totalInteractiveLoginAttempts is the metric that reports the total number of login attempts
 	// using keyboard interactive authentication
 	// using keyboard interactive authentication
 	totalInteractiveLoginAttempts = promauto.NewCounter(prometheus.CounterOpts{
 	totalInteractiveLoginAttempts = promauto.NewCounter(prometheus.CounterOpts{
@@ -777,6 +821,10 @@ func AddLoginAttempt(authMethod string) {
 		totalKeyAndPasswordLoginAttempts.Inc()
 		totalKeyAndPasswordLoginAttempts.Inc()
 	case loginMethodKeyAndKeyboardInt:
 	case loginMethodKeyAndKeyboardInt:
 		totalKeyAndKeyIntLoginAttempts.Inc()
 		totalKeyAndKeyIntLoginAttempts.Inc()
+	case loginMethodTLSCertificate:
+		totalTLSCertLoginAttempts.Inc()
+	case loginMethodTLSCertificateAndPwd:
+		totalTLSCertAndPwdLoginAttempts.Inc()
 	default:
 	default:
 		totalPasswordLoginAttempts.Inc()
 		totalPasswordLoginAttempts.Inc()
 	}
 	}
@@ -795,6 +843,10 @@ func AddLoginResult(authMethod string, err error) {
 			totalKeyAndPasswordLoginOK.Inc()
 			totalKeyAndPasswordLoginOK.Inc()
 		case loginMethodKeyAndKeyboardInt:
 		case loginMethodKeyAndKeyboardInt:
 			totalKeyAndKeyIntLoginOK.Inc()
 			totalKeyAndKeyIntLoginOK.Inc()
+		case loginMethodTLSCertificate:
+			totalTLSCertLoginOK.Inc()
+		case loginMethodTLSCertificateAndPwd:
+			totalTLSCertAndPwdLoginOK.Inc()
 		default:
 		default:
 			totalPasswordLoginOK.Inc()
 			totalPasswordLoginOK.Inc()
 		}
 		}
@@ -809,6 +861,10 @@ func AddLoginResult(authMethod string, err error) {
 			totalKeyAndPasswordLoginFailed.Inc()
 			totalKeyAndPasswordLoginFailed.Inc()
 		case loginMethodKeyAndKeyboardInt:
 		case loginMethodKeyAndKeyboardInt:
 			totalKeyAndKeyIntLoginFailed.Inc()
 			totalKeyAndKeyIntLoginFailed.Inc()
+		case loginMethodTLSCertificate:
+			totalTLSCertLoginFailed.Inc()
+		case loginMethodTLSCertificateAndPwd:
+			totalTLSCertAndPwdLoginFailed.Inc()
 		default:
 		default:
 			totalPasswordLoginFailed.Inc()
 			totalPasswordLoginFailed.Inc()
 		}
 		}

+ 1 - 1
pkgs/build.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 #!/bin/bash
 
 
-NFPM_VERSION=2.2.4
+NFPM_VERSION=2.2.5
 NFPM_ARCH=${NFPM_ARCH:-amd64}
 NFPM_ARCH=${NFPM_ARCH:-amd64}
 if [ -z ${SFTPGO_VERSION} ]
 if [ -z ${SFTPGO_VERSION} ]
 then
 then

+ 2 - 2
sftpd/sftpd_test.go

@@ -6181,7 +6181,7 @@ func TestFilterFileExtensions(t *testing.T) {
 
 
 func TestUserAllowedLoginMethods(t *testing.T) {
 func TestUserAllowedLoginMethods(t *testing.T) {
 	user := getTestUser(true)
 	user := getTestUser(true)
-	user.Filters.DeniedLoginMethods = dataprovider.ValidSSHLoginMethods
+	user.Filters.DeniedLoginMethods = dataprovider.ValidLoginMethods
 	allowedMethods := user.GetAllowedLoginMethods()
 	allowedMethods := user.GetAllowedLoginMethods()
 	assert.Equal(t, 0, len(allowedMethods))
 	assert.Equal(t, 0, len(allowedMethods))
 
 
@@ -6191,7 +6191,7 @@ func TestUserAllowedLoginMethods(t *testing.T) {
 		dataprovider.SSHLoginMethodKeyboardInteractive,
 		dataprovider.SSHLoginMethodKeyboardInteractive,
 	}
 	}
 	allowedMethods = user.GetAllowedLoginMethods()
 	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.SSHLoginMethodKeyAndKeyboardInt, allowedMethods))
 	assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyAndPassword, allowedMethods))
 	assert.True(t, utils.IsStringInSlice(dataprovider.SSHLoginMethodKeyAndPassword, allowedMethods))

+ 15 - 1
templates/user.html

@@ -111,6 +111,20 @@
                 </div>
                 </div>
             </div>
             </div>
             {{end}}
             {{end}}
+
+            <div class="form-group row">
+                <label for="idTLSUsername" class="col-sm-2 col-form-label">TLS username</label>
+                <div class="col-sm-10">
+                    <select class="form-control" id="idTLSUsername" name="tls_username" aria-describedby="tlsUsernameHelpBlock">
+                        <option value="None" {{if eq .User.Filters.TLSUsername "None" }}selected{{end}}>None</option>
+                        <option value="CommonName" {{if eq .User.Filters.TLSUsername "CommonName" }}selected{{end}}>Common Name</option>
+                    </select>
+                    <small id="tlsUsernameHelpBlock" class="form-text text-muted">
+                        Defines the TLS certificate field to use as username. Ignored if mutual TLS is disabled
+                    </small>
+                </div>
+            </div>
+
             <div class="form-group row">
             <div class="form-group row">
                 <label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
                 <label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
                 <div class="col-sm-10">
                 <div class="col-sm-10">
@@ -127,7 +141,7 @@
                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                 <div class="col-sm-10">
                 <div class="col-sm-10">
                     <select class="form-control" id="idLoginMethods" name="ssh_login_methods" multiple>
                     <select class="form-control" id="idLoginMethods" name="ssh_login_methods" multiple>
-                        {{range $method := .ValidSSHLoginMethods}}
+                        {{range $method := .ValidLoginMethods}}
                         <option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
                         <option value="{{$method}}" {{range $m :=$.User.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
                         </option>
                         </option>
                         {{end}}
                         {{end}}

+ 13 - 0
utils/utils.go

@@ -451,3 +451,16 @@ func GetTLSCiphersFromNames(cipherNames []string) []uint16 {
 
 
 	return ciphers
 	return ciphers
 }
 }
+
+// EncodeTLSCertToPem returns the specified certificate PEM encoded.
+// This can be verified using openssl x509 -in cert.crt  -text -noout
+func EncodeTLSCertToPem(tlsCert *x509.Certificate) (string, error) {
+	if len(tlsCert.Raw) == 0 {
+		return "", errors.New("Invalid x509 certificate, no der contents")
+	}
+	publicKeyBlock := pem.Block{
+		Type:  "CERTIFICATE",
+		Bytes: tlsCert.Raw,
+	}
+	return string(pem.EncodeToMemory(&publicKeyBlock)), nil
+}

Some files were not shown because too many files changed in this diff