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.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)

+ 3 - 3
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)
 }
 

+ 14 - 0
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 == "" {

+ 88 - 15
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)
 	}

+ 14 - 0
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 == "" {

+ 5 - 0
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)
 }

+ 5 - 0
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)
 }

+ 14 - 0
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 {

+ 5 - 0
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)
 }

+ 29 - 1
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))

+ 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_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.
 

+ 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`.
     - `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`.

+ 3 - 3
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")

+ 15 - 1
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

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/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)
+}

+ 112 - 27
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)
 }

+ 1 - 1
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

+ 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/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=

+ 21 - 1
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))

+ 11 - 3
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:

+ 19 - 18
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
 }
 

+ 26 - 16
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
 	}

+ 60 - 4
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()
 		}

+ 1 - 1
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

+ 2 - 2
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))

+ 15 - 1
templates/user.html

@@ -111,6 +111,20 @@
                 </div>
             </div>
             {{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">
                 <label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
                 <div class="col-sm-10">
@@ -127,7 +141,7 @@
                 <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
                 <div class="col-sm-10">
                     <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>
                         {{end}}

+ 13 - 0
utils/utils.go

@@ -451,3 +451,16 @@ func GetTLSCiphersFromNames(cipherNames []string) []uint16 {
 
 	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