From ad5d657a1a56732a1a1085f4d32c3df4b5df5621 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 11 Dec 2022 17:15:34 +0100 Subject: [PATCH] add support for password policies you can now set a password expiration and the password change requirement Signed-off-by: Nicola Murino --- docs/groups.md | 2 +- go.mod | 4 +- go.sum | 8 +- internal/cmd/resetpwd.go | 4 - internal/dataprovider/bolt.go | 12 +- internal/dataprovider/dataprovider.go | 48 ++++--- internal/dataprovider/mysql.go | 37 +++++- internal/dataprovider/pgsql.go | 42 ++++++- internal/dataprovider/sqlcommon.go | 8 +- internal/dataprovider/sqlite.go | 36 +++++- internal/dataprovider/sqlqueries.go | 15 +-- internal/dataprovider/user.go | 19 +++ internal/ftpd/ftpd_test.go | 25 ++++ internal/httpd/api_user.go | 3 + internal/httpd/auth_utils.go | 71 ++++++----- internal/httpd/httpd_test.go | 173 +++++++++++++++++++++++++- internal/httpd/internal_test.go | 33 ++++- internal/httpd/middleware.go | 14 ++- internal/httpd/server.go | 86 ++++++------- internal/httpd/webadmin.go | 9 +- internal/httpdtest/httpdtest.go | 6 + internal/sftpd/sftpd_test.go | 33 +++++ internal/webdavd/webdavd_test.go | 21 ++++ templates/webadmin/group.html | 11 ++ templates/webadmin/user.html | 22 ++++ 25 files changed, 612 insertions(+), 130 deletions(-) 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 . +
+ +
+ + + Password expiration as number of days. 0 means no expiration + +
+
+
diff --git a/templates/webadmin/user.html b/templates/webadmin/user.html index 321c268b..8bc8c4b5 100644 --- a/templates/webadmin/user.html +++ b/templates/webadmin/user.html @@ -129,6 +129,17 @@ along with this program. If not, see .
+
+
+ + + + User must change password from WebClient/REST API at next login + +
+
+
Public keys @@ -939,6 +950,17 @@ along with this program. If not, see .
+
+ +
+ + + Password expiration as number of days. 0 means no expiration + +
+
+