浏览代码

webdav: add support for client certificate authentication

Fixes #263
Nicola Murino 4 年之前
父节点
当前提交
141ca6777c
共有 9 个文件被更改,包括 116 次插入7 次删除
  1. 45 1
      common/tlsutils.go
  2. 19 0
      common/tlsutils_test.go
  3. 12 3
      config/config.go
  4. 3 0
      config/config_test.go
  5. 2 0
      docs/full-configuration.md
  6. 3 1
      sftpgo.json
  7. 8 1
      webdavd/server.go
  8. 6 1
      webdavd/webdavd.go
  9. 18 0
      webdavd/webdavd_test.go

+ 45 - 1
common/tlsutils.go

@@ -2,9 +2,14 @@ package common
 
 
 import (
 import (
 	"crypto/tls"
 	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"io/ioutil"
+	"path/filepath"
 	"sync"
 	"sync"
 
 
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/utils"
 )
 )
 
 
 // CertManager defines a TLS certificate manager
 // CertManager defines a TLS certificate manager
@@ -12,7 +17,8 @@ type CertManager struct {
 	certPath string
 	certPath string
 	keyPath  string
 	keyPath  string
 	sync.RWMutex
 	sync.RWMutex
-	cert *tls.Certificate
+	cert    *tls.Certificate
+	rootCAs *x509.CertPool
 }
 }
 
 
 // LoadCertificate loads the configured x509 key pair
 // 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
 // NewCertManager creates a new certificate manager
 func NewCertManager(certificateFile, certificateKeyFile, logSender string) (*CertManager, error) {
 func NewCertManager(certificateFile, certificateKeyFile, logSender string) (*CertManager, error) {
 	manager := &CertManager{
 	manager := &CertManager{

+ 19 - 0
common/tlsutils_test.go

@@ -56,6 +56,25 @@ func TestLoadCertificate(t *testing.T) {
 		assert.Equal(t, certManager.cert, cert)
 		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)
 	err = os.Remove(certPath)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	err = os.Remove(keyPath)
 	err = os.Remove(keyPath)

+ 12 - 3
config/config.go

@@ -49,9 +49,10 @@ var (
 		ApplyProxyConfig: true,
 		ApplyProxyConfig: true,
 	}
 	}
 	defaultWebDAVDBinding = webdavd.Binding{
 	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},
 			Bindings:           []webdavd.Binding{defaultWebDAVDBinding},
 			CertificateFile:    "",
 			CertificateFile:    "",
 			CertificateKeyFile: "",
 			CertificateKeyFile: "",
+			CACertificates:     []string{},
 			Cors: webdavd.Cors{
 			Cors: webdavd.Cors{
 				Enabled:          false,
 				Enabled:          false,
 				AllowedOrigins:   []string{},
 				AllowedOrigins:   []string{},
@@ -629,6 +631,12 @@ func getWebDAVDBindingFromEnv(idx int) {
 		isSet = true
 		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 isSet {
 		if len(globalConf.WebDAVD.Bindings) > idx {
 		if len(globalConf.WebDAVD.Bindings) > idx {
 			globalConf.WebDAVD.Bindings[idx] = binding
 			globalConf.WebDAVD.Bindings[idx] = binding
@@ -672,6 +680,7 @@ func setViperDefaults() {
 	viper.SetDefault("ftpd.certificate_key_file", globalConf.FTPD.CertificateKeyFile)
 	viper.SetDefault("ftpd.certificate_key_file", globalConf.FTPD.CertificateKeyFile)
 	viper.SetDefault("webdavd.certificate_file", globalConf.WebDAVD.CertificateFile)
 	viper.SetDefault("webdavd.certificate_file", globalConf.WebDAVD.CertificateFile)
 	viper.SetDefault("webdavd.certificate_key_file", globalConf.WebDAVD.CertificateKeyFile)
 	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.enabled", globalConf.WebDAVD.Cors.Enabled)
 	viper.SetDefault("webdavd.cors.allowed_origins", globalConf.WebDAVD.Cors.AllowedOrigins)
 	viper.SetDefault("webdavd.cors.allowed_origins", globalConf.WebDAVD.Cors.AllowedOrigins)
 	viper.SetDefault("webdavd.cors.allowed_methods", globalConf.WebDAVD.Cors.AllowedMethods)
 	viper.SetDefault("webdavd.cors.allowed_methods", globalConf.WebDAVD.Cors.AllowedMethods)

+ 3 - 0
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__ADDRESS", "127.0.1.1")
 	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__PORT", "9000")
 	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__PORT", "9000")
 	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__ENABLE_HTTPS", "1")
 	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__ENABLE_HTTPS", "1")
+	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__CLIENT_AUTH_TYPE", "1")
 	t.Cleanup(func() {
 	t.Cleanup(func() {
 		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__ADDRESS")
 		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__ADDRESS")
 		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__PORT")
 		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__ADDRESS")
 		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__PORT")
 		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__PORT")
 		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__ENABLE_HTTPS")
 		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__ENABLE_HTTPS")
+		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__CLIENT_AUTH_TYPE")
 	})
 	})
 
 
 	configDir := ".."
 	configDir := ".."
@@ -612,6 +614,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) {
 	require.Equal(t, 9000, bindings[2].Port)
 	require.Equal(t, 9000, bindings[2].Port)
 	require.Equal(t, "127.0.1.1", bindings[2].Address)
 	require.Equal(t, "127.0.1.1", bindings[2].Address)
 	require.True(t, bindings[2].EnableHTTPS)
 	require.True(t, bindings[2].EnableHTTPS)
+	require.Equal(t, 1, bindings[2].ClientAuthType)
 }
 }
 
 
 func TestConfigFromEnv(t *testing.T) {
 func TestConfigFromEnv(t *testing.T) {

+ 2 - 0
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.
     - `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: "".
     - `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`
     - `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_port`, integer. Deprecated, please use `bindings`
   - `bind_address`, string. 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_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.
   - `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.
   - `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.
     - `enabled`, boolean, set to true to enable CORS.
     - `allowed_origins`, list of strings.
     - `allowed_origins`, list of strings.

+ 3 - 1
sftpgo.json

@@ -67,11 +67,13 @@
       {
       {
         "address": "",
         "address": "",
         "port": 0,
         "port": 0,
-        "enable_https": false
+        "enable_https": false,
+        "client_auth_type": 0
       }
       }
     ],
     ],
     "certificate_file": "",
     "certificate_file": "",
     "certificate_key_file": "",
     "certificate_key_file": "",
+    "ca_certificates": [],
     "cors": {
     "cors": {
       "enabled": false,
       "enabled": false,
       "allowed_origins": [],
       "allowed_origins": [],

+ 8 - 1
webdavd/server.go

@@ -44,11 +44,14 @@ func newServer(config *Configuration, configDir string) (*webDavServer, error) {
 	}
 	}
 	certificateFile := getConfigPath(config.CertificateFile, configDir)
 	certificateFile := getConfigPath(config.CertificateFile, configDir)
 	certificateKeyFile := getConfigPath(config.CertificateKeyFile, configDir)
 	certificateKeyFile := getConfigPath(config.CertificateKeyFile, configDir)
-	if len(certificateFile) > 0 && len(certificateKeyFile) > 0 {
+	if certificateFile != "" && certificateKeyFile != "" {
 		server.certMgr, err = common.NewCertManager(certificateFile, certificateKeyFile, logSender)
 		server.certMgr, err = common.NewCertManager(certificateFile, certificateKeyFile, logSender)
 		if err != nil {
 		if err != nil {
 			return server, err
 			return server, err
 		}
 		}
+		if err := server.certMgr.LoadRootCAs(config.CACertificates, configDir); err != nil {
+			return server, err
+		}
 	}
 	}
 	return server, nil
 	return server, nil
 }
 }
@@ -79,6 +82,10 @@ func (s *webDavServer) listenAndServe(binding Binding) error {
 			GetCertificate: s.certMgr.GetCertificateFunc(),
 			GetCertificate: s.certMgr.GetCertificateFunc(),
 			MinVersion:     tls.VersionTLS12,
 			MinVersion:     tls.VersionTLS12,
 		}
 		}
+		if binding.ClientAuthType == 1 {
+			httpServer.TLSConfig.ClientCAs = s.certMgr.GetRootCAs()
+			httpServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
+		}
 		return httpServer.ListenAndServeTLS("", "")
 		return httpServer.ListenAndServeTLS("", "")
 	}
 	}
 	binding.EnableHTTPS = false
 	binding.EnableHTTPS = false

+ 6 - 1
webdavd/webdavd.go

@@ -68,6 +68,9 @@ type Binding struct {
 	Port int `json:"port" mapstructure:"port"`
 	Port int `json:"port" mapstructure:"port"`
 	// you also need to provide a certificate for enabling HTTPS
 	// you also need to provide a certificate for enabling HTTPS
 	EnableHTTPS bool `json:"enable_https" mapstructure:"enable_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
 // GetAddress returns the binding address
@@ -94,6 +97,8 @@ type Configuration struct {
 	// "paramchange" request to the running service on Windows.
 	// "paramchange" request to the running service on Windows.
 	CertificateFile    string `json:"certificate_file" mapstructure:"certificate_file"`
 	CertificateFile    string `json:"certificate_file" mapstructure:"certificate_file"`
 	CertificateKeyFile string `json:"certificate_key_file" mapstructure:"certificate_key_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 configuration
 	Cors Cors `json:"cors" mapstructure:"cors"`
 	Cors Cors `json:"cors" mapstructure:"cors"`
 	// Cache configuration
 	// Cache configuration
@@ -169,7 +174,7 @@ func getConfigPath(name, configDir string) string {
 	if !utils.IsFileInputValid(name) {
 	if !utils.IsFileInputValid(name) {
 		return ""
 		return ""
 	}
 	}
-	if len(name) > 0 && !filepath.IsAbs(name) {
+	if name != "" && !filepath.IsAbs(name) {
 		return filepath.Join(configDir, name)
 		return filepath.Join(configDir, name)
 	}
 	}
 	return name
 	return name

+ 18 - 0
webdavd/webdavd_test.go

@@ -256,6 +256,24 @@ func TestInitialization(t *testing.T) {
 	}
 	}
 	err = cfg.Initialize(configDir)
 	err = cfg.Initialize(configDir)
 	assert.EqualError(t, err, common.ErrNoBinding.Error())
 	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) {
 func TestBasicHandling(t *testing.T) {