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 . +
+ +
+ + + 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 + +
+
+
diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index e109a983..3dd74174 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -360,6 +360,39 @@ along with this program. If not, see .
+
+ +
+ + + Values in the 50-70 range are suggested for common use cases. 0 means disabled, any password will be accepted + +
+
+ +
+ +
+ + + Password expiration as number of days. 0 means no expiration + +
+
+ +
+ +
+ + + Default expiration for newly created shares as number of days + +
+
+
@@ -953,28 +986,6 @@ along with this program. If not, see .
-
- -
- - - Password expiration as number of days. 0 means no expiration - -
-
- -
- -
- - - Default expiration for newly created shares as number of days - -
-
-