allow to set password strength at user/group level

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2023-03-02 09:11:30 +01:00
parent 662164c7ff
commit 4ba3ae876d
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
11 changed files with 150 additions and 31 deletions

View file

@ -277,7 +277,7 @@ The configuration file contains the following sections:
- `admins`, struct. It defines the password validation rules for SFTPGo admins.
- `min_entropy`, float. Defines the minimum password entropy. Take a looke [here](https://github.com/wagslane/go-password-validator#what-entropy-value-should-i-use) for more details. `0` means disabled, any password will be accepted. Default: `0`.
- `users`, struct. It defines the password validation rules for SFTPGo protocol users.
- `min_entropy`, float. Default: `0`.
- `min_entropy`, float. This value is used as fallback if no more specific password strength is set at user/group level. Default: `0`.
- `password_caching`, boolean. Verifying argon2id passwords has a high memory and computational cost, verifying bcrypt passwords has a high computational cost, by enabling, in memory, password caching you reduce these costs. Default: `true`
- `update_mode`, integer. Defines how the database will be initialized/updated. 0 means automatically. 1 means manually using the initprovider sub-command.
- `create_default_admin`, boolean. Before you can use SFTPGo you need to create an admin account. If you open the admin web UI, a setup screen will guide you in creating the first admin account. You can automatically create the first admin account by enabling this setting and setting the environment variables `SFTPGO_DEFAULT_ADMIN_USERNAME` and `SFTPGO_DEFAULT_ADMIN_PASSWORD`. You can also create the first admin by loading initial data. This setting has no effect if an admin account is already found within the data provider. Default `false`.

View file

@ -16,7 +16,7 @@ The following settings are inherited from the primary group:
- home dir, if set for the group will replace the one defined for the user. The `%username%` placeholder is replaced with the username
- filesystem config, if the provider set for the group is different from the "local provider" will replace the one defined for the user. The `%username%` placeholder is replaced with the username within the defined "prefix", for any vfs, and the "username" for the SFTP filesystem config
- max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time, ftp_security, default share expiration, password expiration: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`
- max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time, ftp_security, default share expiration, password expiration, password strength: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`. The password strength defined at group level is only enforce when users change their password
- expires_in, if defined and the user does not have an expiration date set, defines the expiration of the account in number of days from the creation date
- TLS username, check password hook disabled, pre-login hook disabled, external auth hook disabled, filesystem checks disabled, allow API key authentication, anonymous user: if they are not set for the user they are replaced with the value set for the group
- starting directory, if the user does not have a starting directory set, the value set for the group is used, if any. The `%username%` placeholder is replaced with the username

2
go.mod
View file

@ -52,7 +52,7 @@ require (
github.com/rs/cors v1.8.3
github.com/rs/xid v1.4.0
github.com/rs/zerolog v1.29.0
github.com/sftpgo/sdk v0.1.3-0.20230213182959-2d89540f8810
github.com/sftpgo/sdk v0.1.3-0.20230302063609-7677616c090b
github.com/shirou/gopsutil/v3 v3.23.2
github.com/spf13/afero v1.9.4
github.com/spf13/cobra v1.6.1

4
go.sum
View file

@ -1802,8 +1802,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
github.com/sftpgo/sdk v0.1.3-0.20230213182959-2d89540f8810 h1:9K/1RGoZcWiv2ue1JvAnKwerOzJsCAUqCR2/BnibT8s=
github.com/sftpgo/sdk v0.1.3-0.20230213182959-2d89540f8810/go.mod h1:B1lPGb05WtvvrX5IuhHrSjWdRT867qBaoxlS2Q9+1bA=
github.com/sftpgo/sdk v0.1.3-0.20230302063609-7677616c090b h1:OpQr1PQ1repUl1HYFEG6aDp9ljbovP9ccAfQmNih914=
github.com/sftpgo/sdk v0.1.3-0.20230302063609-7677616c090b/go.mod h1:+STA4nxcXm/uLW3CGXwgnyo0hCeocbEjyznlFxZhtnw=
github.com/shirou/gopsutil/v3 v3.23.2 h1:PAWSuiAszn7IhPMBtXsbSCafej7PqUOvY6YywlQUExU=
github.com/shirou/gopsutil/v3 v3.23.2/go.mod h1:gv0aQw33GLo3pG8SiWKiQrbDzbRY1K80RyZJ7V4Th1M=
github.com/shoenig/test v0.4.3/go.mod h1:xYtyGBC5Q3kzCNyJg/SjgNpfAa2kvmgA0i5+lQso8x0=

View file

@ -2039,7 +2039,16 @@ func UpdateUserPassword(username, plainPwd, executor, ipAddress, role string) er
if err != nil {
return err
}
user.Password = plainPwd
userCopy := user.getACopy()
if err := userCopy.LoadAndApplyGroupSettings(); err != nil {
return err
}
userCopy.Password = plainPwd
if err := createUserPasswordHash(&userCopy); err != nil {
return err
}
user.LastPasswordChange = userCopy.LastPasswordChange
user.Password = userCopy.Password
user.Filters.RequirePasswordChange = false
// the last password change is set when validating the user
if err := provider.updateUser(&user); err != nil {
@ -2438,6 +2447,7 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime
filters.DefaultSharesExpiration = in.DefaultSharesExpiration
filters.PasswordExpiration = in.PasswordExpiration
filters.PasswordStrength = in.PasswordStrength
filters.WebClient = make([]string, len(in.WebClient))
copy(filters.WebClient, in.WebClient)
filters.BandwidthLimits = make([]sdk.BandwidthLimit, 0, len(in.BandwidthLimits))
@ -2963,8 +2973,8 @@ func hashPlainPassword(plainPwd string) (string, error) {
func createUserPasswordHash(user *User) error {
if user.Password != "" && !user.IsPasswordHashed() {
if config.PasswordValidation.Users.MinEntropy > 0 {
if err := passwordvalidator.Validate(user.Password, config.PasswordValidation.Users.MinEntropy); err != nil {
if minEntropy := user.getMinPasswordEntropy(); minEntropy > 0 {
if err := passwordvalidator.Validate(user.Password, minEntropy); err != nil {
return util.NewValidationError(err.Error())
}
}

View file

@ -1013,6 +1013,13 @@ func (u *User) isDirHidden(virtualPath string) bool {
return false
}
func (u *User) getMinPasswordEntropy() float64 {
if u.Filters.PasswordStrength > 0 {
return float64(u.Filters.PasswordStrength)
}
return config.PasswordValidation.Users.MinEntropy
}
// IsFileAllowed returns true if the specified file is allowed by the file restrictions filters.
// The second parameter returned is the deny policy
func (u *User) IsFileAllowed(virtualPath string) (bool, int) {
@ -1748,7 +1755,7 @@ func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters, replacer *s
if u.Filters.MaxUploadFileSize == 0 {
u.Filters.MaxUploadFileSize = filters.MaxUploadFileSize
}
if u.Filters.TLSUsername == "" || u.Filters.TLSUsername == sdk.TLSUsernameNone {
if !u.IsTLSUsernameVerificationEnabled() {
u.Filters.TLSUsername = filters.TLSUsername
}
if !u.Filters.Hooks.CheckPasswordDisabled {
@ -1784,6 +1791,9 @@ func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters, replacer *s
if u.Filters.PasswordExpiration == 0 {
u.Filters.PasswordExpiration = filters.PasswordExpiration
}
if u.Filters.PasswordStrength == 0 {
u.Filters.PasswordStrength = filters.PasswordStrength
}
}
func (u *User) mergeAdditiveProperties(group Group, groupType int, replacer *strings.Replacer) {

View file

@ -1217,6 +1217,7 @@ func TestGroupSettingsOverride(t *testing.T) {
}
group2.UserSettings.DownloadBandwidth = 128
group2.UserSettings.UploadBandwidth = 256
group2.UserSettings.Filters.PasswordStrength = 70
group2.UserSettings.Filters.WebClient = []string{sdk.WebClientInfoChangeDisabled, sdk.WebClientMFADisabled}
_, _, err = httpdtest.UpdateGroup(group2, http.StatusOK)
assert.NoError(t, err)
@ -1226,6 +1227,7 @@ func TestGroupSettingsOverride(t *testing.T) {
assert.Equal(t, sdk.LocalFilesystemProvider, user.FsConfig.Provider)
assert.Equal(t, int64(0), user.DownloadBandwidth)
assert.Equal(t, int64(0), user.UploadBandwidth)
assert.Equal(t, 0, user.Filters.PasswordStrength)
assert.Equal(t, []string{dataprovider.PermAny}, user.GetPermissionsForPath("/"))
assert.Equal(t, []string{dataprovider.PermListItems}, user.GetPermissionsForPath("/"+defaultUsername))
assert.Len(t, user.Filters.WebClient, 2)
@ -1249,6 +1251,7 @@ func TestGroupSettingsOverride(t *testing.T) {
group1.UserSettings.ExpiresIn = 15
group1.UserSettings.Filters.MaxUploadFileSize = 1024 * 1024
group1.UserSettings.Filters.StartDirectory = "/startdir/%username%"
group1.UserSettings.Filters.PasswordStrength = 70
group1.UserSettings.Filters.WebClient = []string{sdk.WebClientInfoChangeDisabled}
group1.UserSettings.Permissions = map[string][]string{
"/": {dataprovider.PermListItems, dataprovider.PermUpload},
@ -1268,6 +1271,7 @@ func TestGroupSettingsOverride(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, user.VirtualFolders, 3)
assert.Equal(t, user.CreatedAt+int64(group1.UserSettings.ExpiresIn)*86400000, user.ExpirationDate)
assert.Equal(t, group1.UserSettings.Filters.PasswordStrength, user.Filters.PasswordStrength)
assert.Equal(t, sdk.SFTPFilesystemProvider, user.FsConfig.Provider)
assert.Equal(t, altAdminUsername, user.FsConfig.SFTPConfig.Username)
assert.Equal(t, "/dirs/"+defaultUsername, user.FsConfig.SFTPConfig.Prefix)
@ -2958,6 +2962,45 @@ func TestPermMFADisabled(t *testing.T) {
assert.NoError(t, err)
}
func TestUpdateUserPassword(t *testing.T) {
g := getTestGroup()
g.UserSettings.Filters.PasswordStrength = 20
g.UserSettings.MaxSessions = 10
group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
assert.NoError(t, err)
u := getTestUser()
u.Filters.RequirePasswordChange = true
u.Groups = []sdk.GroupMapping{
{
Name: group.Name,
Type: sdk.GroupTypePrimary,
},
}
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
lastPwdChange := user.LastPasswordChange
time.Sleep(100 * time.Millisecond)
newPwd := "uaCooGh3pheiShooghah"
err = dataprovider.UpdateUserPassword(user.Username, newPwd, "", "", "")
assert.NoError(t, err)
_, err = dataprovider.CheckUserAndPass(user.Username, newPwd, "", common.ProtocolHTTP)
assert.NoError(t, err)
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
assert.False(t, user.Filters.RequirePasswordChange)
assert.NotEqual(t, lastPwdChange, user.LastPasswordChange)
// check that we don't save group overrides
assert.Equal(t, 0, user.MaxSessions)
assert.Equal(t, 0, user.Filters.PasswordStrength)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
_, err = httpdtest.RemoveGroup(group, http.StatusOK)
assert.NoError(t, err)
}
func TestMustChangePasswordRequirement(t *testing.T) {
u := getTestUser()
u.Filters.RequirePasswordChange = true
@ -18887,6 +18930,16 @@ func TestWebUserAddMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid password expiration")
form.Set("password_expiration", "90")
// test invalid password strength
form.Set("password_strength", "a")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webUserPath, &b)
setJWTCookieForReq(req, webToken)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid password strength")
form.Set("password_strength", "60")
// test invalid tls username
form.Set("tls_username", "username")
b, contentType, _ = getMultipartFormData(form, "", "")
@ -19036,6 +19089,7 @@ func TestWebUserAddMock(t *testing.T) {
assert.Equal(t, 0, newUser.Filters.FTPSecurity)
assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)
assert.Equal(t, 90, newUser.Filters.PasswordExpiration)
assert.Equal(t, 60, newUser.Filters.PasswordStrength)
assert.Greater(t, newUser.LastPasswordChange, int64(0))
assert.True(t, newUser.Filters.RequirePasswordChange)
assert.True(t, util.Contains(newUser.PublicKeys, testPubKey))
@ -19244,6 +19298,7 @@ func TestWebUserUpdateMock(t *testing.T) {
form.Set("max_upload_file_size", "100")
form.Set("default_shares_expiration", "30")
form.Set("password_expiration", "60")
form.Set("password_strength", "40")
form.Set("disconnect", "1")
form.Set("additional_info", user.AdditionalInfo)
form.Set("description", user.Description)
@ -19324,6 +19379,7 @@ func TestWebUserUpdateMock(t *testing.T) {
assert.Equal(t, int64(0), updateUser.Filters.ExternalAuthCacheTime)
assert.Equal(t, 30, updateUser.Filters.DefaultSharesExpiration)
assert.Equal(t, 60, updateUser.Filters.PasswordExpiration)
assert.Equal(t, 40, updateUser.Filters.PasswordStrength)
assert.True(t, updateUser.Filters.RequirePasswordChange)
if val, ok := updateUser.Permissions["/otherdir"]; ok {
assert.True(t, util.Contains(val, dataprovider.PermListItems))
@ -19437,6 +19493,7 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("ftp_security", "1")
form.Set("external_auth_cache_time", "0")
form.Set("description", "desc %username% %password%")
@ -19542,6 +19599,7 @@ func TestUserSaveFromTemplateMock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("external_auth_cache_time", "0")
form.Add("tpl_username", user1)
form.Add("tpl_password", "password1")
@ -19632,6 +19690,7 @@ func TestUserTemplateMock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Add("hooks", "external_auth_disabled")
form.Add("hooks", "check_password_disabled")
form.Set("disable_fs_checks", "checked")
@ -19764,6 +19823,7 @@ func TestUserPlaceholders(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ := http.NewRequest(http.MethodPost, webUserPath, &b)
setJWTCookieForReq(req, token)
@ -20109,6 +20169,7 @@ func TestWebUserS3Mock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("ftp_security", "1")
form.Set("s3_force_path_style", "checked")
form.Set("description", user.Description)
@ -20325,6 +20386,7 @@ func TestWebUserGCSMock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("ftp_security", "1")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -20453,6 +20515,7 @@ func TestWebUserHTTPFsMock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("http_equality_check_mode", "true")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -20579,6 +20642,7 @@ func TestWebUserAzureBlobMock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
// test invalid az_upload_part_size
form.Set("az_upload_part_size", "a")
b, contentType, _ := getMultipartFormData(form, "", "")
@ -20760,6 +20824,7 @@ func TestWebUserCryptMock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
// passphrase cannot be empty
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -20869,6 +20934,7 @@ func TestWebUserSFTPFsMock(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
// empty sftpconfig
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -20995,6 +21061,7 @@ func TestWebUserRole(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "10")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
b, contentType, _ := getMultipartFormData(form, "", "")
req, err := http.NewRequest(http.MethodPost, webUserPath, &b)
assert.NoError(t, err)
@ -22153,6 +22220,7 @@ func TestAddWebGroup(t *testing.T) {
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
b, contentType, err = getMultipartFormData(form, "", "")
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webGroupPath, &b)
@ -22587,6 +22655,7 @@ func TestUpdateWebGroupMock(t *testing.T) {
form.Set("default_shares_expiration", "0")
form.Set("expires_in", "0")
form.Set("password_expiration", "0")
form.Set("password_strength", "0")
form.Set("external_auth_cache_time", "0")
form.Set("fs_provider", strconv.FormatInt(int64(group.UserSettings.FsConfig.Provider), 10))
form.Set("sftp_endpoint", group.UserSettings.FsConfig.SFTPConfig.Endpoint)

View file

@ -1542,6 +1542,10 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
if err != nil {
return filters, fmt.Errorf("invalid password expiration: %w", err)
}
passwordStrength, err := strconv.ParseInt(r.Form.Get("password_strength"), 10, 64)
if err != nil {
return filters, fmt.Errorf("invalid password strength: %w", err)
}
if r.Form.Get("ftp_security") == "1" {
filters.FTPSecurity = 1
}
@ -1557,6 +1561,7 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
filters.WebClient = r.Form["web_client_options"]
filters.DefaultSharesExpiration = int(defaultSharesExpiration)
filters.PasswordExpiration = int(passwordExpiration)
filters.PasswordStrength = int(passwordStrength)
hooks := r.Form["hooks"]
if util.Contains(hooks, "external_auth_disabled") {
filters.Hooks.ExternalAuthDisabled = true

View file

@ -2450,6 +2450,9 @@ func compareBaseUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFil
if expected.PasswordExpiration != actual.PasswordExpiration {
return errors.New("password_expiration mismatch")
}
if expected.PasswordStrength != actual.PasswordStrength {
return errors.New("password_strength mismatch")
}
return nil
}

View file

@ -717,6 +717,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div class="form-group row">
<label for="idPasswordStrength" class="col-sm-2 col-form-label">Password strength</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idPasswordStrength" name="password_strength"
value="{{.Group.UserSettings.Filters.PasswordStrength}}" min="0" max="100" aria-describedby="passwordStrengthHelpBlock">
<small id="passwordStrengthHelpBlock" class="form-text text-muted">
Values in the 50-70 range are suggested for common use cases. 0 means disabled, any password will be accepted. Applied when users change their password
</small>
</div>
</div>
<div class="form-group row">
<label for="idPasswordExpiration" class="col-sm-2 col-form-label">Password expiration</label>
<div class="col-sm-10">

View file

@ -360,6 +360,39 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div class="form-group row">
<label for="idPasswordStrength" class="col-sm-2 col-form-label">Password strength</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idPasswordStrength" name="password_strength"
value="{{.User.Filters.PasswordStrength}}" min="0" max="100" aria-describedby="passwordStrengthHelpBlock">
<small id="passwordStrengthHelpBlock" class="form-text text-muted">
Values in the 50-70 range are suggested for common use cases. 0 means disabled, any password will be accepted
</small>
</div>
</div>
<div class="form-group row">
<label for="idPasswordExpiration" class="col-sm-2 col-form-label">Password expiration</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idPasswordExpiration" name="password_expiration"
value="{{.User.Filters.PasswordExpiration}}" min="0" aria-describedby="passwordExpirationHelpBlock">
<small id="passwordExpirationHelpBlock" class="form-text text-muted">
Password expiration as number of days. 0 means no expiration
</small>
</div>
</div>
<div class="form-group row">
<label for="idDefaultSharesExpiration" class="col-sm-2 col-form-label">Default shares expiration</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idDefaultSharesExpiration" name="default_shares_expiration"
value="{{.User.Filters.DefaultSharesExpiration}}" min="0" aria-describedby="defaultSharesExpirationHelpBlock">
<small id="defaultSharesExpirationHelpBlock" class="form-text text-muted">
Default expiration for newly created shares as number of days
</small>
</div>
</div>
<div class="form-group row">
<label for="idDescription" class="col-sm-2 col-form-label">Description</label>
<div class="col-sm-10">
@ -953,28 +986,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div class="form-group row">
<label for="idPasswordExpiration" class="col-sm-2 col-form-label">Password expiration</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idPasswordExpiration" name="password_expiration"
value="{{.User.Filters.PasswordExpiration}}" min="0" aria-describedby="passwordExpirationHelpBlock">
<small id="passwordExpirationHelpBlock" class="form-text text-muted">
Password expiration as number of days. 0 means no expiration
</small>
</div>
</div>
<div class="form-group row">
<label for="idDefaultSharesExpiration" class="col-sm-2 col-form-label">Default shares expiration</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idDefaultSharesExpiration" name="default_shares_expiration"
value="{{.User.Filters.DefaultSharesExpiration}}" min="0" aria-describedby="defaultSharesExpirationHelpBlock">
<small id="defaultSharesExpirationHelpBlock" class="form-text text-muted">
Default expiration for newly created shares as number of days
</small>
</div>
</div>
<div class="form-group row">
<label for="idHooks" class="col-sm-2 col-form-label">Hooks</label>
<div class="col-sm-10">