Anomie has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/187840

Change subject: API: Add actions for resetting/changing password and email
......................................................................

API: Add actions for resetting/changing password and email

This also involves factoring the existing code out of the corresponding
special pages and into a common backend class.

Bug: T32788
Change-Id: I5e8c57623cf8db7b285f157a7c8ff68f10e622ab
---
M autoload.php
A includes/UserMangler.php
A includes/api/ApiChangeEmail.php
A includes/api/ApiChangePassword.php
M includes/api/ApiMain.php
A includes/api/ApiResetPassword.php
M includes/api/i18n/en.json
M includes/api/i18n/qqq.json
M includes/specials/SpecialChangeEmail.php
M includes/specials/SpecialChangePassword.php
M includes/specials/SpecialPasswordReset.php
A tests/phpunit/includes/UserManglerTest.php
12 files changed, 1,132 insertions(+), 303 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core 
refs/changes/40/187840/1

diff --git a/autoload.php b/autoload.php
index 11b5266..58f75dd 100644
--- a/autoload.php
+++ b/autoload.php
@@ -18,6 +18,8 @@
        'AnsiTermColorer' => __DIR__ . '/maintenance/term/MWTerm.php',
        'ApiBase' => __DIR__ . '/includes/api/ApiBase.php',
        'ApiBlock' => __DIR__ . '/includes/api/ApiBlock.php',
+       'ApiChangeEmail' => __DIR__ . '/includes/api/ApiChangeEmail.php',
+       'ApiChangePassword' => __DIR__ . '/includes/api/ApiChangePassword.php',
        'ApiClearHasMsg' => __DIR__ . '/includes/api/ApiClearHasMsg.php',
        'ApiComparePages' => __DIR__ . '/includes/api/ApiComparePages.php',
        'ApiCreateAccount' => __DIR__ . '/includes/api/ApiCreateAccount.php',
@@ -116,6 +118,7 @@
        'ApiQueryUsers' => __DIR__ . '/includes/api/ApiQueryUsers.php',
        'ApiQueryWatchlist' => __DIR__ . '/includes/api/ApiQueryWatchlist.php',
        'ApiQueryWatchlistRaw' => __DIR__ . 
'/includes/api/ApiQueryWatchlistRaw.php',
+       'ApiResetPassword' => __DIR__ . '/includes/api/ApiResetPassword.php',
        'ApiResult' => __DIR__ . '/includes/api/ApiResult.php',
        'ApiRevisionDelete' => __DIR__ . '/includes/api/ApiRevisionDelete.php',
        'ApiRollback' => __DIR__ . '/includes/api/ApiRollback.php',
@@ -1261,6 +1264,7 @@
        'UserCache' => __DIR__ . '/includes/cache/UserCache.php',
        'UserDupes' => __DIR__ . '/maintenance/userDupes.inc',
        'UserMailer' => __DIR__ . '/includes/mail/UserMailer.php',
+       'UserMangler' => __DIR__ . '/includes/UserMangler.php',
        'UserNotLoggedIn' => __DIR__ . 
'/includes/exception/UserNotLoggedIn.php',
        'UserOptions' => __DIR__ . '/maintenance/userOptions.inc',
        'UserRightsProxy' => __DIR__ . '/includes/UserRightsProxy.php',
diff --git a/includes/UserMangler.php b/includes/UserMangler.php
new file mode 100644
index 0000000..969b0e0
--- /dev/null
+++ b/includes/UserMangler.php
@@ -0,0 +1,426 @@
+<?php
+/**
+ * Methods to do things to a User
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @since 1.25
+ */
+
+class UserMangler {
+       /** @var IContextSource */
+       protected $context;
+
+       /** User being worked with
+        * @var User */
+       protected $user;
+
+       /**
+        * @param User $user User to work with
+        * @param IContextSource $context
+        */
+       public function __construct( User $user, IContextSource $context ) {
+               $this->user = $user;
+               $this->context = $context;
+       }
+
+       /**
+        * Access the context
+        * @return IContextSource
+        */
+       public function getContext() {
+               return $this->context;
+       }
+
+       /**
+        * Access the user being worked with
+        * @return User
+        */
+       public function getUser() {
+               return $this->user;
+       }
+
+       /**
+        * Indicate whether a password can be reset using the given context
+        * @param IContextSource $context
+        * @return Status
+        */
+       public static function canResetPassword( IContextSource $context ) {
+               global $wgAuth;
+               $resetRoutes = $context->getConfig()->get( 
'PasswordResetRoutes' );
+
+               // No if password resets are disabled, or there are no 
allowable routes
+               if ( !is_array( $resetRoutes ) ||
+                       !in_array( true, array_values( $resetRoutes ) )
+               ) {
+                       return Status::newFatal( 'passwordreset-disabled' );
+               }
+
+               // No if the external auth plugin won't allow local password 
changes
+               if ( !$wgAuth->allowPasswordChange() ) {
+                       return Status::newFatal( 'resetpass_forbidden' );
+               }
+
+               // No if email features have been disabled
+               if ( !$context->getConfig()->get( 'EnableEmail' ) ) {
+                       return Status::newFatal( 'passwordreset-emaildisabled' 
);
+               }
+
+               // Don't allow blocked users to submit password resets, to 
prevent
+               // blocked users from being annoying by flooding people with 
password
+               // reset emails.
+               if ( $context->getUser()->isBlocked() ) {
+                       return Status::newFatal( 'blocked-mailpassword' );
+               }
+
+               return Status::newGood();
+       }
+
+       /**
+        * Attempt to reset a password
+        *
+        * $data array should contain keys corresponding to true entries in
+        * $wgPasswordResetRoutes.
+        *
+        * Return values are:
+        * - false: $data did not contain non-empty values
+        * - Status, fatal: Reset or mail sending failed
+        * - Status, good: Reset succeeded, or is pretended to be so.
+        *
+        * Status object value is an object with the following data:
+        *    - users: Users reset. May be empty; the client shouldn't be 
informed of this.
+        *    - email: If $capture (and Users is non-empty), the text of the 
email sent.
+        *    - mailresult: Status from the mail-send attempt, if any
+        *
+        * @param IContextSource $context
+        * @param array $data
+        * @param bool $capture If true, capture the sent email text. Requires 
passwordreset right.
+        * @return Status|false
+        * @throws MWException
+        */
+       public static function resetPassword( IContextSource $context, array 
$data, $capture = false ) {
+               global $wgAuth;
+               $resetRoutes = $context->getConfig()->get( 
'PasswordResetRoutes' );
+
+               if ( $capture && !$context->getUser()->isAllowed( 
'passwordreset' ) ) {
+                       return User::newFatalPermissionDeniedStatus( 
'passwordreset' );
+               }
+
+               if ( !empty( $resetRoutes['domain'] ) && isset( $data['domain'] 
) ) {
+                       if ( $wgAuth->validDomain( $data['domain'] ) ) {
+                               $wgAuth->setDomain( $data['domain'] );
+                       } else {
+                               $wgAuth->setDomain( 'invaliddomain' );
+                       }
+               }
+
+               $status = Status::newGood();
+               $status->value =  (object)array(
+                       'email' => null,
+                       'users' => array(),
+                       'mailresult' => null,
+               );
+
+               if ( !empty( $resetRoutes['username'] ) &&
+                       isset( $data['username'] ) && $data['username'] !== ''
+               ) {
+                       $method = 'username';
+                       $status->value->users = array( User::newFromName( 
$data['username'] ) );
+               } elseif ( !empty( $resetRoutes['email'] ) &&
+                       isset( $data['email'] ) && $data['email'] !== ''
+                       && Sanitizer::validateEmail( $data['email'] )
+               ) {
+                       $method = 'email';
+                       $res = wfGetDB( DB_SLAVE )->select(
+                               'user',
+                               User::selectFields(),
+                               array( 'user_email' => $data['email'] ),
+                               __METHOD__
+                       );
+
+                       if ( $res ) {
+                               $status->value->users = array();
+                               foreach ( $res as $row ) {
+                                       $status->value->users[] = 
User::newFromRow( $row );
+                               }
+                       } else {
+                               // Some sort of database error, probably 
unreachable
+                               throw new MWException( 'Unknown database error 
in ' . __METHOD__ );
+                       }
+               } else {
+                       // The user didn't supply any data
+                       return false;
+               }
+
+               // Check for hooks (captcha etc), and allow them to modify the 
users list
+               $error = array();
+               // Sigh, backwards compatability.
+               $hookData = array();
+               foreach ( $data as $k => $v ) {
+                       $hookData[ucfirst($k)] = $v;
+               }
+               $hookData['Capture'] = $capture;
+               if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', array( 
&$status->value->users, $hookData, &$error ) ) ) {
+                       if ( is_array( $error ) ) {
+                               call_user_func_array( array( $status, 'fatal' 
), $error );
+                       } elseif ( $error === false ) {
+                               // Uh, ok.
+                               return false;
+                       } else {
+                               // Probably a non-localized string. Sigh.
+                               $status->fatal( new RawMessage( '$1', array( 
$error ) ) );
+                       }
+                       return $status;
+               }
+
+               if ( count( $status->value->users ) == 0 ) {
+                       // Don't reveal whether or not an email address is in 
use
+                       if ( $method !== 'email' ) {
+                               $status->fatal( 'noname' );
+                       }
+                       return $status;
+               }
+
+               /** @var $firstUser User */
+               $firstUser = $status->value->users[0];
+
+               if ( !$firstUser instanceof User || !$firstUser->getID() ) {
+                       // Don't parse username as wikitext (bug 65501)
+                       $status->value->users = array();
+                       $status->fatal( 'nosuchuser', wfEscapeWikiText( 
$data['username'] ) );
+                       return $status;
+               }
+
+               // Check against the rate limiter
+               if ( $context->getUser()->pingLimiter( 'mailpassword' ) ) {
+                       $status->throttled = true; // Used by 
Special:PasswordReset
+                       $status->fatal( 'actionthrottledtext' );
+                       return $status;
+               }
+
+               // Check against password throttle
+               foreach ( $status->value->users as $user ) {
+                       if ( $user->isPasswordReminderThrottled() ) {
+                               # Round the time in hours to 3 d.p., in case 
someone is specifying
+                               # minutes or seconds.
+                               $status->fatal(
+                                       'throttled-mailpassword',
+                                       round( $context->getConfig()->get( 
'PasswordReminderResendTime' ), 3 )
+                               );
+                               return $status;
+                       }
+               }
+
+               // All the users will have the same email address
+               if ( $firstUser->getEmail() == '' ) {
+                       // This won't be reachable from the email route, so 
safe to expose the username
+                       $status->fatal( 'noemail', wfEscapeWikiText( 
$firstUser->getName() ) );
+                       return $status;
+               }
+
+               // We need to have a valid IP address for the hook, but per bug 
18347, we should
+               // send the user's name if they're logged in.
+               $ip = $context->getRequest()->getIP();
+               if ( !$ip ) {
+                       $status->fatal( 'badipaddress' );
+                       return $status;
+               }
+               $caller = $context->getUser();
+               Hooks::run( 'User::mailPasswordInternal', array( &$caller, 
&$ip, &$firstUser ) );
+               $username = $caller->getName();
+               $msg = IP::isValid( $username )
+                       ? 'passwordreset-emailtext-ip'
+                       : 'passwordreset-emailtext-user';
+
+               // Send in the user's language; which should hopefully be the 
same
+               $userLanguage = $firstUser->getOption( 'language' );
+
+               $passwords = array();
+               foreach ( $status->value->users as $user ) {
+                       $password = $user->randomPassword();
+                       $user->setNewpassword( $password );
+                       $user->saveSettings();
+                       $passwords[] = $context->msg( 
'passwordreset-emailelement', $user->getName(), $password )
+                               ->inLanguage( $userLanguage )->text(); // We'll 
escape the whole thing later
+               }
+               $passwordBlock = implode( "\n\n", $passwords );
+
+               $email = $context->msg( $msg )->inLanguage( $userLanguage );
+               $email->params(
+                       $username,
+                       $passwordBlock,
+                       count( $passwords ),
+                       '<' . Title::newMainPage()->getCanonicalURL() . '>',
+                       round( $context->getConfig()->get( 'NewPasswordExpiry' 
) / 86400 )
+               );
+
+               $title = $context->msg( 'passwordreset-emailtitle' );
+
+               $result = $firstUser->sendMail( $title->text(), $email->text() 
);
+               $status->value->mailresult = $result;
+
+               if ( $capture ) {
+                       $status->value->email = $email;
+               }
+
+               if ( !$result->isGood() ) {
+                       // @todo FIXME: The email wasn't sent, but we have 
already set
+                       // the password throttle timestamp, so they won't be 
able to try
+                       // again until it expires...  :(
+                       $status->fatal( 'mailerror', $result->getMessage() );
+               }
+
+               return $status;
+       }
+
+       /**
+        * Change the user's password
+        * @param string $oldpass
+        * @param string $newpass
+        * @param string $retype
+        * @return Status
+        */
+       public function changePassword( $oldpass, $newpass, $retype ) {
+               global $wgAuth;
+
+               if ( !$wgAuth->allowPasswordChange() ) {
+                       return Status::newFatal( 'resetpass_forbidden' );
+               }
+
+               $context = $this->getContext();
+               $user = $this->getUser();
+               $isSelf = ( $user->getName() === $context->getUser()->getName() 
);
+
+               if ( !$user || $user->isAnon() ) {
+                       return Status::newFatal( 'nosuchusershort', 
$user->getName() );
+               }
+
+               if ( $newpass !== $retype ) {
+                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'badretype' ) );
+                       return Status::newFatal( 'badretype' );
+               }
+
+               $throttleCount = LoginForm::incLoginThrottle( $user->getName() 
);
+               if ( $throttleCount === true ) {
+                       $lang = $context->getLanguage();
+                       $throttleInfo = $context->getConfig()->get( 
'PasswordAttemptThrottle' );
+                       return Status::newFatal( 'changepassword-throttled',
+                               $lang->formatDuration( $throttleInfo['seconds'] 
)
+                       );
+               }
+
+               // @todo Make these separate messages, since the message is 
written for both cases
+               if ( !$user->checkTemporaryPassword( $oldpass ) && 
!$user->checkPassword( $oldpass ) ) {
+                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'wrongpassword' ) );
+                       return Status::newFatal( 'resetpass-wrong-oldpass' );
+               }
+
+               // User is resetting their password to their old password
+               if ( $oldpass === $newpass ) {
+                       return Status::newFatal( 'resetpass-recycled' );
+               }
+
+               // 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 ( !Hooks::run( 'AbortChangePassword', array( $user, 
$oldpass, $newpass, &$abortMsg ) ) ) {
+                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'abortreset' ) );
+                       return Status::newFatal( $abortMsg );
+               }
+
+               // Please reset throttle for successful logins, thanks!
+               if ( $throttleCount ) {
+                       LoginForm::clearLoginThrottle( $user->getName() );
+               }
+
+               try {
+                       $user->setPassword( $newpass );
+                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'success' ) );
+               } catch ( PasswordError $e ) {
+                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'error' ) );
+                       return Status::newFatal( new RawMessage( '$1', array( 
$e->getMessage() ) ) );
+               }
+
+               if ( $isSelf ) {
+                       // This is needed to keep the user connected since
+                       // changing the password also modifies the user's token.
+                       $remember = $context->getRequest()->getCookie( 'Token' 
) !== null;
+                       $user->setCookies( null, null, $remember );
+               }
+               $user->resetPasswordExpiration();
+               $user->saveSettings();
+               return Status::newGood();
+       }
+
+
+       /**
+        * Change the user's email address
+        * @param string $pass
+        * @param string $newaddr
+        * @return Status
+        */
+       public function changeEmail( $pass, $newaddr ) {
+               global $wgAuth;
+
+               $context = $this->getContext();
+               $user = $this->getUser();
+
+               if ( !$user || $user->isAnon() ) {
+                       return Status::newFatal( 'nosuchusershort', 
$user->getName() );
+               }
+
+               if ( $newaddr != '' && !Sanitizer::validateEmail( $newaddr ) ) {
+                       return Status::newFatal( 'invalidemailaddress' );
+               }
+
+               $throttleCount = LoginForm::incLoginThrottle( $user->getName() 
);
+               if ( $throttleCount === true ) {
+                       $lang = $context->getLanguage();
+                       $throttleInfo = $context->getConfig()->get( 
'PasswordAttemptThrottle' );
+                       return Status::newFatal(
+                               'changeemail-throttled',
+                               $lang->formatDuration( $throttleInfo['seconds'] 
)
+                       );
+               }
+
+               if ( $context->getConfig()->get( 
'RequirePasswordforEmailChange' )
+                       && !$user->checkTemporaryPassword( $pass )
+                       && !$user->checkPassword( $pass )
+               ) {
+                       return Status::newFatal( 'wrongpassword' );
+               }
+
+               if ( $throttleCount ) {
+                       LoginForm::clearLoginThrottle( $user->getName() );
+               }
+
+               $oldaddr = $user->getEmail();
+               $status = $user->setEmailWithConfirmation( $newaddr );
+               if ( !$status->isGood() ) {
+                       return $status;
+               }
+
+               Hooks::run( 'PrefsEmailAudit', array( $user, $oldaddr, $newaddr 
) );
+
+               $user->saveSettings();
+
+               $wgAuth->updateExternalDB( $user );
+
+               return $status;
+       }
+
+}
diff --git a/includes/api/ApiChangeEmail.php b/includes/api/ApiChangeEmail.php
new file mode 100644
index 0000000..3859ae4
--- /dev/null
+++ b/includes/api/ApiChangeEmail.php
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Created on Jan 30, 2015
+ *
+ * Copyright © 2015 Brad Jorsch <bjor...@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ * @since 1.25
+ */
+class ApiChangeEmail extends ApiBase {
+
+       /**
+        * Patrols the article or provides the reason the patrol failed.
+        */
+       public function execute() {
+               if ( $this->getUser()->isAnon() ) {
+                       $this->dieUsage( 'Anonymous users cannot change 
preferences', 'notloggedin' );
+               }
+
+               $params = $this->extractRequestParams();
+               if ( $params['user'] !== null ) {
+                       $user = User::newFromName( $params['user'] );
+                       if ( !$user || $user->isAnon() ) {
+                               $this->dieUsage( 'Specified user does not 
exist', 'baduser_user' );
+                       }
+               } else {
+                       $user = $this->getUser();
+               }
+
+               if ( $params['email'] !== '' && !Sanitizer::validateEmail( 
$params['email'] ) ) {
+                       $this->dieUsage( 'The specified email address is not 
valid', 'badvalue_email' );
+               }
+
+               $mangler = new UserMangler( $user, $this );
+               $status = $mangler->changeEmail( $params['password'], 
$params['email'] );
+
+               $ret = array();
+               if ( $status->isGood() ) {
+                       $ret['status'] = 'success';
+               } else {
+                       $ret['status'] = $status->isOk() ? 'warnings' : 
'failure';
+                       $warnings = $this->getResult()->convertStatusToArray( 
$status, 'warning' );
+                       if ( $warnings ) {
+                               $ret['warnings'] = $warnings;
+                       }
+                       $errors = $this->getResult()->convertStatusToArray( 
$status, 'error' );
+                       if ( $errors ) {
+                               $ret['errors'] = $errors;
+                       }
+               }
+
+               $this->getResult()->addValue( null, $this->getModuleName(), 
$ret );
+       }
+
+       public function mustBePosted() {
+               return true;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function getAllowedParams() {
+               $ret = array(
+                       'user' => array(
+                               ApiBase::PARAM_TYPE => 'user',
+                       ),
+                       'password' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => true,
+                       ),
+                       'email' => array(
+                               ApiBase::PARAM_REQUIRED => true,
+                       ),
+               );
+
+               if ( !$this->getConfig()->get( 'RequirePasswordforEmailChange' 
) ) {
+                       unset( $ret['password'] );
+               }
+
+               return $ret;
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       protected function getExamplesMessages() {
+               $pass = $this->getConfig()->get( 
'RequirePasswordforEmailChange' )
+                       ? '&password=secret'
+                       : '';
+               return array(
+                       
"action=changeemail&email=u...@example.com{$pass}token=123ABC"
+                               => 'apihelp-changeemail-example-simple',
+               );
+       }
+}
diff --git a/includes/api/ApiChangePassword.php 
b/includes/api/ApiChangePassword.php
new file mode 100644
index 0000000..afe8c73
--- /dev/null
+++ b/includes/api/ApiChangePassword.php
@@ -0,0 +1,130 @@
+<?php
+/**
+ * Created on Jan 30, 2015
+ *
+ * Copyright © 2015 Brad Jorsch <bjor...@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ * @since 1.25
+ */
+class ApiChangePassword extends ApiBase {
+
+       /**
+        * Patrols the article or provides the reason the patrol failed.
+        */
+       public function execute() {
+               $params = $this->extractRequestParams();
+
+               if ( $params['user'] !== null ) {
+                       $user = User::newFromName( $params['user'] );
+                       if ( !$user || $user->isAnon() ) {
+                               $this->dieUsage( 'Specified user does not 
exist', 'baduser_user' );
+                       }
+               } else {
+                       $user = $this->getUser();
+                       if ( $user->isAnon() ) {
+                               $this->dieUsage( 'Please log in or use the 
"user" parameter', 'notloggedin' );
+                       }
+               }
+
+               // Make sure we have a token
+               $token = LoginForm::getLoginToken();
+               if ( !$token ) {
+                       LoginForm::setLoginToken();
+                       $token = LoginForm::getLoginToken();
+               }
+
+               if ( $params['token'] === null ) {
+                       $ret = array(
+                               'result' => 'needtoken',
+                               'token' => $token,
+                       );
+               } elseif ( $params['token'] !== $token ) {
+                       $ret = array(
+                               'result' => 'wrongtoken',
+                       );
+               } else {
+                       if ( $params['oldpassword'] === null || 
$params['oldpassword'] === '' ) {
+                               $this->dieUsageMsg( array( 'missingparam', 
'oldpassword' ) );
+                       }
+                       if ( $params['newpassword'] === null || 
$params['newpassword'] === '' ) {
+                               $this->dieUsageMsg( array( 'missingparam', 
'newpassword' ) );
+                       }
+
+                       $mangler = new UserMangler( $user, $this );
+                       $status = $mangler->changePassword(
+                               $params['oldpassword'], $params['newpassword'], 
$params['newpassword']
+                       );
+
+                       $ret = array();
+                       if ( $status->isGood() ) {
+                               $ret['status'] = 'success';
+                       } else {
+                               $ret['status'] = $status->isOk() ? 'warnings' : 
'failure';
+                               $warnings = 
$this->getResult()->convertStatusToArray( $status, 'warning' );
+                               if ( $warnings ) {
+                                       $ret['warnings'] = $warnings;
+                               }
+                               $errors = 
$this->getResult()->convertStatusToArray( $status, 'error' );
+                               if ( $errors ) {
+                                       $ret['errors'] = $errors;
+                               }
+                       }
+               }
+
+               $this->getResult()->addValue( null, $this->getModuleName(), 
$ret );
+       }
+
+       public function mustBePosted() {
+               return true;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function getAllowedParams() {
+               return array(
+                       'user' => array(
+                               ApiBase::PARAM_TYPE => 'user',
+                       ),
+                       'token' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                       ),
+                       'oldpassword' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                       ),
+                       'newpassword' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                       ),
+               );
+       }
+
+       protected function getExamplesMessages() {
+               return array(
+                       'action=changepassword'
+                               => 'apihelp-changepassword-example-token',
+                       
'action=changepassword&oldpassword=old&newpassword=new&token=123ABC'
+                               => 'apihelp-changepassword-example-simple',
+               );
+       }
+}
diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php
index 9a98054..6a34c40 100644
--- a/includes/api/ApiMain.php
+++ b/includes/api/ApiMain.php
@@ -87,6 +87,9 @@
                'options' => 'ApiOptions',
                'imagerotate' => 'ApiImageRotate',
                'revisiondelete' => 'ApiRevisionDelete',
+               'resetpassword' => 'ApiResetPassword',
+               'changepassword' => 'ApiChangePassword',
+               'changeemail' => 'ApiChangeEmail',
        );
 
        /**
diff --git a/includes/api/ApiResetPassword.php 
b/includes/api/ApiResetPassword.php
new file mode 100644
index 0000000..4d90e78
--- /dev/null
+++ b/includes/api/ApiResetPassword.php
@@ -0,0 +1,128 @@
+<?php
+/**
+ * Created on Jan 30, 2015
+ *
+ * Copyright © 2015 Brad Jorsch <bjor...@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup API
+ * @since 1.25
+ */
+class ApiResetPassword extends ApiBase {
+
+       /**
+        * Patrols the article or provides the reason the patrol failed.
+        */
+       public function execute() {
+               $params = $this->extractRequestParams();
+
+               $status = UserMangler::canResetPassword( $this );
+               if ( !$status->isGood() ) {
+                       $this->dieStatus( $status );
+               }
+
+               if ( isset( $params['email'] ) && $params['email'] !== '' &&
+                       !Sanitizer::validateEmail( $params['email'] )
+               ) {
+                       $this->dieUsage( 'The specified email address is not 
valid', 'badvalue_email' );
+               }
+
+               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+               $data = array_intersect_key( $params, $resetRoutes );
+               $status = UserMangler::resetPassword( $this, $data, 
$params['capture'] );
+
+               if ( $status === false ) {
+                       $this->dieUsage( 'No user selection parameters were 
given non-empty values', 'missingparam' );
+               }
+
+               $ret = array();
+               if ( $status->isGood() ) {
+                       $ret['status'] = 'success';
+               } else {
+                       $ret['status'] = $status->isOk() ? 'warnings' : 
'failure';
+                       $warnings = $this->getResult()->convertStatusToArray( 
$status, 'warning' );
+                       if ( $warnings ) {
+                               $ret['warnings'] = $warnings;
+                       }
+                       $errors = $this->getResult()->convertStatusToArray( 
$status, 'error' );
+                       if ( $errors ) {
+                               $ret['errors'] = $errors;
+                       }
+               }
+
+               if ( $params['capture'] && $status->value->email instanceof 
Message ) {
+                       $ret['email'] = $status->value->email->text();
+               }
+
+               $this->getResult()->addValue( null, $this->getModuleName(), 
$ret );
+       }
+
+       public function mustBePosted() {
+               return true;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function getAllowedParams() {
+               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
+
+               $ret = array();
+               if ( !empty( $resetRoutes['username'] ) ) {
+                       $ret['username'] = array(
+                               ApiBase::PARAM_TYPE => 'user',
+                       );
+               }
+               if ( !empty( $resetRoutes['email'] ) ) {
+                       $ret['email'] = array(
+                               ApiBase::PARAM_TYPE => 'string',
+                       );
+               }
+               if ( !empty( $resetRoutes['domain'] ) ) {
+                       $ret['domain'] = array(
+                               ApiBase::PARAM_TYPE => 'string',
+                       );
+               }
+               $ret += array(
+                       'capture' => array(
+                               ApiBase::PARAM_TYPE => 'boolean',
+                       ),
+               );
+
+               return $ret;
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       protected function getExamplesMessages() {
+               return array(
+                       'action=resetpassword&user=Example&token=123ABC'
+                               => 'apihelp-resetpassword-example-user',
+                       
'action=resetpassword&email=u...@example.com&token=123ABC'
+                               => 'apihelp-resetpassword-example-email',
+                       
'action=resetpassword&user=Example&capture=1&token=123ABC'
+                               => 'apihelp-resetpassword-example-user-capture',
+               );
+       }
+}
diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json
index c83058d..92841ac 100644
--- a/includes/api/i18n/en.json
+++ b/includes/api/i18n/en.json
@@ -33,6 +33,20 @@
        "apihelp-block-example-ip-simple": "Block IP 192.0.2.5 for three days 
with reason \"First strike\"",
        "apihelp-block-example-user-complex": "Block user Vandal indefinitely 
with reason \"Vandalism\", and prevent new account creation and email",
 
+       "apihelp-changeemail-description": "Change a user's email address.",
+       "apihelp-changeemail-param-user": "User whose email address is to be 
changed, rather than the current user.",
+       "apihelp-changeemail-param-password": "User's current password.",
+       "apihelp-changeemail-param-email": "New email address to set.",
+       "apihelp-changeemail-example-simple": "Change the current user's email 
address to <kbd>u...@example.com</kbd>.",
+
+       "apihelp-changepassword-description": "Change a user's password.\n\nThe 
first request should be made without the <var>$1oldpassword</var>, 
<var>$1newpassword</var>, or <var>$1token</var> parameters to receive a 
<samp>needtoken</samp> result with the needed token. The second token should 
then supply these three parameters to change the password.",
+       "apihelp-changepassword-param-user": "User whose password is to be 
changed, rather than the current user.",
+       "apihelp-changepassword-param-token": "Login token obtained in first 
request.",
+       "apihelp-changepassword-param-oldpassword": "Current password.",
+       "apihelp-changepassword-param-newpassword": "New password.",
+       "apihelp-changepassword-example-token": "Fetch the token.",
+       "apihelp-changepassword-example-simple": "Change the current user's 
password.",
+
        "apihelp-clearhasmsg-description": "Clears the hasmsg flag for current 
user.",
        "apihelp-clearhasmsg-example-1": "Clear the hasmsg flag for current 
user",
 
@@ -959,6 +973,15 @@
        "apihelp-query+watchlistraw-example-simple": "List pages on the current 
user's watchlist",
        "apihelp-query+watchlistraw-example-generator": "Fetch page info for 
pages on the current user's watchlist",
 
+       "apihelp-resetpassword-description": "Send a password reset email.",
+       "apihelp-resetpassword-param-username": "User whose password is to be 
reset.",
+       "apihelp-resetpassword-param-email": "Send a password reset email to 
this address.",
+       "apihelp-resetpassword-param-domain": "Authentication domain used when 
resetting the password.",
+       "apihelp-resetpassword-param-capture": "Return the text of the email 
send. Requires the <code>passwordreset</code> right.",
+       "apihelp-resetpassword-example-user": "Send a password reset email to 
user <kbd>Example</kbd>.",
+       "apihelp-resetpassword-example-email": "Send a password reset email to 
<kbd>u...@example.com</kbd>, if that address is confirmed for any accounts.",
+       "apihelp-resetpassword-example-user-capture": "Send a password reset 
email to user <kbd>Example</kbd>, and return the text of the email.",
+
        "apihelp-revisiondelete-description": "Delete and undelete revisions.",
        "apihelp-revisiondelete-param-type": "Type of revision deletion being 
performed.",
        "apihelp-revisiondelete-param-target": "Page title for the revision 
deletion, if required for the type.",
diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json
index c8713e8..12e60df 100644
--- a/includes/api/i18n/qqq.json
+++ b/includes/api/i18n/qqq.json
@@ -34,6 +34,18 @@
        "apihelp-block-param-watchuser": 
"{{doc-apihelp-param|block|watchuser}}",
        "apihelp-block-example-ip-simple": "{{doc-apihelp-example|block}}",
        "apihelp-block-example-user-complex": "{{doc-apihelp-example|block}}",
+       "apihelp-changeemail-description": 
"{{apihelp-description|changeemail}}",
+       "apihelp-changeemail-param-email": 
"{{apihelp-param|changeemail|email}}",
+       "apihelp-changeemail-param-password": 
"{{apihelp-param|changeemail|password}}",
+       "apihelp-changeemail-param-user": "{{apihelp-param|changeemail|user}}",
+       "apihelp-changeemail-example-simple": "{{apihelp-example|changeemail}}",
+       "apihelp-changepassword-description": 
"{{apihelp-description|changepassword}}",
+       "apihelp-changepassword-param-newpassword": 
"{{apihelp-param|changepassword|newpassword}}",
+       "apihelp-changepassword-param-oldpassword": 
"{{apihelp-param|changepassword|oldpassword}}",
+       "apihelp-changepassword-param-token": 
"{{apihelp-param|changepassword|token}}",
+       "apihelp-changepassword-param-user": 
"{{apihelp-param|changepassword|user}}",
+       "apihelp-changepassword-example-token": 
"{{apihelp-example|changepassword}}",
+       "apihelp-changepassword-example-simple": 
"{{apihelp-example|changepassword}}",
        "apihelp-clearhasmsg-description": 
"{{doc-apihelp-description|clearhasmsg}}",
        "apihelp-clearhasmsg-example-1": "{{doc-apihelp-example|clearhasmsg}}",
        "apihelp-compare-description": "{{doc-apihelp-description|compare}}",
@@ -874,6 +886,14 @@
        "apihelp-query+watchlistraw-param-token": 
"{{doc-apihelp-param|query+watchlistraw|token}}",
        "apihelp-query+watchlistraw-example-simple": 
"{{doc-apihelp-example|query+watchlistraw}}",
        "apihelp-query+watchlistraw-example-generator": 
"{{doc-apihelp-example|query+watchlistraw}}",
+       "apihelp-resetpassword-description": 
"{{apihelp-description|resetpassword}}",
+       "apihelp-resetpassword-param-capture": 
"{{apihelp-param|resetpassword|capture}}",
+       "apihelp-resetpassword-param-domain": 
"{{apihelp-param|resetpassword|domain}}",
+       "apihelp-resetpassword-param-email": 
"{{apihelp-param|resetpassword|email}}",
+       "apihelp-resetpassword-param-username": 
"{{apihelp-param|resetpassword|username}}",
+       "apihelp-resetpassword-example-user": 
"{{apihelp-example|resetpassword}}",
+       "apihelp-resetpassword-example-email": 
"{{apihelp-example|resetpassword}}",
+       "apihelp-resetpassword-example-user-capture": 
"{{apihelp-example|resetpassword}}",
        "apihelp-revisiondelete-description": 
"{{doc-apihelp-description|revisiondelete}}",
        "apihelp-revisiondelete-param-type": 
"{{doc-apihelp-param|revisiondelete|type}}",
        "apihelp-revisiondelete-param-target": 
"{{doc-apihelp-param|revisiondelete|target}}",
diff --git a/includes/specials/SpecialChangeEmail.php 
b/includes/specials/SpecialChangeEmail.php
index 674cbc8..ee07834 100644
--- a/includes/specials/SpecialChangeEmail.php
+++ b/includes/specials/SpecialChangeEmail.php
@@ -119,7 +119,9 @@
 
        public function onSubmit( array $data ) {
                $password = isset( $data['Password'] ) ? $data['Password'] : 
null;
-               $status = $this->attemptChange( $this->getUser(), $password, 
$data['NewEmail'] );
+
+               $mangler = new UserMangler( $this->getUser(), 
$this->getContext() );
+               $status = $mangler->changeEmail( $password, $data['NewEmail'] );
 
                $this->status = $status;
 
@@ -143,55 +145,6 @@
                                'eauthentsent', $this->getUser()->getName() );
                        $this->getOutput()->addReturnTo( $titleObj, 
wfCgiToArray( $query ) ); // just show the link to go back
                }
-       }
-
-       /**
-        * @param User $user
-        * @param string $pass
-        * @param string $newaddr
-        * @return Status
-        */
-       private function attemptChange( User $user, $pass, $newaddr ) {
-               global $wgAuth;
-
-               if ( $newaddr != '' && !Sanitizer::validateEmail( $newaddr ) ) {
-                       return Status::newFatal( 'invalidemailaddress' );
-               }
-
-               $throttleCount = LoginForm::incLoginThrottle( $user->getName() 
);
-               if ( $throttleCount === true ) {
-                       $lang = $this->getLanguage();
-                       $throttleInfo = $this->getConfig()->get( 
'PasswordAttemptThrottle' );
-                       return Status::newFatal(
-                               'changeemail-throttled',
-                               $lang->formatDuration( $throttleInfo['seconds'] 
)
-                       );
-               }
-
-               if ( $this->getConfig()->get( 'RequirePasswordforEmailChange' )
-                       && !$user->checkTemporaryPassword( $pass )
-                       && !$user->checkPassword( $pass )
-               ) {
-                       return Status::newFatal( 'wrongpassword' );
-               }
-
-               if ( $throttleCount ) {
-                       LoginForm::clearLoginThrottle( $user->getName() );
-               }
-
-               $oldaddr = $user->getEmail();
-               $status = $user->setEmailWithConfirmation( $newaddr );
-               if ( !$status->isGood() ) {
-                       return $status;
-               }
-
-               Hooks::run( 'PrefsEmailAudit', array( $user, $oldaddr, $newaddr 
) );
-
-               $user->saveSettings();
-
-               $wgAuth->updateExternalDB( $user );
-
-               return $status;
        }
 
        public function requiresUnblock() {
diff --git a/includes/specials/SpecialChangePassword.php 
b/includes/specials/SpecialChangePassword.php
index 168095f..167c3cd 100644
--- a/includes/specials/SpecialChangePassword.php
+++ b/includes/specials/SpecialChangePassword.php
@@ -197,7 +197,16 @@
                                throw new ErrorPageError( 'changepassword', 
'resetpass_forbidden' );
                        }
 
-                       $this->attemptReset( $data['Password'], 
$data['NewPassword'], $data['Retype'] );
+                       $user = User::newFromName( $this->mUserName );
+                       if ( !$user || $user->isAnon() ) {
+                               throw new PasswordError( $this->msg( 
'nosuchusershort', $this->mUserName )->text() );
+                       }
+
+                       $mangler = new UserMangler( $user, $this->getContext() 
);
+                       $status = $mangler->changePassword( $data['Password'], 
$data['NewPassword'], $data['Retype'] );
+                       if ( !$status->isGood() ) {
+                               return $status->getWikiText();
+                       }
 
                        return true;
                } catch ( PasswordError $e ) {
@@ -227,81 +236,6 @@
                        $login->setContext( $this->getContext() );
                        $login->execute( null );
                }
-       }
-
-       /**
-        * @param string $oldpass
-        * @param string $newpass
-        * @param string $retype
-        * @throws PasswordError When cannot set the new password because 
requirements not met.
-        */
-       protected function attemptReset( $oldpass, $newpass, $retype ) {
-               $isSelf = ( $this->mUserName === $this->getUser()->getName() );
-               if ( $isSelf ) {
-                       $user = $this->getUser();
-               } else {
-                       $user = User::newFromName( $this->mUserName );
-               }
-
-               if ( !$user || $user->isAnon() ) {
-                       throw new PasswordError( $this->msg( 'nosuchusershort', 
$this->mUserName )->text() );
-               }
-
-               if ( $newpass !== $retype ) {
-                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'badretype' ) );
-                       throw new PasswordError( $this->msg( 'badretype' 
)->text() );
-               }
-
-               $throttleCount = LoginForm::incLoginThrottle( $this->mUserName 
);
-               if ( $throttleCount === true ) {
-                       $lang = $this->getLanguage();
-                       $throttleInfo = $this->getConfig()->get( 
'PasswordAttemptThrottle' );
-                       throw new PasswordError( $this->msg( 
'changepassword-throttled' )
-                               ->params( $lang->formatDuration( 
$throttleInfo['seconds'] ) )
-                               ->text()
-                       );
-               }
-
-               // @todo Make these separate messages, since the message is 
written for both cases
-               if ( !$user->checkTemporaryPassword( $oldpass ) && 
!$user->checkPassword( $oldpass ) ) {
-                       Hooks::run( '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 ( !Hooks::run( 'AbortChangePassword', array( $user, 
$oldpass, $newpass, &$abortMsg ) ) ) {
-                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'abortreset' ) );
-                       throw new PasswordError( $this->msg( $abortMsg 
)->text() );
-               }
-
-               // Please reset throttle for successful logins, thanks!
-               if ( $throttleCount ) {
-                       LoginForm::clearLoginThrottle( $this->mUserName );
-               }
-
-               try {
-                       $user->setPassword( $newpass );
-                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'success' ) );
-               } catch ( PasswordError $e ) {
-                       Hooks::run( 'PrefsPasswordAudit', array( $user, 
$newpass, 'error' ) );
-                       throw new PasswordError( $e->getMessage() );
-               }
-
-               if ( $isSelf ) {
-                       // This is needed to keep the user connected since
-                       // changing the password also modifies the user's token.
-                       $remember = $this->getRequest()->getCookie( 'Token' ) 
!== null;
-                       $user->setCookies( null, null, $remember );
-               }
-               $user->resetPasswordExpiration();
-               $user->saveSettings();
        }
 
        public function requiresUnblock() {
diff --git a/includes/specials/SpecialPasswordReset.php 
b/includes/specials/SpecialPasswordReset.php
index a2dc2ad..859b50d 100644
--- a/includes/specials/SpecialPasswordReset.php
+++ b/includes/specials/SpecialPasswordReset.php
@@ -47,15 +47,13 @@
        }
 
        public function userCanExecute( User $user ) {
-               return $this->canChangePassword( $user ) === true && 
parent::userCanExecute( $user );
+               return $this->canChangePassword( $user )->isGood() && 
parent::userCanExecute( $user );
        }
 
        public function checkExecutePermissions( User $user ) {
-               $error = $this->canChangePassword( $user );
-               if ( is_string( $error ) ) {
-                       throw new ErrorPageError( 'internalerror', $error );
-               } elseif ( !$error ) {
-                       throw new ErrorPageError( 'internalerror', 
'resetpass_forbidden' );
+               $status = $this->canChangePassword( $user );
+               if ( !$status->isGood() ) {
+                       throw new ErrorPageError( 'internalerror', 
$status->getMessage() );
                }
 
                return parent::checkExecutePermissions( $user );
@@ -133,167 +131,47 @@
         * Process the form.  At this point we know that the user passes all 
the criteria in
         * userCanExecute(), and if the data array contains 'Username', etc, 
then Username
         * resets are allowed.
-        * @param array $data
+        * @param array $formData
         * @throws MWException
         * @throws ThrottledError|PermissionsError
         * @return bool|array
         */
-       public function onSubmit( array $data ) {
-               global $wgAuth;
-
-               if ( isset( $data['Domain'] ) ) {
-                       if ( $wgAuth->validDomain( $data['Domain'] ) ) {
-                               $wgAuth->setDomain( $data['Domain'] );
-                       } else {
-                               $wgAuth->setDomain( 'invaliddomain' );
+       public function onSubmit( array $formData ) {
+               $data = array();
+               foreach ( array(
+                       'Domain' => 'domain',
+                       'Username' => 'username',
+                       'Email' => 'email',
+               ) as $formKey => $dataKey ) {
+                       if ( isset( $formData[$formKey] ) ) {
+                               $data[$dataKey] = $formData[$formKey];
                        }
                }
 
                if ( isset( $data['Capture'] ) && !$this->getUser()->isAllowed( 
'passwordreset' ) ) {
-                       // The user knows they don't have the passwordreset 
permission,
-                       // but they tried to spoof the form. That's naughty
                        throw new PermissionsError( 'passwordreset' );
                }
+               $capture = !empty( $formData['Capture'] );
 
-               /**
-                * @var $firstUser User
-                * @var $users User[]
-                */
+               $status = UserMangler::resetPassword( $this->getContext(), 
$data, $capture );
 
-               if ( isset( $data['Username'] ) && $data['Username'] !== '' ) {
-                       $method = 'username';
-                       $users = array( User::newFromName( $data['Username'] ) 
);
-               } elseif ( isset( $data['Email'] )
-                       && $data['Email'] !== ''
-                       && Sanitizer::validateEmail( $data['Email'] )
-               ) {
-                       $method = 'email';
-                       $res = wfGetDB( DB_SLAVE )->select(
-                               'user',
-                               User::selectFields(),
-                               array( 'user_email' => $data['Email'] ),
-                               __METHOD__
-                       );
-
-                       if ( $res ) {
-                               $users = array();
-
-                               foreach ( $res as $row ) {
-                                       $users[] = User::newFromRow( $row );
-                               }
-                       } else {
-                               // Some sort of database error, probably 
unreachable
-                               throw new MWException( 'Unknown database error 
in ' . __METHOD__ );
+               if ( $status instanceof Status ) {
+                       if ( !empty( $status->throttled ) ) {
+                               throw new ThrottledError;
                        }
-               } else {
-                       // The user didn't supply any data
-                       return false;
-               }
 
-               // Check for hooks (captcha etc), and allow them to modify the 
users list
-               $error = array();
-               if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', array( 
&$users, $data, &$error ) ) ) {
-                       return array( $error );
-               }
+                       $this->result = $status->value->mailresult ?: $status;
+                       $this->firstUser = $status->value->users ? 
$status->value->users[0] : null;
+                       $this->email = $status->value->email;
 
-               if ( count( $users ) == 0 ) {
-                       if ( $method == 'email' ) {
-                               // Don't reveal whether or not an email address 
is in use
-                               return true;
-                       } else {
-                               return array( 'noname' );
+                       // If we're capturing and the email got created, that's 
good enough
+                       // to be considered "good" (onSuccess will display 
things correctly)
+                       if ( $capture && $this->email ) {
+                               $status = Status::newGood();
                        }
                }
 
-               $firstUser = $users[0];
-
-               if ( !$firstUser instanceof User || !$firstUser->getID() ) {
-                       // Don't parse username as wikitext (bug 65501)
-                       return array( array( 'nosuchuser', wfEscapeWikiText( 
$data['Username'] ) ) );
-               }
-
-               // Check against the rate limiter
-               if ( $this->getUser()->pingLimiter( 'mailpassword' ) ) {
-                       throw new ThrottledError;
-               }
-
-               // Check against password throttle
-               foreach ( $users as $user ) {
-                       if ( $user->isPasswordReminderThrottled() ) {
-
-                               # Round the time in hours to 3 d.p., in case 
someone is specifying
-                               # minutes or seconds.
-                               return array( array(
-                                       'throttled-mailpassword',
-                                       round( $this->getConfig()->get( 
'PasswordReminderResendTime' ), 3 )
-                               ) );
-                       }
-               }
-
-               // All the users will have the same email address
-               if ( $firstUser->getEmail() == '' ) {
-                       // This won't be reachable from the email route, so 
safe to expose the username
-                       return array( array( 'noemail', wfEscapeWikiText( 
$firstUser->getName() ) ) );
-               }
-
-               // We need to have a valid IP address for the hook, but per bug 
18347, we should
-               // send the user's name if they're logged in.
-               $ip = $this->getRequest()->getIP();
-               if ( !$ip ) {
-                       return array( 'badipaddress' );
-               }
-               $caller = $this->getUser();
-               Hooks::run( 'User::mailPasswordInternal', array( &$caller, 
&$ip, &$firstUser ) );
-               $username = $caller->getName();
-               $msg = IP::isValid( $username )
-                       ? 'passwordreset-emailtext-ip'
-                       : 'passwordreset-emailtext-user';
-
-               // Send in the user's language; which should hopefully be the 
same
-               $userLanguage = $firstUser->getOption( 'language' );
-
-               $passwords = array();
-               foreach ( $users as $user ) {
-                       $password = $user->randomPassword();
-                       $user->setNewpassword( $password );
-                       $user->saveSettings();
-                       $passwords[] = $this->msg( 
'passwordreset-emailelement', $user->getName(), $password )
-                               ->inLanguage( $userLanguage )->text(); // We'll 
escape the whole thing later
-               }
-               $passwordBlock = implode( "\n\n", $passwords );
-
-               $this->email = $this->msg( $msg )->inLanguage( $userLanguage );
-               $this->email->params(
-                       $username,
-                       $passwordBlock,
-                       count( $passwords ),
-                       '<' . Title::newMainPage()->getCanonicalURL() . '>',
-                       round( $this->getConfig()->get( 'NewPasswordExpiry' ) / 
86400 )
-               );
-
-               $title = $this->msg( 'passwordreset-emailtitle' );
-
-               $this->result = $firstUser->sendMail( $title->text(), 
$this->email->text() );
-
-               if ( isset( $data['Capture'] ) && $data['Capture'] ) {
-                       // Save the user, will be used if an error occurs when 
sending the email
-                       $this->firstUser = $firstUser;
-               } else {
-                       // Blank the email if the user is not supposed to see it
-                       $this->email = null;
-               }
-
-               if ( $this->result->isGood() ) {
-                       return true;
-               } elseif ( isset( $data['Capture'] ) && $data['Capture'] ) {
-                       // The email didn't send, but maybe they knew that and 
that's why they captured it
-                       return true;
-               } else {
-                       // @todo FIXME: The email wasn't sent, but we have 
already set
-                       // the password throttle timestamp, so they won't be 
able to try
-                       // again until it expires...  :(
-                       return array( array( 'mailerror', 
$this->result->getMessage() ) );
-               }
+               return $status;
        }
 
        public function onSuccess() {
@@ -315,33 +193,9 @@
        }
 
        protected function canChangePassword( User $user ) {
-               global $wgAuth;
-               $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' );
-
-               // Maybe password resets are disabled, or there are no 
allowable routes
-               if ( !is_array( $resetRoutes ) ||
-                       !in_array( true, array_values( $resetRoutes ) )
-               ) {
-                       return 'passwordreset-disabled';
-               }
-
-               // Maybe the external auth plugin won't allow local password 
changes
-               if ( !$wgAuth->allowPasswordChange() ) {
-                       return 'resetpass_forbidden';
-               }
-
-               // Maybe email features have been disabled
-               if ( !$this->getConfig()->get( 'EnableEmail' ) ) {
-                       return 'passwordreset-emaildisabled';
-               }
-
-               // Maybe the user is blocked (check this here rather than 
relying on the parent
-               // method as we have a more specific error message to use here
-               if ( $user->isBlocked() ) {
-                       return 'blocked-mailpassword';
-               }
-
-               return true;
+               $context = new DerivativeContext( $this->getContext() );
+               $context->setUser( $user );
+               return UserMangler::canResetPassword( $context );
        }
 
        /**
@@ -349,7 +203,7 @@
         * @return bool
         */
        function isListed() {
-               if ( $this->canChangePassword( $this->getUser() ) === true ) {
+               if ( $this->canChangePassword( $this->getUser() )->isGood() ) {
                        return parent::isListed();
                }
 
diff --git a/tests/phpunit/includes/UserManglerTest.php 
b/tests/phpunit/includes/UserManglerTest.php
new file mode 100644
index 0000000..3543c26
--- /dev/null
+++ b/tests/phpunit/includes/UserManglerTest.php
@@ -0,0 +1,238 @@
+<?php
+
+/**
+ * @group Database
+ * @covers UserMangler
+ */
+class UserManglerTest extends MediaWikiTestCase {
+       public function setUp() {
+               parent::setUp();
+               $this->setMwGlobals( array(
+                       'wgGroupPermissions' => array(
+                               'testcapture' => array( 'passwordreset' => true 
),
+                       ),
+                       'wgPasswordReminderResendTime' => false,
+                       'wgRateLimits' => array(),
+                       'wgHooks' => array(),
+               ) );
+       }
+
+       public function testCanResetPassword() {
+               $config = new HashConfig( array() );
+               $context = new RequestContext;
+               $context->setConfig( new MultiConfig( array(
+                       $config,
+                       $context->getConfig(),
+               ) ) );
+
+               $config->set( 'PasswordResetRoutes', array( 'username' => 
false, 'email' => false ) );
+               $status = UserMangler::canResetPassword( $context );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'passwordreset-disabled', 
$status->errors[0]['message'] );
+
+               $config->set( 'PasswordResetRoutes', array( 'username' => true, 
'email' => true ) );
+               $config->set( 'EnableEmail', false );
+               $status = UserMangler::canResetPassword( $context );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'passwordreset-emaildisabled', 
$status->errors[0]['message'] );
+       }
+
+       public function testResetPassword() {
+               global $wgHooks;
+
+               $wgHooks['SpecialPasswordResetOnSubmit'] = array();
+
+               $config = new HashConfig( array(
+                       'PasswordResetRoutes' => array( 'username' => true, 
'email' => true ),
+               ) );
+               $context = new RequestContext;
+               $context->setConfig( new MultiConfig( array(
+                       $config,
+                       $context->getConfig(),
+               ) ) );
+
+               $user = User::newFromName( 'ResetPasswordUnitTestUser' );
+               $user->loadDefaults( 'ResetPasswordUnitTestUser' );
+               if ( !$user->getId() ) {
+                       $user->addToDatabase();
+               }
+               $user->removeGroup( 'testcapture' );
+               $user->setEmail( '' );
+               $user->saveSettings();
+               $context->setUser( $user );
+
+               $status = UserMangler::resetPassword( $context, array( 
'username' => '' ), false );
+               $this->assertFalse( $status );
+
+               $status = UserMangler::resetPassword( $context, array( 
'username' => $user->getName() ), true );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'badaccess-groups', 
$status->errors[0]['message'] );
+
+               $status = UserMangler::resetPassword( $context, array( 
'username' => 'Invalid>Name' ) );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'nosuchuser', $status->errors[0]['message'] 
);
+
+               $i = 0;
+               $db = wfGetDB( DB_SLAVE );
+               do {
+                       $i++;
+                       $email = "example$i@example.invalid";
+               } while ( $db->selectRowCount( 'user', null, array( 
'user_email' => $email ) ) > 0 );
+               $status = UserMangler::resetPassword( $context, array( 'email' 
=> $email ) );
+               $this->assertTrue( $status->isGood() );
+
+               $status = UserMangler::resetPassword( $context, array( 
'username' => $user->getName() ) );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'noemail', $status->errors[0]['message'] );
+
+               $user->setEmail( 'example@example.invalid' );
+               $user->saveSettings();
+
+               $status = UserMangler::resetPassword( $context, array( 
'username' => $user->getName() ) );
+               $this->assertTrue( $status->isGood() );
+               $this->assertNull( $status->value->email );
+
+               $status = UserMangler::resetPassword( $context, array( 'email' 
=> $user->getEmail() ) );
+               $this->assertTrue( $status->isGood() );
+               $this->assertNull( $status->value->email );
+
+               $config->set( 'PasswordResetRoutes', array( 'username' => 
false, 'email' => true ) );
+               $status = UserMangler::resetPassword( $context, array( 
'username' => $user->getName() ) );
+               $this->assertFalse( $status );
+
+               $config->set( 'PasswordResetRoutes', array( 'username' => true, 
'email' => false ) );
+               $status = UserMangler::resetPassword( $context, array( 'email' 
=> $user->getEmail() ) );
+               $this->assertFalse( $status );
+
+               $config->set( 'PasswordResetRoutes', array( 'username' => true, 
'email' => true ) );
+
+               $user->addGroup( 'testcapture' );
+
+               $status = UserMangler::resetPassword( $context, array( 
'username' => $user->getName() ) );
+               $this->assertTrue( $status->isGood() );
+               $this->assertNull( $status->value->email );
+
+               $status = UserMangler::resetPassword( $context, array( 
'username' => $user->getName() ), true );
+               $this->assertTrue( $status->isGood() );
+               $this->assertNotNull( $status->value->email );
+
+               $wgHooks['SpecialPasswordResetOnSubmit'] = array( function ( 
&$users, $hookData, &$error ) {
+                       $error = array( 'hook aborted' );
+                       return false;
+               } );
+               $status = UserMangler::resetPassword( $context, array( 
'username' => $user->getName() ) );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'hook aborted', 
$status->errors[0]['message'] );
+       }
+
+       public function testChangePassword() {
+               global $wgHooks;
+
+               $context = new RequestContext;
+
+               $userAnon = $this->getMockBuilder( 'User' )
+                       ->getMock();
+               $userAnon->method( 'getName' )->willReturn( '192.0.2.1' );
+               $userAnon->method( 'isAnon' )->willReturn( true );
+
+               $user = $this->getMockBuilder( 'User' )
+                       ->getMock();
+               $user->method( 'getName' )->willReturn( 
'ResetPasswordUnitTestUser' );
+               $user->method( 'isAnon' )->willReturn( false );
+               $user->method( 'checkPassword' )->will( $this->returnCallback( 
function ( $p ) {
+                       return $p === 'old';
+               } ) );
+               $user->expects( $this->atLeastOnce() )->method( 'setCookies' );
+
+               $user2 = $this->getMockBuilder( 'User' )
+                       ->getMock();
+               $user2->method( 'getName' )->willReturn( 
'ResetPasswordUnitTestUser2' );
+               $user2->method( 'isAnon' )->willReturn( false );
+               $user2->method( 'checkPassword' )->will( $this->returnCallback( 
function ( $p ) {
+                       return $p === 'old';
+               } ) );
+               $user2->expects( $this->never() )->method( 'setCookies' );
+
+               $context->setUser( $user );
+               $mangler = new UserMangler( $userAnon, $context );
+               $status = $mangler->changePassword( 'old', 'new', 'new' );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'nosuchusershort', 
$status->errors[0]['message'] );
+
+               $mangler = new UserMangler( $user, $context );
+               $status = $mangler->changePassword( 'old', 'new', 'new2' );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'badretype', $status->errors[0]['message'] );
+
+               $status = $mangler->changePassword( 'wrong', 'new', 'new' );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'resetpass-wrong-oldpass', 
$status->errors[0]['message'] );
+
+               $status = $mangler->changePassword( 'old', 'old', 'old' );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'resetpass-recycled', 
$status->errors[0]['message'] );
+
+               $status = $mangler->changePassword( 'old', 'new', 'new' );
+               $this->assertTrue( $status->isGood() );
+
+               $mangler = new UserMangler( $user2, $context );
+               $status = $mangler->changePassword( 'old', 'new', 'new' );
+               $this->assertTrue( $status->isGood() );
+
+               $user2->method( 'setPassword' )->will( $this->throwException( 
new PasswordError( 'error!' ) ) );
+               $status = $mangler->changePassword( 'old', 'new', 'new' );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( array( 'error!' ), 
$status->errors[0]['message']->getParams() );
+       }
+
+       public function testChangeEmail() {
+               global $wgHooks;
+
+               $config = new HashConfig( array(
+                       'RequirePasswordforEmailChange' => true,
+               ) );
+               $context = new RequestContext;
+               $context->setConfig( new MultiConfig( array(
+                       $config,
+                       $context->getConfig(),
+               ) ) );
+
+               $userAnon = $this->getMockBuilder( 'User' )
+                       ->getMock();
+               $userAnon->method( 'getName' )->willReturn( '192.0.2.1' );
+               $userAnon->method( 'isAnon' )->willReturn( true );
+
+               $user = $this->getMockBuilder( 'User' )
+                       ->getMock();
+               $user->method( 'getName' )->willReturn( 
'ResetPasswordUnitTestUser' );
+               $user->method( 'isAnon' )->willReturn( false );
+               $user->method( 'checkPassword' )->will( $this->returnCallback( 
function ( $p ) {
+                       return $p === 'old';
+               } ) );
+               $user->method( 'setEmailWithConfirmation' )->willReturn( 
Status::newGood() );
+
+               $context->setUser( $user );
+               $mangler = new UserMangler( $userAnon, $context );
+               $status = $mangler->changeEmail( 'old', 
'example@example.invalid' );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'nosuchusershort', 
$status->errors[0]['message'] );
+
+               $context->setUser( $user );
+               $mangler = new UserMangler( $user, $context );
+               $status = $mangler->changeEmail( 'old', 'example.invalid' );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'invalidemailaddress', 
$status->errors[0]['message'] );
+
+               $status = $mangler->changeEmail( 'wrong', 
'example@example.invalid' );
+               $this->assertFalse( $status->isOk() );
+               $this->assertSame( 'wrongpassword', 
$status->errors[0]['message'] );
+
+               $status = $mangler->changeEmail( 'old', 
'example@example.invalid' );
+               $this->assertTrue( $status->isGood() );
+
+               $config->set( 'RequirePasswordforEmailChange', false );
+               $status = $mangler->changeEmail( 'wrong', 
'example@example.invalid' );
+               $this->assertTrue( $status->isGood() );
+       }
+
+}

-- 
To view, visit https://gerrit.wikimedia.org/r/187840
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I5e8c57623cf8db7b285f157a7c8ff68f10e622ab
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/core
Gerrit-Branch: master
Gerrit-Owner: Anomie <bjor...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to