user: add TLS certificates

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-01-14 21:36:23 +01:00
parent 0722c4369b
commit d939a82225
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
20 changed files with 203 additions and 23 deletions

2
go.mod
View file

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

4
go.sum
View file

@ -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.20240114195211-3f4916cc829c h1:07TYPvNbOnmKsBxjNsUr+gsILIUWflw1UYwjn1jognM=
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/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=

View file

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

View file

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

View file

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

View file

@ -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 // IsTLSVerificationEnabled returns true if we need to check the TLS authentication
// from the client TLS certificate func (u *User) IsTLSVerificationEnabled() bool {
func (u *User) IsTLSUsernameVerificationEnabled() 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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -746,14 +746,6 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
{{- 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 (support@sftpgo.com).
</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">

View file

@ -629,6 +629,70 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
<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 (support@sftpgo.com).
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}}