ソースを参照

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>
Nicola Murino 2 年 前
コミット
ad5d657a1a

+ 1 - 1
docs/groups.md

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

+ 2 - 2
go.mod

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

+ 4 - 4
go.sum

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

+ 0 - 4
internal/cmd/resetpwd.go

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

+ 6 - 6
internal/dataprovider/bolt.go

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

+ 32 - 16
internal/dataprovider/dataprovider.go

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

+ 36 - 1
internal/dataprovider/mysql.go

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

+ 41 - 1
internal/dataprovider/pgsql.go

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

+ 4 - 4
internal/dataprovider/sqlcommon.go

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

+ 35 - 1
internal/dataprovider/sqlite.go

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

+ 8 - 7
internal/dataprovider/sqlqueries.go

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

+ 19 - 0
internal/dataprovider/user.go

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

+ 25 - 0
internal/ftpd/ftpd_test.go

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

+ 3 - 0
internal/httpd/api_user.go

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

+ 37 - 32
internal/httpd/auth_utils.go

@@ -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) Decode(token map[string]any) {
-	c.Permissions = nil
-	username := token[claimUsernameKey]
-
-	switch v := username.(type) {
-	case string:
-		c.Username = v
+func (c *jwtTokenClaims) decodeBoolean(val any) bool {
+	switch v := val.(type) {
+	case bool:
+		return v
+	default:
+		return false
 	}
+}
 
-	signature := token[jwt.SubjectKey]
-
-	switch v := signature.(type) {
+func (c *jwtTokenClaims) decodeString(val any) string {
+	switch v := val.(type) {
 	case string:
-		c.Signature = v
+		return v
+	default:
+		return ""
 	}
+}
 
-	audience := token[jwt.AudienceKey]
+func (c *jwtTokenClaims) Decode(token map[string]any) {
+	c.Permissions = nil
+	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 {

+ 171 - 2
internal/httpd/httpd_test.go

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

+ 32 - 1
internal/httpd/internal_test.go

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

+ 10 - 4
internal/httpd/middleware.go

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

+ 44 - 42
internal/httpd/server.go

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

+ 8 - 1
internal/httpd/webadmin.go

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

+ 6 - 0
internal/httpdtest/httpdtest.go

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

+ 33 - 0
internal/sftpd/sftpd_test.go

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

+ 21 - 0
internal/webdavd/webdavd_test.go

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

+ 11 - 0
templates/webadmin/group.html

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

+ 22 - 0
templates/webadmin/user.html

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