diff --git a/docs/groups.md b/docs/groups.md
index 21a0022a..e45ea60c 100644
--- a/docs/groups.md
+++ b/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
diff --git a/go.mod b/go.mod
index c3ab4f44..4dc4424e 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index 459294b1..6d19df52 100644
--- a/go.sum
+++ b/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=
diff --git a/internal/cmd/resetpwd.go b/internal/cmd/resetpwd.go
index 77f076e0..196084c8 100644
--- a/internal/cmd/resetpwd.go
+++ b/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 {
diff --git a/internal/dataprovider/bolt.go b/internal/dataprovider/bolt.go
index 1962772f..986db37c 100644
--- a/internal/dataprovider/bolt.go
+++ b/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 {
diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go
index 2ccc7e1e..601f4f94 100644
--- a/internal/dataprovider/dataprovider.go
+++ b/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
diff --git a/internal/dataprovider/mysql.go b/internal/dataprovider/mysql.go
index 3e2e607d..99d6d430 100644
--- a/internal/dataprovider/mysql.go
+++ b/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)
+}
diff --git a/internal/dataprovider/pgsql.go b/internal/dataprovider/pgsql.go
index d6857934..df89868f 100644
--- a/internal/dataprovider/pgsql.go
+++ b/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)
+}
diff --git a/internal/dataprovider/sqlcommon.go b/internal/dataprovider/sqlcommon.go
index 7878d861..c9d86e77 100644
--- a/internal/dataprovider/sqlcommon.go
+++ b/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())
diff --git a/internal/dataprovider/sqlite.go b/internal/dataprovider/sqlite.go
index a151c90b..5d9190f0 100644
--- a/internal/dataprovider/sqlite.go
+++ b/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()
diff --git a/internal/dataprovider/sqlqueries.go b/internal/dataprovider/sqlqueries.go
index 6c9aa96c..fd14600d 100644
--- a/internal/dataprovider/sqlqueries.go
+++ b/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 {
diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go
index 8f5edded..ca87e187 100644
--- a/internal/dataprovider/user.go
+++ b/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,
diff --git a/internal/ftpd/ftpd_test.go b/internal/ftpd/ftpd_test.go
index a891c570..f32c2580 100644
--- a/internal/ftpd/ftpd_test.go
+++ b/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}
diff --git a/internal/httpd/api_user.go b/internal/httpd/api_user.go
index 54d41b64..4c23ba58 100644
--- a/internal/httpd/api_user.go
+++ b/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 {
diff --git a/internal/httpd/auth_utils.go b/internal/httpd/auth_utils.go
index 911ab70f..f9a9dcb6 100644
--- a/internal/httpd/auth_utils.go
+++ b/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) 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 {
diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go
index 8880d2a5..173ef1f8 100644
--- a/internal/httpd/httpd_test.go
+++ b/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}
diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go
index 70985b41..7a344530 100644
--- a/internal/httpd/internal_test.go
+++ b/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)
diff --git a/internal/httpd/middleware.go b/internal/httpd/middleware.go
index 03eaf846..89b012da 100644
--- a/internal/httpd/middleware.go
+++ b/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 {
diff --git a/internal/httpd/server.go b/internal/httpd/server.go
index 84578abf..b3ecea53 100644
--- a/internal/httpd/server.go
+++ b/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)
})
}
diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go
index 6563a977..da25104f 100644
--- a/internal/httpd/webadmin.go
+++ b/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
diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go
index 0de69574..5a811c0d 100644
--- a/internal/httpdtest/httpdtest.go
+++ b/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
}
diff --git a/internal/sftpd/sftpd_test.go b/internal/sftpd/sftpd_test.go
index 496ba9a9..08fc2831 100644
--- a/internal/sftpd/sftpd_test.go
+++ b/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)
diff --git a/internal/webdavd/webdavd_test.go b/internal/webdavd/webdavd_test.go
index 3b5d911d..916505ce 100644
--- a/internal/webdavd/webdavd_test.go
+++ b/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
diff --git a/templates/webadmin/group.html b/templates/webadmin/group.html
index 084b1491..618d7cc1 100644
--- a/templates/webadmin/group.html
+++ b/templates/webadmin/group.html
@@ -703,6 +703,17 @@ along with this program. If not, see