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
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
9 changed files with 116 additions and 7 deletions

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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