Password Expiration
authorcsteipp <csteipp@wikimedia.org>
Wed, 9 Oct 2013 18:09:28 +0000 (11:09 -0700)
committercsteipp <csteipp@wikimedia.org>
Thu, 20 Feb 2014 17:41:51 +0000 (09:41 -0800)
Add functionality to expire users' passwords:
 * Adds column to the user table to keep a password expiration
 * Adds $wgPasswordExpirationDays, which will force users to reset
   their passwords after a set number of days. By default, this set
   to false, so passwords never expire.
 * Adds a default grace period of 7 days, where if the user's password
   is expired, they can still login, but are encouraged to reset their
   password.
 * Adds hook 'LoginPasswordResetMessage' to update reset message, in
   case an extension wants to vary the message on a particular reset
   event.
 * Adds hook 'ResetPasswordExpiration' to allow extensions to change
   the expiration date when the user resets their password. E.g., if
   an extension wants to vary the expiration based on the user's group.

If the user is in the grace period, they get a password reset form
added to the login successful page. If an extension prevents showing
the login successful page (like CentralAuth), it should be updated to
show a password change form during the grace period. After the grace
period, the user will not be able to login without changing their
password.

Also prevents a successful reset if the user is "changing" their
password to their existing password.

No passwords will expire by default. Sites will have to call
User->expirePassword() from their own maintenance script to trigger a
password reset for a user.

Bug: 54997
Change-Id: I92a9fc63b409b182b1d7b48781d73fc7216f8061

18 files changed:
RELEASE-NOTES-1.23
docs/hooks.txt
includes/DefaultSettings.php
includes/User.php
includes/installer/MysqlUpdater.php
includes/installer/PostgresUpdater.php
includes/installer/SqliteUpdater.php
includes/specials/SpecialChangePassword.php
includes/specials/SpecialUserlogin.php
languages/messages/MessagesEn.php
languages/messages/MessagesQqq.php
maintenance/archives/patch-user_password_expire.sql [new file with mode: 0644]
maintenance/language/messages.inc
maintenance/mssql/tables.sql
maintenance/oracle/tables.sql
maintenance/postgres/tables.sql
maintenance/tables.sql
tests/phpunit/includes/UserTest.php

index 64c2b3b..f8fb65f 100644 (file)
@@ -105,6 +105,8 @@ production.
 * 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
index 9268f83..b9423a8 100644 (file)
@@ -1606,6 +1606,11 @@ $password: the password being submitted and found wanting
 $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.
@@ -2031,6 +2036,10 @@ IContextSource $context: The RequestContext the skin is being created for.
 &$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
index b3e04d0..3526c96 100644 (file)
@@ -1382,6 +1382,18 @@ $wgNewPasswordExpiry = 3600 * 24 * 7;
  */
 $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.
  *
index d863a06..9401b37 100644 (file)
@@ -30,7 +30,7 @@ define( 'USER_TOKEN_LENGTH', 32 );
  * 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.
@@ -90,6 +90,7 @@ class User {
                'mEmailAuthenticated',
                'mEmailToken',
                'mEmailTokenExpires',
+               'mPasswordExpires',
                'mRegistration',
                'mEditCount',
                // user_groups table
@@ -185,6 +186,8 @@ class User {
                $mEmail, $mTouched, $mToken, $mEmailAuthenticated,
                $mEmailToken, $mEmailTokenExpires, $mRegistration, $mEditCount,
                $mGroups, $mOptionOverrides;
+
+       protected $mPasswordExpires;
        //@}
 
        /**
@@ -735,6 +738,73 @@ class User {
                }
        }
 
+       /**
+        * 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?
         *
@@ -890,6 +960,8 @@ class User {
                $this->mEmailAuthenticated = null;
                $this->mEmailToken = '';
                $this->mEmailTokenExpires = null;
+               $this->mPasswordExpires = null;
+               $this->resetPasswordExpiration( false );
                $this->mRegistration = wfTimestamp( TS_MW );
                $this->mGroups = array();
 
@@ -1097,6 +1169,7 @@ class User {
                        $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;
@@ -3324,6 +3397,7 @@ class User {
                                '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__
@@ -4749,6 +4823,7 @@ class User {
                        'user_email_authenticated',
                        'user_email_token',
                        'user_email_token_expires',
+                       'user_password_expires',
                        'user_registration',
                        'user_editcount',
                );
index 33ff65e..bcd50b3 100644 (file)
@@ -249,6 +249,7 @@ class MysqlUpdater extends DatabaseUpdater {
                                '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' ),
                );
        }
 
index 1e0651c..c5c10b3 100644 (file)
@@ -404,6 +404,7 @@ class PostgresUpdater extends DatabaseUpdater {
                        // 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' ),
                );
        }
 
index 2869d1f..ea19efa 100644 (file)
@@ -127,6 +127,7 @@ class SqliteUpdater extends DatabaseUpdater {
                                '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' ),
                );
        }
 
index 484e00c..47f5b1e 100644 (file)
@@ -30,6 +30,12 @@ class SpecialChangePassword extends FormSpecialPage {
 
        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 );
@@ -52,13 +58,34 @@ class SpecialChangePassword extends FormSpecialPage {
                }
        }
 
+       /**
+        * 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(
@@ -116,6 +143,9 @@ class SpecialChangePassword extends FormSpecialPage {
                );
                $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' ) );
        }
@@ -211,17 +241,25 @@ class SpecialChangePassword extends FormSpecialPage {
                        );
                }
 
+               // @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 );
@@ -240,7 +278,7 @@ class SpecialChangePassword extends FormSpecialPage {
                        // changing the password also modifies the user's token.
                        $user->setCookies();
                }
-
+               $user->resetPasswordExpiration();
                $user->saveSettings();
        }
 
index eca1839..537709b 100644 (file)
@@ -49,6 +49,7 @@ class LoginForm extends SpecialPage {
        var $mSkipCookieCheck, $mReturnToQuery, $mToken, $mStickHTTPS;
        var $mType, $mReason, $mRealName;
        var $mAbortLoginErrorMsg = null;
+       private $mTempPasswordUsed;
        private $mLoaded = false;
        private $mSecureLoginUrl;
 
@@ -632,6 +633,8 @@ class LoginForm extends SpecialPage {
                                // 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;
@@ -639,6 +642,10 @@ class LoginForm extends SpecialPage {
                } 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;
@@ -792,7 +799,11 @@ class LoginForm extends SpecialPage {
                                        $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' );
                                }
@@ -836,7 +847,7 @@ class LoginForm extends SpecialPage {
                                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() );
@@ -862,14 +873,20 @@ class LoginForm extends SpecialPage {
        }
 
        /**
-        * @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 );
        }
 
index c382bbf..421453b 100644 (file)
@@ -1267,8 +1267,7 @@ If you choose to provide it, this will be used for giving the user attribution f
 '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:',
@@ -1282,8 +1281,13 @@ To finish logging in, you must set a new password here:',
 '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',
index f0ddc54..ea65c88 100644 (file)
@@ -1638,8 +1638,12 @@ Parameters:
 '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]].
diff --git a/maintenance/archives/patch-user_password_expire.sql b/maintenance/archives/patch-user_password_expire.sql
new file mode 100644 (file)
index 0000000..3e716d3
--- /dev/null
@@ -0,0 +1,3 @@
+-- For setting a password expiration date for users
+ALTER TABLE /*$wgDBprefix*/user
+  ADD COLUMN user_password_expires varbinary(14) DEFAULT NULL;
index 73f5ca2..574b34e 100644 (file)
@@ -583,8 +583,12 @@ $wgMessageStructure = array(
                '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',
index dc560e3..4a3cdea 100644 (file)
@@ -46,6 +46,7 @@ CREATE TABLE /*_*/mwuser (
    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);
index a6b5350..f05c0cc 100644 (file)
@@ -18,7 +18,8 @@ CREATE TABLE &mw_prefix.mwuser ( -- replace reserved word 'user'
   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);
index 3aa8109..e482141 100644 (file)
@@ -40,7 +40,8 @@ CREATE TABLE mwuser ( -- replace reserved word 'user'
   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);
 
index fb2f0e1..ca610fc 100644 (file)
@@ -126,7 +126,12 @@ CREATE TABLE /*_*/user (
   -- 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);
index ff33e82..2ddf05a 100644 (file)
@@ -234,4 +234,28 @@ class UserTest extends MediaWikiTestCase {
                $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;
+       }
 }