webdav: add support for client certificate authentication

Fixes #263
This commit is contained in:
Nicola Murino
2020-12-28 19:48:23 +01:00
parent 3c16a19269
commit 141ca6777c
9 changed files with 116 additions and 7 deletions

View File

@@ -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
@@ -13,6 +18,7 @@ type CertManager struct {
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{

View File

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

View File

@@ -52,6 +52,7 @@ var (
Address: "", Address: "",
Port: 0, Port: 0,
EnableHTTPS: false, 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)

View File

@@ -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) {

View File

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

View File

@@ -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": [],

View File

@@ -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

View File

@@ -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

View File

@@ -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) {