Move CSRF token handling into MediaWiki\Session\Session

User keeps most of its token-related methods because anon edit tokens
are special. Login and createaccount tokens are completely moved.

Change-Id: I524218fab7e2d78fd24482ad364428e98dc48bdf
This commit is contained in:
Brad Jorsch 2015-09-22 10:33:24 -04:00
parent 36a87a8902
commit 94ba53f677
20 changed files with 484 additions and 124 deletions

View file

@ -81,6 +81,8 @@ production.
MediaWiki\Session\SessionProvider.
** The User cannot be loaded from session until after Setup.php completes.
Attempts to do so will be ignored and the User will remain unloaded.
** CSRF tokens may be fetched from the MediaWiki\Session\Session, which uses
the MediaWiki\Session\Token class.
* MediaWiki will now auto-create users as necessary, removing the need for
extensions to do so. An 'autocreateaccount' right is added to allow
auto-creation when 'createaccount' is not granted to all users.
@ -88,6 +90,10 @@ production.
* Most cookie-handling methods in User are deprecated.
* $wgAllowAsyncCopyUploads and $CopyUploadAsyncTimeout were removed. This was an
experimental feature that has never worked.
* Login and createaccount tokens now vary by timestamp.
* LoginForm::getLoginToken() and LoginForm::getCreateaccountToken()
return a MediaWiki\Session\Token, and tokens must be checked using that
class's methods.
=== New features in 1.27 ===
* $wgDataCenterId and $wgDataCenterRoles where added, which will serve as

View file

@ -721,6 +721,7 @@ $wgAutoloadLocalClasses = array(
'LogFormatter' => __DIR__ . '/includes/logging/LogFormatter.php',
'LogPage' => __DIR__ . '/includes/logging/LogPage.php',
'LogPager' => __DIR__ . '/includes/logging/LogPager.php',
'LoggedOutEditToken' => __DIR__ . '/includes/user/LoggedOutEditToken.php',
'LoggedUpdateMaintenance' => __DIR__ . '/maintenance/Maintenance.php',
'LoginForm' => __DIR__ . '/includes/specials/SpecialUserlogin.php',
'LonelyPagesPage' => __DIR__ . '/includes/specials/SpecialLonelypages.php',
@ -797,6 +798,7 @@ $wgAutoloadLocalClasses = array(
'MediaWiki\\Session\\SessionManagerInterface' => __DIR__ . '/includes/session/SessionManagerInterface.php',
'MediaWiki\\Session\\SessionProvider' => __DIR__ . '/includes/session/SessionProvider.php',
'MediaWiki\\Session\\SessionProviderInterface' => __DIR__ . '/includes/session/SessionProviderInterface.php',
'MediaWiki\\Session\\Token' => __DIR__ . '/includes/session/Token.php',
'MediaWiki\\Session\\UserInfo' => __DIR__ . '/includes/session/UserInfo.php',
'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
'MediaWiki\\Tidy\\Html5Depurate' => __DIR__ . '/includes/tidy/Html5Depurate.php',

View file

@ -513,7 +513,8 @@ sites statistics information.
'ApiQueryTokensRegisterTypes': Use this hook to add additional token types to
action=query&meta=tokens. Note that most modules will probably be able to use
the 'csrf' token instead of creating their own token types.
&$salts: array( type => salt to pass to User::getEditToken() )
&$salts: array( type => salt to pass to User::getEditToken() or array of salt
and key to pass to Session::getToken() )
'APIQueryUsersTokens': DEPRECATED! Use ApiQueryTokensRegisterTypes instead.
Use this hook to add custom token to list=users. Every token has an action,

View file

@ -1260,11 +1260,10 @@ abstract class ApiBase extends ContextSource {
);
}
if ( $this->getUser()->matchEditToken(
$token,
$salts[$tokenType],
$this->getRequest()
) ) {
$tokenObj = ApiQueryTokens::getToken(
$this->getUser(), $this->getRequest()->getSession(), $salts[$tokenType]
);
if ( $tokenObj->match( $token ) ) {
return true;
}

View file

@ -32,21 +32,22 @@ class ApiCheckToken extends ApiBase {
$params = $this->extractRequestParams();
$token = $params['token'];
$maxage = $params['maxtokenage'];
$request = $this->getRequest();
$salts = ApiQueryTokens::getTokenTypeSalts();
$salt = $salts[$params['type']];
$res = array();
if ( $this->getUser()->matchEditToken( $token, $salt, $request, $maxage ) ) {
$tokenObj = ApiQueryTokens::getToken(
$this->getUser(), $this->getRequest()->getSession(), $salts[$params['type']]
);
if ( $tokenObj->match( $token, $maxage ) ) {
$res['result'] = 'valid';
} elseif ( $maxage !== null && $this->getUser()->matchEditToken( $token, $salt, $request ) ) {
} elseif ( $maxage !== null && $tokenObj->match( $token ) ) {
$res['result'] = 'expired';
} else {
$res['result'] = 'invalid';
}
$ts = User::getEditTokenTimestamp( $token );
$ts = MediaWiki\Session\Token::getTimestamp( $token );
if ( $ts !== null ) {
$mwts = new MWTimestamp();
$mwts->timestamp->setTimestamp( $ts );

View file

@ -149,8 +149,11 @@ class ApiCreateAccount extends ApiBase {
// Token was incorrect, so add it to result, but don't throw an exception
// since not having the correct token is part of the normal
// flow of events.
$result['token'] = LoginForm::getCreateaccountToken();
$result['token'] = LoginForm::getCreateaccountToken()->toString();
$result['result'] = 'NeedToken';
$this->setWarning( 'Fetching a token via action=createaccount is deprecated. ' .
'Use action=query&meta=tokens&type=createaccount instead.' );
$this->logFeatureUsage( 'action=createaccount&!token' );
} elseif ( !$status->isOK() ) {
// There was an error. Die now.
$this->dieStatus( $status );
@ -200,7 +203,11 @@ class ApiCreateAccount extends ApiBase {
ApiBase::PARAM_TYPE => 'password',
),
'domain' => null,
'token' => null,
'token' => array(
ApiBase::PARAM_TYPE => 'string',
ApiBase::PARAM_REQUIRED => false, // for BC
ApiBase::PARAM_HELP_MSG => array( 'api-help-param-token', 'createaccount' ),
),
'email' => array(
ApiBase::PARAM_TYPE => 'string',
ApiBase::PARAM_REQUIRED => $this->getConfig()->get( 'EmailConfirmToEdit' ),

View file

@ -84,12 +84,9 @@ class ApiLogin extends ApiBase {
// Check login token
$token = LoginForm::getLoginToken();
if ( !$token ) {
LoginForm::setLoginToken();
if ( $token->wasNew() || !$params['token'] ) {
$authRes = LoginForm::NEED_TOKEN;
} elseif ( !$params['token'] ) {
$authRes = LoginForm::NEED_TOKEN;
} elseif ( $token !== $params['token'] ) {
} elseif ( !$token->match( $params['token'] ) ) {
$authRes = LoginForm::WRONG_TOKEN;
}
@ -159,7 +156,10 @@ class ApiLogin extends ApiBase {
case LoginForm::NEED_TOKEN:
$result['result'] = 'NeedToken';
$result['token'] = LoginForm::getLoginToken();
$result['token'] = LoginForm::getLoginToken()->toString();
$this->setWarning( 'Fetching a token via action=login is deprecated. ' .
'Use action=query&meta=tokens&type=login instead.' );
$this->logFeatureUsage( 'action=login&!lgtoken' );
// @todo: See above about deprecation
$result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' );
@ -254,7 +254,11 @@ class ApiLogin extends ApiBase {
ApiBase::PARAM_TYPE => 'password',
),
'domain' => null,
'token' => null,
'token' => array(
ApiBase::PARAM_TYPE => 'string',
ApiBase::PARAM_REQUIRED => false, // for BC
ApiBase::PARAM_HELP_MSG => array( 'api-help-param-token', 'login' ),
),
);
}

View file

@ -44,16 +44,24 @@ class ApiQueryTokens extends ApiQueryBase {
return;
}
$user = $this->getUser();
$session = $this->getRequest()->getSession();
$salts = self::getTokenTypeSalts();
foreach ( $params['type'] as $type ) {
$salt = $salts[$type];
$val = $this->getUser()->getEditToken( $salt, $this->getRequest() );
$res[$type . 'token'] = $val;
$res[$type . 'token'] = self::getToken( $user, $session, $salts[$type] )->toString();
}
$this->getResult()->addValue( 'query', $this->getModuleName(), $res );
}
/**
* Get the salts for known token types
* @return (string|array)[] Returning a string will use that as the salt
* for User::getEditTokenObject() to fetch the token, which will give a
* LoggedOutEditToken (always "+\\") for anonymous users. Returning an
* array will use it as parameters to MediaWiki\\Session\\Session::getToken(),
* which will always return a full token even for anonymous users.
*/
public static function getTokenTypeSalts() {
static $salts = null;
if ( !$salts ) {
@ -63,6 +71,8 @@ class ApiQueryTokens extends ApiQueryBase {
'patrol' => 'patrol',
'rollback' => 'rollback',
'userrights' => 'userrights',
'login' => array( '', 'login' ),
'createaccount' => array( '', 'createaccount' ),
);
Hooks::run( 'ApiQueryTokensRegisterTypes', array( &$salts ) );
ksort( $salts );
@ -71,6 +81,27 @@ class ApiQueryTokens extends ApiQueryBase {
return $salts;
}
/**
* Get a token from a salt
* @param User $user
* @param MediaWiki\\Session\\Session $session
* @param string|array $salt A string will be used as the salt for
* User::getEditTokenObject() to fetch the token, which will give a
* LoggedOutEditToken (always "+\\") for anonymous users. An array will
* be used as parameters to MediaWiki\\Session\\Session::getToken(), which
* will always return a full token even for anonymous users. An array will
* also persist the session.
* @return MediaWiki\\Session\\Token
*/
public static function getToken( User $user, MediaWiki\Session\Session $session, $salt ) {
if ( is_array( $salt ) ) {
$session->persist();
return call_user_func_array( array( $session, 'getToken' ), $salt );
} else {
return $user->getEditTokenObject( $salt, $session->getRequest() );
}
}
public function getAllowedParams() {
return array(
'type' => array(
@ -90,6 +121,11 @@ class ApiQueryTokens extends ApiQueryBase {
);
}
public function isReadMode() {
// So login tokens can be fetched on private wikis
return false;
}
public function getCacheMode( $params ) {
return 'private';
}

View file

@ -81,7 +81,7 @@ class ApiTokens extends ApiBase {
foreach ( ApiQueryTokens::getTokenTypeSalts() as $name => $salt ) {
if ( !isset( $types[$name] ) ) {
$types[$name] = function () use ( $salt, $user, $request ) {
return $user->getEditToken( $salt, $request );
return ApiQueryTokens::getToken( $user, $request->getSession(), $salt )->toString();
};
}
}

View file

@ -314,6 +314,58 @@ final class Session implements \Countable, \Iterator {
}
}
/**
* Fetch a CSRF token from the session
*
* Note that this does not persist the session, which you'll probably want
* to do if you want the token to actually be useful.
*
* @param string|string[] $salt Token salt
* @param string $key Token key
* @return MediaWiki\\Session\\SessionToken
*/
public function getToken( $salt = '', $key = 'default' ) {
$new = false;
$secrets = $this->get( 'wsTokenSecrets' );
if ( !is_array( $secrets ) ) {
$secrets = array();
}
if ( isset( $secrets[$key] ) && is_string( $secrets[$key] ) ) {
$secret = $secrets[$key];
} else {
$secret = \MWCryptRand::generateHex( 32 );
$secrets[$key] = $secret;
$this->set( 'wsTokenSecrets', $secrets );
$new = true;
}
if ( is_array( $salt ) ) {
$salt = join( '|', $salt );
}
return new Token( $secret, (string)$salt, $new );
}
/**
* Remove a CSRF token from the session
*
* The next call to self::getToken() with $key will generate a new secret.
*
* @param string $key Token key
*/
public function resetToken( $key = 'default' ) {
$secrets = $this->get( 'wsTokenSecrets' );
if ( is_array( $secrets ) && isset( $secrets[$key] ) ) {
unset( $secrets[$key] );
$this->set( 'wsTokenSecrets', $secrets );
}
}
/**
* Remove all CSRF tokens from the session
*/
public function resetAllTokens() {
$this->remove( 'wsTokenSecrets' );
}
/**
* Delay automatic saving while multiple updates are being made
*

125
includes/session/Token.php Normal file
View file

@ -0,0 +1,125 @@
<?php
/**
* MediaWiki session token
*
* 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 Session
*/
namespace MediaWiki\Session;
/**
* Value object representing a CSRF token
*
* @ingroup Session
* @since 1.27
*/
class Token {
/** CSRF token suffix. Plus and terminal backslash are included to stop
* editing from certain broken proxies. */
const SUFFIX = '+\\';
private $secret = '';
private $salt = '';
private $new = false;
/**
* @param string $secret Token secret
* @param string $salt Token salt
* @param bool $new Whether the secret was newly-created
*/
public function __construct( $secret, $salt, $new = false ) {
$this->secret = $secret;
$this->salt = $salt;
$this->new = $new;
}
/**
* Decode the timestamp from a token string
*
* Does not validate the token beyond the syntactic checks necessary to
* be able to extract the timestamp.
*
* @param string $token
* @param int|null
*/
public static function getTimestamp( $token ) {
$suffixLen = strlen( self::SUFFIX );
$len = strlen( $token );
if ( $len <= 32 + $suffixLen ||
substr( $token, -$suffixLen ) !== self::SUFFIX ||
strspn( $token, '0123456789abcdef' ) + $suffixLen !== $len
) {
return null;
}
return hexdec( substr( $token, 32, -$suffixLen ) );
}
/**
* Get the string representation of the token at a timestamp
* @param int timestamp
* @return string
*/
protected function toStringAtTimestamp( $timestamp ) {
return hash_hmac( 'md5', $timestamp . $this->salt, $this->secret, false ) .
dechex( $timestamp ) .
self::SUFFIX;
}
/**
* Get the string representation of the token
* @return string
*/
public function toString() {
return $this->toStringAtTimestamp( wfTimestamp() );
}
public function __toString() {
return $this->toString();
}
/**
* Test if the token-string matches this token
* @param string $userToken
* @param int|null $maxAge Return false if $userToken is older than this many seconds
* @return bool
*/
public function match( $userToken, $maxAge = null ) {
$timestamp = self::getTimestamp( $userToken );
if ( $timestamp === null ) {
return false;
}
if ( $maxAge !== null && $timestamp < wfTimestamp() - $maxAge ) {
// Expired token
return false;
}
$sessionToken = $this->toStringAtTimestamp( $timestamp );
return hash_equals( $sessionToken, $userToken );
}
/**
* Indicate whether this token was just created
* @return bool
*/
public function wasNew() {
return $this->new;
}
}

View file

@ -111,13 +111,10 @@ class SpecialChangePassword extends FormSpecialPage {
);
if ( !$this->getUser()->isLoggedIn() ) {
if ( !LoginForm::getLoginToken() ) {
LoginForm::setLoginToken();
}
$fields['LoginOnChangeToken'] = array(
'type' => 'hidden',
'label' => 'Change Password Token',
'default' => LoginForm::getLoginToken(),
'default' => LoginForm::getLoginToken()->toString(),
);
}
@ -179,7 +176,7 @@ class SpecialChangePassword extends FormSpecialPage {
}
if ( !$this->getUser()->isLoggedIn()
&& $request->getVal( 'wpLoginOnChangeToken' ) !== LoginForm::getLoginToken()
&& !LoginForm::getLoginToken()->match( $request->getVal( 'wpLoginOnChangeToken' ) )
) {
// Potential CSRF (bug 62497)
return false;
@ -218,8 +215,8 @@ class SpecialChangePassword extends FormSpecialPage {
$this->getOutput()->returnToMain();
} else {
$request = $this->getRequest();
LoginForm::setLoginToken();
$token = LoginForm::getLoginToken();
LoginForm::clearLoginToken();
$token = LoginForm::getLoginToken()->toString();
$data = array(
'action' => 'submitlogin',
'wpName' => $this->mUserName,

View file

@ -531,9 +531,8 @@ class LoginForm extends SpecialPage {
}
# Request forgery checks.
if ( !self::getCreateaccountToken() ) {
self::setCreateaccountToken();
$token = self::getCreateaccountToken();
if ( $token->wasNew() ) {
return Status::newFatal( 'nocookiesfornew' );
}
@ -543,7 +542,7 @@ class LoginForm extends SpecialPage {
}
# Validate the createaccount token
if ( $this->mToken !== self::getCreateaccountToken() ) {
if ( !$token->match( $this->mToken ) ) {
return Status::newFatal( 'sessionfailure' );
}
@ -737,9 +736,8 @@ class LoginForm extends SpecialPage {
// but wrong-token attempts do.
// If the user doesn't have a login token yet, set one.
if ( !self::getLoginToken() ) {
self::setLoginToken();
$token = self::getLoginToken();
if ( $token->wasNew() ) {
return self::NEED_TOKEN;
}
// If the user didn't pass a login token, tell them we need one
@ -753,7 +751,7 @@ class LoginForm extends SpecialPage {
}
// Validate the login token
if ( $this->mToken !== self::getLoginToken() ) {
if ( !$token->match( $this->mToken ) ) {
return self::WRONG_TOKEN;
}
@ -1492,15 +1490,9 @@ class LoginForm extends SpecialPage {
$template->set( 'loggedinuser', $user->getName() );
if ( $this->mType == 'signup' ) {
if ( !self::getCreateaccountToken() ) {
self::setCreateaccountToken();
}
$template->set( 'token', self::getCreateaccountToken() );
$template->set( 'token', self::getCreateaccountToken()->toString() );
} else {
if ( !self::getLoginToken() ) {
self::setLoginToken();
}
$template->set( 'token', self::getLoginToken() );
$template->set( 'token', self::getLoginToken()->toString() );
}
# Prepare language selection links as needed
@ -1576,22 +1568,25 @@ class LoginForm extends SpecialPage {
/**
* Get the login token from the current session
* @return mixed
* @since 1.27 returns a MediaWiki\\Session\\Token instead of a string
* @return MediaWiki\\Session\\Token
*/
public static function getLoginToken() {
global $wgRequest;
return $wgRequest->getSessionData( 'wsLoginToken' );
return $wgRequest->getSession()->getToken( '', 'login' );
}
/**
* Randomly generate a new login token and attach it to the current session
* Formerly randomly generated a login token that would be returned by
* $this->getLoginToken().
*
* Since 1.27, this is a no-op. The token is generated as necessary by
* $this->getLoginToken().
*
* @deprecated since 1.27
*/
public static function setLoginToken() {
global $wgRequest;
// Generate a token directly instead of using $user->getEditToken()
// because the latter reuses wsEditToken in the session
$wgRequest->setSessionData( 'wsLoginToken', MWCryptRand::generateHex( 32 ) );
wfDeprecated( __METHOD__, '1.27' );
}
/**
@ -1599,24 +1594,30 @@ class LoginForm extends SpecialPage {
*/
public static function clearLoginToken() {
global $wgRequest;
$wgRequest->setSessionData( 'wsLoginToken', null );
$wgRequest->getSession()->resetToken( 'login' );
}
/**
* Get the createaccount token from the current session
* @return mixed
* @since 1.27 returns a MediaWiki\\Session\\Token instead of a string
* @return MediaWiki\\Session\\Token
*/
public static function getCreateaccountToken() {
global $wgRequest;
return $wgRequest->getSessionData( 'wsCreateaccountToken' );
return $wgRequest->getSession()->getToken( '', 'createaccount' );
}
/**
* Randomly generate a new createaccount token and attach it to the current session
* Formerly randomly generated a createaccount token that would be returned
* by $this->getCreateaccountToken().
*
* Since 1.27, this is a no-op. The token is generated as necessary by
* $this->getCreateaccountToken().
*
* @deprecated since 1.27
*/
public static function setCreateaccountToken() {
global $wgRequest;
$wgRequest->setSessionData( 'wsCreateaccountToken', MWCryptRand::generateHex( 32 ) );
wfDeprecated( __METHOD__, '1.27' );
}
/**
@ -1624,7 +1625,7 @@ class LoginForm extends SpecialPage {
*/
public static function clearCreateaccountToken() {
global $wgRequest;
$wgRequest->setSessionData( 'wsCreateaccountToken', null );
$wgRequest->getSession()->resetToken( 'createaccount' );
}
/**

View file

@ -0,0 +1,47 @@
<?php
/**
* MediaWiki edit token
*
* 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 Session
*/
use MediaWiki\Session\Token;
/**
* Value object representing a logged-out user's edit token
*
* This exists so that code generically dealing with MediaWiki\\Session\\Token
* (i.e. the API) doesn't have to have so many special cases for anon edit
* tokens.
*
* @since 1.27
*/
class LoggedOutEditToken extends MediaWiki\Session\Token {
public function __construct() {
parent::__construct( '', '', false );
}
protected function toStringAtTimestamp( $timestamp ) {
return self::SUFFIX;
}
public function match( $userToken, $maxAge = null ) {
return $userToken === self::SUFFIX;
}
}

View file

@ -24,9 +24,10 @@ use MediaWiki\Session\SessionManager;
/**
* String Some punctuation to prevent editing from broken text-mangling proxies.
* @deprecated since 1.27, use \\MediaWiki\\Session\\Token::SUFFIX
* @ingroup Constants
*/
define( 'EDIT_TOKEN_SUFFIX', '+\\' );
define( 'EDIT_TOKEN_SUFFIX', MediaWiki\Session\Token::SUFFIX );
/**
* The User object encapsulates all of the user-specific settings (user_id,
@ -47,6 +48,7 @@ class User implements IDBAccessObject {
/**
* Global constant made accessible as class constants so that autoloader
* magic can be used.
* @deprecated since 1.27, use \\MediaWiki\\Session\\Token::SUFFIX
*/
const EDIT_TOKEN_SUFFIX = EDIT_TOKEN_SUFFIX;
@ -4066,30 +4068,25 @@ class User implements IDBAccessObject {
}
/**
* Internal implementation for self::getEditToken() and
* self::matchEditToken().
* Initialize (if necessary) and return a session token value
* which can be used in edit forms to show that the user's
* login credentials aren't being hijacked with a foreign form
* submission.
*
* @param string|array $salt
* @param WebRequest $request
* @param string|int $timestamp
* @return string
* @since 1.27
* @param string|array $salt Array of Strings Optional function-specific data for hashing
* @param WebRequest|null $request WebRequest object to use or null to use $wgRequest
* @return MediaWiki\\Session\\Token The new edit token
*/
private function getEditTokenAtTimestamp( $salt, $request, $timestamp ) {
public function getEditTokenObject( $salt = '', $request = null ) {
if ( $this->isAnon() ) {
return self::EDIT_TOKEN_SUFFIX;
} else {
$token = $request->getSessionData( 'wsEditToken' );
if ( $token === null ) {
$token = MWCryptRand::generateHex( 32 );
$request->setSessionData( 'wsEditToken', $token );
}
if ( is_array( $salt ) ) {
$salt = implode( '|', $salt );
}
return hash_hmac( 'md5', $timestamp . $salt, $token, false ) .
dechex( $timestamp ) .
self::EDIT_TOKEN_SUFFIX;
return new LoggedOutEditToken();
}
if ( !$request ) {
$request = $this->getRequest();
}
return $request->getSession()->getToken( $salt );
}
/**
@ -4099,29 +4096,23 @@ class User implements IDBAccessObject {
* submission.
*
* @since 1.19
*
* @param string|array $salt Array of Strings Optional function-specific data for hashing
* @param WebRequest|null $request WebRequest object to use or null to use $wgRequest
* @return string The new edit token
*/
public function getEditToken( $salt = '', $request = null ) {
return $this->getEditTokenAtTimestamp(
$salt, $request ?: $this->getRequest(), wfTimestamp()
);
return $this->getEditTokenObject( $salt, $request )->toString();
}
/**
* Get the embedded timestamp from a token.
* @deprecated since 1.27, use \\MediaWiki\\Session\\Token::getTimestamp instead.
* @param string $val Input token
* @return int|null
*/
public static function getEditTokenTimestamp( $val ) {
$suffixLen = strlen( self::EDIT_TOKEN_SUFFIX );
if ( strlen( $val ) <= 32 + $suffixLen ) {
return null;
}
return hexdec( substr( $val, 32, -$suffixLen ) );
wfDeprecated( __METHOD__, '1.27' );
return MediaWiki\Session\Token::getTimestamp( $val );
}
/**
@ -4137,28 +4128,7 @@ class User implements IDBAccessObject {
* @return bool Whether the token matches
*/
public function matchEditToken( $val, $salt = '', $request = null, $maxage = null ) {
if ( $this->isAnon() ) {
return $val === self::EDIT_TOKEN_SUFFIX;
}
$timestamp = self::getEditTokenTimestamp( $val );
if ( $timestamp === null ) {
return false;
}
if ( $maxage !== null && $timestamp < wfTimestamp() - $maxage ) {
// Expired token
return false;
}
$sessionToken = $this->getEditTokenAtTimestamp(
$salt, $request ?: $this->getRequest(), $timestamp
);
if ( !hash_equals( $sessionToken, $val ) ) {
wfDebug( "User::matchEditToken: broken session data\n" );
}
return hash_equals( $sessionToken, $val );
return $this->getEditTokenObject( $salt, $request )->match( $val, $maxage );
}
/**

View file

@ -10,7 +10,6 @@
class ApiCreateAccountTest extends ApiTestCase {
protected function setUp() {
parent::setUp();
LoginForm::setCreateaccountToken();
$this->setMwGlobals( array( 'wgEnableEmail' => true ) );
}
@ -114,7 +113,7 @@ class ApiCreateAccountTest extends ApiTestCase {
public function testNoName() {
$this->doApiRequest( array(
'action' => 'createaccount',
'token' => LoginForm::getCreateaccountToken(),
'token' => LoginForm::getCreateaccountToken()->toString(),
'password' => 'password',
) );
}
@ -127,7 +126,7 @@ class ApiCreateAccountTest extends ApiTestCase {
$this->doApiRequest( array(
'action' => 'createaccount',
'name' => 'testName',
'token' => LoginForm::getCreateaccountToken(),
'token' => LoginForm::getCreateaccountToken()->toString(),
) );
}
@ -139,7 +138,7 @@ class ApiCreateAccountTest extends ApiTestCase {
$this->doApiRequest( array(
'action' => 'createaccount',
'name' => 'Apitestsysop',
'token' => LoginForm::getCreateaccountToken(),
'token' => LoginForm::getCreateaccountToken()->toString(),
'password' => 'password',
'email' => 'test@domain.test',
) );
@ -153,7 +152,7 @@ class ApiCreateAccountTest extends ApiTestCase {
$this->doApiRequest( array(
'action' => 'createaccount',
'name' => 'Test User',
'token' => LoginForm::getCreateaccountToken(),
'token' => LoginForm::getCreateaccountToken()->toString(),
'password' => 'password',
'email' => 'invalid',
) );

View file

@ -14,11 +14,11 @@ class ApiLoginTest extends ApiTestCase {
*/
public function testApiLoginNoName() {
$session = array(
'wsLoginToken' => 'foobar'
'wsTokenSecrets' => array( 'login' => 'foobar' ),
);
$data = $this->doApiRequest( array( 'action' => 'login',
'lgname' => '', 'lgpassword' => self::$users['sysop']->password,
'lgtoken' => 'foobar',
'lgtoken' => (string)( new MediaWiki\Session\Token( 'foobar', '' ) )
), $session );
$this->assertEquals( 'NoName', $data[0]['login']['result'] );
}

View file

@ -148,12 +148,12 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
if ( isset( $session['wsToken'] ) && $session['wsToken'] ) {
// @todo Why does this directly mess with the session? Fix that.
// add edit token to fake session
$session['wsEditToken'] = $session['wsToken'];
$session['wsTokenSecrets']['default'] = $session['wsToken'];
// add token to request parameters
$timestamp = wfTimestamp();
$params['token'] = hash_hmac( 'md5', $timestamp, $session['wsToken'] ) .
dechex( $timestamp ) .
User::EDIT_TOKEN_SUFFIX;
MediaWiki\Session\Token::SUFFIX;
return $this->doApiRequest( $params, $session, false, $user );
} else {

View file

@ -199,4 +199,51 @@ class SessionTest extends MediaWikiTestCase {
$this->assertTrue( $backend->dirty );
}
public function testTokens() {
$rc = new \ReflectionClass( 'MediaWiki\\Session\\Session' );
if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
$this->markTestSkipped(
'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
);
}
// Instead of actually constructing the Session, we use reflection to
// bypass the constructor and plug a mock SessionBackend into the
// private fields to avoid having to actually create a SessionBackend.
$backend = new DummySessionBackend;
$session = $rc->newInstanceWithoutConstructor();
$priv = \TestingAccessWrapper::newFromObject( $session );
$priv->backend = $backend;
$priv->index = 42;
$token = \TestingAccessWrapper::newFromObject( $session->getToken() );
$this->assertArrayHasKey( 'wsTokenSecrets', $backend->data );
$this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] );
$secret = $backend->data['wsTokenSecrets']['default'];
$this->assertSame( $secret, $token->secret );
$this->assertSame( '', $token->salt );
$this->assertTrue( $token->wasNew() );
$token = \TestingAccessWrapper::newFromObject( $session->getToken( 'foo' ) );
$this->assertSame( $secret, $token->secret );
$this->assertSame( 'foo', $token->salt );
$this->assertFalse( $token->wasNew() );
$backend->data['wsTokenSecrets']['secret'] = 'sekret';
$token = \TestingAccessWrapper::newFromObject(
$session->getToken( array( 'bar', 'baz' ), 'secret' )
);
$this->assertSame( 'sekret', $token->secret );
$this->assertSame( 'bar|baz', $token->salt );
$this->assertFalse( $token->wasNew() );
$session->resetToken( 'secret' );
$this->assertArrayHasKey( 'wsTokenSecrets', $backend->data );
$this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] );
$this->assertArrayNotHasKey( 'secret', $backend->data['wsTokenSecrets'] );
$session->resetAllTokens();
$this->assertArrayNotHasKey( 'wsTokenSecrets', $backend->data );
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace MediaWiki\Session;
use MediaWikiTestCase;
/**
* @group Session
* @covers MediaWiki\Session\Token
*/
class TokenTest extends MediaWikiTestCase {
public function testBasics() {
$token = $this->getMockBuilder( 'MediaWiki\\Session\\Token' )
->setMethods( array( 'toStringAtTimestamp' ) )
->setConstructorArgs( array( 'sekret', 'salty', true ) )
->getMock();
$token->expects( $this->any() )->method( 'toStringAtTimestamp' )
->will( $this->returnValue( 'faketoken+\\' ) );
$this->assertSame( 'faketoken+\\', $token->toString() );
$this->assertSame( 'faketoken+\\', (string)$token );
$this->assertTrue( $token->wasNew() );
$token = new Token( 'sekret', 'salty', false );
$this->assertFalse( $token->wasNew() );
}
public function testToStringAtTimestamp() {
$token = \TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) );
$this->assertSame(
'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\',
$token->toStringAtTimestamp( 1447362018 )
);
$this->assertSame(
'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\',
$token->toStringAtTimestamp( 1447362026 )
);
}
public function testGetTimestamp() {
$this->assertSame(
1447362018, Token::getTimestamp( 'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\' )
);
$this->assertSame(
1447362026, Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\' )
);
$this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) );
$this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be+\\' ) );
$this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9x76c224cfb400d43be5644fdea+\\' ) );
}
public function testMatch() {
$token = \TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) );
$test = $token->toStringAtTimestamp( time() - 10 );
$this->assertTrue( $token->match( $test ) );
$this->assertTrue( $token->match( $test, 12 ) );
$this->assertFalse( $token->match( $test, 8 ) );
$this->assertFalse( $token->match( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) );
}
}