mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-24 16:40:26 +00:00
user: add TLS certificates
Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
0722c4369b
commit
d939a82225
20 changed files with 203 additions and 23 deletions
2
go.mod
2
go.mod
|
@ -53,7 +53,7 @@ require (
|
|||
github.com/rs/cors v1.10.1
|
||||
github.com/rs/xid v1.5.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/spf13/afero v1.11.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
|
|
4
go.sum
4
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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
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/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
|
|
|
@ -390,6 +390,10 @@ func (c *Configuration) loadPrivateKey() (crypto.PrivateKey, error) {
|
|||
}
|
||||
|
||||
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
|
||||
switch keyBlock.Type {
|
||||
|
|
|
@ -29,6 +29,7 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
|
@ -1212,7 +1213,7 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str
|
|||
if err != nil {
|
||||
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
|
||||
// in future updates we have to return an error
|
||||
user, err := CheckUserAndPass(username, password, ip, protocol)
|
||||
|
@ -2623,6 +2624,8 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
|
|||
filters.PasswordStrength = in.PasswordStrength
|
||||
filters.WebClient = make([]string, len(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))
|
||||
for _, limit := range in.BandwidthLimits {
|
||||
bwLimit := sdk.BandwidthLimit{
|
||||
|
@ -3023,6 +3026,25 @@ func validateFilterProtocols(filters *sdk.BaseUserFilters) error {
|
|||
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 {
|
||||
checkEmptyFiltersStruct(filters)
|
||||
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))
|
||||
}
|
||||
}
|
||||
if err := validateTLSCerts(filters.TLSCerts); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, opts := range filters.WebClient {
|
||||
if !util.Contains(sdk.WebClientOptions, 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 {
|
||||
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.Username == tlsCert.Subject.CommonName {
|
||||
return *user, nil
|
||||
|
|
|
@ -179,6 +179,7 @@ func (g *Group) validateUserSettings() error {
|
|||
}
|
||||
g.UserSettings.Permissions = permissions
|
||||
}
|
||||
g.UserSettings.Filters.TLSCerts = nil
|
||||
if err := validateBaseFilters(&g.UserSettings.Filters); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -468,9 +468,11 @@ func (u *User) IsPasswordHashed() bool {
|
|||
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 != "" {
|
||||
return u.Filters.TLSUsername != sdk.TLSUsernameNone
|
||||
}
|
||||
|
@ -1757,7 +1759,7 @@ func (u *User) mergePrimaryGroupFilters(filters *sdk.BaseUserFilters, replacer *
|
|||
if u.Filters.MaxUploadFileSize == 0 {
|
||||
u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize
|
||||
}
|
||||
if !u.IsTLSUsernameVerificationEnabled() {
|
||||
if !u.IsTLSVerificationEnabled() {
|
||||
u.Filters.TLSUsername = filters.TLSUsername
|
||||
}
|
||||
if !u.Filters.Hooks.CheckPasswordDisabled {
|
||||
|
|
|
@ -3519,6 +3519,25 @@ func TestClientCertificateAuth(t *testing.T) {
|
|||
if assert.Error(t, err) {
|
||||
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
|
||||
user.Filters.DeniedLoginMethods = append(user.Filters.DeniedLoginMethods, dataprovider.LoginMethodTLSCertificate,
|
||||
|
|
|
@ -245,7 +245,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo
|
|||
updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err)
|
||||
return nil, dataprovider.ErrInvalidCredentials
|
||||
}
|
||||
if dbUser.IsTLSUsernameVerificationEnabled() {
|
||||
if dbUser.IsTLSVerificationEnabled() {
|
||||
dbUser, err = dataprovider.CheckUserAndTLSCert(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -821,12 +821,32 @@ func TestRoleRelations(t *testing.T) {
|
|||
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) {
|
||||
g := getTestGroup()
|
||||
g.UserSettings.Filters.TLSCerts = []string{"invalid cert"} // ignored for groups
|
||||
group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, group.CreatedAt, 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)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, group, groupGet)
|
||||
|
|
|
@ -48,6 +48,10 @@ import (
|
|||
"github.com/drakkan/sftpgo/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
jsonAPISuffix = "/json"
|
||||
)
|
||||
|
||||
var (
|
||||
compressor = middleware.NewCompressor(5)
|
||||
xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto")
|
||||
|
@ -1683,7 +1687,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
|
|||
router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
|
||||
Get(webUsersPath, s.handleGetWebUsers)
|
||||
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).
|
||||
Get(webUserPath, s.handleWebAddUserGet)
|
||||
router.With(s.checkPerm(dataprovider.PermAdminChangeUsers), s.refreshCookie).
|
||||
|
@ -1694,7 +1698,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
|
|||
router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
|
||||
Get(webGroupsPath, s.handleWebGetGroups)
|
||||
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).
|
||||
Get(webGroupPath, s.handleWebAddGroupGet)
|
||||
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).
|
||||
Get(webFoldersPath, s.handleWebGetFolders)
|
||||
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).
|
||||
Get(webFolderPath, s.handleWebAddFolderGet)
|
||||
router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Post(webFolderPath, s.handleWebAddFolderPost)
|
||||
|
|
|
@ -1961,6 +1961,10 @@ func updateRepeaterFormFields(r *http.Request) {
|
|||
r.Form.Add("public_keys", r.Form.Get(k))
|
||||
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]") {
|
||||
base, _ := strings.CutSuffix(k, "[vfolder_path]")
|
||||
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 {
|
||||
return user, err
|
||||
}
|
||||
filters.TLSCerts = r.Form["tls_certs"]
|
||||
user = dataprovider.User{
|
||||
BaseUser: sdk.BaseUser{
|
||||
Username: strings.TrimSpace(r.Form.Get("username")),
|
||||
|
|
|
@ -283,6 +283,7 @@ func AddGroup(group dataprovider.Group, expectedStatusCode int) (dataprovider.Gr
|
|||
body, _ = getResponseBody(resp)
|
||||
}
|
||||
if err == nil {
|
||||
group.UserSettings.Filters.TLSCerts = nil
|
||||
err = checkGroup(group, newGroup)
|
||||
}
|
||||
return newGroup, body, err
|
||||
|
@ -2412,6 +2413,16 @@ func compareUserFilterSubStructs(expected sdk.BaseUserFilters, actual sdk.BaseUs
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -185,6 +185,7 @@ const (
|
|||
I18nErrorFsUsernameRequired = "storage.username_required"
|
||||
I18nAddGroupTitle = "title.add_group"
|
||||
I18nUpdateGroupTitle = "title.update_group"
|
||||
I18nErrorInvalidTLSCert = "user.tls_cert_invalid"
|
||||
)
|
||||
|
||||
// NewI18nError returns a I18nError wrappring the provided error
|
||||
|
|
|
@ -293,7 +293,7 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us
|
|||
if cachedUser.IsExpired() {
|
||||
dataprovider.RemoveCachedWebDAVUser(username)
|
||||
} else {
|
||||
if !cachedUser.User.IsTLSUsernameVerificationEnabled() {
|
||||
if !cachedUser.User.IsTLSVerificationEnabled() {
|
||||
// for backward compatibility with 2.0.x we only check the password
|
||||
tlsCert = nil
|
||||
loginMethod = dataprovider.LoginMethodPassword
|
||||
|
|
|
@ -2800,6 +2800,13 @@ func TestClientCertificateAuth(t *testing.T) {
|
|||
client := getWebDavClient(user, true, tlsConfig)
|
||||
err = checkBasicFunc(client)
|
||||
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, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
|
||||
|
|
|
@ -5499,6 +5499,10 @@ components:
|
|||
tls_username:
|
||||
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`'
|
||||
tls_certs:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
hooks:
|
||||
$ref: '#/components/schemas/HooksFilter'
|
||||
disable_fs_checks:
|
||||
|
|
|
@ -467,7 +467,10 @@
|
|||
"submit_export": "Generate and export users",
|
||||
"invalid_quota_size": "Invalid quota size",
|
||||
"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": {
|
||||
"view_manage": "View and manage groups",
|
||||
|
|
|
@ -467,7 +467,10 @@
|
|||
"submit_export": "Genera ed esporta utenti",
|
||||
"invalid_quota_size": "Quota (dimensione) non valida",
|
||||
"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": {
|
||||
"view_manage": "Visualizza e gestisci gruppi",
|
||||
|
|
|
@ -746,14 +746,6 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
|||
{{- end}}
|
||||
|
||||
{{- 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">
|
||||
<label for="idTLSUsername" data-i18n="filters.tls_username" class="col-md-3 col-form-label">TLS username</label>
|
||||
<div class="col-md-9">
|
||||
|
@ -776,6 +768,14 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
|||
</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">
|
||||
<label for="idHooks" data-i18n="filters.hooks" class="col-md-3 col-form-label">Hooks</label>
|
||||
<div class="col-md-9">
|
||||
|
|
|
@ -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 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}}
|
||||
|
||||
<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_patterns');
|
||||
initRepeater('#src_bandwidth_limits');
|
||||
initRepeater('#tls_certs');
|
||||
initRepeaterItems();
|
||||
//{{- if .Error}}
|
||||
//{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}
|
||||
|
|
Loading…
Reference in a new issue