瀏覽代碼

user: add TLS certificates

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 年之前
父節點
當前提交
d939a82225

+ 1 - 1
go.mod

@@ -53,7 +53,7 @@ require (
 	github.com/rs/cors v1.10.1
 	github.com/rs/cors v1.10.1
 	github.com/rs/xid v1.5.0
 	github.com/rs/xid v1.5.0
 	github.com/rs/zerolog v1.31.0
 	github.com/rs/zerolog v1.31.0
-	github.com/sftpgo/sdk v0.1.6-0.20231105181545-b44c8058fc25
+	github.com/sftpgo/sdk v0.1.6-0.20240114195211-3f4916cc829c
 	github.com/shirou/gopsutil/v3 v3.23.12
 	github.com/shirou/gopsutil/v3 v3.23.12
 	github.com/spf13/afero v1.11.0
 	github.com/spf13/afero v1.11.0
 	github.com/spf13/cobra v1.8.0
 	github.com/spf13/cobra v1.8.0

+ 2 - 2
go.sum

@@ -350,8 +350,8 @@ github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJ
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
-github.com/sftpgo/sdk v0.1.6-0.20231105181545-b44c8058fc25 h1:R8cTb41ZX5WSYw8q8ufTKQfOvXh7aLQWqdnteDY/96U=
-github.com/sftpgo/sdk v0.1.6-0.20231105181545-b44c8058fc25/go.mod h1:6s/PFoLUd7FXG3wGlrdVhrA0SJOwri2h9kzTph/2oiU=
+github.com/sftpgo/sdk v0.1.6-0.20240114195211-3f4916cc829c h1:07TYPvNbOnmKsBxjNsUr+gsILIUWflw1UYwjn1jognM=
+github.com/sftpgo/sdk v0.1.6-0.20240114195211-3f4916cc829c/go.mod h1:AWoY2YYe/P1ymfTlRER/meERQjCcZZTbgVPGcPQgaqc=
 github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
 github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
 github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
 github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=

+ 4 - 0
internal/acme/acme.go

@@ -390,6 +390,10 @@ func (c *Configuration) loadPrivateKey() (crypto.PrivateKey, error) {
 	}
 	}
 
 
 	keyBlock, _ := pem.Decode(keyBytes)
 	keyBlock, _ := pem.Decode(keyBytes)
+	if keyBlock == nil {
+		acmeLog(logger.LevelError, "unable to parse private key from file %q: pem decoding failed", c.accountKeyPath)
+		return nil, errors.New("pem decoding failed")
+	}
 
 
 	var privateKey crypto.PrivateKey
 	var privateKey crypto.PrivateKey
 	switch keyBlock.Type {
 	switch keyBlock.Type {

+ 32 - 1
internal/dataprovider/dataprovider.go

@@ -29,6 +29,7 @@ import (
 	"encoding/base64"
 	"encoding/base64"
 	"encoding/hex"
 	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
+	"encoding/pem"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"hash"
 	"hash"
@@ -1212,7 +1213,7 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str
 	if err != nil {
 	if err != nil {
 		return user, loginMethod, err
 		return user, loginMethod, err
 	}
 	}
-	if !user.IsTLSUsernameVerificationEnabled() {
+	if !user.IsTLSVerificationEnabled() {
 		// for backward compatibility with 2.0.x we only check the password and change the login method here
 		// 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
 		// in future updates we have to return an error
 		user, err := CheckUserAndPass(username, password, ip, protocol)
 		user, err := CheckUserAndPass(username, password, ip, protocol)
@@ -2623,6 +2624,8 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
 	filters.PasswordStrength = in.PasswordStrength
 	filters.PasswordStrength = in.PasswordStrength
 	filters.WebClient = make([]string, len(in.WebClient))
 	filters.WebClient = make([]string, len(in.WebClient))
 	copy(filters.WebClient, in.WebClient)
 	copy(filters.WebClient, in.WebClient)
+	filters.TLSCerts = make([]string, len(in.TLSCerts))
+	copy(filters.TLSCerts, in.TLSCerts)
 	filters.BandwidthLimits = make([]sdk.BandwidthLimit, 0, len(in.BandwidthLimits))
 	filters.BandwidthLimits = make([]sdk.BandwidthLimit, 0, len(in.BandwidthLimits))
 	for _, limit := range in.BandwidthLimits {
 	for _, limit := range in.BandwidthLimits {
 		bwLimit := sdk.BandwidthLimit{
 		bwLimit := sdk.BandwidthLimit{
@@ -3023,6 +3026,25 @@ func validateFilterProtocols(filters *sdk.BaseUserFilters) error {
 	return nil
 	return nil
 }
 }
 
 
+func validateTLSCerts(certs []string) error {
+	for idx, cert := range certs {
+		derBlock, _ := pem.Decode([]byte(cert))
+		if derBlock == nil {
+			return util.NewI18nError(
+				util.NewValidationError(fmt.Sprintf("invalid TLS certificate %d", idx)),
+				util.I18nErrorInvalidTLSCert,
+			)
+		}
+		if _, err := x509.ParseCertificate(derBlock.Bytes); err != nil {
+			return util.NewI18nError(
+				util.NewValidationError(fmt.Sprintf("error parsing TLS certificate %d", idx)),
+				util.I18nErrorInvalidTLSCert,
+			)
+		}
+	}
+	return nil
+}
+
 func validateBaseFilters(filters *sdk.BaseUserFilters) error {
 func validateBaseFilters(filters *sdk.BaseUserFilters) error {
 	checkEmptyFiltersStruct(filters)
 	checkEmptyFiltersStruct(filters)
 	if err := validateIPFilters(filters); err != nil {
 	if err := validateIPFilters(filters); err != nil {
@@ -3047,6 +3069,9 @@ func validateBaseFilters(filters *sdk.BaseUserFilters) error {
 			return util.NewValidationError(fmt.Sprintf("invalid TLS username: %q", filters.TLSUsername))
 			return util.NewValidationError(fmt.Sprintf("invalid TLS username: %q", filters.TLSUsername))
 		}
 		}
 	}
 	}
+	if err := validateTLSCerts(filters.TLSCerts); err != nil {
+		return err
+	}
 	for _, opts := range filters.WebClient {
 	for _, opts := range filters.WebClient {
 		if !util.Contains(sdk.WebClientOptions, opts) {
 		if !util.Contains(sdk.WebClientOptions, opts) {
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %q", opts))
 			return util.NewValidationError(fmt.Sprintf("invalid web client options %q", opts))
@@ -3312,6 +3337,12 @@ func checkUserAndTLSCertificate(user *User, protocol string, tlsCert *x509.Certi
 	}
 	}
 	switch protocol {
 	switch protocol {
 	case protocolFTP, protocolWebDAV:
 	case protocolFTP, protocolWebDAV:
+		for _, cert := range user.Filters.TLSCerts {
+			derBlock, _ := pem.Decode([]byte(cert))
+			if derBlock != nil && bytes.Equal(derBlock.Bytes, tlsCert.Raw) {
+				return *user, nil
+			}
+		}
 		if user.Filters.TLSUsername == sdk.TLSUsernameCN {
 		if user.Filters.TLSUsername == sdk.TLSUsernameCN {
 			if user.Username == tlsCert.Subject.CommonName {
 			if user.Username == tlsCert.Subject.CommonName {
 				return *user, nil
 				return *user, nil

+ 1 - 0
internal/dataprovider/group.go

@@ -179,6 +179,7 @@ func (g *Group) validateUserSettings() error {
 		}
 		}
 		g.UserSettings.Permissions = permissions
 		g.UserSettings.Permissions = permissions
 	}
 	}
+	g.UserSettings.Filters.TLSCerts = nil
 	if err := validateBaseFilters(&g.UserSettings.Filters); err != nil {
 	if err := validateBaseFilters(&g.UserSettings.Filters); err != nil {
 		return err
 		return err
 	}
 	}

+ 6 - 4
internal/dataprovider/user.go

@@ -468,9 +468,11 @@ func (u *User) IsPasswordHashed() bool {
 	return util.IsStringPrefixInSlice(u.Password, hashPwdPrefixes)
 	return util.IsStringPrefixInSlice(u.Password, hashPwdPrefixes)
 }
 }
 
 
-// IsTLSUsernameVerificationEnabled returns true if we need to extract the username
-// from the client TLS certificate
-func (u *User) IsTLSUsernameVerificationEnabled() bool {
+// IsTLSVerificationEnabled returns true if we need to check the TLS authentication
+func (u *User) IsTLSVerificationEnabled() bool {
+	if len(u.Filters.TLSCerts) > 0 {
+		return true
+	}
 	if u.Filters.TLSUsername != "" {
 	if u.Filters.TLSUsername != "" {
 		return u.Filters.TLSUsername != sdk.TLSUsernameNone
 		return u.Filters.TLSUsername != sdk.TLSUsernameNone
 	}
 	}
@@ -1757,7 +1759,7 @@ func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer *
 	if u.Filters.MaxUploadFileSize == 0 {
 	if u.Filters.MaxUploadFileSize == 0 {
 		u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize
 		u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize
 	}
 	}
-	if !u.IsTLSUsernameVerificationEnabled() {
+	if !u.IsTLSVerificationEnabled() {
 		u.Filters.TLSUsername = filters.TLSUsername
 		u.Filters.TLSUsername = filters.TLSUsername
 	}
 	}
 	if !u.Filters.Hooks.CheckPasswordDisabled {
 	if !u.Filters.Hooks.CheckPasswordDisabled {

+ 19 - 0
internal/ftpd/ftpd_test.go

@@ -3519,6 +3519,25 @@ func TestClientCertificateAuth(t *testing.T) {
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "does not match username")
 		assert.Contains(t, err.Error(), "does not match username")
 	}
 	}
+	// add the certs to the user
+	user2.Filters.TLSUsername = sdk.TLSUsernameNone
+	user2.Filters.TLSCerts = []string{client2Crt, client1Crt}
+	user2, _, err = httpdtest.UpdateUser(user2, http.StatusOK, "")
+	assert.NoError(t, err)
+	client, err = getFTPClient(user2, true, tlsConfig)
+	if assert.NoError(t, err) {
+		err = checkBasicFTP(client)
+		assert.NoError(t, err)
+		err = client.Quit()
+		assert.NoError(t, err)
+	}
+	user2.Filters.TLSCerts = []string{client2Crt}
+	user2, _, err = httpdtest.UpdateUser(user2, http.StatusOK, "")
+	assert.NoError(t, err)
+	_, err = getFTPClient(user2, true, tlsConfig)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "TLS certificate is not valid")
+	}
 
 
 	// now disable certificate authentication
 	// now disable certificate authentication
 	user.Filters.DeniedLoginMethods = append(user.Filters.DeniedLoginMethods, dataprovider.LoginMethodTLSCertificate,
 	user.Filters.DeniedLoginMethods = append(user.Filters.DeniedLoginMethods, dataprovider.LoginMethodTLSCertificate,

+ 1 - 1
internal/ftpd/server.go

@@ -245,7 +245,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo
 				updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err)
 				updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err)
 				return nil, dataprovider.ErrInvalidCredentials
 				return nil, dataprovider.ErrInvalidCredentials
 			}
 			}
-			if dbUser.IsTLSUsernameVerificationEnabled() {
+			if dbUser.IsTLSVerificationEnabled() {
 				dbUser, err = dataprovider.CheckUserAndTLSCert(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0])
 				dbUser, err = dataprovider.CheckUserAndTLSCert(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0])
 				if err != nil {
 				if err != nil {
 					return nil, err
 					return nil, err

+ 20 - 0
internal/httpd/httpd_test.go

@@ -821,12 +821,32 @@ func TestRoleRelations(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }
 
 
+func TestTLSCert(t *testing.T) {
+	u := getTestUser()
+	u.Filters.TLSCerts = []string{"not a cert"}
+	_, resp, err := httpdtest.AddUser(u, http.StatusBadRequest)
+	assert.NoError(t, err, string(resp))
+	assert.Contains(t, string(resp), "invalid TLS certificate")
+
+	u.Filters.TLSCerts = []string{httpsCert}
+	user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	if assert.Len(t, user.Filters.TLSCerts, 1) {
+		assert.Equal(t, httpsCert, user.Filters.TLSCerts[0])
+	}
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestBasicGroupHandling(t *testing.T) {
 func TestBasicGroupHandling(t *testing.T) {
 	g := getTestGroup()
 	g := getTestGroup()
+	g.UserSettings.Filters.TLSCerts = []string{"invalid cert"} // ignored for groups
 	group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
 	group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Greater(t, group.CreatedAt, int64(0))
 	assert.Greater(t, group.CreatedAt, int64(0))
 	assert.Greater(t, group.UpdatedAt, int64(0))
 	assert.Greater(t, group.UpdatedAt, int64(0))
+	assert.Len(t, group.UserSettings.Filters.TLSCerts, 0)
 	groupGet, _, err := httpdtest.GetGroupByName(group.Name, http.StatusOK)
 	groupGet, _, err := httpdtest.GetGroupByName(group.Name, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, group, groupGet)
 	assert.Equal(t, group, groupGet)

+ 7 - 3
internal/httpd/server.go

@@ -48,6 +48,10 @@ import (
 	"github.com/drakkan/sftpgo/v2/internal/version"
 	"github.com/drakkan/sftpgo/v2/internal/version"
 )
 )
 
 
+const (
+	jsonAPISuffix = "/json"
+)
+
 var (
 var (
 	compressor      = middleware.NewCompressor(5)
 	compressor      = middleware.NewCompressor(5)
 	xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto")
 	xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto")
@@ -1683,7 +1687,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
 			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
 				Get(webUsersPath, s.handleGetWebUsers)
 				Get(webUsersPath, s.handleGetWebUsers)
 			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), compressor.Handler, s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), compressor.Handler, s.refreshCookie).
-				Get(webUsersPath+"/json", getAllUsers)
+				Get(webUsersPath+jsonAPISuffix, getAllUsers)
 			router.With(s.checkPerm(dataprovider.PermAdminAddUsers), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminAddUsers), s.refreshCookie).
 				Get(webUserPath, s.handleWebAddUserGet)
 				Get(webUserPath, s.handleWebAddUserGet)
 			router.With(s.checkPerm(dataprovider.PermAdminChangeUsers), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminChangeUsers), s.refreshCookie).
@@ -1694,7 +1698,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
 				Get(webGroupsPath, s.handleWebGetGroups)
 				Get(webGroupsPath, s.handleWebGetGroups)
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), compressor.Handler, s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), compressor.Handler, s.refreshCookie).
-				Get(webGroupsPath+"/json", getAllGroups)
+				Get(webGroupsPath+jsonAPISuffix, getAllGroups)
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
 				Get(webGroupPath, s.handleWebAddGroupGet)
 				Get(webGroupPath, s.handleWebAddGroupGet)
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(webGroupPath, s.handleWebAddGroupPost)
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(webGroupPath, s.handleWebAddGroupPost)
@@ -1709,7 +1713,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
 				Get(webFoldersPath, s.handleWebGetFolders)
 				Get(webFoldersPath, s.handleWebGetFolders)
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), compressor.Handler, s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), compressor.Handler, s.refreshCookie).
-				Get(webFoldersPath+"/json", getAllFolders)
+				Get(webFoldersPath+jsonAPISuffix, getAllFolders)
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
 				Get(webFolderPath, s.handleWebAddFolderGet)
 				Get(webFolderPath, s.handleWebAddFolderGet)
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Post(webFolderPath, s.handleWebAddFolderPost)
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Post(webFolderPath, s.handleWebAddFolderPost)

+ 5 - 0
internal/httpd/webadmin.go

@@ -1961,6 +1961,10 @@ func updateRepeaterFormFields(r *http.Request) {
 			r.Form.Add("public_keys", r.Form.Get(k))
 			r.Form.Add("public_keys", r.Form.Get(k))
 			continue
 			continue
 		}
 		}
+		if hasPrefixAndSuffix(k, "tls_certs[", "][tls_cert]") {
+			r.Form.Add("tls_certs", strings.TrimSpace(r.Form.Get(k)))
+			continue
+		}
 		if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") {
 		if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") {
 			base, _ := strings.CutSuffix(k, "[vfolder_path]")
 			base, _ := strings.CutSuffix(k, "[vfolder_path]")
 			r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k)))
 			r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k)))
@@ -2059,6 +2063,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	if err != nil {
 	if err != nil {
 		return user, err
 		return user, err
 	}
 	}
+	filters.TLSCerts = r.Form["tls_certs"]
 	user = dataprovider.User{
 	user = dataprovider.User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{
 			Username:             strings.TrimSpace(r.Form.Get("username")),
 			Username:             strings.TrimSpace(r.Form.Get("username")),

+ 11 - 0
internal/httpdtest/httpdtest.go

@@ -283,6 +283,7 @@ func AddGroup(group dataprovider.Group, expectedStatusCode int) (dataprovider.Gr
 		body, _ = getResponseBody(resp)
 		body, _ = getResponseBody(resp)
 	}
 	}
 	if err == nil {
 	if err == nil {
+		group.UserSettings.Filters.TLSCerts = nil
 		err = checkGroup(group, newGroup)
 		err = checkGroup(group, newGroup)
 	}
 	}
 	return newGroup, body, err
 	return newGroup, body, err
@@ -2412,6 +2413,16 @@ func compareUserFilterSubStructs(expected sdk.BaseUserFilters, actual sdk.BaseUs
 			return errors.New("web client options contents mismatch")
 			return errors.New("web client options contents mismatch")
 		}
 		}
 	}
 	}
+
+	if len(expected.TLSCerts) != len(actual.TLSCerts) {
+		return errors.New("TLS certs mismatch")
+	}
+	for _, cert := range expected.TLSCerts {
+		if !util.Contains(actual.TLSCerts, cert) {
+			return errors.New("TLS certs content mismatch")
+		}
+	}
+
 	return compareUserFiltersEqualFields(expected, actual)
 	return compareUserFiltersEqualFields(expected, actual)
 }
 }
 
 

+ 1 - 0
internal/util/i18n.go

@@ -185,6 +185,7 @@ const (
 	I18nErrorFsUsernameRequired        = "storage.username_required"
 	I18nErrorFsUsernameRequired        = "storage.username_required"
 	I18nAddGroupTitle                  = "title.add_group"
 	I18nAddGroupTitle                  = "title.add_group"
 	I18nUpdateGroupTitle               = "title.update_group"
 	I18nUpdateGroupTitle               = "title.update_group"
+	I18nErrorInvalidTLSCert            = "user.tls_cert_invalid"
 )
 )
 
 
 // NewI18nError returns a I18nError wrappring the provided error
 // NewI18nError returns a I18nError wrappring the provided error

+ 1 - 1
internal/webdavd/server.go

@@ -293,7 +293,7 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us
 		if cachedUser.IsExpired() {
 		if cachedUser.IsExpired() {
 			dataprovider.RemoveCachedWebDAVUser(username)
 			dataprovider.RemoveCachedWebDAVUser(username)
 		} else {
 		} else {
-			if !cachedUser.User.IsTLSUsernameVerificationEnabled() {
+			if !cachedUser.User.IsTLSVerificationEnabled() {
 				// for backward compatibility with 2.0.x we only check the password
 				// for backward compatibility with 2.0.x we only check the password
 				tlsCert = nil
 				tlsCert = nil
 				loginMethod = dataprovider.LoginMethodPassword
 				loginMethod = dataprovider.LoginMethodPassword

+ 7 - 0
internal/webdavd/webdavd_test.go

@@ -2800,6 +2800,13 @@ func TestClientCertificateAuth(t *testing.T) {
 	client := getWebDavClient(user, true, tlsConfig)
 	client := getWebDavClient(user, true, tlsConfig)
 	err = checkBasicFunc(client)
 	err = checkBasicFunc(client)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+	user.Filters.TLSUsername = sdk.TLSUsernameNone
+	user.Filters.TLSCerts = []string{client1Crt}
+	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.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword, dataprovider.LoginMethodTLSCertificate}
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")

+ 4 - 0
openapi/openapi.yaml

@@ -5499,6 +5499,10 @@ components:
         tls_username:
         tls_username:
           type: string
           type: string
           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. Currently the only supported value is `CommonName`'
           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. Currently the only supported value is `CommonName`'
+        tls_certs:
+          type: array
+          items:
+            type: string
         hooks:
         hooks:
           $ref: '#/components/schemas/HooksFilter'
           $ref: '#/components/schemas/HooksFilter'
         disable_fs_checks:
         disable_fs_checks:

+ 4 - 1
static/locales/en/translation.json

@@ -467,7 +467,10 @@
         "submit_export": "Generate and export users",
         "submit_export": "Generate and export users",
         "invalid_quota_size": "Invalid quota size",
         "invalid_quota_size": "Invalid quota size",
         "expires_in": "Expires in",
         "expires_in": "Expires in",
-        "expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration"
+        "expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration",
+        "tls_certs": "TLS certificates",
+        "tls_cert_help": "Paste your PEM encoded TLS certificate here",
+        "tls_cert_invalid": "Invalid TLS certificate"
     },
     },
     "group": {
     "group": {
         "view_manage": "View and manage groups",
         "view_manage": "View and manage groups",

+ 4 - 1
static/locales/it/translation.json

@@ -467,7 +467,10 @@
         "submit_export": "Genera ed esporta utenti",
         "submit_export": "Genera ed esporta utenti",
         "invalid_quota_size": "Quota (dimensione) non valida",
         "invalid_quota_size": "Quota (dimensione) non valida",
         "expires_in": "Scadenza",
         "expires_in": "Scadenza",
-        "expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza"
+        "expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza",
+        "tls_certs": "Certificati TLS",
+        "tls_cert_help": "Incolla qui il tuo certificato TLS codificato PEM",
+        "tls_cert_invalid": "Certificato TLS non valido"
     },
     },
     "group": {
     "group": {
         "view_manage": "Visualizza e gestisci gruppi",
         "view_manage": "Visualizza e gestisci gruppi",

+ 8 - 8
templates/webadmin/fsconfig.html

@@ -746,14 +746,6 @@ explicit grant from the SFTPGo Team ([email protected]).
 {{- end}}
 {{- end}}
 
 
 {{- define "user_group_advanced"}}
 {{- define "user_group_advanced"}}
-<div class="form-group row mt-10">
-    <label for="idStartDirectory" data-i18n="filters.start_directory" class="col-md-3 col-form-label">Start directory</label>
-    <div class="col-md-9">
-        <input id="idStartDirectory" type="text" class="form-control" name="start_directory" value="{{.StartDirectory}}" aria-describedby="idStartDirectoryHelp" />
-        <div id="idStartDirectoryHelp" class="form-text" data-i18n="filters.start_directory_help"></div>
-    </div>
-</div>
-
 <div class="form-group row mt-10">
 <div class="form-group row mt-10">
     <label for="idTLSUsername" data-i18n="filters.tls_username" class="col-md-3 col-form-label">TLS username</label>
     <label for="idTLSUsername" data-i18n="filters.tls_username" class="col-md-3 col-form-label">TLS username</label>
     <div class="col-md-9">
     <div class="col-md-9">
@@ -776,6 +768,14 @@ explicit grant from the SFTPGo Team ([email protected]).
     </div>
     </div>
 </div>
 </div>
 
 
+<div class="form-group row mt-10">
+    <label for="idStartDirectory" data-i18n="filters.start_directory" class="col-md-3 col-form-label">Start directory</label>
+    <div class="col-md-9">
+        <input id="idStartDirectory" type="text" class="form-control" name="start_directory" value="{{.StartDirectory}}" aria-describedby="idStartDirectoryHelp" />
+        <div id="idStartDirectoryHelp" class="form-text" data-i18n="filters.start_directory_help"></div>
+    </div>
+</div>
+
 <div class="form-group row mt-10">
 <div class="form-group row mt-10">
     <label for="idHooks" data-i18n="filters.hooks" class="col-md-3 col-form-label">Hooks</label>
     <label for="idHooks" data-i18n="filters.hooks" class="col-md-3 col-form-label">Hooks</label>
     <div class="col-md-9">
     <div class="col-md-9">

+ 65 - 0
templates/webadmin/user.html

@@ -629,6 +629,70 @@ explicit grant from the SFTPGo Team ([email protected]).
                     <div id="collapseAdvanced" class="accordion-collapse collapse" aria-labelledby="headingAdvanced" data-bs-parent="#accordionUser">
                     <div id="collapseAdvanced" class="accordion-collapse collapse" aria-labelledby="headingAdvanced" data-bs-parent="#accordionUser">
                         <div class="accordion-body">
                         <div class="accordion-body">
 
 
+                            <div class="card mt-10">
+                                <div class="card-header bg-light">
+                                    <h3 data-i18n="user.tls_certs" class="card-title section-title-inner">TLS certificates</h3>
+                                </div>
+                                <div class="card-body">
+                                    <div id="tls_certs">
+                                        <div class="form-group">
+                                            <div data-repeater-list="tls_certs">
+                                                {{- range $idx, $val := .User.Filters.TLSCerts}}
+                                                <div data-repeater-item>
+                                                    <div class="form-group row">
+                                                        <div class="col-md-9 mt-3 mt-md-8">
+                                                            <textarea data-i18n="[placeholder]user.tls_cert_help" class="form-control" name="tls_cert" rows="4">{{$val}}</textarea>
+                                                        </div>
+                                                        <div class="col-md-3 mt-3 mt-md-8">
+                                                            <a href="#" data-repeater-delete
+                                                                class="btn btn-light-danger">
+                                                                <i class="ki-duotone ki-trash fs-5">
+                                                                    <span class="path1"></span>
+                                                                    <span class="path2"></span>
+                                                                    <span class="path3"></span>
+                                                                    <span class="path4"></span>
+                                                                    <span class="path5"></span>
+                                                                </i>
+                                                                <span data-i18n="general.delete">Delete</span>
+                                                            </a>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                                {{- else}}
+                                                <div data-repeater-item>
+                                                    <div class="form-group row">
+                                                        <div class="col-md-9 mt-3 mt-md-8">
+                                                            <textarea data-i18n="[placeholder]user.tls_cert_help" class="form-control" name="tls_cert" rows="4"></textarea>
+                                                        </div>
+                                                        <div class="col-md-3 mt-3 mt-md-8">
+                                                            <a href="#" data-repeater-delete
+                                                                class="btn btn-light-danger">
+                                                                <i class="ki-duotone ki-trash fs-5">
+                                                                    <span class="path1"></span>
+                                                                    <span class="path2"></span>
+                                                                    <span class="path3"></span>
+                                                                    <span class="path4"></span>
+                                                                    <span class="path5"></span>
+                                                                </i>
+                                                                <span data-i18n="general.delete">Delete</span>
+                                                            </a>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                                {{- end}}
+                                            </div>
+                                        </div>
+
+                                        <div class="form-group mt-5">
+                                            <a href="#" data-repeater-create class="btn btn-light-primary">
+                                                <i class="ki-duotone ki-plus fs-3"></i>
+                                                <span data-i18n="general.add">Add</span>
+                                            </a>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+
                             {{template "user_group_advanced" .User.Filters}}
                             {{template "user_group_advanced" .User.Filters}}
 
 
                             <div class="form-group row mt-10 {{if not .User.HasExternalAuth}}d-none{{end}}">
                             <div class="form-group row mt-10 {{if not .User.HasExternalAuth}}d-none{{end}}">
@@ -728,6 +792,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             initRepeater('#directory_permissions');
             initRepeater('#directory_permissions');
             initRepeater('#directory_patterns');
             initRepeater('#directory_patterns');
             initRepeater('#src_bandwidth_limits');
             initRepeater('#src_bandwidth_limits');
+            initRepeater('#tls_certs');
             initRepeaterItems();
             initRepeaterItems();
             //{{- if .Error}}
             //{{- if .Error}}
             //{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}
             //{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}