* HTMLForm 'select', 'selectandother', 'selectorother', 'multiselect', and
'radio' fields can now use message keys as labels via the 'options-messages'
parameter, which overrides the 'options' parameter.
+* Admins can expire users users passwords manually, or on a schedule using the
+ $wgPasswordExpirationDays configuration setting.
=== Bug fixes in 1.23 ===
* (bug 41759) The "updated since last visit" markers (on history pages, recent
$retval: a LoginForm class constant with authenticateUserData() return
value (SUCCESS, WRONG_PASS, etc.).
+'LoginPasswordResetMessage': User is being requested to reset their password on login.
+Use this hook to change the Message that will be output on Special:ChangePassword.
+&$msg: Message object that will be shown to the user
+$username: Username of the user who's password was expired.
+
'LogLine': Processes a single log entry on Special:Log.
$log_type: string for the type of log entry (e.g. 'move'). Corresponds to
logging.log_type database field.
&$skin: A variable reference you may set a Skin instance or string key on to
override the skin that will be used for the context.
+'ResetPasswordExpiration': Allow extensions to set a default password expiration
+$user: The user having their password expiration reset
+&$newExpire: The new expiration date
+
'ResetSessionID': Called from wfResetSessionID
$oldSessionID: old session id
$newSessionID: new session id
*/
$wgUserEmailConfirmationTokenExpiry = 7 * 24 * 60 * 60;
+/**
+ * The number of days that a user's password is good for. After this number of days, the
+ * user will be asked to reset their password. Set to false to disable password expiration.
+ */
+$wgPasswordExpirationDays = false;
+
+/**
+ * If a user's password is expired, the number of seconds when they can still login,
+ * and cancel their password change, but are sent to the password change form on each login.
+ */
+$wgPasswordExpireGrace = 3600 * 24 * 7; // 7 days
+
/**
* SMTP Mode.
*
* Int Serialized record version.
* @ingroup Constants
*/
-define( 'MW_USER_VERSION', 8 );
+define( 'MW_USER_VERSION', 9 );
/**
* String Some punctuation to prevent editing from broken text-mangling proxies.
'mEmailAuthenticated',
'mEmailToken',
'mEmailTokenExpires',
+ 'mPasswordExpires',
'mRegistration',
'mEditCount',
// user_groups table
$mEmail, $mTouched, $mToken, $mEmailAuthenticated,
$mEmailToken, $mEmailTokenExpires, $mRegistration, $mEditCount,
$mGroups, $mOptionOverrides;
+
+ protected $mPasswordExpires;
//@}
/**
}
}
+ /**
+ * Expire a user's password
+ * @since 1.23
+ * @param $ts Mixed: optional timestamp to convert, default 0 for the current time
+ */
+ public function expirePassword( $ts = 0 ) {
+ $this->load();
+ $timestamp = wfTimestamp( TS_MW, $ts );
+ $this->mPasswordExpires = $timestamp;
+ $this->saveSettings();
+ }
+
+ /**
+ * Clear the password expiration for a user
+ * @since 1.23
+ * @param bool $load ensure user object is loaded first
+ */
+ public function resetPasswordExpiration( $load = true ) {
+ global $wgPasswordExpirationDays;
+ if ( $load ) {
+ $this->load();
+ }
+ $newExpire = null;
+ if ( $wgPasswordExpirationDays ) {
+ $newExpire = wfTimestamp(
+ TS_MW,
+ time() + ( $wgPasswordExpirationDays * 24 * 3600 )
+ );
+ }
+ // Give extensions a chance to force an expiration
+ wfRunHooks( 'ResetPasswordExpiration', array( $this, &$newExpire ) );
+ $this->mPasswordExpires = $newExpire;
+ }
+
+ /**
+ * Check if the user's password is expired.
+ * TODO: Put this and password length into a PasswordPolicy object
+ * @since 1.23
+ * @return string|bool The expiration type, or false if not expired
+ * hard: A password change is required to login
+ * soft: Allow login, but encourage password change
+ * false: Password is not expired
+ */
+ public function getPasswordExpired() {
+ global $wgPasswordExpireGrace;
+ $expired = false;
+ $now = wfTimestamp();
+ $expiration = $this->getPasswordExpireDate();
+ $expUnix = wfTimestamp( TS_UNIX, $expiration );
+ if ( $expiration !== null && $expUnix < $now ) {
+ $expired = ( $expUnix + $wgPasswordExpireGrace < $now ) ? 'hard' : 'soft';
+ }
+ return $expired;
+ }
+
+ /**
+ * Get this user's password expiration date. Since this may be using
+ * the cached User object, we assume that whatever mechanism is setting
+ * the expiration date is also expiring the User cache.
+ * @since 1.23
+ * @return string|false the datestamp of the expiration, or null if not set
+ */
+ public function getPasswordExpireDate() {
+ $this->load();
+ return $this->mPasswordExpires;
+ }
+
/**
* Does a string look like an e-mail address?
*
$this->mEmailAuthenticated = null;
$this->mEmailToken = '';
$this->mEmailTokenExpires = null;
+ $this->mPasswordExpires = null;
+ $this->resetPasswordExpiration( false );
$this->mRegistration = wfTimestamp( TS_MW );
$this->mGroups = array();
$this->mEmailAuthenticated = wfTimestampOrNull( TS_MW, $row->user_email_authenticated );
$this->mEmailToken = $row->user_email_token;
$this->mEmailTokenExpires = wfTimestampOrNull( TS_MW, $row->user_email_token_expires );
+ $this->mPasswordExpires = wfTimestampOrNull( TS_MW, $row->user_password_expires );
$this->mRegistration = wfTimestampOrNull( TS_MW, $row->user_registration );
} else {
$all = false;
'user_token' => strval( $this->mToken ),
'user_email_token' => $this->mEmailToken,
'user_email_token_expires' => $dbw->timestampOrNull( $this->mEmailTokenExpires ),
+ 'user_password_expires' => $dbw->timestampOrNull( $this->mPasswordExpires ),
), array( /* WHERE */
'user_id' => $this->mId
), __METHOD__
'user_email_authenticated',
'user_email_token',
'user_email_token_expires',
+ 'user_password_expires',
'user_registration',
'user_editcount',
);
'patch-logging_user_text_type_time_index.sql' ),
array( 'addIndex', 'logging', 'log_user_text_time', 'patch-logging_user_text_time_index.sql' ),
array( 'addField', 'page', 'page_links_updated', 'patch-page_links_updated.sql' ),
+ array( 'addField', 'user', 'user_password_expires', 'patch-user_password_expire.sql' ),
);
}
// 1.23
array( 'addPgField', 'recentchanges', 'rc_source', "TEXT NOT NULL DEFAULT ''" ),
array( 'addPgField', 'page', 'page_links_updated', "TIMESTAMPTZ NULL" ),
+ array( 'addPgField', 'mwuser', 'user_password_expires', 'TIMESTAMPTZ NULL' ),
);
}
'patch-logging_user_text_type_time_index.sql' ),
array( 'addIndex', 'logging', 'log_user_text_time', 'patch-logging_user_text_time_index.sql' ),
array( 'addField', 'page', 'page_links_updated', 'patch-page_links_updated.sql' ),
+ array( 'addField', 'user', 'user_password_expires', 'patch-user_password_expire.sql' ),
);
}
protected $mUserName, $mDomain;
+ // Optional Wikitext Message to show above the password change form
+ protected $mPreTextMessage = null;
+
+ // label for old password input
+ protected $mOldPassMsg = null;
+
public function __construct() {
parent::__construct( 'ChangePassword', 'editmyprivateinfo' );
$this->listed( false );
}
}
+ /**
+ * Set a message at the top of the Change Password form
+ * @since 1.23
+ * @param Message $msg to parse and add to the form header
+ */
+ public function setChangeMessage( Message $msg ) {
+ $this->mPreTextMessage = $msg;
+ }
+
+ /**
+ * Set a message at the top of the Change Password form
+ * @since 1.23
+ * @param string $msg Message label for old/temp password field
+ */
+ public function setOldPasswordMessage( $msg ) {
+ $this->mOldPassMsg = $msg;
+ }
+
protected function getFormFields() {
global $wgCookieExpiration;
$user = $this->getUser();
$request = $this->getRequest();
- $oldpassMsg = $user->isLoggedIn() ? 'oldpassword' : 'resetpass-temp-password';
+ $oldpassMsg = $this->mOldPassMsg;
+ if ( !isset( $oldpassMsg ) ) {
+ $oldpassMsg = $user->isLoggedIn() ? 'oldpassword' : 'resetpass-temp-password';
+ }
$fields = array(
'Name' => array(
);
$form->addButton( 'wpCancel', $this->msg( 'resetpass-submit-cancel' )->text() );
$form->setHeaderText( $this->msg( 'resetpass_text' )->parseAsBlock() );
+ if ( $this->mPreTextMessage instanceof Message ) {
+ $form->addPreText( $this->mPreTextMessage->parseAsBlock() );
+ }
$form->addHiddenFields(
$this->getRequest()->getValues( 'wpName', 'wpDomain', 'returnto', 'returntoquery' ) );
}
);
}
+ // @TODO Make these separate messages, since the message is written for both cases
+ if ( !$user->checkTemporaryPassword( $oldpass ) && !$user->checkPassword( $oldpass ) ) {
+ wfRunHooks( 'PrefsPasswordAudit', array( $user, $newpass, 'wrongpassword' ) );
+ throw new PasswordError( $this->msg( 'resetpass-wrong-oldpass' )->text() );
+ }
+
+ // User is resetting their password to their old password
+ if ( $oldpass === $newpass ) {
+ throw new PasswordError( $this->msg( 'resetpass-recycled' )->text() );
+ }
+
+ // Do AbortChangePassword after checking mOldpass, so we don't leak information
+ // by possibly aborting a new password before verifying the old password.
$abortMsg = 'resetpass-abort-generic';
if ( !wfRunHooks( 'AbortChangePassword', array( $user, $oldpass, $newpass, &$abortMsg ) ) ) {
wfRunHooks( 'PrefsPasswordAudit', array( $user, $newpass, 'abortreset' ) );
throw new PasswordError( $this->msg( $abortMsg )->text() );
}
- if ( !$user->checkTemporaryPassword( $oldpass ) && !$user->checkPassword( $oldpass ) ) {
- wfRunHooks( 'PrefsPasswordAudit', array( $user, $newpass, 'wrongpassword' ) );
- throw new PasswordError( $this->msg( 'resetpass-wrong-oldpass' )->text() );
- }
-
// Please reset throttle for successful logins, thanks!
if ( $throttleCount ) {
LoginForm::clearLoginThrottle( $this->mUserName );
// changing the password also modifies the user's token.
$user->setCookies();
}
-
+ $user->resetPasswordExpiration();
$user->saveSettings();
}
var $mSkipCookieCheck, $mReturnToQuery, $mToken, $mStickHTTPS;
var $mType, $mReason, $mRealName;
var $mAbortLoginErrorMsg = null;
+ private $mTempPasswordUsed;
private $mLoaded = false;
private $mSecureLoginUrl;
// At this point we just return an appropriate code/ indicating
// that the UI should show a password reset form; bot inter-
// faces etc will probably just fail cleanly here.
+ $this->mAbortLoginErrorMsg = 'resetpass-temp-emailed';
+ $this->mTempPasswordUsed = true;
$retval = self::RESET_PASS;
} else {
$retval = ( $this->mPassword == '' ) ? self::EMPTY_PASS : self::WRONG_PASS;
} elseif ( $wgBlockDisablesLogin && $u->isBlocked() ) {
// If we've enabled it, make it so that a blocked user cannot login
$retval = self::USER_BLOCKED;
+ } elseif ( $u->getPasswordExpired() == 'hard' ) {
+ // Force reset now, without logging in
+ $retval = self::RESET_PASS;
+ $this->mAbortLoginErrorMsg = 'resetpass-expired';
} else {
$wgAuth->updateUser( $u );
$wgUser = $u;
$this->getContext()->setLanguage( $userLang );
// Reset SessionID on Successful login (bug 40995)
$this->renewSessionId();
- $this->successfulLogin();
+ if ( $this->getUser()->getPasswordExpired() == 'soft' ) {
+ $this->resetLoginForm( $this->msg( 'resetpass-expired-soft' ) );
+ } else {
+ $this->successfulLogin();
+ }
} else {
$this->cookieRedirectCheck( 'login' );
}
break;
case self::RESET_PASS:
$error = $this->mAbortLoginErrorMsg ?: 'resetpass_announce';
- $this->resetLoginForm( $this->msg( $error )->text() );
+ $this->resetLoginForm( $this->msg( $error ) );
break;
case self::CREATE_BLOCKED:
$this->userBlockedMessage( $this->getUser()->isBlockedFromCreateAccount() );
}
/**
- * @param $error string
+ * Show the Special:ChangePassword form, with custom message
+ * @param Message $msg
*/
- function resetLoginForm( $error ) {
- $this->getOutput()->addHTML( Xml::element( 'p', array( 'class' => 'error' ), $error ) );
+ protected function resetLoginForm( Message $msg ) {
+ // Allow hooks to explain this password reset in more detail
+ wfRunHooks( 'LoginPasswordResetMessage', array( &$msg, $this->mUsername ) );
$reset = new SpecialChangePassword();
$derivative = new DerivativeContext( $this->getContext() );
$derivative->setTitle( $reset->getPageTitle() );
$reset->setContext( $derivative );
+ if ( !$this->mTempPasswordUsed ) {
+ $reset->setOldPasswordMessage( 'oldpassword' );
+ }
+ $reset->setChangeMessage( $msg );
$reset->execute( null );
}
'changepassword-summary' => '', # do not translate or duplicate this message to other languages
'changepassword-throttled' => 'You have made too many recent login attempts.
Please wait $1 before trying again.',
-'resetpass_announce' => 'You logged in with a temporary emailed code.
-To finish logging in, you must set a new password here:',
+'resetpass_announce' => 'To finish logging in, you must set a new password.',
'resetpass_text' => '<!-- Add text here -->', # only translate this message to other languages if you have to change it
'resetpass_header' => 'Change account password',
'oldpassword' => 'Old password:',
'resetpass-submit-cancel' => 'Cancel',
'resetpass-wrong-oldpass' => 'Invalid temporary or current password.
You may have already successfully changed your password or requested a new temporary password.',
+'resetpass-recycled' => 'Please reset your password to something other than your current password.',
+'resetpass-temp-emailed' => 'You logged in with a temporary emailed code.
+To finish logging in, you must set a new password here:',
'resetpass-temp-password' => 'Temporary password:',
'resetpass-abort-generic' => 'Password change has been aborted by an extension.',
+'resetpass-expired' => 'Your password has expired. Please set a new password to login.',
+'resetpass-expired-soft' => 'Your password has expired, and needs to be reset. Please choose a new password now, or click cancel to reset it later.',
# Special:PasswordReset
'passwordreset' => 'Reset password',
'resetpass-submit-cancel' => 'Used on [[Special:ResetPass]].
{{Identical|Cancel}}',
'resetpass-wrong-oldpass' => 'Error message shown on [[Special:ChangePassword]] when the old password is not valid.',
+'resetpass-recycled' => 'Error message shown on [[Special:ChangePassword]] when a user attempts to reset their password to their existing password.',
+'resetpass-temp-emailed' => 'Message shown on [[Special:ChangePassword]] when a user logs in with a temporary password, and must set a new password.',
'resetpass-temp-password' => 'The label of the input box for the temporary password (received by email) on the form displayed after logging in with a temporary password.',
'resetpass-abort-generic' => 'Generic error message shown on [[Special:ChangePassword]] when an extension aborts a password change from a hook.',
+'resetpass-expired' => 'Generic error message shown on [[Special:ChangePassword]] when a user\'s password is expired',
+'resetpass-expired-soft' => 'Generic marning message shown on [[Special:ChangePassword]] when a user needs to reset their password, but they are not prevented from logging in at this time',
# Special:PasswordReset
'passwordreset' => 'Title of [[Special:PasswordReset]].
--- /dev/null
+-- For setting a password expiration date for users
+ALTER TABLE /*$wgDBprefix*/user
+ ADD COLUMN user_password_expires varbinary(14) DEFAULT NULL;
'resetpass-submit-loggedin',
'resetpass-submit-cancel',
'resetpass-wrong-oldpass',
+ 'resetpass-recycled',
+ 'resetpass-temp-emailed',
'resetpass-temp-password',
'resetpass-abort-generic',
+ 'resetpass-expired',
+ 'resetpass-expired-soft',
),
'passwordreset' => array(
'passwordreset',
user_email_token_expires varchar(14) DEFAULT NULL,
user_registration varchar(14) DEFAULT NULL,
user_editcount INT NULL DEFAULT NULL
+ user_password_expires DATETIME DEFAULT NULL
);
CREATE UNIQUE INDEX /*i*/user_name ON /*_*/mwuser (user_name);
CREATE INDEX /*i*/user_email_token ON /*_*/mwuser (user_email_token);
user_options CLOB,
user_touched TIMESTAMP(6) WITH TIME ZONE,
user_registration TIMESTAMP(6) WITH TIME ZONE,
- user_editcount NUMBER
+ user_editcount NUMBER,
+ user_password_expires TIMESTAMP(6) WITH TIME ZONE NULL DEFAULT NULL
);
ALTER TABLE &mw_prefix.mwuser ADD CONSTRAINT &mw_prefix.mwuser_pk PRIMARY KEY (user_id);
CREATE UNIQUE INDEX &mw_prefix.mwuser_u01 ON &mw_prefix.mwuser (user_name);
user_email_authenticated TIMESTAMPTZ,
user_touched TIMESTAMPTZ,
user_registration TIMESTAMPTZ,
- user_editcount INTEGER
+ user_editcount INTEGER,
+ user_password_expires TIMESTAMPTZ NULL
);
CREATE INDEX user_email_token_idx ON mwuser (user_email_token);
-- Meant primarily for heuristic checks to give an impression of whether
-- the account has been used much.
--
- user_editcount int
+ user_editcount int,
+
+ -- Expiration date for user password. Use $user->expirePassword()
+ -- to force a password reset.
+ user_password_expires varbinary(14) DEFAULT NULL
+
) /*$wgDBTableOptions*/;
CREATE UNIQUE INDEX /*i*/user_name ON /*_*/user (user_name);
$this->assertEquals( $wgDefaultUserOptions['cols'], $this->user->getOption( 'cols' ) );
$this->assertEquals( 'test', $this->user->getOption( 'someoption' ) );
}
+
+ /**
+ * Test password expiration.
+ * @covers User::getPasswordExpired()
+ */
+ public function testPasswordExpire() {
+ global $wgPasswordExpireGrace;
+ $wgTemp = $wgPasswordExpireGrace;
+ $wgPasswordExpireGrace = 3600 * 24 * 7; // 7 days
+
+ $user = User::newFromName( 'UnitTestUser' );
+ $user->loadDefaults();
+ $this->assertEquals( false, $user->getPasswordExpired() );
+
+ $ts = time() - ( 3600 * 24 * 1 ); // 1 day ago
+ $user->expirePassword( $ts );
+ $this->assertEquals( 'soft', $user->getPasswordExpired() );
+
+ $ts = time() - ( 3600 * 24 * 10 ); // 10 days ago
+ $user->expirePassword( $ts );
+ $this->assertEquals( 'hard', $user->getPasswordExpired() );
+
+ $wgPasswordExpireGrace = $wgTemp;
+ }
}