diff --git a/docs/full-configuration.md b/docs/full-configuration.md
index 5401328e..45cf7df1 100644
--- a/docs/full-configuration.md
+++ b/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`.
diff --git a/docs/groups.md b/docs/groups.md
index 6420f872..8c1b411e 100644
--- a/docs/groups.md
+++ b/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
diff --git a/go.mod b/go.mod
index 37e0b282..fded97fc 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index 2b885162..918c7e90 100644
--- a/go.sum
+++ b/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=
diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go
index 83c09ac8..d0e69d03 100644
--- a/internal/dataprovider/dataprovider.go
+++ b/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())
}
}
diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go
index 76c3bc82..63e114f2 100644
--- a/internal/dataprovider/user.go
+++ b/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) {
diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go
index c3dfc852..ee5de9a1 100644
--- a/internal/httpd/httpd_test.go
+++ b/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)
diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go
index 60ded1a3..7b56b475 100644
--- a/internal/httpd/webadmin.go
+++ b/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
diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go
index 56e9287b..a97e11e9 100644
--- a/internal/httpdtest/httpdtest.go
+++ b/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
}
diff --git a/templates/webadmin/group.html b/templates/webadmin/group.html
index 3faddc00..f8d02425 100644
--- a/templates/webadmin/group.html
+++ b/templates/webadmin/group.html
@@ -717,6 +717,17 @@ along with this program. If not, see