add support for password policies

you can now set a password expiration and the password change requirement

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-12-11 17:15:34 +01:00
parent e2bebc99d1
commit ad5d657a1a
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
25 changed files with 612 additions and 130 deletions

View file

@ -15,7 +15,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: 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: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`
- 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

4
go.mod
View file

@ -36,7 +36,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.1
github.com/jackc/pgx/v5 v5.2.0
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
github.com/klauspost/compress v1.15.12
github.com/klauspost/compress v1.15.13
github.com/lestrrat-go/jwx/v2 v2.0.8
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mattn/go-sqlite3 v1.14.16
@ -51,7 +51,7 @@ require (
github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5
github.com/rs/xid v1.4.0
github.com/rs/zerolog v1.28.0
github.com/sftpgo/sdk v0.1.3-0.20221208080405-e682ae869318
github.com/sftpgo/sdk v0.1.3-0.20221211151321-578e45601b27
github.com/shirou/gopsutil/v3 v3.22.11
github.com/spf13/afero v1.9.3
github.com/spf13/cobra v1.6.1

8
go.sum
View file

@ -1061,8 +1061,8 @@ github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYs
github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.12 h1:YClS/PImqYbn+UILDnqxQCZ3RehC9N318SU3kElDUEM=
github.com/klauspost/compress v1.15.12/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
github.com/klauspost/compress v1.15.13 h1:NFn1Wr8cfnenSJSA46lLq4wHCcBzKTSjnBIexDMMOV0=
github.com/klauspost/compress v1.15.13/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.1/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.2 h1:xPMwiykqNK9VK0NYC3+jTMYv9I6Vl3YdjZgPZKG3zO0=
@ -1451,8 +1451,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.20221208080405-e682ae869318 h1:oDr2it5L9nh13+P3BzyIqx89gN9Kfrsdk4cw42HaIuQ=
github.com/sftpgo/sdk v0.1.3-0.20221208080405-e682ae869318/go.mod h1:3GpW3Qy8IHH6kex0ny+Y6ayeYb9OJxz8Pxh3IZgAs2E=
github.com/sftpgo/sdk v0.1.3-0.20221211151321-578e45601b27 h1:DjNme+rcw3zaiEkWyyrtimqDZd/83GW4qZhUghBkyrI=
github.com/sftpgo/sdk v0.1.3-0.20221211151321-578e45601b27/go.mod h1:3GpW3Qy8IHH6kex0ny+Y6ayeYb9OJxz8Pxh3IZgAs2E=
github.com/shirou/gopsutil/v3 v3.22.11 h1:kxsPKS+Eeo+VnEQ2XCaGJepeP6KY53QoRTETx3+1ndM=
github.com/shirou/gopsutil/v3 v3.22.11/go.mod h1:xl0EeL4vXJ+hQMAGN8B9VFpxukEMA0XdevQOe5MZ1oY=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=

View file

@ -68,10 +68,6 @@ Please take a look at the usage below to customize the options.`,
logger.ErrorToConsole("memory provider is not supported")
os.Exit(1)
}
// ignore actions
providerConf.Actions.Hook = ""
providerConf.Actions.ExecuteFor = nil
providerConf.Actions.ExecuteOn = nil
logger.InfoToConsole("Initializing provider: %q config file: %q", providerConf.Driver, viper.ConfigFileUsed())
err = dataprovider.Initialize(providerConf, configDir, false)
if err != nil {

View file

@ -35,7 +35,7 @@ import (
)
const (
boltDatabaseVersion = 24
boltDatabaseVersion = 25
)
var (
@ -2833,10 +2833,10 @@ func (p *BoltProvider) migrateDatabase() error {
providerLog(logger.LevelError, "%v", err)
logger.ErrorToConsole("%v", err)
return err
case version == 23:
logger.InfoToConsole(fmt.Sprintf("updating database schema version: %d -> 24", version))
providerLog(logger.LevelInfo, "updating database schema version: %d -> 24", version)
return updateBoltDatabaseVersion(p.dbHandle, 24)
case version == 23, version == 24:
logger.InfoToConsole(fmt.Sprintf("updating database schema version: %d -> 25", version))
providerLog(logger.LevelInfo, "updating database schema version: %d -> 25", version)
return updateBoltDatabaseVersion(p.dbHandle, 25)
default:
if version > boltDatabaseVersion {
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@ -2858,7 +2858,7 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error {
return errors.New("current version match target version, nothing to do")
}
switch dbVersion.Version {
case 24:
case 24, 25:
logger.InfoToConsole("downgrading database schema version: %d -> 23", dbVersion.Version)
providerLog(logger.LevelInfo, "downgrading database schema version: %d -> 23", dbVersion.Version)
err := p.dbHandle.Update(func(tx *bolt.Tx) error {

View file

@ -713,7 +713,7 @@ type Provider interface {
addUser(user *User) error
updateUser(user *User) error
deleteUser(user User, softDelete bool) error
updateUserPassword(username, password string) error
updateUserPassword(username, password string) error // used internally when converting passwords from other hash
getUsers(limit int, offset int, order, role string) ([]User, error)
dumpUsers() ([]User, error)
getRecentlyUpdatedUsers(after int64) ([]User, error)
@ -1307,6 +1307,7 @@ func GetUserAfterIDPAuth(username, ip, protocol string, oidcTokenFields *map[str
var err error
if config.PreLoginHook != "" {
user, err = executePreLoginHook(username, LoginMethodIDP, ip, protocol, oidcTokenFields)
user.Filters.RequirePasswordChange = false
} else {
user, err = UserExists(username, "")
}
@ -1942,21 +1943,19 @@ func AddUser(user *User, executor, ipAddress, role string) error {
// UpdateUserPassword updates the user password
func UpdateUserPassword(username, plainPwd, executor, ipAddress, role string) error {
if config.PasswordValidation.Users.MinEntropy > 0 {
if err := passwordvalidator.Validate(plainPwd, config.PasswordValidation.Users.MinEntropy); err != nil {
return util.NewValidationError(err.Error())
}
}
hashedPwd, err := hashPlainPassword(plainPwd)
user, err := provider.userExists(username, role)
if err != nil {
return util.NewGenericError(fmt.Sprintf("unable to set the new password: %v", err))
return err
}
err = provider.updateUserPassword(username, hashedPwd)
if err != nil {
return util.NewGenericError(fmt.Sprintf("unable to set the new password: %v", err))
user.Password = plainPwd
user.Filters.RequirePasswordChange = false
// the last password change is set when validating the user
if err := provider.updateUser(&user); err != nil {
return err
}
webDAVUsersCache.swap(&user)
cachedPasswords.Remove(username)
executeAction(operationUpdate, executor, ipAddress, actionObjectUser, username, role, &User{})
executeAction(operationUpdate, executor, ipAddress, actionObjectUser, username, role, &user)
return nil
}
@ -2336,6 +2335,7 @@ func copyBaseUserFilters(in sdk.BaseUserFilters) sdk.BaseUserFilters {
filters.AllowAPIKeyAuth = in.AllowAPIKeyAuth
filters.ExternalAuthCacheTime = in.ExternalAuthCacheTime
filters.DefaultSharesExpiration = in.DefaultSharesExpiration
filters.PasswordExpiration = in.PasswordExpiration
filters.WebClient = make([]string, len(in.WebClient))
copy(filters.WebClient, in.WebClient)
filters.BandwidthLimits = make([]sdk.BandwidthLimit, 0, len(in.BandwidthLimits))
@ -2809,6 +2809,16 @@ func validateBaseFilters(filters *sdk.BaseUserFilters) error {
return validateFiltersPatternExtensions(filters)
}
func validateCombinedUserFilters(user *User) error {
if user.Filters.TOTPConfig.Enabled && util.Contains(user.Filters.WebClient, sdk.WebClientMFADisabled) {
return util.NewValidationError("two-factor authentication cannot be disabled for a user with an active configuration")
}
if user.Filters.RequirePasswordChange && util.Contains(user.Filters.WebClient, sdk.WebClientPasswordChangeDisabled) {
return util.NewValidationError("you cannot require password change and at the same time disallow it")
}
return nil
}
func validateBaseParams(user *User) error {
if user.Username == "" {
return util.NewValidationError("username is mandatory")
@ -2880,6 +2890,7 @@ func createUserPasswordHash(user *User) error {
return err
}
user.Password = hashedPwd
user.LastPasswordChange = util.GetTimeAsMsSinceEpoch(time.Now())
}
return nil
}
@ -2950,10 +2961,7 @@ func ValidateUser(user *User) error {
if !user.HasExternalAuth() {
user.Filters.ExternalAuthCacheTime = 0
}
if user.Filters.TOTPConfig.Enabled && util.Contains(user.Filters.WebClient, sdk.WebClientMFADisabled) {
return util.NewValidationError("two-factor authentication cannot be disabled for a user with an active configuration")
}
return nil
return validateCombinedUserFilters(user)
}
func isPasswordOK(user *User, password string) (bool, error) {
@ -3048,6 +3056,9 @@ func checkUserAndPass(user *User, password, ip, protocol string) (User, error) {
if err != nil {
return *user, err
}
if protocol != protocolHTTP && user.MustChangePassword() {
return *user, errors.New("login not allowed, password change required")
}
if user.Filters.IsAnonymous {
user.setAnonymousSettings()
return *user, nil
@ -3705,6 +3716,7 @@ func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFi
userLastLogin := u.LastLogin
userFirstDownload := u.FirstDownload
userFirstUpload := u.FirstUpload
userLastPwdChange := u.LastPasswordChange
userCreatedAt := u.CreatedAt
totpConfig := u.Filters.TOTPConfig
recoveryCodes := u.Filters.RecoveryCodes
@ -3719,6 +3731,7 @@ func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFi
u.UsedDownloadDataTransfer = userUsedDownloadTransfer
u.LastQuotaUpdate = userLastQuotaUpdate
u.LastLogin = userLastLogin
u.LastPasswordChange = userLastPwdChange
u.FirstDownload = userFirstDownload
u.FirstUpload = userFirstUpload
u.CreatedAt = userCreatedAt
@ -3893,6 +3906,7 @@ func updateUserFromExtAuthResponse(user *User, password, pkey string) {
if pkey != "" && !util.IsStringPrefixInSlice(pkey, user.PublicKeys) {
user.PublicKeys = append(user.PublicKeys, pkey)
}
user.LastPasswordChange = 0
}
func doExternalAuth(username, password string, pubKey []byte, keyboardInteractive, ip, protocol string,
@ -3956,6 +3970,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
user.LastQuotaUpdate = u.LastQuotaUpdate
user.LastLogin = u.LastLogin
user.LastPasswordChange = u.LastPasswordChange
user.FirstDownload = u.FirstDownload
user.FirstUpload = u.FirstUpload
user.CreatedAt = u.CreatedAt
@ -4030,6 +4045,7 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
user.LastQuotaUpdate = u.LastQuotaUpdate
user.LastLogin = u.LastLogin
user.LastPasswordChange = u.LastPasswordChange
user.FirstDownload = u.FirstDownload
user.FirstUpload = u.FirstUpload
// preserve TOTP config and recovery codes

View file

@ -183,6 +183,9 @@ const (
"ALTER TABLE `{{users}}` DROP COLUMN `role_id`;" +
"ALTER TABLE `{{admins}}` DROP COLUMN `role_id`;" +
"DROP TABLE `{{roles}}` CASCADE;"
mysqlV25SQL = "ALTER TABLE `{{users}}` ADD COLUMN `last_password_change` bigint DEFAULT 0 NOT NULL; " +
"ALTER TABLE `{{users}}` ALTER COLUMN `last_password_change` DROP DEFAULT; "
mysqlV25DownSQL = "ALTER TABLE `{{users}}` DROP COLUMN `last_password_change`; "
)
// MySQLProvider defines the auth provider for MySQL/MariaDB database
@ -739,6 +742,8 @@ func (p *MySQLProvider) migrateDatabase() error { //nolint:dupl
return err
case version == 23:
return updateMySQLDatabaseFromV23(p.dbHandle)
case version == 24:
return updateMySQLDatabaseFromV24(p.dbHandle)
default:
if version > sqlDatabaseVersion {
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@ -763,6 +768,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
switch dbVersion.Version {
case 24:
return downgradeMySQLDatabaseFromV24(p.dbHandle)
case 25:
return downgradeMySQLDatabaseFromV25(p.dbHandle)
default:
return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
}
@ -774,13 +781,27 @@ func (p *MySQLProvider) resetDatabase() error {
}
func updateMySQLDatabaseFromV23(dbHandle *sql.DB) error {
return updateMySQLDatabaseFrom23To24(dbHandle)
if err := updateMySQLDatabaseFrom23To24(dbHandle); err != nil {
return err
}
return updateMySQLDatabaseFromV24(dbHandle)
}
func updateMySQLDatabaseFromV24(dbHandle *sql.DB) error {
return updateMySQLDatabaseFrom24To25(dbHandle)
}
func downgradeMySQLDatabaseFromV24(dbHandle *sql.DB) error {
return downgradeMySQLDatabaseFrom24To23(dbHandle)
}
func downgradeMySQLDatabaseFromV25(dbHandle *sql.DB) error {
if err := downgradeMySQLDatabaseFrom25To24(dbHandle); err != nil {
return err
}
return downgradeMySQLDatabaseFromV24(dbHandle)
}
func updateMySQLDatabaseFrom23To24(dbHandle *sql.DB) error {
logger.InfoToConsole("updating database schema version: 23 -> 24")
providerLog(logger.LevelInfo, "updating database schema version: 23 -> 24")
@ -791,6 +812,13 @@ func updateMySQLDatabaseFrom23To24(dbHandle *sql.DB) error {
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 24, true)
}
func updateMySQLDatabaseFrom24To25(dbHandle *sql.DB) error {
logger.InfoToConsole("updating database schema version: 24 -> 25")
providerLog(logger.LevelInfo, "updating database schema version: 24 -> 25")
sql := strings.ReplaceAll(mysqlV25SQL, "{{users}}", sqlTableUsers)
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 25, true)
}
func downgradeMySQLDatabaseFrom24To23(dbHandle *sql.DB) error {
logger.InfoToConsole("downgrading database schema version: 24 -> 23")
providerLog(logger.LevelInfo, "downgrading database schema version: 24 -> 23")
@ -800,3 +828,10 @@ func downgradeMySQLDatabaseFrom24To23(dbHandle *sql.DB) error {
sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 23, false)
}
func downgradeMySQLDatabaseFrom25To24(dbHandle *sql.DB) error {
logger.InfoToConsole("downgrading database schema version: 25 -> 24")
providerLog(logger.LevelInfo, "downgrading database schema version: 25 -> 24")
sql := strings.ReplaceAll(mysqlV25DownSQL, "{{users}}", sqlTableUsers)
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 24, false)
}

View file

@ -194,6 +194,10 @@ CREATE INDEX "{{prefix}}users_role_id_idx" ON "{{users}}" ("role_id");
ALTER TABLE "{{admins}}" DROP COLUMN "role_id" CASCADE;
DROP TABLE "{{roles}}" CASCADE;
`
pgsqlV25SQL = `ALTER TABLE "{{users}}" ADD COLUMN "last_password_change" bigint DEFAULT 0 NOT NULL;
ALTER TABLE "{{users}}" ALTER COLUMN "last_password_change" DROP DEFAULT;
`
pgsqlV25DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "last_password_change" CASCADE;`
)
// PGSQLProvider defines the auth provider for PostgreSQL database
@ -709,6 +713,8 @@ func (p *PGSQLProvider) migrateDatabase() error { //nolint:dupl
return err
case version == 23:
return updatePgSQLDatabaseFromV23(p.dbHandle)
case version == 24:
return updatePgSQLDatabaseFromV24(p.dbHandle)
default:
if version > sqlDatabaseVersion {
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@ -733,6 +739,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
switch dbVersion.Version {
case 24:
return downgradePgSQLDatabaseFromV24(p.dbHandle)
case 25:
return downgradePgSQLDatabaseFromV25(p.dbHandle)
default:
return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
}
@ -744,13 +752,27 @@ func (p *PGSQLProvider) resetDatabase() error {
}
func updatePgSQLDatabaseFromV23(dbHandle *sql.DB) error {
return updatePgSQLDatabaseFrom23To24(dbHandle)
if err := updatePgSQLDatabaseFrom23To24(dbHandle); err != nil {
return err
}
return updatePgSQLDatabaseFromV24(dbHandle)
}
func updatePgSQLDatabaseFromV24(dbHandle *sql.DB) error {
return updatePgSQLDatabaseFrom24To25(dbHandle)
}
func downgradePgSQLDatabaseFromV24(dbHandle *sql.DB) error {
return downgradePgSQLDatabaseFrom24To23(dbHandle)
}
func downgradePgSQLDatabaseFromV25(dbHandle *sql.DB) error {
if err := downgradePgSQLDatabaseFrom25To24(dbHandle); err != nil {
return err
}
return downgradePgSQLDatabaseFromV24(dbHandle)
}
func updatePgSQLDatabaseFrom23To24(dbHandle *sql.DB) error {
logger.InfoToConsole("updating database schema version: 23 -> 24")
providerLog(logger.LevelInfo, "updating database schema version: 23 -> 24")
@ -761,6 +783,17 @@ func updatePgSQLDatabaseFrom23To24(dbHandle *sql.DB) error {
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 24, true)
}
func updatePgSQLDatabaseFrom24To25(dbHandle *sql.DB) error {
logger.InfoToConsole("updating database schema version: 24 -> 25")
providerLog(logger.LevelInfo, "updating database schema version: 24 -> 25")
sql := pgsqlV25SQL
if config.Driver == CockroachDataProviderName {
sql = strings.ReplaceAll(sql, `ALTER TABLE "{{users}}" ALTER COLUMN "last_password_change" DROP DEFAULT;`, "")
}
sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 25, true)
}
func downgradePgSQLDatabaseFrom24To23(dbHandle *sql.DB) error {
logger.InfoToConsole("downgrading database schema version: 24 -> 23")
providerLog(logger.LevelInfo, "downgrading database schema version: 24 -> 23")
@ -770,3 +803,10 @@ func downgradePgSQLDatabaseFrom24To23(dbHandle *sql.DB) error {
sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 23, false)
}
func downgradePgSQLDatabaseFrom25To24(dbHandle *sql.DB) error {
logger.InfoToConsole("downgrading database schema version: 25 -> 24")
providerLog(logger.LevelInfo, "downgrading database schema version: 25 -> 24")
sql := strings.ReplaceAll(pgsqlV25DownSQL, "{{users}}", sqlTableUsers)
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 24, false)
}

View file

@ -34,7 +34,7 @@ import (
)
const (
sqlDatabaseVersion = 24
sqlDatabaseVersion = 25
defaultSQLQueryTimeout = 10 * time.Second
longSQLQueryTimeout = 60 * time.Second
)
@ -1119,7 +1119,7 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
user.MaxSessions, user.QuotaSize, user.QuotaFiles, string(permissions), user.UploadBandwidth,
user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters), string(fsConfig), user.AdditionalInfo,
user.Description, user.Email, util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(time.Now()),
user.UploadDataTransfer, user.DownloadDataTransfer, user.TotalDataTransfer, user.Role)
user.UploadDataTransfer, user.DownloadDataTransfer, user.TotalDataTransfer, user.Role, user.LastPasswordChange)
if err != nil {
return err
}
@ -1170,7 +1170,7 @@ func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
user.QuotaSize, user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status,
user.ExpirationDate, string(filters), string(fsConfig), user.AdditionalInfo, user.Description, user.Email,
util.GetTimeAsMsSinceEpoch(time.Now()), user.UploadDataTransfer, user.DownloadDataTransfer, user.TotalDataTransfer,
user.Role, user.ID)
user.Role, user.LastPasswordChange, user.ID)
if err != nil {
return err
}
@ -1926,7 +1926,7 @@ func getUserFromDbRow(row sqlScanner) (User, error) {
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
&additionalInfo, &description, &email, &user.CreatedAt, &user.UpdatedAt, &user.UploadDataTransfer, &user.DownloadDataTransfer,
&user.TotalDataTransfer, &user.UsedUploadDataTransfer, &user.UsedDownloadDataTransfer, &user.DeletedAt, &user.FirstDownload,
&user.FirstUpload, &role)
&user.FirstUpload, &role, &user.LastPasswordChange)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user, util.NewRecordNotFoundError(err.Error())

View file

@ -175,6 +175,8 @@ ALTER TABLE "{{users}}" DROP COLUMN role_id;
ALTER TABLE "{{admins}}" DROP COLUMN role_id;
DROP TABLE "{{roles}}";
`
sqliteV25SQL = `ALTER TABLE "{{users}}" ADD COLUMN "last_password_change" bigint DEFAULT 0 NOT NULL;`
sqliteV25DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "last_password_change";`
)
// SQLiteProvider defines the auth provider for SQLite database
@ -669,6 +671,8 @@ func (p *SQLiteProvider) migrateDatabase() error { //nolint:dupl
return err
case version == 23:
return updateSQLiteDatabaseFromV23(p.dbHandle)
case version == 24:
return updateSQLiteDatabaseFromV24(p.dbHandle)
default:
if version > sqlDatabaseVersion {
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@ -693,6 +697,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
switch dbVersion.Version {
case 24:
return downgradeSQLiteDatabaseFromV24(p.dbHandle)
case 25:
return downgradeSQLiteDatabaseFromV25(p.dbHandle)
default:
return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
}
@ -704,13 +710,27 @@ func (p *SQLiteProvider) resetDatabase() error {
}
func updateSQLiteDatabaseFromV23(dbHandle *sql.DB) error {
return updateSQLiteDatabaseFrom23To24(dbHandle)
if err := updateSQLiteDatabaseFrom23To24(dbHandle); err != nil {
return err
}
return updateSQLiteDatabaseFromV24(dbHandle)
}
func updateSQLiteDatabaseFromV24(dbHandle *sql.DB) error {
return updateSQLiteDatabaseFrom24To25(dbHandle)
}
func downgradeSQLiteDatabaseFromV24(dbHandle *sql.DB) error {
return downgradeSQLiteDatabaseFrom24To23(dbHandle)
}
func downgradeSQLiteDatabaseFromV25(dbHandle *sql.DB) error {
if err := downgradeSQLiteDatabaseFrom25To24(dbHandle); err != nil {
return err
}
return downgradeSQLiteDatabaseFromV24(dbHandle)
}
func updateSQLiteDatabaseFrom23To24(dbHandle *sql.DB) error {
logger.InfoToConsole("updating database schema version: 23 -> 24")
providerLog(logger.LevelInfo, "updating database schema version: 23 -> 24")
@ -721,6 +741,13 @@ func updateSQLiteDatabaseFrom23To24(dbHandle *sql.DB) error {
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 24, true)
}
func updateSQLiteDatabaseFrom24To25(dbHandle *sql.DB) error {
logger.InfoToConsole("updating database schema version: 24 -> 25")
providerLog(logger.LevelInfo, "updating database schema version: 24 -> 25")
sql := strings.ReplaceAll(sqliteV25SQL, "{{users}}", sqlTableUsers)
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 25, true)
}
func downgradeSQLiteDatabaseFrom24To23(dbHandle *sql.DB) error {
logger.InfoToConsole("downgrading database schema version: 24 -> 23")
providerLog(logger.LevelInfo, "downgrading database schema version: 24 -> 23")
@ -731,6 +758,13 @@ func downgradeSQLiteDatabaseFrom24To23(dbHandle *sql.DB) error {
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 23, false)
}
func downgradeSQLiteDatabaseFrom25To24(dbHandle *sql.DB) error {
logger.InfoToConsole("downgrading database schema version: 25 -> 24")
providerLog(logger.LevelInfo, "downgrading database schema version: 25 -> 24")
sql := strings.ReplaceAll(sqliteV25DownSQL, "{{users}}", sqlTableUsers)
return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 24, false)
}
/*func setPragmaFK(dbHandle *sql.DB, value string) error {
ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
defer cancel()

View file

@ -27,7 +27,7 @@ const (
"u.permissions,u.used_quota_size,u.used_quota_files,u.last_quota_update,u.upload_bandwidth,u.download_bandwidth," +
"u.expiration_date,u.last_login,u.status,u.filters,u.filesystem,u.additional_info,u.description,u.email,u.created_at," +
"u.updated_at,u.upload_data_transfer,u.download_data_transfer,u.total_data_transfer," +
"u.used_upload_data_transfer,u.used_download_data_transfer,u.deleted_at,u.first_download,u.first_upload,r.name"
"u.used_upload_data_transfer,u.used_download_data_transfer,u.deleted_at,u.first_download,u.first_upload,r.name,u.last_password_change"
selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update,name,description,filesystem"
selectAdminFields = "a.id,a.username,a.password,a.status,a.email,a.permissions,a.filters,a.additional_info,a.description,a.created_at,a.updated_at,a.last_login,r.name"
selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id"
@ -588,32 +588,33 @@ func getAddUserQuery(role string) string {
return fmt.Sprintf(`INSERT INTO %s (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
filesystem,additional_info,description,email,created_at,updated_at,upload_data_transfer,download_data_transfer,total_data_transfer,
used_upload_data_transfer,used_download_data_transfer,deleted_at,first_download,first_upload,role_id)
used_upload_data_transfer,used_download_data_transfer,deleted_at,first_download,first_upload,role_id,last_password_change)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,0,0,0,
COALESCE((SELECT id from %s WHERE name=%s),%s))`,
COALESCE((SELECT id from %s WHERE name=%s),%s),%s)`,
sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4],
sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14],
sqlPlaceholders[15], sqlPlaceholders[16], sqlPlaceholders[17], sqlPlaceholders[18], sqlPlaceholders[19],
sqlPlaceholders[20], sqlPlaceholders[21], sqlPlaceholders[22], sqlPlaceholders[23], sqlTableRoles,
sqlPlaceholders[24], getCoalesceDefaultForRole(role))
sqlPlaceholders[24], getCoalesceDefaultForRole(role), sqlPlaceholders[25])
}
func getUpdateUserQuery(role string) string {
return fmt.Sprintf(`UPDATE %s SET password=%s,public_keys=%s,home_dir=%s,uid=%s,gid=%s,max_sessions=%s,quota_size=%s,
quota_files=%s,permissions=%s,upload_bandwidth=%s,download_bandwidth=%s,status=%s,expiration_date=%s,filters=%s,filesystem=%s,
additional_info=%s,description=%s,email=%s,updated_at=%s,upload_data_transfer=%s,download_data_transfer=%s,
total_data_transfer=%s,role_id=COALESCE((SELECT id from %s WHERE name=%s),%s) WHERE id = %s`,
total_data_transfer=%s,role_id=COALESCE((SELECT id from %s WHERE name=%s),%s),last_password_change=%s WHERE id = %s`,
sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4],
sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14],
sqlPlaceholders[15], sqlPlaceholders[16], sqlPlaceholders[17], sqlPlaceholders[18], sqlPlaceholders[19],
sqlPlaceholders[20], sqlPlaceholders[21], sqlTableRoles, sqlPlaceholders[22], getCoalesceDefaultForRole(role),
sqlPlaceholders[23])
sqlPlaceholders[23], sqlPlaceholders[24])
}
func getUpdateUserPasswordQuery() string {
return fmt.Sprintf(`UPDATE %s SET password=%s WHERE username = %s`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
return fmt.Sprintf(`UPDATE %s SET password=%s WHERE username = %s`,
sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
}
func getDeleteUserQuery(softDelete bool) string {

View file

@ -119,6 +119,8 @@ type UserTOTPConfig struct {
// TODO: rename to UserOptions in v3
type UserFilters struct {
sdk.BaseUserFilters
// User must change password from WebClient/REST API at next login.
RequirePasswordChange bool `json:"require_password_change,omitempty"`
// Time-based one time passwords configuration
TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
// Recovery codes to use if the user loses access to their second factor auth device.
@ -1108,6 +1110,18 @@ func (u *User) CanDeleteFromWeb(target string) bool {
return u.HasAnyPerm(permsDeleteAny, target)
}
// MustChangePassword returns true if the user must change the password
func (u *User) MustChangePassword() bool {
if u.Filters.RequirePasswordChange {
return true
}
if u.Filters.PasswordExpiration == 0 {
return false
}
lastPwdChange := util.GetTimeFromMsecSinceEpoch(u.LastPasswordChange)
return lastPwdChange.Add(time.Duration(u.Filters.PasswordExpiration) * 24 * time.Hour).Before(time.Now())
}
// MustSetSecondFactor returns true if the user must set a second factor authentication
func (u *User) MustSetSecondFactor() bool {
if len(u.Filters.TwoFactorAuthProtocols) > 0 {
@ -1751,6 +1765,9 @@ func (u *User) mergePrimaryGroupFilters(filters sdk.BaseUserFilters, replacer *s
if u.Filters.DefaultSharesExpiration == 0 {
u.Filters.DefaultSharesExpiration = filters.DefaultSharesExpiration
}
if u.Filters.PasswordExpiration == 0 {
u.Filters.PasswordExpiration = filters.PasswordExpiration
}
}
func (u *User) mergeAdditiveProperties(group Group, groupType int, replacer *strings.Replacer) {
@ -1864,6 +1881,7 @@ func (u *User) getACopy() User {
filters := UserFilters{
BaseUserFilters: copyBaseUserFilters(u.Filters.BaseUserFilters),
}
filters.RequirePasswordChange = u.Filters.RequirePasswordChange
filters.TOTPConfig.Enabled = u.Filters.TOTPConfig.Enabled
filters.TOTPConfig.ConfigName = u.Filters.TOTPConfig.ConfigName
filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
@ -1909,6 +1927,7 @@ func (u *User) getACopy() User {
LastLogin: u.LastLogin,
FirstDownload: u.FirstDownload,
FirstUpload: u.FirstUpload,
LastPasswordChange: u.LastPasswordChange,
AdditionalInfo: u.AdditionalInfo,
Description: u.Description,
CreatedAt: u.CreatedAt,

View file

@ -1054,6 +1054,31 @@ func TestMultiFactorAuth(t *testing.T) {
assert.NoError(t, err)
}
func TestMustChangePasswordRequirement(t *testing.T) {
u := getTestUser()
u.Filters.RequirePasswordChange = true
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
_, err = getFTPClient(user, true, nil)
assert.Error(t, err)
err = dataprovider.UpdateUserPassword(user.Username, defaultPassword, "", "", "")
assert.NoError(t, err)
client, err := getFTPClient(user, true, nil)
if assert.NoError(t, err) {
err = checkBasicFTP(client)
assert.NoError(t, err)
err := client.Quit()
assert.NoError(t, err)
}
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestSecondFactorRequirement(t *testing.T) {
u := getTestUser()
u.Filters.TwoFactorAuthProtocols = []string{common.ProtocolFTP}

View file

@ -103,6 +103,7 @@ func addUser(w http.ResponseWriter, r *http.Request) {
if claims.Role != "" {
user.Role = claims.Role
}
user.LastPasswordChange = 0
user.Filters.RecoveryCodes = nil
user.Filters.TOTPConfig = dataprovider.UserTOTPConfig{
Enabled: false,
@ -164,6 +165,7 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
}
userID := user.ID
username = user.Username
lastPwdChange := user.LastPasswordChange
totpConfig := user.Filters.TOTPConfig
recoveryCodes := user.Filters.RecoveryCodes
currentPermissions := user.Permissions
@ -197,6 +199,7 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
user.Username = username
user.Filters.TOTPConfig = totpConfig
user.Filters.RecoveryCodes = recoveryCodes
user.LastPasswordChange = lastPwdChange
user.SetEmptySecretsIfNil()
// we use new Permissions if passed otherwise the old ones
if len(user.Permissions) == 0 {

View file

@ -54,6 +54,7 @@ const (
claimRole = "role"
claimAPIKey = "api_key"
claimNodeID = "node_id"
claimMustChangePasswordKey = "chpwd"
claimMustSetSecondFactorKey = "2fa_required"
claimRequiredTwoFactorProtocols = "2fa_protos"
claimHideUserPageSection = "hus"
@ -79,6 +80,7 @@ type jwtTokenClaims struct {
APIKeyID string
NodeID string
MustSetTwoFactorAuth bool
MustChangePassword bool
RequiredTwoFactorProtocols []string
HideUserPageSections int
}
@ -108,6 +110,9 @@ func (c *jwtTokenClaims) asMap() map[string]any {
claims[claimNodeID] = c.NodeID
}
claims[jwt.SubjectKey] = c.Signature
if c.MustChangePassword {
claims[claimMustChangePasswordKey] = c.MustChangePassword
}
if c.MustSetTwoFactorAuth {
claims[claimMustSetSecondFactorKey] = c.MustSetTwoFactorAuth
}
@ -122,73 +127,73 @@ func (c *jwtTokenClaims) asMap() map[string]any {
}
func (c *jwtTokenClaims) decodeSliceString(val any) []string {
var result []string
switch v := val.(type) {
case []any:
result := make([]string, 0, len(v))
for _, elem := range v {
switch elemValue := elem.(type) {
case string:
result = append(result, elemValue)
}
}
return result
case []string:
return v
default:
return nil
}
}
return result
func (c *jwtTokenClaims) decodeBoolean(val any) bool {
switch v := val.(type) {
case bool:
return v
default:
return false
}
}
func (c *jwtTokenClaims) decodeString(val any) string {
switch v := val.(type) {
case string:
return v
default:
return ""
}
}
func (c *jwtTokenClaims) Decode(token map[string]any) {
c.Permissions = nil
username := token[claimUsernameKey]
switch v := username.(type) {
case string:
c.Username = v
}
signature := token[jwt.SubjectKey]
switch v := signature.(type) {
case string:
c.Signature = v
}
c.Username = c.decodeString(token[claimUsernameKey])
c.Signature = c.decodeString(token[jwt.SubjectKey])
audience := token[jwt.AudienceKey]
switch v := audience.(type) {
case []string:
c.Audience = v
}
if val, ok := token[claimAPIKey]; ok {
switch v := val.(type) {
case string:
c.APIKeyID = v
}
c.APIKeyID = c.decodeString(val)
}
if val, ok := token[claimNodeID]; ok {
switch v := val.(type) {
case string:
c.NodeID = v
}
c.NodeID = c.decodeString(val)
}
if val, ok := token[claimRole]; ok {
switch v := val.(type) {
case string:
c.Role = v
}
c.Role = c.decodeString(val)
}
permissions := token[claimPermissionsKey]
c.Permissions = c.decodeSliceString(permissions)
if val, ok := token[claimMustChangePasswordKey]; ok {
c.MustChangePassword = c.decodeBoolean(val)
}
if val, ok := token[claimMustSetSecondFactorKey]; ok {
switch v := val.(type) {
case bool:
c.MustSetTwoFactorAuth = v
}
c.MustSetTwoFactorAuth = c.decodeBoolean(val)
}
if val, ok := token[claimRequiredTwoFactorProtocols]; ok {

View file

@ -569,6 +569,8 @@ func TestBasicUserHandling(t *testing.T) {
u.Email = "user@user.com"
user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err, string(resp))
lastPwdChange := user.LastPasswordChange
assert.Greater(t, lastPwdChange, int64(0))
user.MaxSessions = 10
user.QuotaSize = 4096
user.QuotaFiles = 2
@ -587,6 +589,7 @@ func TestBasicUserHandling(t *testing.T) {
user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
assert.Equal(t, originalUser.ID, user.ID)
assert.Equal(t, lastPwdChange, user.LastPasswordChange)
user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
assert.NoError(t, err)
@ -2579,6 +2582,115 @@ func TestPermMFADisabled(t *testing.T) {
assert.NoError(t, err)
}
func TestMustChangePasswordRequirement(t *testing.T) {
u := getTestUser()
u.Filters.RequirePasswordChange = true
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
assert.True(t, user.Filters.RequirePasswordChange)
token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, userFilesPath, nil)
assert.NoError(t, err)
setBearerForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "Password change required. Please set a new password to continue to use your account")
req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
assert.NoError(t, err)
req.RequestURI = webClientFilesPath
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "Password change required. Please set a new password to continue to use your account")
// change pwd
pwd := make(map[string]string)
pwd["current_password"] = defaultPassword
pwd["new_password"] = altAdminPassword
asJSON, err := json.Marshal(pwd)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
// check that the change pwd bool is changed
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
assert.False(t, user.Filters.RequirePasswordChange)
// get a new token
token, err = getJWTAPIUserTokenFromTestServer(defaultUsername, altAdminPassword)
assert.NoError(t, err)
webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, altAdminPassword)
assert.NoError(t, err)
// the new token should work
req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
assert.NoError(t, err)
req.RequestURI = webClientFilesPath
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
// check the same as above but changing password from the WebClient UI
user.Filters.RequirePasswordChange = true
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, altAdminPassword)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
assert.NoError(t, err)
req.RequestURI = webClientFilesPath
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
assert.NoError(t, err)
form := make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("current_password", altAdminPassword)
form.Set("new_password1", defaultPassword)
form.Set("new_password2", defaultPassword)
req, err = http.NewRequest(http.MethodPost, webChangeClientPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
token, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, userDirsPath, nil)
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
assert.NoError(t, err)
req.RequestURI = webClientFilesPath
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestTwoFactorRequirements(t *testing.T) {
u := getTestUser()
u.Filters.TwoFactorAuthProtocols = []string{common.ProtocolHTTP, common.ProtocolFTP}
@ -5866,7 +5978,7 @@ func TestNamingRules(t *testing.T) {
rr := executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "the following characters are allowed")
// test user reset password
// test user reset password. Setting the new password will fail because the username is not valid
form = make(url.Values)
form.Set("username", user.Username)
form.Set(csrfFormToken, csrfToken)
@ -5887,7 +5999,8 @@ func TestNamingRules(t *testing.T) {
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "the following characters are allowed")
adminAPIToken, err = getJWTAPITokenFromTestServer(admin.Username, defaultTokenAuthPass)
assert.NoError(t, err)
@ -17551,6 +17664,7 @@ func TestWebUserAddMock(t *testing.T) {
form.Set("total_data_transfer", "0")
form.Set("external_auth_cache_time", "0")
form.Set("start_directory", "start/dir")
form.Set("require_password_change", "1")
b, contentType, _ := getMultipartFormData(form, "", "")
// test invalid url escape
req, _ = http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
@ -17697,6 +17811,16 @@ func TestWebUserAddMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "invalid default shares expiration")
form.Set("default_shares_expiration", "10")
// test invalid password expiration
form.Set("password_expiration", "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 expiration")
form.Set("password_expiration", "90")
// test invalid tls username
form.Set("tls_username", "username")
b, contentType, _ = getMultipartFormData(form, "", "")
@ -17845,6 +17969,9 @@ func TestWebUserAddMock(t *testing.T) {
assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
assert.Equal(t, 0, newUser.Filters.FTPSecurity)
assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)
assert.Equal(t, 90, newUser.Filters.PasswordExpiration)
assert.Greater(t, newUser.LastPasswordChange, int64(0))
assert.True(t, newUser.Filters.RequirePasswordChange)
assert.True(t, util.Contains(newUser.PublicKeys, testPubKey))
if val, ok := newUser.Permissions["/subdir"]; ok {
assert.True(t, util.Contains(val, dataprovider.PermListItems))
@ -17961,6 +18088,10 @@ func TestWebUserUpdateMock(t *testing.T) {
setBearerForReq(req, apiToken)
rr := executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
lastPwdChange := user.LastPasswordChange
assert.Greater(t, lastPwdChange, int64(0))
// add TOTP config
configName, _, secret, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username)
assert.NoError(t, err)
@ -17992,6 +18123,7 @@ func TestWebUserUpdateMock(t *testing.T) {
assert.NoError(t, err)
assert.True(t, user.Filters.TOTPConfig.Enabled)
assert.Equal(t, int64(4000), user.TotalDataTransfer)
assert.Equal(t, lastPwdChange, user.LastPasswordChange)
if assert.Len(t, user.Filters.BandwidthLimits, 1) {
if assert.Len(t, user.Filters.BandwidthLimits[0].Sources, 2) {
assert.Equal(t, "10.8.0.0/16", user.Filters.BandwidthLimits[0].Sources[0])
@ -18045,11 +18177,13 @@ func TestWebUserUpdateMock(t *testing.T) {
form.Set("denied_protocols", common.ProtocolFTP)
form.Set("max_upload_file_size", "100")
form.Set("default_shares_expiration", "30")
form.Set("password_expiration", "60")
form.Set("disconnect", "1")
form.Set("additional_info", user.AdditionalInfo)
form.Set("description", user.Description)
form.Set("tls_username", string(sdk.TLSUsernameCN))
form.Set("allow_api_key_auth", "1")
form.Set("require_password_change", "1")
form.Set("external_auth_cache_time", "120")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -18123,6 +18257,8 @@ func TestWebUserUpdateMock(t *testing.T) {
assert.Equal(t, int64(0), updateUser.UploadDataTransfer)
assert.Equal(t, int64(0), updateUser.Filters.ExternalAuthCacheTime)
assert.Equal(t, 30, updateUser.Filters.DefaultSharesExpiration)
assert.Equal(t, 60, updateUser.Filters.PasswordExpiration)
assert.True(t, updateUser.Filters.RequirePasswordChange)
if val, ok := updateUser.Permissions["/otherdir"]; ok {
assert.True(t, util.Contains(val, dataprovider.PermListItems))
assert.True(t, util.Contains(val, dataprovider.PermUpload))
@ -18234,6 +18370,7 @@ func TestUserTemplateWithFoldersMock(t *testing.T) {
form.Set("fs_provider", "0")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("ftp_security", "1")
form.Set("external_auth_cache_time", "0")
form.Set("description", "desc %username% %password%")
@ -18338,6 +18475,7 @@ func TestUserSaveFromTemplateMock(t *testing.T) {
form.Set("fs_provider", "0")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("external_auth_cache_time", "0")
form.Add("tpl_username", user1)
form.Add("tpl_password", "password1")
@ -18427,6 +18565,7 @@ func TestUserTemplateMock(t *testing.T) {
form.Set("denied_extensions", "/dir2::.zip")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Add("hooks", "external_auth_disabled")
form.Add("hooks", "check_password_disabled")
form.Set("disable_fs_checks", "checked")
@ -18558,6 +18697,7 @@ func TestUserPlaceholders(t *testing.T) {
form.Set("external_auth_cache_time", "0")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ := http.NewRequest(http.MethodPost, webUserPath, &b)
setJWTCookieForReq(req, token)
@ -18839,6 +18979,8 @@ func TestWebUserS3Mock(t *testing.T) {
checkResponseCode(t, http.StatusCreated, rr)
err = render.DecodeJSON(rr.Body, &user)
assert.NoError(t, err)
lastPwdChange := user.LastPasswordChange
assert.Greater(t, lastPwdChange, int64(0))
user.FsConfig.Provider = sdk.S3FilesystemProvider
user.FsConfig.S3Config.Bucket = "test"
user.FsConfig.S3Config.Region = "eu-west-1"
@ -18898,6 +19040,7 @@ func TestWebUserS3Mock(t *testing.T) {
form.Set("pattern_policy1", "1")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("ftp_security", "1")
form.Set("s3_force_path_style", "checked")
form.Set("description", user.Description)
@ -18986,6 +19129,7 @@ func TestWebUserS3Mock(t *testing.T) {
assert.Equal(t, updateUser.FsConfig.S3Config.UploadPartMaxTime, user.FsConfig.S3Config.UploadPartMaxTime)
assert.Equal(t, updateUser.FsConfig.S3Config.DownloadPartSize, user.FsConfig.S3Config.DownloadPartSize)
assert.Equal(t, updateUser.FsConfig.S3Config.DownloadConcurrency, user.FsConfig.S3Config.DownloadConcurrency)
assert.Equal(t, lastPwdChange, updateUser.LastPasswordChange)
assert.True(t, updateUser.FsConfig.S3Config.ForcePathStyle)
if assert.Equal(t, 2, len(updateUser.Filters.FilePatterns)) {
for _, filter := range updateUser.Filters.FilePatterns {
@ -19028,6 +19172,7 @@ func TestWebUserS3Mock(t *testing.T) {
assert.Equal(t, updateUser.FsConfig.S3Config.AccessSecret.GetPayload(), lastUpdatedUser.FsConfig.S3Config.AccessSecret.GetPayload())
assert.Empty(t, lastUpdatedUser.FsConfig.S3Config.AccessSecret.GetKey())
assert.Empty(t, lastUpdatedUser.FsConfig.S3Config.AccessSecret.GetAdditionalData())
assert.Equal(t, lastPwdChange, lastUpdatedUser.LastPasswordChange)
// now clear credentials
form.Set("s3_access_key", "")
form.Set("s3_access_secret", "")
@ -19107,6 +19252,7 @@ func TestWebUserGCSMock(t *testing.T) {
form.Set("pattern_type0", "allowed")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("ftp_security", "1")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -19232,6 +19378,7 @@ func TestWebUserHTTPFsMock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
form.Set("http_equality_check_mode", "true")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -19357,6 +19504,7 @@ func TestWebUserAzureBlobMock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
// test invalid az_upload_part_size
form.Set("az_upload_part_size", "a")
b, contentType, _ := getMultipartFormData(form, "", "")
@ -19537,6 +19685,7 @@ func TestWebUserCryptMock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
// passphrase cannot be empty
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -19645,6 +19794,7 @@ func TestWebUserSFTPFsMock(t *testing.T) {
form.Set("pattern_type1", "denied")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
// empty sftpconfig
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@ -19770,6 +19920,7 @@ func TestWebUserRole(t *testing.T) {
form.Set("total_data_transfer", strconv.FormatInt(user.TotalDataTransfer, 10))
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "10")
form.Set("password_expiration", "0")
b, contentType, _ := getMultipartFormData(form, "", "")
req, err := http.NewRequest(http.MethodPost, webUserPath, &b)
assert.NoError(t, err)
@ -20687,6 +20838,7 @@ func TestAddWebGroup(t *testing.T) {
assert.Contains(t, rr.Body.String(), "invalid max upload file size")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "0")
b, contentType, err = getMultipartFormData(form, "", "")
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webGroupPath, &b)
@ -21119,6 +21271,7 @@ func TestUpdateWebGroupMock(t *testing.T) {
form.Set("total_data_transfer", "0")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
form.Set("password_expiration", "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)
@ -22043,6 +22196,22 @@ func TestStaticFilesMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
}
func TestPasswordChangeRequired(t *testing.T) {
user := getTestUser()
assert.False(t, user.MustChangePassword())
user.Filters.RequirePasswordChange = true
assert.True(t, user.MustChangePassword())
user.Filters.RequirePasswordChange = false
assert.False(t, user.MustChangePassword())
user.Filters.PasswordExpiration = 2
user.LastPasswordChange = util.GetTimeAsMsSinceEpoch(time.Now())
assert.False(t, user.MustChangePassword())
user.LastPasswordChange = util.GetTimeAsMsSinceEpoch(time.Now().Add(49 * time.Hour))
assert.False(t, user.MustChangePassword())
user.LastPasswordChange = util.GetTimeAsMsSinceEpoch(time.Now().Add(-49 * time.Hour))
assert.True(t, user.MustChangePassword())
}
func TestSecondFactorRequirements(t *testing.T) {
user := getTestUser()
user.Filters.TwoFactorAuthProtocols = []string{common.ProtocolHTTP, common.ProtocolSSH}

View file

@ -1284,7 +1284,7 @@ func TestJWTTokenValidation(t *testing.T) {
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusBadRequest, rr.Code)
fn = server.checkSecondFactorRequirement(r)
fn = server.checkAuthRequirements(r)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, nil)
req.RequestURI = webClientProfilePath
@ -2901,6 +2901,37 @@ func TestDbResetCodeManager(t *testing.T) {
}
}
func TestDecodeToken(t *testing.T) {
nodeID := "nodeID"
token := map[string]any{
claimUsernameKey: defaultAdminUsername,
claimPermissionsKey: []string{dataprovider.PermAdminAny},
jwt.SubjectKey: "",
claimNodeID: nodeID,
claimMustChangePasswordKey: false,
claimMustSetSecondFactorKey: true,
}
c := jwtTokenClaims{}
c.Decode(token)
assert.Equal(t, defaultAdminUsername, c.Username)
assert.Equal(t, nodeID, c.NodeID)
assert.False(t, c.MustChangePassword)
assert.True(t, c.MustSetTwoFactorAuth)
token[claimMustChangePasswordKey] = 10
c = jwtTokenClaims{}
c.Decode(token)
assert.False(t, c.MustChangePassword)
token[claimMustChangePasswordKey] = true
c = jwtTokenClaims{}
c.Decode(token)
assert.True(t, c.MustChangePassword)
claims := c.asMap()
assert.Equal(t, token, claims)
}
func TestEventRoleFilter(t *testing.T) {
defaultVal := "default"
req, err := http.NewRequest(http.MethodGet, fsEventsPath+"?role=role1", nil)

View file

@ -212,7 +212,8 @@ func (s *httpdServer) checkHTTPUserPerm(perm string) func(next http.Handler) htt
}
}
func (s *httpdServer) checkSecondFactorRequirement(next http.Handler) http.Handler {
// checkAuthRequirements checks if the user must set a second factor auth or change the password
func (s *httpdServer) checkAuthRequirements(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
@ -225,9 +226,14 @@ func (s *httpdServer) checkSecondFactorRequirement(next http.Handler) http.Handl
}
tokenClaims := jwtTokenClaims{}
tokenClaims.Decode(claims)
if tokenClaims.MustSetTwoFactorAuth {
message := fmt.Sprintf("Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols: %v",
strings.Join(tokenClaims.RequiredTwoFactorProtocols, ", "))
if tokenClaims.MustSetTwoFactorAuth || tokenClaims.MustChangePassword {
var message string
if tokenClaims.MustSetTwoFactorAuth {
message = fmt.Sprintf("Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols: %v",
strings.Join(tokenClaims.RequiredTwoFactorProtocols, ", "))
} else {
message = "Password change required. Please set a new password to continue to use your account"
}
if isWebRequest(r) {
s.renderClientForbiddenPage(w, r, message)
} else {

View file

@ -684,6 +684,7 @@ func (s *httpdServer) loginUser(
Signature: user.GetSignature(),
Role: user.Role,
MustSetTwoFactorAuth: user.MustSetSecondFactor(),
MustChangePassword: user.MustChangePassword(),
RequiredTwoFactorProtocols: user.Filters.TwoFactorAuthProtocols,
}
@ -842,6 +843,7 @@ func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Re
Signature: user.GetSignature(),
Role: user.Role,
MustSetTwoFactorAuth: user.MustSetSecondFactor(),
MustChangePassword: user.MustChangePassword(),
RequiredTwoFactorProtocols: user.Filters.TwoFactorAuthProtocols,
}
@ -1315,10 +1317,10 @@ func (s *httpdServer) initializeRouter() {
router.Use(jwtAuthenticatorAPIUser)
router.With(forbidAPIKeyAuthentication).Get(userLogoutPath, s.logout)
router.With(forbidAPIKeyAuthentication, s.checkSecondFactorRequirement,
s.checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).Put(userPwdPath, changeUserPassword)
router.With(forbidAPIKeyAuthentication, s.checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
Put(userPwdPath, changeUserPassword)
router.With(forbidAPIKeyAuthentication).Get(userProfilePath, getUserProfile)
router.With(forbidAPIKeyAuthentication, s.checkSecondFactorRequirement).Put(userProfilePath, updateUserProfile)
router.With(forbidAPIKeyAuthentication, s.checkAuthRequirements).Put(userProfilePath, updateUserProfile)
// user TOTP APIs
router.With(forbidAPIKeyAuthentication, s.checkHTTPUserPerm(sdk.WebClientMFADisabled)).
Get(userTOTPConfigsPath, getTOTPConfigs)
@ -1333,34 +1335,34 @@ func (s *httpdServer) initializeRouter() {
router.With(forbidAPIKeyAuthentication, s.checkHTTPUserPerm(sdk.WebClientMFADisabled)).
Post(user2FARecoveryCodesPath, generateRecoveryCodes)
router.With(s.checkSecondFactorRequirement, compressor.Handler).Get(userDirsPath, readUserFolder)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
router.With(s.checkAuthRequirements, compressor.Handler).Get(userDirsPath, readUserFolder)
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
Post(userDirsPath, createUserDir)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
Patch(userDirsPath, renameUserDir)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
Delete(userDirsPath, deleteUserDir)
router.With(s.checkSecondFactorRequirement).Get(userFilesPath, getUserFile)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
router.With(s.checkAuthRequirements).Get(userFilesPath, getUserFile)
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
Post(userFilesPath, uploadUserFiles)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
Patch(userFilesPath, renameUserFile)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
Delete(userFilesPath, deleteUserFile)
router.With(s.checkSecondFactorRequirement).Post(userStreamZipPath, getUserFilesAsZipStream)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
router.With(s.checkAuthRequirements).Post(userStreamZipPath, getUserFilesAsZipStream)
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
Get(userSharesPath, getShares)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
Post(userSharesPath, addShare)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
Get(userSharesPath+"/{id}", getShareByID)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
Put(userSharesPath+"/{id}", updateShare)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
Delete(userSharesPath+"/{id}", deleteShare)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
Post(userUploadFilePath, uploadUserFile)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled)).
Patch(userFilesDirsMetadataPath, setFileDirMetadata)
})
@ -1451,33 +1453,33 @@ func (s *httpdServer) setupWebClientRoutes() {
router.Use(jwtAuthenticatorWebClient)
router.Get(webClientLogoutPath, s.handleWebClientLogout)
router.With(s.checkSecondFactorRequirement, s.refreshCookie).Get(webClientFilesPath, s.handleClientGetFiles)
router.With(s.checkSecondFactorRequirement, s.refreshCookie).Get(webClientViewPDFPath, s.handleClientViewPDF)
router.With(s.checkSecondFactorRequirement, s.refreshCookie).Get(webClientGetPDFPath, s.handleClientGetPDF)
router.With(s.checkSecondFactorRequirement, s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientFilesPath, s.handleClientGetFiles)
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientViewPDFPath, s.handleClientViewPDF)
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientGetPDFPath, s.handleClientGetPDF)
router.With(s.checkAuthRequirements, s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Post(webClientFilePath, uploadUserFile)
router.With(s.checkSecondFactorRequirement, s.refreshCookie).Get(webClientEditFilePath, s.handleClientEditFile)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientEditFilePath, s.handleClientEditFile)
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Patch(webClientFilesPath, renameUserFile)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Delete(webClientFilesPath, deleteUserFile)
router.With(s.checkSecondFactorRequirement, compressor.Handler, s.refreshCookie).
router.With(s.checkAuthRequirements, compressor.Handler, s.refreshCookie).
Get(webClientDirsPath, s.handleClientGetDirContents)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Post(webClientDirsPath, createUserDir)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Patch(webClientDirsPath, renameUserDir)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Delete(webClientDirsPath, deleteUserDir)
router.With(s.checkSecondFactorRequirement, s.refreshCookie).
router.With(s.checkAuthRequirements, s.refreshCookie).
Get(webClientDownloadZipPath, s.handleWebClientDownloadZip)
router.With(s.checkSecondFactorRequirement, s.refreshCookie).Get(webClientProfilePath,
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientProfilePath,
s.handleClientGetProfile)
router.With(s.checkSecondFactorRequirement).Post(webClientProfilePath, s.handleWebClientProfilePost)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
router.With(s.checkAuthRequirements).Post(webClientProfilePath, s.handleWebClientProfilePost)
router.With(s.checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
Get(webChangeClientPwdPath, s.handleWebClientChangePwd)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
router.With(s.checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
Post(webChangeClientPwdPath, s.handleWebClientChangePwdPost)
router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie).
Get(webClientMFAPath, s.handleWebClientMFA)
@ -1491,17 +1493,17 @@ func (s *httpdServer) setupWebClientRoutes() {
Get(webClientRecoveryCodesPath, getRecoveryCodes)
router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
Post(webClientRecoveryCodesPath, generateRecoveryCodes)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
Get(webClientSharesPath, s.handleClientGetShares)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
Get(webClientSharePath, s.handleClientAddShareGet)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
Post(webClientSharePath, s.handleClientAddSharePost)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
Get(webClientSharePath+"/{id}", s.handleClientUpdateShareGet)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
Post(webClientSharePath+"/{id}", s.handleClientUpdateSharePost)
router.With(s.checkSecondFactorRequirement, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), verifyCSRFHeader).
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), verifyCSRFHeader).
Delete(webClientSharePath+"/{id}", deleteShare)
})
}

View file

@ -1410,6 +1410,10 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
if err != nil {
return filters, fmt.Errorf("invalid default shares expiration: %w", err)
}
passwordExpiration, err := strconv.ParseInt(r.Form.Get("password_expiration"), 10, 64)
if err != nil {
return filters, fmt.Errorf("invalid password expiration: %w", err)
}
if r.Form.Get("ftp_security") == "1" {
filters.FTPSecurity = 1
}
@ -1424,6 +1428,7 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
filters.TLSUsername = sdk.TLSUsername(r.Form.Get("tls_username"))
filters.WebClient = r.Form["web_client_options"]
filters.DefaultSharesExpiration = int(defaultSharesExpiration)
filters.PasswordExpiration = int(passwordExpiration)
hooks := r.Form["hooks"]
if util.Contains(hooks, "external_auth_disabled") {
filters.Hooks.ExternalAuthDisabled = true
@ -1946,7 +1951,8 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
Role: r.Form.Get("role"),
},
Filters: dataprovider.UserFilters{
BaseUserFilters: filters,
BaseUserFilters: filters,
RequirePasswordChange: r.Form.Get("require_password_change") != "",
},
VirtualFolders: getVirtualFoldersFromPostFields(r),
FsConfig: fsConfig,
@ -2983,6 +2989,7 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req
updatedUser.Username = user.Username
updatedUser.Filters.RecoveryCodes = user.Filters.RecoveryCodes
updatedUser.Filters.TOTPConfig = user.Filters.TOTPConfig
updatedUser.LastPasswordChange = user.LastPasswordChange
updatedUser.SetEmptySecretsIfNil()
if updatedUser.Password == redactedSecret {
updatedUser.Password = user.Password

View file

@ -1833,6 +1833,9 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
if expected.Email != actual.Email {
return errors.New("email mismatch")
}
if expected.Filters.RequirePasswordChange != actual.Filters.RequirePasswordChange {
return errors.New("require_password_change mismatch")
}
if err := compareUserPermissions(expected.Permissions, actual.Permissions); err != nil {
return err
}
@ -2264,6 +2267,9 @@ func compareBaseUserFilters(expected sdk.BaseUserFilters, actual sdk.BaseUserFil
if expected.DefaultSharesExpiration != actual.DefaultSharesExpiration {
return errors.New("default_shares_expiration mismatch")
}
if expected.PasswordExpiration != actual.PasswordExpiration {
return errors.New("password_expiration mismatch")
}
return nil
}

View file

@ -2878,6 +2878,38 @@ func TestInteractiveLoginWithPasscode(t *testing.T) {
assert.NoError(t, err)
}
func TestMustChangePasswordRequirement(t *testing.T) {
usePubKey := true
u := getTestUser(usePubKey)
u.Filters.RequirePasswordChange = true
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
// public key auth works even if the user must change password
conn, client, err := getSftpClient(user, usePubKey)
if assert.NoError(t, err) {
defer conn.Close()
defer client.Close()
assert.NoError(t, checkBasicSFTP(client))
}
// password auth does not work
_, _, err = getSftpClient(user, false)
assert.Error(t, err)
// change password
err = dataprovider.UpdateUserPassword(user.Username, defaultPassword, "", "", "")
assert.NoError(t, err)
conn, client, err = getSftpClient(user, false)
if assert.NoError(t, err) {
defer conn.Close()
defer client.Close()
assert.NoError(t, checkBasicSFTP(client))
}
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestSecondFactorRequirement(t *testing.T) {
usePubKey := true
u := getTestUser(usePubKey)
@ -4105,6 +4137,7 @@ func TestExternalAuthReturningAnonymousUser(t *testing.T) {
updatedUser, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
assert.NoError(t, err)
user.UpdatedAt = updatedUser.UpdatedAt
user.LastPasswordChange = updatedUser.LastPasswordChange
assert.Equal(t, user, updatedUser)
_, err = httpdtest.RemoveUser(user, http.StatusOK)

View file

@ -1523,6 +1523,27 @@ func TestMaxPerHostConnections(t *testing.T) {
common.Config.MaxPerHostConnections = oldValue
}
func TestMustChangePasswordRequirement(t *testing.T) {
u := getTestUser()
u.Filters.RequirePasswordChange = true
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
client := getWebDavClient(user, false, nil)
assert.Error(t, checkBasicFunc(client))
err = dataprovider.UpdateUserPassword(user.Username, defaultPassword, "", "", "")
assert.NoError(t, err)
client = getWebDavClient(user, false, nil)
assert.NoError(t, checkBasicFunc(client))
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestMaxSessions(t *testing.T) {
u := getTestUser()
u.MaxSessions = 1

View file

@ -703,6 +703,17 @@ 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="{{.Group.UserSettings.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">

View file

@ -129,6 +129,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div class="form-group">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idRequirePasswordChange" name="require_password_change"
{{if .User.Filters.RequirePasswordChange}}checked{{end}} aria-describedby="requireChangePwdHelpBlock">
<label for="idRequirePasswordChange" class="form-check-label">Require password change</label>
<small id="requireChangePwdHelpBlock" class="form-text text-muted">
User must change password from WebClient/REST API at next login
</small>
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header">
<b>Public keys</b>
@ -939,6 +950,17 @@ 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">