diff --git a/common/tlsutils.go b/common/tlsutils.go index d2f029a2..df8aa3be 100644 --- a/common/tlsutils.go +++ b/common/tlsutils.go @@ -2,9 +2,14 @@ package common import ( "crypto/tls" + "crypto/x509" + "fmt" + "io/ioutil" + "path/filepath" "sync" "github.com/drakkan/sftpgo/logger" + "github.com/drakkan/sftpgo/utils" ) // CertManager defines a TLS certificate manager @@ -12,7 +17,8 @@ type CertManager struct { certPath string keyPath string sync.RWMutex - cert *tls.Certificate + cert *tls.Certificate + rootCAs *x509.CertPool } // LoadCertificate loads the configured x509 key pair @@ -39,6 +45,44 @@ func (m *CertManager) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Cert } } +// GetRootCAs returns the set of root certificate authorities that servers +// use if required to verify a client certificate +func (m *CertManager) GetRootCAs() *x509.CertPool { + return m.rootCAs +} + +// LoadRootCAs tries to load root CA certificate authorities from the given paths +func (m *CertManager) LoadRootCAs(caCertificates []string, configDir string) error { + if len(caCertificates) == 0 { + return nil + } + + rootCAs := x509.NewCertPool() + + for _, rootCA := range caCertificates { + if !utils.IsFileInputValid(rootCA) { + return fmt.Errorf("invalid root CA certificate %#v", rootCA) + } + if rootCA != "" && !filepath.IsAbs(rootCA) { + rootCA = filepath.Join(configDir, rootCA) + } + crt, err := ioutil.ReadFile(rootCA) + if err != nil { + return err + } + if rootCAs.AppendCertsFromPEM(crt) { + logger.Debug(logSender, "", "TLS certificate authority %#v successfully loaded", rootCA) + } else { + err := fmt.Errorf("unable to load TLS certificate authority %#v", rootCA) + logger.Debug(logSender, "", "%v", err) + return err + } + } + + m.rootCAs = rootCAs + return nil +} + // NewCertManager creates a new certificate manager func NewCertManager(certificateFile, certificateKeyFile, logSender string) (*CertManager, error) { manager := &CertManager{ diff --git a/common/tlsutils_test.go b/common/tlsutils_test.go index b0efb848..ee292050 100644 --- a/common/tlsutils_test.go +++ b/common/tlsutils_test.go @@ -56,6 +56,25 @@ func TestLoadCertificate(t *testing.T) { assert.Equal(t, certManager.cert, cert) } + err = certManager.LoadRootCAs(nil, "") + assert.NoError(t, err) + + err = certManager.LoadRootCAs([]string{""}, "") + assert.Error(t, err) + + err = certManager.LoadRootCAs([]string{"invalid"}, "") + assert.Error(t, err) + + // laoding the key as root CA must fail + err = certManager.LoadRootCAs([]string{keyPath}, "") + assert.Error(t, err) + + err = certManager.LoadRootCAs([]string{certPath}, "") + assert.NoError(t, err) + + rootCa := certManager.GetRootCAs() + assert.NotNil(t, rootCa) + err = os.Remove(certPath) assert.NoError(t, err) err = os.Remove(keyPath) diff --git a/config/config.go b/config/config.go index aef0bf26..22509c56 100644 --- a/config/config.go +++ b/config/config.go @@ -49,9 +49,10 @@ var ( ApplyProxyConfig: true, } defaultWebDAVDBinding = webdavd.Binding{ - Address: "", - Port: 0, - EnableHTTPS: false, + Address: "", + Port: 0, + EnableHTTPS: false, + ClientAuthType: 0, } ) @@ -124,6 +125,7 @@ func Init() { Bindings: []webdavd.Binding{defaultWebDAVDBinding}, CertificateFile: "", CertificateKeyFile: "", + CACertificates: []string{}, Cors: webdavd.Cors{ Enabled: false, AllowedOrigins: []string{}, @@ -629,6 +631,12 @@ func getWebDAVDBindingFromEnv(idx int) { isSet = true } + clientAuthType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__CLIENT_AUTH_TYPE", idx)) + if ok { + binding.ClientAuthType = clientAuthType + isSet = true + } + if isSet { if len(globalConf.WebDAVD.Bindings) > idx { globalConf.WebDAVD.Bindings[idx] = binding @@ -672,6 +680,7 @@ func setViperDefaults() { viper.SetDefault("ftpd.certificate_key_file", globalConf.FTPD.CertificateKeyFile) viper.SetDefault("webdavd.certificate_file", globalConf.WebDAVD.CertificateFile) viper.SetDefault("webdavd.certificate_key_file", globalConf.WebDAVD.CertificateKeyFile) + viper.SetDefault("webdavd.ca_certificates", globalConf.WebDAVD.CACertificates) viper.SetDefault("webdavd.cors.enabled", globalConf.WebDAVD.Cors.Enabled) viper.SetDefault("webdavd.cors.allowed_origins", globalConf.WebDAVD.Cors.AllowedOrigins) viper.SetDefault("webdavd.cors.allowed_methods", globalConf.WebDAVD.Cors.AllowedMethods) diff --git a/config/config_test.go b/config/config_test.go index 5de9d040..6bb4f003 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -589,6 +589,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) { os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__ADDRESS", "127.0.1.1") os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__PORT", "9000") os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__ENABLE_HTTPS", "1") + os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__CLIENT_AUTH_TYPE", "1") t.Cleanup(func() { os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__ADDRESS") os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__PORT") @@ -596,6 +597,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__ADDRESS") os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__PORT") os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__ENABLE_HTTPS") + os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__CLIENT_AUTH_TYPE") }) configDir := ".." @@ -612,6 +614,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) { require.Equal(t, 9000, bindings[2].Port) require.Equal(t, "127.0.1.1", bindings[2].Address) require.True(t, bindings[2].EnableHTTPS) + require.Equal(t, 1, bindings[2].ClientAuthType) } func TestConfigFromEnv(t *testing.T) { diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 3e31b027..5602753c 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -116,10 +116,12 @@ The configuration file contains the following sections: - `port`, integer. The port used for serving WebDAV requests. 0 means disabled. Default: 0. - `address`, string. Leave blank to listen on all available network interfaces. Default: "". - `enable_https`, boolean. Set to `true` and provide both a certificate and a key file to enable HTTPS connection for this binding. Default `false` + - `client_auth_type`, integer. Set to `1` to require client certificate authentication in addition to basic auth. You need to define at least a certificate authority for this to work. Default: 0. - `bind_port`, integer. Deprecated, please use `bindings` - `bind_address`, string. Deprecated, please use `bindings` - `certificate_file`, string. Certificate for WebDAV over HTTPS. This can be an absolute path or a path relative to the config dir. - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. A certificate and a private key are required to enable HTTPS connections. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows. + - `ca_certificates`, list of strings. Set of root certificate authorities to use to verify client certificates. - `cors` struct containing CORS configuration. SFTPGo uses [Go CORS handler](https://github.com/rs/cors), please refer to upstream documentation for fields meaning and their default values. - `enabled`, boolean, set to true to enable CORS. - `allowed_origins`, list of strings. diff --git a/sftpgo.json b/sftpgo.json index f6f43b5d..02cf8351 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -67,11 +67,13 @@ { "address": "", "port": 0, - "enable_https": false + "enable_https": false, + "client_auth_type": 0 } ], "certificate_file": "", "certificate_key_file": "", + "ca_certificates": [], "cors": { "enabled": false, "allowed_origins": [], diff --git a/webdavd/server.go b/webdavd/server.go index eed4e671..4f36c43b 100644 --- a/webdavd/server.go +++ b/webdavd/server.go @@ -44,11 +44,14 @@ func newServer(config *Configuration, configDir string) (*webDavServer, error) { } certificateFile := getConfigPath(config.CertificateFile, configDir) certificateKeyFile := getConfigPath(config.CertificateKeyFile, configDir) - if len(certificateFile) > 0 && len(certificateKeyFile) > 0 { + if certificateFile != "" && certificateKeyFile != "" { server.certMgr, err = common.NewCertManager(certificateFile, certificateKeyFile, logSender) if err != nil { return server, err } + if err := server.certMgr.LoadRootCAs(config.CACertificates, configDir); err != nil { + return server, err + } } return server, nil } @@ -79,6 +82,10 @@ func (s *webDavServer) listenAndServe(binding Binding) error { GetCertificate: s.certMgr.GetCertificateFunc(), MinVersion: tls.VersionTLS12, } + if binding.ClientAuthType == 1 { + httpServer.TLSConfig.ClientCAs = s.certMgr.GetRootCAs() + httpServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert + } return httpServer.ListenAndServeTLS("", "") } binding.EnableHTTPS = false diff --git a/webdavd/webdavd.go b/webdavd/webdavd.go index 5231b1bb..20c340a4 100644 --- a/webdavd/webdavd.go +++ b/webdavd/webdavd.go @@ -68,6 +68,9 @@ type Binding struct { Port int `json:"port" mapstructure:"port"` // you also need to provide a certificate for enabling HTTPS EnableHTTPS bool `json:"enable_https" mapstructure:"enable_https"` + // set to 1 to require client certificate authentication in addition to basic auth. + // You need to define at least a certificate authority for this to work + ClientAuthType int `json:"client_auth_type" mapstructure:"client_auth_type"` } // GetAddress returns the binding address @@ -94,6 +97,8 @@ type Configuration struct { // "paramchange" request to the running service on Windows. CertificateFile string `json:"certificate_file" mapstructure:"certificate_file"` CertificateKeyFile string `json:"certificate_key_file" mapstructure:"certificate_key_file"` + // CACertificates defines the set of root certificate authorities to use to verify client certificates. + CACertificates []string `json:"ca_certificates" mapstructure:"ca_certificates"` // CORS configuration Cors Cors `json:"cors" mapstructure:"cors"` // Cache configuration @@ -169,7 +174,7 @@ func getConfigPath(name, configDir string) string { if !utils.IsFileInputValid(name) { return "" } - if len(name) > 0 && !filepath.IsAbs(name) { + if name != "" && !filepath.IsAbs(name) { return filepath.Join(configDir, name) } return name diff --git a/webdavd/webdavd_test.go b/webdavd/webdavd_test.go index fd2016d8..acf519b5 100644 --- a/webdavd/webdavd_test.go +++ b/webdavd/webdavd_test.go @@ -256,6 +256,24 @@ func TestInitialization(t *testing.T) { } err = cfg.Initialize(configDir) assert.EqualError(t, err, common.ErrNoBinding.Error()) + + cfg.CertificateFile = certPath + cfg.CertificateKeyFile = keyPath + cfg.CACertificates = []string{""} + + cfg.Bindings = []webdavd.Binding{ + { + Port: 9022, + ClientAuthType: 1, + EnableHTTPS: true, + }, + } + err = cfg.Initialize(configDir) + assert.Error(t, err) + + cfg.CACertificates = nil + err = cfg.Initialize(configDir) + assert.Error(t, err) } func TestBasicHandling(t *testing.T) {