瀏覽代碼

allow to set password strength at user/group level

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 年之前
父節點
當前提交
4ba3ae876d

+ 1 - 1
docs/full-configuration.md

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

+ 1 - 1
docs/groups.md

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

+ 1 - 1
go.mod

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

+ 2 - 2
go.sum

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

+ 13 - 3
internal/dataprovider/dataprovider.go

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

+ 11 - 1
internal/dataprovider/user.go

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

+ 69 - 0
internal/httpd/httpd_test.go

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

+ 5 - 0
internal/httpd/webadmin.go

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

+ 3 - 0
internal/httpdtest/httpdtest.go

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

+ 11 - 0
templates/webadmin/group.html

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

+ 33 - 22
templates/webadmin/user.html

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