wiki.techinc.nl/includes/user/PasswordReset.php
Gergő Tisza 3617c982c9 Use AuthManager on special pages
Rewrite authentication-related special pages to use AuthManager.
All the changes mentioned below only take effect when
$wgDisableAuthManager is false.

LoginForm is rewritten to use HTMLForm and split into UserLogin
and CreateAccount; ChangePassword and PasswordReset are rewritten;
ChangeEmail and Preferences are updated. Four new special pages
are added to handle the new capabilities of AuthManager (linked
accounts, secondary authentication providers): LinkAccounts,
UnlinkAccounts, ChangeCredentials, RemoveCredentials.

The old form-based hooks (ChangePasswordForm, UserCreateForm,
UserLoginForm) are deprecated. A new, more generic hook is
available to alter the forms (AuthChangeFormFields);
form changes that involve new fields should be done via
$wgAuthManagerConfig.

UserLoginComplete is limited to web-based login; for more
generic functionality UserLoggedIn can be used instead.

Hooks that assume password-based login (PrefsPasswordAudit,
AbortChangePassword) are removed; the first functionality
is replaced by ChangeAuthenticationDataAudit, the second is
handled by AuthManager. LoginPasswordResetMessage is removed,
the functionality can be recreated via authentication providers.

There are several smaller backwards incompatible changes:
* Adding fields to the login/signup forms by manipulating the
  template via the extraInput/extrafields parameters is not
  supported anymore. Depending on the authn configuration the
  login/signup process might be multistep and it would be
  complicated to ensure that extensions can access the data
  at the right moment. Instead, you can create an
  AuthenticationProvider which can define its own fields and
  process them when the authentication is over.
  (There is B/C support for a transitional period that  works with
  the default login form, but might break with configurations that
  require multiple steps or redirects.)
* Removed cookie redirect check. This was added in 2003 in 9ead07fe9
  for the benefit of bots, but with MediaWiki having an API these days
  there is little reason to keep it. Same for the wpSkipCookieCheck
  flag (added in 2008 in 29c73e8265).
* Instead of embedding a password field on sensitive special pages
  such as ChangeEmail, such pages rely on AuthManager for elevated
  security (which typically involves requiring the user to log in again
  unless their last login was more than a few minutes ago).
  Accordingly, wgRequirePasswordforEmailChange is removed.
* Special:ChangePassword requires login now.
* Special:ResetPassword now sends a separate email to each user when called
  with a shared email address.
* the Reason field had a message with 'prefsectiontip' class
  which was sorta broken but used in extensions for formatting.
  HTMLForm does not support that, so this commit turns it into a help message
  which will break formatting. See https://gerrit.wikimedia.org/r/#/c/231884

Bug: T110277
Change-Id: I8b52ec8ddf494f23941807638f149f15b5e46b0c
Depends-On: If4e0dfb6ee6674f0dace80a01850e2d0cbbdb47a
2016-05-16 15:12:13 +00:00

257 lines
8.7 KiB
PHP

<?php
/**
* User password reset helper for MediaWiki.
*
* 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
*/
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
/**
* Helper class for the password reset functionality shared by the web UI and the API.
*
* Requires the TemporaryPasswordPrimaryAuthenticationProvider and the
* EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent
* functionality) to be enabled.
*/
class PasswordReset {
/** @var Config */
protected $config;
/** @var AuthManager */
protected $authManager;
/**
* In-process cache for isAllowed lookups, by username. Contains pairs of StatusValue objects
* (for false and true value of $displayPassword, respectively).
* @var HashBagOStuff
*/
private $permissionCache;
public function __construct( Config $config, AuthManager $authManager ) {
$this->config = $config;
$this->authManager = $authManager;
$this->permissionCache = new HashBagOStuff( [ 'maxKeys' => 1 ] );
}
/**
* Check if a given user has permission to use this functionality.
* @param User $user
* @param bool $displayPassword If set, also check whether the user is allowed to reset the
* password of another user and see the temporary password.
* @return StatusValue
*/
public function isAllowed( User $user, $displayPassword = false ) {
$statuses = $this->permissionCache->get( $user->getName() );
if ( $statuses ) {
list ( $status, $status2 ) = $statuses;
} else {
$resetRoutes = $this->config->get( 'PasswordResetRoutes' );
$status = StatusValue::newGood();
if ( !is_array( $resetRoutes ) ||
!in_array( true, array_values( $resetRoutes ), true )
) {
// Maybe password resets are disabled, or there are no allowable routes
$status = StatusValue::newFatal( 'passwordreset-disabled' );
} elseif (
( $providerStatus = $this->authManager->allowsAuthenticationDataChange(
new TemporaryPasswordAuthenticationRequest(), false ) )
&& !$providerStatus->isGood()
) {
// Maybe the external auth plugin won't allow local password changes
$status = StatusValue::newFatal( 'resetpass_forbidden-reason',
$providerStatus->getMessage() );
} elseif ( !$this->config->get( 'EnableEmail' ) ) {
// Maybe email features have been disabled
$status = StatusValue::newFatal( 'passwordreset-emaildisabled' );
} elseif ( !$user->isAllowed( 'editmyprivateinfo' ) ) {
// Maybe not all users have permission to change private data
$status = StatusValue::newFatal( 'badaccess' );
} elseif ( $user->isBlocked() ) {
// 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
$status = StatusValue::newFatal( 'blocked-mailpassword' );
}
$status2 = StatusValue::newGood();
if ( !$user->isAllowed( 'passwordreset' ) ) {
$status2 = StatusValue::newFatal( 'badaccess' );
}
$this->permissionCache->set( $user->getName(), [ $status, $status2 ] );
}
if ( !$displayPassword || !$status->isGood() ) {
return $status;
} else {
return $status2;
}
}
/**
* Do a password reset. Authorization is the caller's responsibility.
*
* 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 User $performingUser The user that does the password reset
* @param string $username The user whose password is reset
* @param string $email Alternative way to specify the user
* @param bool $displayPassword Whether to display the password
* @return StatusValue Will contain the passwords as a username => password array if the
* $displayPassword flag was set
* @throws LogicException When the user is not allowed to perform the action
* @throws MWException On unexpected DB errors
*/
public function execute(
User $performingUser, $username = null, $email = null, $displayPassword = false
) {
if ( !$this->isAllowed( $performingUser, $displayPassword )->isGood() ) {
$action = $this->isAllowed( $performingUser )->isGood() ? 'display' : 'reset';
throw new LogicException( 'User ' . $performingUser->getName()
. ' is not allowed to ' . $action . ' passwords' );
}
$resetRoutes = $this->config->get( 'PasswordResetRoutes' )
+ [ 'username' => false, 'email' => false ];
if ( $resetRoutes['username'] && $username ) {
$method = 'username';
$users = [ User::newFromName( $username ) ];
} elseif ( $resetRoutes['email'] && $email ) {
if ( !Sanitizer::validateEmail( $email ) ) {
return StatusValue::newFatal( 'passwordreset-invalidemail' );
}
$method = 'email';
$users = $this->getUsersByEmail( $email );
} else {
// The user didn't supply any data
return StatusValue::newFatal( 'passwordreset-nodata' );
}
// Check for hooks (captcha etc), and allow them to modify the users list
$error = [];
$data = [
'Username' => $username,
'Email' => $email,
'Capture' => $displayPassword ? '1' : null,
];
if ( !Hooks::run( 'SpecialPasswordResetOnSubmit', [ &$users, $data, &$error ] ) ) {
return StatusValue::newFatal( wfMessage( $error ) );
}
if ( !$users ) {
if ( $method === 'email' ) {
// Don't reveal whether or not an email address is in use
return StatusValue::newGood( [] );
} else {
return StatusValue::newFatal( 'noname' );
}
}
$firstUser = $users[0];
if ( !$firstUser instanceof User || !$firstUser->getId() ) {
// Don't parse username as wikitext (bug 65501)
return StatusValue::newFatal( wfMessage( 'nosuchuser', wfEscapeWikiText( $username ) ) );
}
// Check against the rate limiter
if ( $performingUser->pingLimiter( 'mailpassword' ) ) {
return StatusValue::newFatal( 'actionthrottledtext' );
}
// 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 StatusValue::newFatal( wfMessage( '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 = $performingUser->getRequest()->getIP();
if ( !$ip ) {
return StatusValue::newFatal( 'badipaddress' );
}
Hooks::run( 'User::mailPasswordInternal', [ &$performingUser, &$ip, &$firstUser ] );
$result = StatusValue::newGood();
$reqs = [];
foreach ( $users as $user ) {
$req = TemporaryPasswordAuthenticationRequest::newRandom();
$req->username = $user->getName();
$req->mailpassword = true;
$req->hasBackchannel = $displayPassword;
$req->caller = $performingUser->getName();
$status = $this->authManager->allowsAuthenticationDataChange( $req, true );
if ( $status->isGood() && $status->getValue() !== 'ignored' ) {
$reqs[] = $req;
} elseif ( $result->isGood() ) {
// only record the first error, to avoid exposing the number of users having the
// same email address
if ( $status->getValue() === 'ignored' ) {
$status = StatusValue::newFatal( 'passwordreset-ignored' );
}
$result->merge( $status );
}
}
if ( !$result->isGood() ) {
return $result;
}
$passwords = [];
foreach ( $reqs as $req ) {
$this->authManager->changeAuthenticationData( $req );
// TODO record mail sending errors
if ( $displayPassword ) {
$passwords[$req->username] = $req->password;
}
}
return StatusValue::newGood( $passwords );
}
/**
* @param string $email
* @return User[]
* @throws MWException On unexpected database errors
*/
protected function getUsersByEmail( $email ) {
$res = wfGetDB( DB_SLAVE )->select(
'user',
User::selectFields(),
[ 'user_email' => $email ],
__METHOD__
);
if ( !$res ) {
// Some sort of database error, probably unreachable
throw new MWException( 'Unknown database error in ' . __METHOD__ );
}
$users = [];
foreach ( $res as $row ) {
$users[] = User::newFromRow( $row );
}
return $users;
}
}