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