فهرست منبع

WebDAV: improve TLS certificate authentication

For each user you can now configure:

- TLS certificate auth
- TLS certificate auth and password
- Password auth

For TLS certificate auth, the certificate common name is used as
username
Nicola Murino 4 سال پیش
والد
کامیت
534b253c20

+ 75 - 1
dataprovider/dataprovider.go

@@ -570,6 +570,80 @@ func CheckAdminAndPass(username, password, ip string) (Admin, error) {
 	return provider.validateAdminAndPass(username, password, ip)
 }
 
+// CheckCachedUserCredentials checks the credentials for a cached user
+func CheckCachedUserCredentials(user *CachedUser, password, loginMethod, protocol string, tlsCert *x509.Certificate) error {
+	if loginMethod != LoginMethodPassword {
+		_, err := checkUserAndTLSCertificate(&user.User, protocol, tlsCert)
+		if err != nil {
+			return err
+		}
+		if loginMethod == LoginMethodTLSCertificate {
+			if !user.User.IsLoginMethodAllowed(LoginMethodTLSCertificate, nil) {
+				return fmt.Errorf("Certificate login method is not allowed for user %#v", user.User.Username)
+			}
+			return nil
+		}
+	}
+	if err := checkLoginConditions(&user.User); err != nil {
+		return err
+	}
+	if password == "" {
+		return ErrInvalidCredentials
+	}
+	if user.Password != "" {
+		if password == user.Password {
+			return nil
+		}
+	} else {
+		if ok, _ := isPasswordOK(&user.User, password); ok {
+			return nil
+		}
+	}
+	return ErrInvalidCredentials
+}
+
+// CheckCompositeCredentials checks multiple credentials.
+// WebDAV users can send both a password and a TLS certificate within the same request
+func CheckCompositeCredentials(username, password, ip, loginMethod, protocol string, tlsCert *x509.Certificate) (User, string, error) {
+	if loginMethod == LoginMethodPassword {
+		user, err := CheckUserAndPass(username, password, ip, protocol)
+		return user, loginMethod, err
+	}
+	user, err := CheckUserBeforeTLSAuth(username, ip, protocol, tlsCert)
+	if err != nil {
+		return user, loginMethod, err
+	}
+	if !user.IsTLSUsernameVerificationEnabled() {
+		// for backward compatibility with 2.0.x we only check the password and change the login method here
+		// in future updates we have to return an error
+		user, err := CheckUserAndPass(username, password, ip, protocol)
+		return user, LoginMethodPassword, err
+	}
+	user, err = checkUserAndTLSCertificate(&user, protocol, tlsCert)
+	if err != nil {
+		return user, loginMethod, err
+	}
+	if loginMethod == LoginMethodTLSCertificate && !user.IsLoginMethodAllowed(LoginMethodTLSCertificate, nil) {
+		return user, loginMethod, fmt.Errorf("Certificate login method is not allowed for user %#v", user.Username)
+	}
+	if loginMethod == LoginMethodTLSCertificateAndPwd {
+		if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
+			user, err = doExternalAuth(username, password, nil, "", ip, protocol, nil)
+			if err != nil {
+				return user, loginMethod, err
+			}
+		}
+		if config.PreLoginHook != "" {
+			user, err = executePreLoginHook(username, LoginMethodPassword, ip, protocol)
+			if err != nil {
+				return user, loginMethod, err
+			}
+		}
+		user, err = checkUserAndPass(&user, password, ip, protocol)
+	}
+	return user, loginMethod, err
+}
+
 // 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) {
@@ -1458,7 +1532,7 @@ func checkUserAndTLSCertificate(user *User, protocol string, tlsCert *x509.Certi
 		return *user, err
 	}
 	switch protocol {
-	case "FTP":
+	case "FTP", "DAV":
 		if user.Filters.TLSUsername == TLSUsernameCN {
 			if user.Username == tlsCert.Subject.CommonName {
 				return *user, nil

+ 1 - 1
docs/dynamic-user-mod.md

@@ -6,7 +6,7 @@ To enable dynamic user modification, you must set the absolute path of your prog
 The external program can read the following environment variables to get info about the user trying to login:
 
 - `SFTPGO_LOGIND_USER`, it contains the user trying to login serialized as JSON. A JSON serialized user id equal to zero means the user does not exist inside SFTPGo
-- `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey` and `keyboard-interactive`
+- `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey`, `keyboard-interactive`, `TLSCertificate`
 - `SFTPGO_LOGIND_IP`, ip address of the user trying to login
 - `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`
 

+ 1 - 1
docs/full-configuration.md

@@ -132,7 +132,7 @@ 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 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`.

+ 1 - 1
docs/post-login-hook.md

@@ -10,7 +10,7 @@ If the hook defines an external program it can reads the following environment v
 
 - `SFTPGO_LOGIND_USER`, it contains the user serialized as JSON. The username is empty if the connection is closed for authentication timeout
 - `SFTPGO_LOGIND_IP`
-- `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive` or `no_auth_tryed`
+- `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive`, `TLSCertificate`, `TLSCertificate+password` or `no_auth_tryed`
 - `SFTPGO_LOGIND_STATUS`, 1 means login OK, 0 login KO
 - `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`
 

+ 10 - 7
go.mod

@@ -1,6 +1,6 @@
 module github.com/drakkan/sftpgo
 
-go 1.15
+go 1.16
 
 require (
 	cloud.google.com/go v0.78.0 // indirect
@@ -9,16 +9,17 @@ require (
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46 // indirect
 	github.com/alexedwards/argon2id v0.0.0-20201228115903-cf543ebc1f7b
-	github.com/aws/aws-sdk-go v1.37.18
+	github.com/aws/aws-sdk-go v1.37.20
 	github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
 	github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d
 	github.com/fclairamb/ftpserverlib v0.13.0
 	github.com/frankban/quicktest v1.11.3 // indirect
-	github.com/go-chi/chi v1.5.3
+	github.com/go-chi/chi/v5 v5.0.0
 	github.com/go-chi/jwtauth v1.2.0
 	github.com/go-chi/render v1.0.1
 	github.com/go-ole/go-ole v1.2.5 // indirect
 	github.com/go-sql-driver/mysql v1.5.0
+	github.com/golang/snappy v0.0.3 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/google/uuid v1.2.0 // indirect
 	github.com/google/wire v0.5.0 // indirect
@@ -26,11 +27,12 @@ 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/backoff/v2 v2.0.8 // indirect
 	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
-	github.com/miekg/dns v1.1.39 // indirect
+	github.com/miekg/dns v1.1.40 // indirect
 	github.com/minio/sha256-simd v1.0.0
 	github.com/minio/sio v0.2.1
 	github.com/mitchellh/mapstructure v1.4.1 // indirect
@@ -39,7 +41,7 @@ require (
 	github.com/pires/go-proxyproto v0.4.2
 	github.com/pkg/sftp v1.12.1-0.20210222152308-b8102da57e75
 	github.com/prometheus/client_golang v1.9.0
-	github.com/prometheus/common v0.17.0 // indirect
+	github.com/prometheus/common v0.18.0 // indirect
 	github.com/prometheus/procfs v0.6.0 // indirect
 	github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b
 	github.com/rs/xid v1.2.1
@@ -62,10 +64,11 @@ require (
 	golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
 	golang.org/x/net v0.0.0-20210224082022-3d97a244fca7
 	golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93 // indirect
-	golang.org/x/sys v0.0.0-20210223212115-eede4237b368
+	golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46
 	golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect
 	google.golang.org/api v0.40.0
-	google.golang.org/genproto v0.0.0-20210224155714-063164c882e6 // indirect
+	google.golang.org/genproto v0.0.0-20210226172003-ab064af71705 // indirect
+	google.golang.org/grpc v1.36.0 // indirect
 	gopkg.in/ini.v1 v1.62.0 // indirect
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect

+ 19 - 18
go.sum

@@ -77,7 +77,6 @@ github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpz
 github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
 github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
 github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
-github.com/Azure/go-autorest/autorest/mocks v0.4.1 h1:K0laFcLE6VLTOwNgSxaGbUcLPuGXlNkbVvq4cW4nIHk=
 github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
 github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE=
 github.com/Azure/go-autorest/autorest/validation v0.3.0/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E=
@@ -117,8 +116,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.36.1/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
-github.com/aws/aws-sdk-go v1.37.18 h1:SRdWLg+DqMFWX8HB3UvXyAoZpw9IDIUYnSTwgzOYbqg=
-github.com/aws/aws-sdk-go v1.37.18/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
+github.com/aws/aws-sdk-go v1.37.20 h1:CJCXpMYmBJrRH8YwoSE0oB9S3J5ax+62F14sYlDCztg=
+github.com/aws/aws-sdk-go v1.37.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -211,9 +210,10 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
 github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
 github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
+github.com/go-chi/chi v1.5.1 h1:kfTK3Cxd/dkMu/rKs5ZceWYp+t5CtiE7vmaTv3LjC6w=
 github.com/go-chi/chi v1.5.1/go.mod h1:REp24E+25iKvxgeTfHmdUoL5x15kBiDBlnIl5bCwe2k=
-github.com/go-chi/chi v1.5.3 h1:+DVDS9/D3MTbEu3WrrH3oz9oP6PlSPSNj8LLw3X17yU=
-github.com/go-chi/chi v1.5.3/go.mod h1:Q8xfe6s3fjZyMr8ZTv5jL+vxhVaFyCq2s+RvSfzTD0E=
+github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg=
+github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
 github.com/go-chi/jwtauth v1.2.0 h1:Z116SPpevIABBYsv8ih/AHYBHmd4EufKSKsLUnWdrTM=
 github.com/go-chi/jwtauth v1.2.0/go.mod h1:NTUpKoTQV6o25UwYE6w/VaLUu83hzrVKYTVo+lE6qDA=
 github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
@@ -243,7 +243,6 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
-github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
 github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
@@ -288,8 +287,9 @@ github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw=
 github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
+github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -455,8 +455,9 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
 github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
-github.com/lestrrat-go/backoff/v2 v2.0.7 h1:i2SeK33aOFJlUNJZzf2IpXRBvqBBnaGXfY5Xaop/GsE=
 github.com/lestrrat-go/backoff/v2 v2.0.7/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
+github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A=
+github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
 github.com/lestrrat-go/codegen v1.0.0/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
 github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkOpYc=
 github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
@@ -492,8 +493,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
-github.com/miekg/dns v1.1.39 h1:6dRfDGnHiXOMmTZkwWANy7bBXXlKls5Qu+pn+Ue0TLo=
-github.com/miekg/dns v1.1.39/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
+github.com/miekg/dns v1.1.40 h1:pyyPFfGMnciYUk/mXpKkVmeMQjfXqt3FAJ2hy7tPiLA=
+github.com/miekg/dns v1.1.40/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
 github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
 github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
 github.com/minio/sio v0.2.1 h1:NjzKiIMSMcHediVQR0AFVx2tp7Wxh9tKPfDI3kH7aHQ=
@@ -549,7 +550,6 @@ github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnh
 github.com/otiai10/copy v1.5.0 h1:SoXDGnlTUZoqB/wSuj/Y5L6T5i6iN4YRAcMCd+JnLNU=
 github.com/otiai10/copy v1.5.0/go.mod h1:XWfuS3CrI0R6IE0FbgHsEazaXO8G0LpMp9o8tos0x4E=
 github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
-github.com/otiai10/curr v1.0.0 h1:TJIWdbX0B+kpNagQrjgq8bCMrbhiuX73M2XwgtDMoOI=
 github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
 github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
 github.com/otiai10/mint v1.3.2 h1:VYWnrP5fXmz1MXvjuUvcBrXSjGE6xjON+axB/UrpO3E=
@@ -601,8 +601,8 @@ github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
 github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
 github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
 github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
-github.com/prometheus/common v0.17.0 h1:kDIZLI74SS+3tedSvEkykgBkD7txMxaJAPj8DtJUKYA=
-github.com/prometheus/common v0.17.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
+github.com/prometheus/common v0.18.0 h1:WCVKW7aL6LEe1uryfI9dnEc2ZqNB1Fn0ok930v0iL1Y=
+github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
@@ -842,8 +842,8 @@ golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210223212115-eede4237b368 h1:fDE3p0qf2V1co1vfj3/o87Ps8Hq6QTGNxJ5Xe7xSp80=
-golang.org/x/sys v0.0.0-20210223212115-eede4237b368/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46 h1:V066+OYJ66oTjnhm4Yrn7SXIwSCiDQJxpBxmvqb1N1c=
+golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -1013,8 +1013,8 @@ google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210203152818-3206188e46ba/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210224155714-063164c882e6 h1:bXUwz2WkXXrXgiLxww3vWmoSHLOGv4ipdPdTvKymcKw=
-google.golang.org/genproto v0.0.0-20210224155714-063164c882e6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210226172003-ab064af71705 h1:PYBmACG+YEv8uQPW0r1kJj8tR+gkF0UWq7iFdUezwEw=
+google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -1038,8 +1038,9 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
 google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
 google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
-google.golang.org/grpc v1.35.0 h1:TwIQcH3es+MojMVojxxfQ3l3OF2KzlRxML2xZq0kRo8=
 google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.36.0 h1:o1bcQ6imQMIOpdrO3SWf2z5RV72WbDwdXuK0MDlc8As=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

+ 1 - 1
httpd/httpd.go

@@ -14,7 +14,7 @@ import (
 	"sync"
 	"time"
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/jwtauth"
 
 	"github.com/drakkan/sftpgo/common"

+ 1 - 1
httpd/internal_test.go

@@ -20,7 +20,7 @@ import (
 	"testing"
 	"time"
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/jwtauth"
 	"github.com/lestrrat-go/jwx/jwt"
 	"github.com/rs/xid"

+ 1 - 1
httpd/schema/openapi.yaml

@@ -1378,7 +1378,7 @@ components:
           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: defines the TLS certificate field to use as username. For FTP clients it must match the name provided using the "USER" command. For WebDAV, if no username is provided, the CN will be used as username. For WebDAV clients it must match the implicit or provided username. Ignored if mutual TLS is disabled
       description: Additional user restrictions
     Secret:
       type: object

+ 2 - 2
httpd/server.go

@@ -9,8 +9,8 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/go-chi/chi"
-	"github.com/go-chi/chi/middleware"
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/chi/v5/middleware"
 	"github.com/go-chi/jwtauth"
 	"github.com/go-chi/render"
 

+ 1 - 1
logger/request_logger.go

@@ -5,7 +5,7 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/go-chi/chi/middleware"
+	"github.com/go-chi/chi/v5/middleware"
 	"github.com/rs/zerolog"
 
 	"github.com/drakkan/sftpgo/metrics"

+ 1 - 1
metrics/metrics.go

@@ -4,7 +4,7 @@
 package metrics
 
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promauto"
 	"github.com/prometheus/client_golang/prometheus/promhttp"

+ 1 - 1
metrics/metrics_disabled.go

@@ -3,7 +3,7 @@
 package metrics
 
 import (
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 
 	"github.com/drakkan/sftpgo/version"
 )

+ 2 - 2
telemetry/router.go

@@ -3,8 +3,8 @@ package telemetry
 import (
 	"net/http"
 
-	"github.com/go-chi/chi"
-	"github.com/go-chi/chi/middleware"
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/chi/v5/middleware"
 	"github.com/go-chi/render"
 
 	"github.com/drakkan/sftpgo/common"

+ 1 - 1
telemetry/telemetry.go

@@ -12,7 +12,7 @@ import (
 	"runtime"
 	"time"
 
-	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/v5"
 
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/logger"

+ 41 - 17
webdavd/internal_test.go

@@ -398,7 +398,7 @@ func TestUserInvalidParams(t *testing.T) {
 	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", u.Username), nil)
 	assert.NoError(t, err)
 
-	_, err = server.validateUser(u, req)
+	_, err = server.validateUser(u, req, dataprovider.LoginMethodPassword)
 	if assert.Error(t, err) {
 		assert.EqualError(t, err, fmt.Sprintf("cannot login user with invalid home dir: %#v", u.HomeDir))
 	}
@@ -422,7 +422,7 @@ func TestUserInvalidParams(t *testing.T) {
 		VirtualPath: vdirPath2,
 	})
 
-	_, err = server.validateUser(u, req)
+	_, err = server.validateUser(u, req, dataprovider.LoginMethodPassword)
 	if assert.Error(t, err) {
 		assert.EqualError(t, err, "overlapping mapped folders are allowed only with quota tracking disabled")
 	}
@@ -923,14 +923,15 @@ func TestBasicUsersCache(t *testing.T) {
 
 	ipAddr := "127.0.0.1"
 
-	_, _, _, err = server.authenticate(req, ipAddr) //nolint:dogsled
+	_, _, _, _, err = server.authenticate(req, ipAddr) //nolint:dogsled
 	assert.Error(t, err)
 
 	now := time.Now()
 	req.SetBasicAuth(username, password)
-	_, isCached, _, err := server.authenticate(req, ipAddr)
+	_, isCached, _, loginMethod, err := server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMethod)
 	// now the user should be cached
 	var cachedUser *dataprovider.CachedUser
 	result, ok := dataprovider.GetCachedWebDAVUser(username)
@@ -939,14 +940,14 @@ func TestBasicUsersCache(t *testing.T) {
 		assert.False(t, cachedUser.IsExpired())
 		assert.True(t, cachedUser.Expiration.After(now.Add(time.Duration(c.Cache.Users.ExpirationTime)*time.Minute)))
 		// authenticate must return the cached user now
-		authUser, isCached, _, err := server.authenticate(req, ipAddr)
+		authUser, isCached, _, _, err := server.authenticate(req, ipAddr)
 		assert.NoError(t, err)
 		assert.True(t, isCached)
 		assert.Equal(t, cachedUser.User, authUser)
 	}
 	// a wrong password must fail
 	req.SetBasicAuth(username, "wrong")
-	_, _, _, err = server.authenticate(req, ipAddr) //nolint:dogsled
+	_, _, _, _, err = server.authenticate(req, ipAddr) //nolint:dogsled
 	assert.EqualError(t, err, dataprovider.ErrInvalidCredentials.Error())
 	req.SetBasicAuth(username, password)
 
@@ -959,9 +960,10 @@ func TestBasicUsersCache(t *testing.T) {
 		assert.True(t, cachedUser.IsExpired())
 	}
 	// now authenticate should get the user from the data provider and update the cache
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMethod)
 	result, ok = dataprovider.GetCachedWebDAVUser(username)
 	if assert.True(t, ok) {
 		cachedUser = result.(*dataprovider.CachedUser)
@@ -973,9 +975,10 @@ func TestBasicUsersCache(t *testing.T) {
 	_, ok = dataprovider.GetCachedWebDAVUser(username)
 	assert.False(t, ok)
 
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMethod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMethod)
 	_, ok = dataprovider.GetCachedWebDAVUser(username)
 	assert.True(t, ok)
 	// cache is invalidated after user deletion
@@ -1045,23 +1048,26 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
 	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user1.Username, password+"1")
-	_, isCached, _, err := server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err := server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user2.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user2.Username, password+"2")
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user3.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user3.Username, password+"3")
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 
 	// the first 3 users are now cached
 	_, ok := dataprovider.GetCachedWebDAVUser(user1.Username)
@@ -1074,9 +1080,10 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user4.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user4.Username, password+"4")
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 	// user1, the first cached, should be removed now
 	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
 	assert.False(t, ok)
@@ -1091,9 +1098,10 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user1.Username, password+"1")
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 	_, ok = dataprovider.GetCachedWebDAVUser(user2.Username)
 	assert.False(t, ok)
 	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
@@ -1107,9 +1115,10 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user2.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user2.Username, password+"2")
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 	_, ok = dataprovider.GetCachedWebDAVUser(user3.Username)
 	assert.False(t, ok)
 	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
@@ -1123,9 +1132,10 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user3.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user3.Username, password+"3")
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 	_, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
 	assert.False(t, ok)
 	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
@@ -1144,16 +1154,18 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user4.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user4.Username, password+"4")
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 
 	req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/%v", user1.Username), nil)
 	assert.NoError(t, err)
 	req.SetBasicAuth(user1.Username, password+"1")
-	_, isCached, _, err = server.authenticate(req, ipAddr)
+	_, isCached, _, loginMehod, err = server.authenticate(req, ipAddr)
 	assert.NoError(t, err)
 	assert.False(t, isCached)
+	assert.Equal(t, dataprovider.LoginMethodPassword, loginMehod)
 	_, ok = dataprovider.GetCachedWebDAVUser(user2.Username)
 	assert.False(t, ok)
 	_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
@@ -1272,3 +1284,15 @@ func TestVerifyTLSConnection(t *testing.T) {
 
 	certMgr = oldCertMgr
 }
+
+func TestMisc(t *testing.T) {
+	oldCertMgr := certMgr
+
+	certMgr = nil
+	err := ReloadCertificateMgr()
+	assert.Nil(t, err)
+	val := getConfigPath("", ".")
+	assert.Empty(t, val)
+
+	certMgr = oldCertMgr
+}

+ 78 - 46
webdavd/server.go

@@ -14,7 +14,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/go-chi/chi/middleware"
+	"github.com/go-chi/chi/v5/middleware"
 	"github.com/rs/cors"
 	"github.com/rs/xid"
 	"golang.org/x/net/webdav"
@@ -69,10 +69,15 @@ func (s *webDavServer) listenAndServe(compressor *middleware.Compressor) error {
 		}
 		logger.Debug(logSender, "", "configured TLS cipher suites for binding %#v: %v", s.binding.GetAddress(),
 			httpServer.TLSConfig.CipherSuites)
-		if s.binding.ClientAuthType == 1 {
+		if s.binding.isMutualTLSEnabled() {
 			httpServer.TLSConfig.ClientCAs = certMgr.GetRootCAs()
-			httpServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
 			httpServer.TLSConfig.VerifyConnection = s.verifyTLSConnection
+			switch s.binding.ClientAuthType {
+			case 1:
+				httpServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
+			case 2:
+				httpServer.TLSConfig.ClientAuth = tls.VerifyClientCertIfGiven
+			}
 		}
 		logger.Info(logSender, "", "starting HTTPS serving, binding: %v", s.binding.GetAddress())
 		return httpServer.ListenAndServeTLS("", "")
@@ -92,6 +97,9 @@ func (s *webDavServer) 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")
 		}
@@ -152,28 +160,28 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		http.Error(w, common.ErrConnectionDenied.Error(), http.StatusForbidden)
 		return
 	}
-	user, _, lockSystem, err := s.authenticate(r, ipAddr)
+	user, _, lockSystem, loginMethod, err := s.authenticate(r, ipAddr)
 	if err != nil {
 		w.Header().Set("WWW-Authenticate", "Basic realm=\"SFTPGo WebDAV\"")
-		http.Error(w, err401.Error(), http.StatusUnauthorized)
+		http.Error(w, fmt.Sprintf("Authentication error: %v", err), http.StatusUnauthorized)
 		return
 	}
 
-	connectionID, err := s.validateUser(&user, r)
+	connectionID, err := s.validateUser(&user, r, loginMethod)
 	if err != nil {
-		updateLoginMetrics(&user, ipAddr, err)
+		updateLoginMetrics(&user, ipAddr, loginMethod, err)
 		http.Error(w, err.Error(), http.StatusForbidden)
 		return
 	}
 
 	fs, err := user.GetFilesystem(connectionID)
 	if err != nil {
-		updateLoginMetrics(&user, ipAddr, err)
+		updateLoginMetrics(&user, ipAddr, loginMethod, err)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	updateLoginMetrics(&user, ipAddr, err)
+	updateLoginMetrics(&user, ipAddr, loginMethod, err)
 
 	ctx := context.WithValue(r.Context(), requestIDKey, connectionID)
 	ctx = context.WithValue(ctx, requestStartKey, time.Now())
@@ -202,12 +210,32 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	handler.ServeHTTP(w, r.WithContext(ctx))
 }
 
-func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.User, bool, webdav.LockSystem, error) {
+func (s *webDavServer) getCredentialsAndLoginMethod(r *http.Request) (string, string, string, *x509.Certificate, bool) {
+	var tlsCert *x509.Certificate
+	loginMethod := dataprovider.LoginMethodPassword
+	username, password, ok := r.BasicAuth()
+	if s.binding.isMutualTLSEnabled() && r.TLS != nil {
+		if len(r.TLS.PeerCertificates) > 0 {
+			tlsCert = r.TLS.PeerCertificates[0]
+			if ok {
+				loginMethod = dataprovider.LoginMethodTLSCertificateAndPwd
+			} else {
+				loginMethod = dataprovider.LoginMethodTLSCertificate
+				username = tlsCert.Subject.CommonName
+				password = ""
+			}
+			ok = true
+		}
+	}
+	return username, password, loginMethod, tlsCert, ok
+}
+
+func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.User, bool, webdav.LockSystem, string, error) {
 	var user dataprovider.User
 	var err error
-	username, password, ok := r.BasicAuth()
+	username, password, loginMethod, tlsCert, ok := s.getCredentialsAndLoginMethod(r)
 	if !ok {
-		return user, false, nil, err401
+		return user, false, nil, loginMethod, err401
 	}
 	result, ok := dataprovider.GetCachedWebDAVUser(username)
 	if ok {
@@ -215,43 +243,47 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us
 		if cachedUser.IsExpired() {
 			dataprovider.RemoveCachedWebDAVUser(username)
 		} else {
-			if password != "" && cachedUser.Password == password {
-				return cachedUser.User, true, cachedUser.LockSystem, nil
+			if !cachedUser.User.IsTLSUsernameVerificationEnabled() {
+				// for backward compatibility with 2.0.x we only check the password
+				tlsCert = nil
+				loginMethod = dataprovider.LoginMethodPassword
+			}
+			if err := dataprovider.CheckCachedUserCredentials(cachedUser, password, loginMethod, common.ProtocolWebDAV, tlsCert); err == nil {
+				return cachedUser.User, true, cachedUser.LockSystem, loginMethod, nil
 			}
-			updateLoginMetrics(&cachedUser.User, ip, dataprovider.ErrInvalidCredentials)
-			return user, false, nil, dataprovider.ErrInvalidCredentials
+			updateLoginMetrics(&cachedUser.User, ip, loginMethod, dataprovider.ErrInvalidCredentials)
+			return user, false, nil, loginMethod, dataprovider.ErrInvalidCredentials
 		}
 	}
-	user, err = dataprovider.CheckUserAndPass(username, password, ip, common.ProtocolWebDAV)
+	user, loginMethod, err = dataprovider.CheckCompositeCredentials(username, password, ip, loginMethod,
+		common.ProtocolWebDAV, tlsCert)
 	if err != nil {
 		user.Username = username
-		updateLoginMetrics(&user, ip, err)
-		return user, false, nil, err
+		updateLoginMetrics(&user, ip, loginMethod, err)
+		return user, false, nil, loginMethod, err
 	}
 	lockSystem := webdav.NewMemLS()
-	if password != "" {
-		cachedUser := &dataprovider.CachedUser{
-			User:       user,
-			Password:   password,
-			LockSystem: lockSystem,
-		}
-		if s.config.Cache.Users.ExpirationTime > 0 {
-			cachedUser.Expiration = time.Now().Add(time.Duration(s.config.Cache.Users.ExpirationTime) * time.Minute)
-		}
-		dataprovider.CacheWebDAVUser(cachedUser, s.config.Cache.Users.MaxSize)
-		if user.FsConfig.Provider != dataprovider.SFTPFilesystemProvider {
-			// for sftp fs check root path does nothing so don't open a useless SFTP connection
-			tempFs, err := user.GetFilesystem("temp")
-			if err == nil {
-				tempFs.CheckRootPath(user.Username, user.UID, user.GID)
-				tempFs.Close()
-			}
+	cachedUser := &dataprovider.CachedUser{
+		User:       user,
+		Password:   password,
+		LockSystem: lockSystem,
+	}
+	if s.config.Cache.Users.ExpirationTime > 0 {
+		cachedUser.Expiration = time.Now().Add(time.Duration(s.config.Cache.Users.ExpirationTime) * time.Minute)
+	}
+	dataprovider.CacheWebDAVUser(cachedUser, s.config.Cache.Users.MaxSize)
+	if user.FsConfig.Provider != dataprovider.SFTPFilesystemProvider {
+		// for sftp fs check root path does nothing so don't open a useless SFTP connection
+		tempFs, err := user.GetFilesystem("temp")
+		if err == nil {
+			tempFs.CheckRootPath(user.Username, user.UID, user.GID)
+			tempFs.Close()
 		}
 	}
-	return user, false, lockSystem, nil
+	return user, false, lockSystem, loginMethod, nil
 }
 
-func (s *webDavServer) validateUser(user *dataprovider.User, r *http.Request) (string, error) {
+func (s *webDavServer) validateUser(user *dataprovider.User, r *http.Request, loginMethod string) (string, error) {
 	connID := xid.New().String()
 	connectionID := fmt.Sprintf("%v_%v", common.ProtocolWebDAV, connID)
 
@@ -264,9 +296,9 @@ func (s *webDavServer) validateUser(user *dataprovider.User, r *http.Request) (s
 		logger.Debug(logSender, connectionID, "cannot login user %#v, protocol DAV is not allowed", user.Username)
 		return connID, fmt.Errorf("Protocol DAV 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 connID, 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 connID, fmt.Errorf("Login method %v is not allowed for user %#v", loginMethod, user.Username)
 	}
 	if user.MaxSessions > 0 {
 		activeSessions := common.Connections.GetActiveSessions(user.Username)
@@ -335,16 +367,16 @@ func checkRemoteAddress(r *http.Request) {
 	}
 }
 
-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, common.ProtocolWebDAV, err.Error())
+		logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, err.Error())
 		event := common.HostEventLoginFailed
 		if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
 			event = common.HostEventUserNotFound
 		}
 		common.AddDefenderEvent(ip, event)
 	}
-	metrics.AddLoginResult(dataprovider.LoginMethodPassword, err)
-	dataprovider.ExecutePostLoginHook(user, dataprovider.LoginMethodPassword, ip, common.ProtocolWebDAV, err)
+	metrics.AddLoginResult(loginMethod, err)
+	dataprovider.ExecutePostLoginHook(user, loginMethod, ip, common.ProtocolWebDAV, err)
 }

+ 5 - 1
webdavd/webdavd.go

@@ -5,7 +5,7 @@ import (
 	"fmt"
 	"path/filepath"
 
-	"github.com/go-chi/chi/middleware"
+	"github.com/go-chi/chi/v5/middleware"
 
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/logger"
@@ -88,6 +88,10 @@ type Binding struct {
 	TLSCipherSuites []string `json:"tls_cipher_suites" mapstructure:"tls_cipher_suites"`
 }
 
+func (b *Binding) isMutualTLSEnabled() bool {
+	return b.ClientAuthType == 1 || b.ClientAuthType == 2
+}
+
 // GetAddress returns the binding address
 func (b *Binding) GetAddress() string {
 	return fmt.Sprintf("%s:%d", b.Address, b.Port)

+ 574 - 47
webdavd/webdavd_test.go

@@ -3,6 +3,7 @@ package webdavd_test
 import (
 	"bytes"
 	"crypto/rand"
+	"crypto/tls"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -36,15 +37,17 @@ import (
 )
 
 const (
-	logSender        = "webavdTesting"
-	webDavServerAddr = "127.0.0.1:9090"
-	webDavServerPort = 9090
-	sftpServerAddr   = "127.0.0.1:9022"
-	defaultUsername  = "test_user_dav"
-	defaultPassword  = "test_password"
-	configDir        = ".."
-	osWindows        = "windows"
-	webDavCert       = `-----BEGIN CERTIFICATE-----
+	logSender           = "webavdTesting"
+	webDavServerAddr    = "localhost:9090"
+	webDavTLSServerAddr = "localhost:9443"
+	webDavServerPort    = 9090
+	webDavTLSServerPort = 9443
+	sftpServerAddr      = "127.0.0.1:9022"
+	defaultUsername     = "test_user_dav"
+	defaultPassword     = "test_password"
+	configDir           = ".."
+	osWindows           = "windows"
+	webDavCert          = `-----BEGIN CERTIFICATE-----
 MIICHTCCAaKgAwIBAgIUHnqw7QnB1Bj9oUsNpdb+ZkFPOxMwCgYIKoZIzj0EAwIw
 RTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGElu
 dGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDAyMDQwOTUzMDRaFw0zMDAyMDEw
@@ -67,8 +70,161 @@ UM2lmBLIXpGgBwYFK4EEACKhZANiAARCjRMqJ85rzMC998X5z761nJ+xL3bkmGVq
 WvrJ51t5OxV0v25NsOgR82CANXUgvhVYs7vNFN+jxtb2aj6Xg+/2G/BNxkaFspIV
 CzgWkxiz7XE4lgUwX44FCXZM3+JeUbI=
 -----END EC PRIVATE KEY-----`
-	testFileName   = "test_file_dav.dat"
-	testDLFileName = "test_download_dav.dat"
+	caCRT = `-----BEGIN CERTIFICATE-----
+MIIE5jCCAs6gAwIBAgIBATANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDEwhDZXJ0
+QXV0aDAeFw0yMTAxMDIyMTIwNTVaFw0yMjA3MDIyMTMwNTJaMBMxETAPBgNVBAMT
+CENlcnRBdXRoMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4Tiho5xW
+AC15JRkMwfp3/TJwI2As7MY5dele5cmdr5bHAE+sRKqC+Ti88OJWCV5saoyax/1S
+CjxJlQMZMl169P1QYJskKjdG2sdv6RLWLMgwSNRRjxp/Bw9dHdiEb9MjLgu28Jro
+9peQkHcRHeMf5hM9WvlIJGrdzbC4hUehmqggcqgARainBkYjf0SwuWxHeu4nMqkp
+Ak5tcSTLCjHfEFHZ9Te0TIPG5YkWocQKyeLgu4lvuU+DD2W2lym+YVUtRMGs1Env
+k7p+N0DcGU26qfzZ2sF5ZXkqm7dBsGQB9pIxwc2Q8T1dCIyP9OQCKVILdc5aVFf1
+cryQFHYzYNNZXFlIBims5VV5Mgfp8ESHQSue+v6n6ykecLEyKt1F1Y/MWY/nWUSI
+8zdq83jdBAZVjo9MSthxVn57/06s/hQca65IpcTZV2gX0a+eRlAVqaRbAhL3LaZe
+bYsW3WHKoUOftwemuep3nL51TzlXZVL7Oz/ClGaEOsnGG9KFO6jh+W768qC0zLQI
+CdE7v2Zex98sZteHCg9fGJHIaYoF0aJG5P3WI5oZf2fy7UIYN9ADLFZiorCXAZEh
+CSU6mDoRViZ4RGR9GZxbDZ9KYn7O8M/KCR72bkQg73TlMsk1zSXEw0MKLUjtsw6c
+rZ0Jt8t3sRatHO3JrYHALMt9vZfyNCZp0IsCAwEAAaNFMEMwDgYDVR0PAQH/BAQD
+AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO1yCNAGr/zQTJIi8lw3
+w5OiuBvMMA0GCSqGSIb3DQEBCwUAA4ICAQA6gCNuM7r8mnx674dm31GxBjQy5ZwB
+7CxDzYEvL/oiZ3Tv3HlPfN2LAAsJUfGnghh9DOytenL2CTZWjl/emP5eijzmlP+9
+zva5I6CIMCf/eDDVsRdO244t0o4uG7+At0IgSDM3bpVaVb4RHZNjEziYChsEYY8d
+HK6iwuRSvFniV6yhR/Vj1Ymi9yZ5xclqseLXiQnUB0PkfIk23+7s42cXB16653fH
+O/FsPyKBLiKJArizLYQc12aP3QOrYoYD9+fAzIIzew7A5C0aanZCGzkuFpO6TRlD
+Tb7ry9Gf0DfPpCgxraH8tOcmnqp/ka3hjqo/SRnnTk0IFrmmLdarJvjD46rKwBo4
+MjyAIR1mQ5j8GTlSFBmSgETOQ/EYvO3FPLmra1Fh7L+DvaVzTpqI9fG3TuyyY+Ri
+Fby4ycTOGSZOe5Fh8lqkX5Y47mCUJ3zHzOA1vUJy2eTlMRGpu47Eb1++Vm6EzPUP
+2EF5aD+zwcssh+atZvQbwxpgVqVcyLt91RSkKkmZQslh0rnlTb68yxvUnD3zw7So
+o6TAf9UvwVMEvdLT9NnFd6hwi2jcNte/h538GJwXeBb8EkfpqLKpTKyicnOdkamZ
+7E9zY8SHNRYMwB9coQ/W8NvufbCgkvOoLyMXk5edbXofXl3PhNGOlraWbghBnzf5
+r3rwjFsQOoZotA==
+-----END CERTIFICATE-----`
+	caCRL = `-----BEGIN X509 CRL-----
+MIICpzCBkAIBATANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDEwhDZXJ0QXV0aBcN
+MjEwMTAyMjEzNDA1WhcNMjMwMTAyMjEzNDA1WjAkMCICEQC+l04DbHWMyC3fG09k
+VXf+Fw0yMTAxMDIyMTM0MDVaoCMwITAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJc
+N8OTorgbzDANBgkqhkiG9w0BAQsFAAOCAgEAEJ7z+uNc8sqtxlOhSdTGDzX/xput
+E857kFQkSlMnU2whQ8c+XpYrBLA5vIZJNSSwohTpM4+zVBX/bJpmu3wqqaArRO9/
+YcW5mQk9Anvb4WjQW1cHmtNapMTzoC9AiYt/OWPfy+P6JCgCr4Hy6LgQyIRL6bM9
+VYTalolOm1qa4Y5cIeT7iHq/91mfaqo8/6MYRjLl8DOTROpmw8OS9bCXkzGKdCat
+AbAzwkQUSauyoCQ10rpX+Y64w9ng3g4Dr20aCqPf5osaqplEJ2HTK8ljDTidlslv
+9anQj8ax3Su89vI8+hK+YbfVQwrThabgdSjQsn+veyx8GlP8WwHLAQ379KjZjWg+
+OlOSwBeU1vTdP0QcB8X5C2gVujAyuQekbaV86xzIBOj7vZdfHZ6ee30TZ2FKiMyg
+7/N2OqW0w77ChsjB4MSHJCfuTgIeg62GzuZXLM+Q2Z9LBdtm4Byg+sm/P52adOEg
+gVb2Zf4KSvsAmA0PIBlu449/QXUFcMxzLFy7mwTeZj2B4Ln0Hm0szV9f9R8MwMtB
+SyLYxVH+mgqaR6Jkk22Q/yYyLPaELfafX5gp/AIXG8n0zxfVaTvK3auSgb1Q6ZLS
+5QH9dSIsmZHlPq7GoSXmKpMdjUL8eaky/IMteioyXgsBiATzl5L2dsw6MTX3MDF0
+QbDK+MzhmbKfDxs=
+-----END X509 CRL-----`
+	client1Crt = `-----BEGIN CERTIFICATE-----
+MIIEITCCAgmgAwIBAgIRAIppZHoj1hM80D7WzTEKLuAwDQYJKoZIhvcNAQELBQAw
+EzERMA8GA1UEAxMIQ2VydEF1dGgwHhcNMjEwMTAyMjEyMzEwWhcNMjIwNzAyMjEz
+MDUxWjASMRAwDgYDVQQDEwdjbGllbnQxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAoKbYY9MdF2kF/nhBESIiZTdVYtA8XL9xrIZyDj9EnCiTxHiVbJtH
+XVwszqSl5TRrotPmnmAQcX3r8OCk+z+RQZ0QQj257P3kG6q4rNnOcWCS5xEd20jP
+yhQ3m+hMGfZsotNTQze1ochuQgLUN6IPyPxZkH22ia3jX4iu1eo/QxeLYHj1UHw4
+3Cii9yE+j5kPUC21xmnrGKdUrB55NYLXHx6yTIqYR5znSOVB8oJi18/hwdZmH859
+DHhm0Hx1HrS+jbjI3+CMorZJ3WUyNf+CkiVLD3xYutPbxzEpwiqkG/XYzLH0habT
+cDcILo18n+o3jvem2KWBrDhyairjIDscwQIDAQABo3EwbzAOBgNVHQ8BAf8EBAMC
+A7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBSJ5GIv
+zIrE4ZSQt2+CGblKTDswizAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJcN8OTorgb
+zDANBgkqhkiG9w0BAQsFAAOCAgEALh4f5GhvNYNou0Ab04iQBbLEdOu2RlbK1B5n
+K9P/umYenBHMY/z6HT3+6tpcHsDuqE8UVdq3f3Gh4S2Gu9m8PRitT+cJ3gdo9Plm
+3rD4ufn/s6rGg3ppydXcedm17492tbccUDWOBZw3IO/ASVq13WPgT0/Kev7cPq0k
+sSdSNhVeXqx8Myc2/d+8GYyzbul2Kpfa7h9i24sK49E9ftnSmsIvngONo08eT1T0
+3wAOyK2981LIsHaAWcneShKFLDB6LeXIT9oitOYhiykhFlBZ4M1GNlSNfhQ8IIQP
+xbqMNXCLkW4/BtLhGEEcg0QVso6Kudl9rzgTfQknrdF7pHp6rS46wYUjoSyIY6dl
+oLmnoAVJX36J3QPWelePI9e07X2wrTfiZWewwgw3KNRWjd6/zfPLe7GoqXnK1S2z
+PT8qMfCaTwKTtUkzXuTFvQ8bAo2My/mS8FOcpkt2oQWeOsADHAUX7fz5BCoa2DL3
+k/7Mh4gVT+JYZEoTwCFuYHgMWFWe98naqHi9lB4yR981p1QgXgxO7qBeipagKY1F
+LlH1iwXUqZ3MZnkNA+4e1Fglsw3sa/rC+L98HnznJ/YbTfQbCP6aQ1qcOymrjMud
+7MrFwqZjtd/SK4Qx1VpK6jGEAtPgWBTUS3p9ayg6lqjMBjsmySWfvRsDQbq6P5Ct
+O/e3EH8=
+-----END CERTIFICATE-----`
+	client1Key = `-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAoKbYY9MdF2kF/nhBESIiZTdVYtA8XL9xrIZyDj9EnCiTxHiV
+bJtHXVwszqSl5TRrotPmnmAQcX3r8OCk+z+RQZ0QQj257P3kG6q4rNnOcWCS5xEd
+20jPyhQ3m+hMGfZsotNTQze1ochuQgLUN6IPyPxZkH22ia3jX4iu1eo/QxeLYHj1
+UHw43Cii9yE+j5kPUC21xmnrGKdUrB55NYLXHx6yTIqYR5znSOVB8oJi18/hwdZm
+H859DHhm0Hx1HrS+jbjI3+CMorZJ3WUyNf+CkiVLD3xYutPbxzEpwiqkG/XYzLH0
+habTcDcILo18n+o3jvem2KWBrDhyairjIDscwQIDAQABAoIBAEBSjVFqtbsp0byR
+aXvyrtLX1Ng7h++at2jca85Ihq//jyqbHTje8zPuNAKI6eNbmb0YGr5OuEa4pD9N
+ssDmMsKSoG/lRwwcm7h4InkSvBWpFShvMgUaohfHAHzsBYxfnh+TfULsi0y7c2n6
+t/2OZcOTRkkUDIITnXYiw93ibHHv2Mv2bBDu35kGrcK+c2dN5IL5ZjTjMRpbJTe2
+44RBJbdTxHBVSgoGBnugF+s2aEma6Ehsj70oyfoVpM6Aed5kGge0A5zA1JO7WCn9
+Ay/DzlULRXHjJIoRWd2NKvx5n3FNppUc9vJh2plRHalRooZ2+MjSf8HmXlvG2Hpb
+ScvmWgECgYEA1G+A/2KnxWsr/7uWIJ7ClcGCiNLdk17Pv3DZ3G4qUsU2ITftfIbb
+tU0Q/b19na1IY8Pjy9ptP7t74/hF5kky97cf1FA8F+nMj/k4+wO8QDI8OJfzVzh9
+PwielA5vbE+xmvis5Hdp8/od1Yrc/rPSy2TKtPFhvsqXjqoUmOAjDP8CgYEAwZjH
+9dt1sc2lx/rMxihlWEzQ3JPswKW9/LJAmbRBoSWF9FGNjbX7uhWtXRKJkzb8ZAwa
+88azluNo2oftbDD/+jw8b2cDgaJHlLAkSD4O1D1RthW7/LKD15qZ/oFsRb13NV85
+ZNKtwslXGbfVNyGKUVFm7fVA8vBAOUey+LKDFj8CgYEAg8WWstOzVdYguMTXXuyb
+ruEV42FJaDyLiSirOvxq7GTAKuLSQUg1yMRBIeQEo2X1XU0JZE3dLodRVhuO4EXP
+g7Dn4X7Th9HSvgvNuIacowWGLWSz4Qp9RjhGhXhezUSx2nseY6le46PmFavJYYSR
+4PBofMyt4PcyA6Cknh+KHmkCgYEAnTriG7ETE0a7v4DXUpB4TpCEiMCy5Xs2o8Z5
+ZNva+W+qLVUWq+MDAIyechqeFSvxK6gRM69LJ96lx+XhU58wJiFJzAhT9rK/g+jS
+bsHH9WOfu0xHkuHA5hgvvV2Le9B2wqgFyva4HJy82qxMxCu/VG/SMqyfBS9OWbb7
+ibQhdq0CgYAl53LUWZsFSZIth1vux2LVOsI8C3X1oiXDGpnrdlQ+K7z57hq5EsRq
+GC+INxwXbvKNqp5h0z2MvmKYPDlGVTgw8f8JjM7TkN17ERLcydhdRrMONUryZpo8
+1xTob+8blyJgfxZUIAKbMbMbIiU0WAF0rfD/eJJwS4htOW/Hfv4TGA==
+-----END RSA PRIVATE KEY-----`
+	// client 2 crt is revoked
+	client2Crt = `-----BEGIN CERTIFICATE-----
+MIIEITCCAgmgAwIBAgIRAL6XTgNsdYzILd8bT2RVd/4wDQYJKoZIhvcNAQELBQAw
+EzERMA8GA1UEAxMIQ2VydEF1dGgwHhcNMjEwMTAyMjEyMzIwWhcNMjIwNzAyMjEz
+MDUxWjASMRAwDgYDVQQDEwdjbGllbnQyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEA6xjW5KQR3/OFQtV5M75WINqQ4AzXSu6DhSz/yumaaQZP/UxY+6hi
+jcrFzGo9MMie/Sza8DhkXOFAl2BelUubrOeB2cl+/Gr8OCyRi2Gv6j3zCsuN/4jQ
+tNaoez/IbkDvI3l/ZpzBtnuNY2RiemGgHuORXHRVf3qVlsw+npBIRW5rM2HkO/xG
+oZjeBErWVu390Lyn+Gvk2TqQDnkutWnxUC60/zPlHhXZ4BwaFAekbSnjsSDB1YFM
+s8HwW4oBryoxdj3/+/qLrBHt75IdLw3T7/V1UDJQM3EvSQOr12w4egpldhtsC871
+nnBQZeY6qA5feffIwwg/6lJm70o6S6OX6wIDAQABo3EwbzAOBgNVHQ8BAf8EBAMC
+A7gwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBTB84v5
+t9HqhLhMODbn6oYkEQt3KzAfBgNVHSMEGDAWgBTtcgjQBq/80EySIvJcN8OTorgb
+zDANBgkqhkiG9w0BAQsFAAOCAgEALGtBCve5k8tToL3oLuXp/oSik6ovIB/zq4I/
+4zNMYPU31+ZWz6aahysgx1JL1yqTa3Qm8o2tu52MbnV10dM7CIw7c/cYa+c+OPcG
+5LF97kp13X+r2axy+CmwM86b4ILaDGs2Qyai6VB6k7oFUve+av5o7aUrNFpqGCJz
+HWdtHZSVA3JMATzy0TfWanwkzreqfdw7qH0yZ9bDURlBKAVWrqnCstva9jRuv+AI
+eqxr/4Ro986TFjJdoAP3Vr16CPg7/B6GA/KmsBWJrpeJdPWq4i2gpLKvYZoy89qD
+mUZf34RbzcCtV4NvV1DadGnt4us0nvLrvS5rL2+2uWD09kZYq9RbLkvgzF/cY0fz
+i7I1bi5XQ+alWe0uAk5ZZL/D+GTRYUX1AWwCqwJxmHrMxcskMyO9pXvLyuSWRDLo
+YNBrbX9nLcfJzVCp+X+9sntTHjs4l6Cw+fLepJIgtgqdCHtbhTiv68vSM6cgb4br
+6n2xrXRKuioiWFOrTSRr+oalZh8dGJ/xvwY8IbWknZAvml9mf1VvfE7Ma5P777QM
+fsbYVTq0Y3R/5hIWsC3HA5z6MIM8L1oRe/YyhP3CTmrCHkVKyDOosGXpGz+JVcyo
+cfYkY5A3yFKB2HaCwZSfwFmRhxkrYWGEbHv3Cd9YkZs1J3hNhGFZyVMC9Uh0S85a
+6zdDidU=
+-----END CERTIFICATE-----`
+	client2Key = `-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA6xjW5KQR3/OFQtV5M75WINqQ4AzXSu6DhSz/yumaaQZP/UxY
++6hijcrFzGo9MMie/Sza8DhkXOFAl2BelUubrOeB2cl+/Gr8OCyRi2Gv6j3zCsuN
+/4jQtNaoez/IbkDvI3l/ZpzBtnuNY2RiemGgHuORXHRVf3qVlsw+npBIRW5rM2Hk
+O/xGoZjeBErWVu390Lyn+Gvk2TqQDnkutWnxUC60/zPlHhXZ4BwaFAekbSnjsSDB
+1YFMs8HwW4oBryoxdj3/+/qLrBHt75IdLw3T7/V1UDJQM3EvSQOr12w4egpldhts
+C871nnBQZeY6qA5feffIwwg/6lJm70o6S6OX6wIDAQABAoIBAFatstVb1KdQXsq0
+cFpui8zTKOUiduJOrDkWzTygAmlEhYtrccdfXu7OWz0x0lvBLDVGK3a0I/TGrAzj
+4BuFY+FM/egxTVt9in6fmA3et4BS1OAfCryzUdfK6RV//8L+t+zJZ/qKQzWnugpy
+QYjDo8ifuMFwtvEoXizaIyBNLAhEp9hnrv+Tyi2O2gahPvCHsD48zkyZRCHYRstD
+NH5cIrwz9/RJgPO1KI+QsJE7Nh7stR0sbr+5TPU4fnsL2mNhMUF2TJrwIPrc1yp+
+YIUjdnh3SO88j4TQT3CIrWi8i4pOy6N0dcVn3gpCRGaqAKyS2ZYUj+yVtLO4KwxZ
+SZ1lNvECgYEA78BrF7f4ETfWSLcBQ3qxfLs7ibB6IYo2x25685FhZjD+zLXM1AKb
+FJHEXUm3mUYrFJK6AFEyOQnyGKBOLs3S6oTAswMPbTkkZeD1Y9O6uv0AHASLZnK6
+pC6ub0eSRF5LUyTQ55Jj8D7QsjXJueO8v+G5ihWhNSN9tB2UA+8NBmkCgYEA+weq
+cvoeMIEMBQHnNNLy35bwfqrceGyPIRBcUIvzQfY1vk7KW6DYOUzC7u+WUzy/hA52
+DjXVVhua2eMQ9qqtOav7djcMc2W9RbLowxvno7K5qiCss013MeWk64TCWy+WMp5A
+AVAtOliC3hMkIKqvR2poqn+IBTh1449agUJQqTMCgYEAu06IHGq1GraV6g9XpGF5
+wqoAlMzUTdnOfDabRilBf/YtSr+J++ThRcuwLvXFw7CnPZZ4TIEjDJ7xjj3HdxeE
+fYYjineMmNd40UNUU556F1ZLvJfsVKizmkuCKhwvcMx+asGrmA+tlmds4p3VMS50
+KzDtpKzLWlmU/p/RINWlRmkCgYBy0pHTn7aZZx2xWKqCDg+L2EXPGqZX6wgZDpu7
+OBifzlfM4ctL2CmvI/5yPmLbVgkgBWFYpKUdiujsyyEiQvWTUKhn7UwjqKDHtcsk
+G6p7xS+JswJrzX4885bZJ9Oi1AR2yM3sC9l0O7I4lDbNPmWIXBLeEhGMmcPKv/Kc
+91Ff4wKBgQCF3ur+Vt0PSU0ucrPVHjCe7tqazm0LJaWbPXL1Aw0pzdM2EcNcW/MA
+w0kqpr7MgJ94qhXCBcVcfPuFN9fBOadM3UBj1B45Cz3pptoK+ScI8XKno6jvVK/p
+xr5cb9VBRBtB9aOKVfuRhpatAfS2Pzm2Htae9lFn7slGPUmu2hkjDw==
+-----END RSA PRIVATE KEY-----`
+	testFileName       = "test_file_dav.dat"
+	testDLFileName     = "test_download_dav.dat"
+	tlsClient1Username = "client1"
+	tlsClient2Username = "client2"
 )
 
 var (
@@ -81,6 +237,8 @@ var (
 	logFilePath     string
 	certPath        string
 	keyPath         string
+	caCrtPath       string
+	caCRLPath       string
 )
 
 func TestMain(m *testing.M) {
@@ -108,6 +266,8 @@ func TestMain(m *testing.M) {
 
 	certPath = filepath.Join(os.TempDir(), "test_dav.crt")
 	keyPath = filepath.Join(os.TempDir(), "test_dav.key")
+	caCrtPath = filepath.Join(os.TempDir(), "test_dav_ca.crt")
+	caCRLPath = filepath.Join(os.TempDir(), "test_dav_crl.crt")
 	err = os.WriteFile(certPath, []byte(webDavCert), os.ModePerm)
 	if err != nil {
 		logger.ErrorToConsole("error writing WebDAV certificate: %v", err)
@@ -118,6 +278,16 @@ func TestMain(m *testing.M) {
 		logger.ErrorToConsole("error writing WebDAV private key: %v", err)
 		os.Exit(1)
 	}
+	err = os.WriteFile(caCrtPath, []byte(caCRT), os.ModePerm)
+	if err != nil {
+		logger.ErrorToConsole("error writing WebDAV CA crt: %v", err)
+		os.Exit(1)
+	}
+	err = os.WriteFile(caCRLPath, []byte(caCRL), os.ModePerm)
+	if err != nil {
+		logger.ErrorToConsole("error writing WebDAV CRL: %v", err)
+		os.Exit(1)
+	}
 
 	err = common.Initialize(commonConf)
 	if err != nil {
@@ -155,10 +325,19 @@ func TestMain(m *testing.M) {
 	sftpdConf.HostKeys = []string{hostKeyPath}
 
 	webDavConf := config.GetWebDAVDConfig()
+	webDavConf.CertificateFile = certPath
+	webDavConf.CertificateKeyFile = keyPath
+	webDavConf.CACertificates = []string{caCrtPath}
+	webDavConf.CARevocationLists = []string{caCRLPath}
 	webDavConf.Bindings = []webdavd.Binding{
 		{
 			Port: webDavServerPort,
 		},
+		{
+			Port:           webDavTLSServerPort,
+			EnableHTTPS:    true,
+			ClientAuthType: 2,
+		},
 	}
 	webDavConf.Cors = webdavd.Cors{
 		Enabled:        true,
@@ -209,6 +388,7 @@ func TestMain(m *testing.M) {
 	}()
 
 	waitTCPListening(webDavConf.Bindings[0].GetAddress())
+	waitTCPListening(webDavConf.Bindings[1].GetAddress())
 	waitTCPListening(httpdConf.Bindings[0].GetAddress())
 	waitTCPListening(sftpdConf.Bindings[0].GetAddress())
 	webdavd.ReloadCertificateMgr() //nolint:errcheck
@@ -220,6 +400,8 @@ func TestMain(m *testing.M) {
 	os.Remove(postConnectPath)
 	os.Remove(certPath)
 	os.Remove(keyPath)
+	os.Remove(caCrtPath)
+	os.Remove(caCRLPath)
 	os.Remove(hostKeyPath)
 	os.Remove(hostKeyPath + ".pub")
 	os.Exit(exitCode)
@@ -281,6 +463,13 @@ func TestInitialization(t *testing.T) {
 	cfg.CARevocationLists = nil
 	err = cfg.Initialize(configDir)
 	assert.Error(t, err)
+
+	cfg.CertificateFile = certPath
+	cfg.CertificateKeyFile = keyPath
+	cfg.CACertificates = []string{caCrtPath}
+	cfg.CARevocationLists = []string{caCRLPath}
+	err = cfg.Initialize(configDir)
+	assert.Error(t, err)
 }
 
 func TestBasicHandling(t *testing.T) {
@@ -293,7 +482,7 @@ func TestBasicHandling(t *testing.T) {
 	sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
 	for _, user := range []dataprovider.User{localUser, sftpUser} {
-		client := getWebDavClient(user)
+		client := getWebDavClient(user, true, nil)
 		assert.NoError(t, checkBasicFunc(client))
 		testFilePath := filepath.Join(homeBasePath, testFileName)
 		testFileSize := int64(65535)
@@ -378,7 +567,7 @@ func TestBasicHandlingCryptFs(t *testing.T) {
 	u.QuotaSize = 6553600
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 	assert.NoError(t, checkBasicFunc(client))
 
 	testFilePath := filepath.Join(homeBasePath, testFileName)
@@ -459,7 +648,7 @@ func TestPropPatch(t *testing.T) {
 	for _, u := range []dataprovider.User{getTestUser(), getTestUserWithCryptFs(), sftpUser} {
 		user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 		assert.NoError(t, err)
-		client := getWebDavClient(user)
+		client := getWebDavClient(user, true, nil)
 		assert.NoError(t, checkBasicFunc(client), sftpUser.Username)
 
 		testFilePath := filepath.Join(homeBasePath, testFileName)
@@ -500,10 +689,10 @@ func TestLoginInvalidPwd(t *testing.T) {
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 	assert.NoError(t, checkBasicFunc(client))
 	user.Password = "wrong"
-	client = getWebDavClient(user)
+	client = getWebDavClient(user, false, nil)
 	assert.Error(t, checkBasicFunc(client))
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
@@ -511,7 +700,7 @@ func TestLoginInvalidPwd(t *testing.T) {
 
 func TestLoginNonExistentUser(t *testing.T) {
 	user := getTestUser()
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, true, nil)
 	assert.Error(t, checkBasicFunc(client))
 }
 
@@ -527,17 +716,17 @@ func TestDefender(t *testing.T) {
 
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, true, nil)
 	assert.NoError(t, checkBasicFunc(client))
 
 	for i := 0; i < 3; i++ {
 		user.Password = "wrong_pwd"
-		client = getWebDavClient(user)
+		client = getWebDavClient(user, false, nil)
 		assert.Error(t, checkBasicFunc(client))
 	}
 
 	user.Password = defaultPassword
-	client = getWebDavClient(user)
+	client = getWebDavClient(user, true, nil)
 	err = checkBasicFunc(client)
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "403")
@@ -568,10 +757,10 @@ func TestLoginExternalAuth(t *testing.T) {
 	providerConf.ExternalAuthScope = 0
 	err = dataprovider.Initialize(providerConf, configDir, true)
 	assert.NoError(t, err)
-	client := getWebDavClient(u)
+	client := getWebDavClient(u, false, nil)
 	assert.NoError(t, checkBasicFunc(client))
 	u.Username = defaultUsername + "1"
-	client = getWebDavClient(u)
+	client = getWebDavClient(u, false, nil)
 	assert.Error(t, checkBasicFunc(client))
 	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
 	assert.NoError(t, err)
@@ -608,20 +797,20 @@ func TestPreLoginHook(t *testing.T) {
 	assert.NoError(t, err)
 	_, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusNotFound)
 	assert.NoError(t, err)
-	client := getWebDavClient(u)
+	client := getWebDavClient(u, true, nil)
 	assert.NoError(t, checkBasicFunc(client))
 
 	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
 	assert.NoError(t, err)
 	// test login with an existing user
-	client = getWebDavClient(user)
+	client = getWebDavClient(user, true, nil)
 	assert.NoError(t, checkBasicFunc(client))
 	err = os.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), os.ModePerm)
 	assert.NoError(t, err)
 	// update the user to remove it from the cache
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
-	client = getWebDavClient(user)
+	client = getWebDavClient(user, true, nil)
 	assert.Error(t, checkBasicFunc(client))
 	// update the user to remove it from the cache
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
@@ -629,7 +818,7 @@ func TestPreLoginHook(t *testing.T) {
 	user.Status = 0
 	err = os.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm)
 	assert.NoError(t, err)
-	client = getWebDavClient(user)
+	client = getWebDavClient(user, true, nil)
 	assert.Error(t, checkBasicFunc(client))
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
@@ -658,7 +847,7 @@ func TestPostConnectHook(t *testing.T) {
 	assert.NoError(t, err)
 	err = os.WriteFile(postConnectPath, getPostConnectScriptContent(0), os.ModePerm)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 	assert.NoError(t, checkBasicFunc(client))
 	err = os.WriteFile(postConnectPath, getPostConnectScriptContent(1), os.ModePerm)
 	assert.NoError(t, err)
@@ -684,7 +873,7 @@ func TestMaxConnections(t *testing.T) {
 
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, true, nil)
 	assert.NoError(t, checkBasicFunc(client))
 	// now add a fake connection
 	fs := vfs.NewOsFs("id", os.TempDir(), nil)
@@ -708,7 +897,7 @@ func TestMaxSessions(t *testing.T) {
 	u.MaxSessions = 1
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 	assert.NoError(t, checkBasicFunc(client))
 	// now add a fake connection
 	fs := vfs.NewOsFs("id", os.TempDir(), nil)
@@ -731,7 +920,7 @@ func TestLoginWithIPilters(t *testing.T) {
 	u.Filters.AllowedIP = []string{"172.19.0.0/16"}
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, true, nil)
 	assert.Error(t, checkBasicFunc(client))
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
@@ -765,7 +954,7 @@ func TestDownloadErrors(t *testing.T) {
 	}
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 	testFilePath1 := filepath.Join(user.HomeDir, subDir1, "file.zipp")
 	testFilePath2 := filepath.Join(user.HomeDir, subDir2, "file.zipp")
 	testFilePath3 := filepath.Join(user.HomeDir, subDir2, "file.jpg")
@@ -815,7 +1004,7 @@ func TestUploadErrors(t *testing.T) {
 	}
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, true, nil)
 	testFilePath := filepath.Join(homeBasePath, testFileName)
 	testFileSize := user.QuotaSize
 	err = createTestFile(testFilePath, testFileSize)
@@ -859,13 +1048,13 @@ func TestDeniedLoginMethod(t *testing.T) {
 	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, true, nil)
 	assert.Error(t, checkBasicFunc(client))
 
 	user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPublicKey, dataprovider.SSHLoginMethodKeyAndKeyboardInt}
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
-	client = getWebDavClient(user)
+	client = getWebDavClient(user, true, nil)
 	assert.NoError(t, checkBasicFunc(client))
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
@@ -879,13 +1068,13 @@ func TestDeniedProtocols(t *testing.T) {
 	u.Filters.DeniedProtocols = []string{common.ProtocolWebDAV}
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 	assert.Error(t, checkBasicFunc(client))
 
 	user.Filters.DeniedProtocols = []string{common.ProtocolSSH, common.ProtocolFTP}
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
-	client = getWebDavClient(user)
+	client = getWebDavClient(user, false, nil)
 	assert.NoError(t, checkBasicFunc(client))
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
@@ -918,7 +1107,7 @@ func TestQuotaLimits(t *testing.T) {
 		testFilePath2 := filepath.Join(homeBasePath, testFileName2)
 		err = createTestFile(testFilePath2, testFileSize2)
 		assert.NoError(t, err)
-		client := getWebDavClient(user)
+		client := getWebDavClient(user, true, nil)
 		// test quota files
 		err = uploadFile(testFilePath, testFileName+".quota", testFileSize, client)
 		assert.NoError(t, err)
@@ -1001,7 +1190,7 @@ func TestUploadMaxSize(t *testing.T) {
 		testFilePath1 := filepath.Join(homeBasePath, testFileName1)
 		err = createTestFile(testFilePath1, testFileSize1)
 		assert.NoError(t, err)
-		client := getWebDavClient(user)
+		client := getWebDavClient(user, false, nil)
 		err = uploadFile(testFilePath1, testFileName1, testFileSize1, client)
 		assert.Error(t, err)
 		err = uploadFile(testFilePath, testFileName, testFileSize, client)
@@ -1048,7 +1237,7 @@ func TestClientClose(t *testing.T) {
 		testFilePath := filepath.Join(homeBasePath, testFileName)
 		err = createTestFile(testFilePath, testFileSize)
 		assert.NoError(t, err)
-		client := getWebDavClient(user)
+		client := getWebDavClient(user, true, nil)
 		assert.NoError(t, checkBasicFunc(client))
 
 		var wg sync.WaitGroup
@@ -1149,7 +1338,7 @@ func TestLoginWithDatabaseCredentials(t *testing.T) {
 
 	assert.NoFileExists(t, credentialsFile)
 
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 
 	err = client.Connect()
 	assert.NoError(t, err)
@@ -1183,7 +1372,7 @@ func TestLoginInvalidFs(t *testing.T) {
 	err = os.Remove(credentialsFile)
 	assert.NoError(t, err)
 
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, true, nil)
 	assert.Error(t, checkBasicFunc(client))
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
@@ -1208,7 +1397,7 @@ func TestBytesRangeRequests(t *testing.T) {
 		fileContent := []byte("test file contents")
 		err = os.WriteFile(testFilePath, fileContent, os.ModePerm)
 		assert.NoError(t, err)
-		client := getWebDavClient(user)
+		client := getWebDavClient(user, true, nil)
 		err = uploadFile(testFilePath, testFileName, int64(len(fileContent)), client)
 		assert.NoError(t, err)
 		remotePath := fmt.Sprintf("http://%v/%v", webDavServerAddr, testFileName)
@@ -1290,7 +1479,7 @@ func TestGETAsPROPFIND(t *testing.T) {
 			resp.Body.Close()
 		}
 	}
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 	err = client.MkdirAll(path.Join(subDir1, "sub", "sub1"), os.ModePerm)
 	assert.NoError(t, err)
 	subPath := fmt.Sprintf("http://%v/%v", webDavServerAddr, subDir1)
@@ -1341,7 +1530,7 @@ func TestStat(t *testing.T) {
 	u.Permissions["/subdir"] = []string{dataprovider.PermUpload, dataprovider.PermListItems, dataprovider.PermDownload}
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, true, nil)
 	subDir := "subdir"
 	testFilePath := filepath.Join(homeBasePath, testFileName)
 	testFileSize := int64(65535)
@@ -1387,7 +1576,7 @@ func TestUploadOverwriteVfolder(t *testing.T) {
 	assert.NoError(t, err)
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
-	client := getWebDavClient(user)
+	client := getWebDavClient(user, false, nil)
 	files, err := client.ReadDir(".")
 	assert.NoError(t, err)
 	vdirFound := false
@@ -1442,7 +1631,7 @@ func TestMiscCommands(t *testing.T) {
 	assert.NoError(t, err)
 	for _, user := range []dataprovider.User{localUser, sftpUser} {
 		dir := "testDir"
-		client := getWebDavClient(user)
+		client := getWebDavClient(user, true, nil)
 		err = client.MkdirAll(path.Join(dir, "sub1", "sub2"), os.ModePerm)
 		assert.NoError(t, err)
 		testFilePath := filepath.Join(homeBasePath, testFileName)
@@ -1504,6 +1693,319 @@ func TestMiscCommands(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestClientCertificateAuthRevokedCert(t *testing.T) {
+	u := getTestUser()
+	u.Username = tlsClient2Username
+	u.Filters.TLSUsername = dataprovider.TLSUsernameCN
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	tlsConfig := &tls.Config{
+		ServerName:         "localhost",
+		InsecureSkipVerify: true, // use this for tests only
+		MinVersion:         tls.VersionTLS12,
+	}
+	tlsCert, err := tls.X509KeyPair([]byte(client2Crt), []byte(client2Key))
+	assert.NoError(t, err)
+	tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert)
+	client := getWebDavClient(user, true, tlsConfig)
+	err = checkBasicFunc(client)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "bad certificate")
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestClientCertificateAuth(t *testing.T) {
+	u := getTestUser()
+	u.Username = tlsClient1Username
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword, dataprovider.LoginMethodTLSCertificateAndPwd}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	tlsConfig := &tls.Config{
+		ServerName:         "localhost",
+		InsecureSkipVerify: true, // use this for tests only
+		MinVersion:         tls.VersionTLS12,
+	}
+	tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key))
+	assert.NoError(t, err)
+	tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert)
+	// TLS username is not enabled, mutual TLS should fail
+	resp, err := getTLSHTTPClient(tlsConfig).Get(fmt.Sprintf("https://%v/", webDavTLSServerAddr))
+	if assert.NoError(t, err) {
+		defer resp.Body.Close()
+		body, err := io.ReadAll(resp.Body)
+		assert.NoError(t, err)
+		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, string(body))
+	}
+
+	user.Filters.TLSUsername = dataprovider.TLSUsernameCN
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	client := getWebDavClient(user, true, tlsConfig)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+
+	user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword, dataprovider.LoginMethodTLSCertificate}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	client = getWebDavClient(user, true, tlsConfig)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestWrongClientCertificate(t *testing.T) {
+	u := getTestUser()
+	u.Username = tlsClient2Username
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodTLSCertificateAndPwd}
+	u.Filters.TLSUsername = dataprovider.TLSUsernameCN
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	tlsConfig := &tls.Config{
+		ServerName:         "localhost",
+		InsecureSkipVerify: true, // use this for tests only
+		MinVersion:         tls.VersionTLS12,
+	}
+	tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key))
+	assert.NoError(t, err)
+	tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert)
+
+	// the certificate common name is client1 and it does not exists
+	resp, err := getTLSHTTPClient(tlsConfig).Get(fmt.Sprintf("https://%v/", webDavTLSServerAddr))
+	if assert.NoError(t, err) {
+		defer resp.Body.Close()
+		body, err := io.ReadAll(resp.Body)
+		assert.NoError(t, err)
+		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, string(body))
+	}
+
+	user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword, dataprovider.LoginMethodTLSCertificate}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	// now create client1
+	u = getTestUser()
+	u.Username = tlsClient1Username
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword, dataprovider.LoginMethodTLSCertificate}
+	u.Filters.TLSUsername = dataprovider.TLSUsernameCN
+	user1, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	resp, err = getTLSHTTPClient(tlsConfig).Get(fmt.Sprintf("https://%v:%v@%v/", tlsClient2Username, defaultPassword,
+		webDavTLSServerAddr))
+	if assert.NoError(t, err) {
+		defer resp.Body.Close()
+		body, err := io.ReadAll(resp.Body)
+		assert.NoError(t, err)
+		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, string(body))
+		assert.Contains(t, string(body), "CN \"client1\" does not match username \"client2\"")
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user1, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user1.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestClientCertificateAuthCachedUser(t *testing.T) {
+	u := getTestUser()
+	u.Username = tlsClient1Username
+	u.Filters.TLSUsername = dataprovider.TLSUsernameCN
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodTLSCertificateAndPwd}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	tlsConfig := &tls.Config{
+		ServerName:         "localhost",
+		InsecureSkipVerify: true, // use this for tests only
+		MinVersion:         tls.VersionTLS12,
+	}
+	tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key))
+	assert.NoError(t, err)
+	tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert)
+	client := getWebDavClient(user, true, tlsConfig)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+	// the user is now cached without a password, try a simple password login with and without TLS
+	client = getWebDavClient(user, true, nil)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+
+	client = getWebDavClient(user, false, nil)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+
+	// and now with a wrong password
+	user.Password = "wrong"
+	client = getWebDavClient(user, false, nil)
+	err = checkBasicFunc(client)
+	assert.Error(t, err)
+
+	// allow cert+password only
+	user.Password = ""
+	user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodTLSCertificate}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	client = getWebDavClient(user, true, tlsConfig)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+	// the user is now cached
+	client = getWebDavClient(user, true, tlsConfig)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+	// password auth should work too
+	client = getWebDavClient(user, false, nil)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+
+	client = getWebDavClient(user, true, nil)
+	err = checkBasicFunc(client)
+	assert.NoError(t, err)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
+func TestExternatAuthWithClientCert(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	u := getTestUser()
+	u.Username = tlsClient1Username
+	u.Filters.TLSUsername = dataprovider.TLSUsernameCN
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodTLSCertificate, dataprovider.LoginMethodPassword}
+	err := dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	assert.NoError(t, err)
+	providerConf.ExternalAuthHook = extAuthPath
+	providerConf.ExternalAuthScope = 0
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+
+	tlsConfig := &tls.Config{
+		ServerName:         "localhost",
+		InsecureSkipVerify: true, // use this for tests only
+		MinVersion:         tls.VersionTLS12,
+	}
+	tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key))
+	assert.NoError(t, err)
+	tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert)
+	client := getWebDavClient(u, true, tlsConfig)
+	assert.NoError(t, checkBasicFunc(client))
+
+	resp, err := getTLSHTTPClient(tlsConfig).Get(fmt.Sprintf("https://%v:%v@%v/", tlsClient2Username, defaultPassword,
+		webDavTLSServerAddr))
+	if assert.NoError(t, err) {
+		defer resp.Body.Close()
+		body, err := io.ReadAll(resp.Body)
+		assert.NoError(t, err)
+		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, string(body))
+		assert.Contains(t, string(body), "invalid credentials")
+	}
+
+	user, _, err := httpdtest.GetUserByUsername(tlsClient1Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, tlsClient1Username, user.Username)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf = config.GetProviderConf()
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	err = os.Remove(extAuthPath)
+	assert.NoError(t, err)
+}
+
+func TestPreLoginHookWithClientCert(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	u := getTestUser()
+	u.Username = tlsClient1Username
+	u.Filters.TLSUsername = dataprovider.TLSUsernameCN
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodTLSCertificate, dataprovider.LoginMethodPassword}
+	err := dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = os.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm)
+	assert.NoError(t, err)
+	providerConf.PreLoginHook = preLoginPath
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	_, _, err = httpdtest.GetUserByUsername(tlsClient1Username, http.StatusNotFound)
+	assert.NoError(t, err)
+	tlsConfig := &tls.Config{
+		ServerName:         "localhost",
+		InsecureSkipVerify: true, // use this for tests only
+		MinVersion:         tls.VersionTLS12,
+	}
+	tlsCert, err := tls.X509KeyPair([]byte(client1Crt), []byte(client1Key))
+	assert.NoError(t, err)
+	tlsConfig.Certificates = append(tlsConfig.Certificates, tlsCert)
+	client := getWebDavClient(u, true, tlsConfig)
+	assert.NoError(t, checkBasicFunc(client))
+
+	user, _, err := httpdtest.GetUserByUsername(tlsClient1Username, http.StatusOK)
+	assert.NoError(t, err)
+	// test login with an existing user
+	client = getWebDavClient(user, true, tlsConfig)
+	assert.NoError(t, checkBasicFunc(client))
+	err = os.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), os.ModePerm)
+	assert.NoError(t, err)
+	// update the user to remove it from the cache
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	client = getWebDavClient(user, true, tlsConfig)
+	assert.Error(t, checkBasicFunc(client))
+	// update the user to remove it from the cache
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	user.Status = 0
+	err = os.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm)
+	assert.NoError(t, err)
+	client = getWebDavClient(user, true, tlsConfig)
+	assert.Error(t, checkBasicFunc(client))
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf = config.GetProviderConf()
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
+	err = os.Remove(preLoginPath)
+	assert.NoError(t, err)
+}
+
 func checkBasicFunc(client *gowebdav.Client) error {
 	err := client.Connect()
 	if err != nil {
@@ -1557,14 +2059,39 @@ func downloadFile(remoteSourcePath string, localDestPath string, expectedSize in
 	return nil
 }
 
-func getWebDavClient(user dataprovider.User) *gowebdav.Client {
+func getTLSHTTPClient(tlsConfig *tls.Config) *http.Client {
+	customTransport := http.DefaultTransport.(*http.Transport).Clone()
+	customTransport.TLSClientConfig = tlsConfig
+
+	return &http.Client{
+		Timeout:   5 * time.Second,
+		Transport: customTransport,
+	}
+}
+
+func getWebDavClient(user dataprovider.User, useTLS bool, tlsConfig *tls.Config) *gowebdav.Client {
 	rootPath := fmt.Sprintf("http://%v/", webDavServerAddr)
+	if useTLS {
+		rootPath = fmt.Sprintf("https://%v/", webDavTLSServerAddr)
+		if tlsConfig == nil {
+			tlsConfig = &tls.Config{
+				ServerName:         "localhost",
+				InsecureSkipVerify: true, // use this for tests only
+				MinVersion:         tls.VersionTLS12,
+			}
+		}
+	}
 	pwd := defaultPassword
 	if user.Password != "" {
 		pwd = user.Password
 	}
 	client := gowebdav.NewClient(rootPath, user.Username, pwd)
 	client.SetTimeout(5 * time.Second)
+	if tlsConfig != nil {
+		customTransport := http.DefaultTransport.(*http.Transport).Clone()
+		customTransport.TLSClientConfig = tlsConfig
+		client.SetTransport(customTransport)
+	}
 	return client
 }