add support for password policies
you can now set a password expiration and the password change requirement Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
e2bebc99d1
commit
ad5d657a1a
25 changed files with 612 additions and 130 deletions
|
@ -15,7 +15,7 @@ The following settings are inherited from the primary group:
|
|||
|
||||
- home dir, if set for the group will replace the one defined for the user. The `%username%` placeholder is replaced with the username
|
||||
- filesystem config, if the provider set for the group is different from the "local provider" will replace the one defined for the user. The `%username%` placeholder is replaced with the username within the defined "prefix", for any vfs, and the "username" for the SFTP filesystem config
|
||||
- max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time, ftp_security, default share expiration: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`
|
||||
- max sessions, quota size/files, upload/download bandwidth, upload/download/total data transfer, max upload size, external auth cache time, ftp_security, default share expiration, password expiration: if they are set to `0` for the user they are replaced with the value set for the group, if different from `0`
|
||||
- TLS username, check password hook disabled, pre-login hook disabled, external auth hook disabled, filesystem checks disabled, allow API key authentication, anonymous user: if they are not set for the user they are replaced with the value set for the group
|
||||
- starting directory, if the user does not have a starting directory set, the value set for the group is used, if any. The `%username%` placeholder is replaced with the username
|
||||
|
||||
|
|
4
go.mod
4
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
|
||||
|
|
8
go.sum
8
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=
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -703,6 +703,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idPasswordExpiration" class="col-sm-2 col-form-label">Password expiration</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" id="idPasswordExpiration" name="password_expiration"
|
||||
value="{{.Group.UserSettings.Filters.PasswordExpiration}}" min="0" aria-describedby="passwordExpirationHelpBlock">
|
||||
<small id="passwordExpirationHelpBlock" class="form-text text-muted">
|
||||
Password expiration as number of days. 0 means no expiration
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idDefaultSharesExpiration" class="col-sm-2 col-form-label">Default shares expiration</label>
|
||||
<div class="col-sm-10">
|
||||
|
|
|
@ -129,6 +129,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="idRequirePasswordChange" name="require_password_change"
|
||||
{{if .User.Filters.RequirePasswordChange}}checked{{end}} aria-describedby="requireChangePwdHelpBlock">
|
||||
<label for="idRequirePasswordChange" class="form-check-label">Require password change</label>
|
||||
<small id="requireChangePwdHelpBlock" class="form-text text-muted">
|
||||
User must change password from WebClient/REST API at next login
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header">
|
||||
<b>Public keys</b>
|
||||
|
@ -939,6 +950,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idPasswordExpiration" class="col-sm-2 col-form-label">Password expiration</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" id="idPasswordExpiration" name="password_expiration"
|
||||
value="{{.User.Filters.PasswordExpiration}}" min="0" aria-describedby="passwordExpirationHelpBlock">
|
||||
<small id="passwordExpirationHelpBlock" class="form-text text-muted">
|
||||
Password expiration as number of days. 0 means no expiration
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idDefaultSharesExpiration" class="col-sm-2 col-form-label">Default shares expiration</label>
|
||||
<div class="col-sm-10">
|
||||
|
|
Loading…
Reference in a new issue