Add AuthManager support for ReCaptcha, ReCaptchaNoCaptcha

Also remove references to "two words" from ReCaptcha labels.
The captcha image doesn't always contain two words.

Bug: T110302
Change-Id: I544656289480056152a1db195babb6dadf29bc71
This commit is contained in:
Gergő Tisza 2016-05-03 16:42:00 +00:00
parent 31c59374a4
commit 3e3b91b527
26 changed files with 577 additions and 63 deletions

View file

@ -425,7 +425,7 @@ class FancyCaptcha extends SimpleCaptcha {
/** @var CaptchaAuthenticationRequest $req */
$req =
AuthenticationRequest::getRequestByClass( $requests,
CaptchaAuthenticationRequest::class );
CaptchaAuthenticationRequest::class, true );
if ( !$req ) {
return;
}

View file

@ -81,7 +81,7 @@ class MathCaptcha extends SimpleCaptcha {
/** @var CaptchaAuthenticationRequest $req */
$req =
AuthenticationRequest::getRequestByClass( $requests,
CaptchaAuthenticationRequest::class );
CaptchaAuthenticationRequest::class, true );
if ( !$req ) {
return;
}

View file

@ -0,0 +1,51 @@
<?php
/**
* Creates a ReCaptcha widget. Does not return any data; handling the data submitted by the
* widget is callers' responsibility.
*/
class HTMLReCaptchaField extends HTMLFormField {
/** @var string Public key parameter to be passed to ReCaptcha. */
protected $key;
/** @var string Theme parameter to be passed to ReCaptcha. */
protected $theme;
/** @var bool Use secure connection to ReCaptcha. */
protected $secure;
/** @var string Error returned by ReCaptcha in the previous round. */
protected $error;
/**
* Parameters:
* - key: (string, required) ReCaptcha public key
* - theme: (string, required) ReCaptcha theme
* - secure: (bool) Use secure connection to ReCaptcha
* - error: (string) ReCaptcha error from previous round
* @param array $params
*/
public function __construct( array $params ) {
$params += [
'secure' => true,
'error' => null,
];
parent::__construct( $params );
$this->key = $params['key'];
$this->theme = $params['theme'];
$this->secure = $params['secure'];
$this->error = $params['error'];
}
public function getInputHTML( $value ) {
$attribs = $this->getAttributes( [ 'tabindex' ] ) + [ 'theme' => $this->theme ];
$js = 'var RecaptchaOptions = ' . Xml::encodeJsVar( $attribs );
$widget = recaptcha_get_html( $this->key, $this->error, $this->secure );
return Html::inlineScript( $js ) . $widget;
}
public function skipLoadData( $request ) {
return true;
}
}

View file

@ -0,0 +1,31 @@
<?php
/**
* Do not generate any input element, just accept a value. How that value gets submitted is someone
* else's responsibility.
*/
class HTMLSubmittedValueField extends HTMLFormField {
public function getTableRow( $value ) {
return '';
}
public function getDiv( $value ) {
return '';
}
public function getRaw( $value ) {
return '';
}
public function getInputHTML( $value ) {
return '';
}
public function canDisplayErrors() {
return false;
}
public function hasVisibleOutput() {
return false;
}
}

View file

@ -1,5 +1,7 @@
<?php
use MediaWiki\Auth\AuthenticationRequest;
class ReCaptcha extends SimpleCaptcha {
// used for recaptcha-edit, recaptcha-addurl, recaptcha-badlogin, recaptcha-createaccount,
// recaptcha-create, recaptcha-sendemail via getMessage()
@ -21,29 +23,29 @@ class ReCaptcha extends SimpleCaptcha {
[ 'theme' => $wgReCaptchaTheme, 'tabindex' => $tabIndex ]
);
return Html::inlineScript(
$js
) . recaptcha_get_html( $wgReCaptchaPublicKey, $this->recaptcha_error, $useHttps );
return Html::inlineScript( $js ) .
recaptcha_get_html( $wgReCaptchaPublicKey, $this->recaptcha_error, $useHttps );
}
function passCaptchaLimitedFromRequest( WebRequest $request, User $user ) {
// API is hardwired to return captchaId and captchaWord,
// so use that if the standard two are empty
$challenge = $request->getVal( 'recaptcha_challenge_field', $request->getVal( 'captchaId' ) );
$response = $request->getVal( 'recaptcha_response_field', $request->getVal( 'captchaWord' ) );
return $this->passCaptchaLimited( $challenge, $response, $user );
}
/**
* Calls the library function recaptcha_check_answer to verify the users input.
* Sets $this->recaptcha_error if the user is incorrect.
* @param string $challenge Challenge value
* @param string $response Response value
* @return boolean
*
*/
function passCaptcha( $_, $__ ) {
function passCaptcha( $challenge, $response ) {
global $wgReCaptchaPrivateKey, $wgRequest;
// API is hardwired to return wpCaptchaId and wpCaptchaWord,
// so use that if the standard two are empty
$challenge = $wgRequest->getVal(
'recaptcha_challenge_field', $wgRequest->getVal( 'wpCaptchaId' )
);
$response = $wgRequest->getVal(
'recaptcha_response_field', $wgRequest->getVal( 'wpCaptchaWord' )
);
if ( $response === null ) {
// new captcha session
return false;
@ -51,12 +53,8 @@ class ReCaptcha extends SimpleCaptcha {
$ip = $wgRequest->getIP();
$recaptcha_response = recaptcha_check_answer(
$wgReCaptchaPrivateKey,
$ip,
$challenge,
$response
);
$recaptcha_response =
recaptcha_check_answer( $wgReCaptchaPrivateKey, $ip, $challenge, $response );
if ( !$recaptcha_response->is_valid ) {
$this->recaptcha_error = $recaptcha_response->error;
@ -113,4 +111,73 @@ class ReCaptcha extends SimpleCaptcha {
return true;
}
public function getError() {
// do not treat failed captcha attempts as errors
if ( in_array( $this->recaptcha_error, [
'invalid-request-cookie', 'incorrect-captcha-sol',
], true ) ) {
return null;
}
return $this->recaptcha_error;
}
public function storeCaptcha( $info ) {
// ReCaptcha is stored by Google; the ID will be generated at that time as well, and
// the one returned here won't be used. Just pretend this worked.
return 'not used';
}
public function retrieveCaptcha( $index ) {
// just pretend it worked
return [ 'index' => $index ];
}
public function getCaptcha() {
// ReCaptcha is handled by frontend code + an external provider; nothing to do here.
return [];
}
public function getCaptchaInfo( $captchaData, $id ) {
return wfMessage( 'recaptcha-info' );
}
public function createAuthenticationRequest() {
return new ReCaptchaAuthenticationRequest();
}
public function onAuthChangeFormFields(
array $requests, array $fieldInfo, array &$formDescriptor, $action
) {
global $wgReCaptchaPublicKey, $wgReCaptchaTheme;
$req = AuthenticationRequest::getRequestByClass( $requests,
CaptchaAuthenticationRequest::class, true );
if ( !$req ) {
return;
}
// ugly way to retrieve error information
$captcha = ConfirmEditHooks::getInstance();
$formDescriptor['captchaInfo'] = [
'class' => HTMLReCaptchaField::class,
'key' => $wgReCaptchaPublicKey,
'theme' => $wgReCaptchaTheme,
'secure' => isset( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] === 'on',
'error' => $captcha->getError(),
] + $formDescriptor['captchaInfo'];
// the custom form element cannot return multiple fields; work around that by
// "redirecting" ReCaptcha names to standard names
$formDescriptor['captchaId'] = [
'class' => HTMLSubmittedValueField::class,
'name' => 'recaptcha_challenge_field',
];
$formDescriptor['captchaWord'] = [
'class' => HTMLSubmittedValueField::class,
'name' => 'recaptcha_response_field',
];
}
}

View file

@ -0,0 +1,38 @@
<?php
use MediaWiki\Auth\AuthenticationRequest;
/**
* Authentication request for ReCaptcha v1. Unlike the parent class, no session storage is used;
* that's handled by Google.
*/
class ReCaptchaAuthenticationRequest extends CaptchaAuthenticationRequest {
public function __construct() {
parent::__construct( null, null );
}
public function loadFromSubmission( array $data ) {
// unhack the hack in parent
return AuthenticationRequest::loadFromSubmission( $data );
}
public function getFieldInfo() {
$fieldInfo = parent::getFieldInfo();
if ( !$fieldInfo ) {
return false;
}
return array_merge( $fieldInfo, [
'captchaId' => [
'type' => 'string',
'label' => wfMessage( 'recaptcha-id-label' ),
'help' => wfMessage( 'recaptcha-id-help' ),
],
'captchaWord' => [
'type' => 'string',
'label' => wfMessage( 'recaptcha-label' ),
'help' => wfMessage( 'recaptcha-help' ),
],
] );
}
}

View file

@ -9,7 +9,10 @@
]
},
"AutoloadClasses": {
"ReCaptcha": "ReCaptcha.class.php"
"ReCaptcha": "ReCaptcha.class.php",
"HTMLReCaptchaField": "HTMLReCaptchaField.php",
"HTMLSubmittedValueField": "HTMLSubmittedValueField.php",
"ReCaptchaAuthenticationRequest": "ReCaptchaAuthenticationRequest.php"
},
"config": {
"CaptchaClass": "ReCaptcha",

View file

@ -3,12 +3,17 @@
"authors": []
},
"recaptcha-desc": "reCAPTCHA module for Confirm Edit",
"recaptcha-edit": "To protect the wiki against automated edit spam, we kindly ask you to type the two words you see in the box below:",
"recaptcha-addurl": "Your edit includes new external links. To protect the wiki against automated spam, we kindly ask you to type the two words you see in the box below:",
"recaptcha-badlogin": "To protect the wiki against automated password cracking, we kindly ask you to type the two words you see in the box below:",
"recaptcha-createaccount": "To protect the wiki against automated account creation, we kindly ask you to type the two words you see in the box below:",
"recaptcha-edit": "To protect the wiki against automated edit spam, we kindly ask you to type the words you see in the box below:",
"recaptcha-addurl": "Your edit includes new external links. To protect the wiki against automated spam, we kindly ask you to type the words you see in the box below:",
"recaptcha-badlogin": "To protect the wiki against automated password cracking, we kindly ask you to type the words you see in the box below:",
"recaptcha-createaccount": "To protect the wiki against automated account creation, we kindly ask you to type the words you see in the box below:",
"recaptcha-createaccount-fail": "Incorrect or missing reCAPTCHA answer.",
"recaptcha-create": "To protect the wiki against automated page creation, we kindly ask you to type the two words you see in the box below:",
"recaptcha-create": "To protect the wiki against automated page creation, we kindly ask you to type the words you see in the box below:",
"recaptcha-info": "Please solve a ReCaptcha challenge and return the challenge and response values as captchaId and captchaWord.",
"recaptcha-apihelp-param-recaptcha_challenge_field": "Field from the ReCaptcha widget",
"recaptcha-apihelp-param-recaptcha_response_field": "Field from the ReCaptcha widget"
"recaptcha-apihelp-param-recaptcha_response_field": "Field from the ReCaptcha widget",
"recaptcha-id-label": "ReCaptcha challenge",
"recaptcha-id-help": "ReCaptcha challenge value",
"recaptcha-label": "ReCaptcha solution",
"recaptcha-help": "ReCaptcha solution value"
}

View file

@ -13,6 +13,11 @@
"recaptcha-createaccount": "{{Related|ConfirmEdit-createaccount}}",
"recaptcha-createaccount-fail": "{{Related|ConfirmEdit-createaccount-fail}}",
"recaptcha-create": "{{Related|ConfirmEdit-create}}",
"recaptcha-info": "Explanation of how to solve the CAPTCHA for API clients.",
"recaptcha-apihelp-param-recaptcha_challenge_field": "{{doc-apihelp-param|description=the \"recaptcha_challenge_field\" parameter added by [[mw:Extension:ConfirmEdit]]|noseealso=1}}\nSee also {{msg-mw|recaptcha-apihelp-param-recaptcha_response_field}}",
"recaptcha-apihelp-param-recaptcha_response_field": "{{doc-apihelp-param|description=the \"recaptcha_response_field\" parameter added by [[mw:Extension:ConfirmEdit]]|noseealso=1}}\nSee also {{msg-mw|recaptcha-apihelp-param-recaptcha_challenge_field}}"
"recaptcha-apihelp-param-recaptcha_response_field": "{{doc-apihelp-param|description=the \"recaptcha_response_field\" parameter added by [[mw:Extension:ConfirmEdit]]|noseealso=1}}\nSee also {{msg-mw|recaptcha-apihelp-param-recaptcha_challenge_field}}",
"recaptcha-id-label": "API CAPTCHA challenge ID field label.",
"recaptcha-id-help": "API CAPTCHA challenge ID field help.",
"recaptcha-label": "API CAPTCHA solution ID field label.",
"recaptcha-help": "API CAPTCHA solution ID field help."
}

View file

@ -0,0 +1,74 @@
<?php
/**
* Creates a ReCaptcha v2 widget. Does not return any data; handling the data submitted by the
* widget is callers' responsibility.
*/
class HTMLReCaptchaNoCaptchaField extends HTMLFormField {
/** @var string Public key parameter to be passed to ReCaptcha. */
protected $key;
/** @var string Error returned by ReCaptcha in the previous round. */
protected $error;
/**
* Parameters:
* - key: (string, required) ReCaptcha public key
* - error: (string) ReCaptcha error from previous round
* @param array $params
*/
public function __construct( array $params ) {
$params += [ 'error' => null ];
parent::__construct( $params );
$this->key = $params['key'];
$this->error = $params['error'];
$this->mName = 'g-recaptcha-response';
}
public function getInputHTML( $value ) {
$out = $this->mParent->getOutput();
$lang = htmlspecialchars( urlencode( $this->mParent->getLanguage()->getCode() ) );
// Insert reCAPTCHA script, in display language, if available.
// Language falls back to the browser's display language.
// See https://developers.google.com/recaptcha/docs/faq
$out->addHeadItem(
'g-recaptchascript',
"<script src=\"https://www.google.com/recaptcha/api.js?hl={$lang}\" async defer></script>"
);
$output = Html::element( 'div', [
'class' => [
'g-recaptcha',
'mw-confirmedit-captcha-fail' => !!$this->error,
],
'data-sitekey' => $this->key,
] );
$htmlUrlencoded = htmlspecialchars( urlencode( $this->key ) );
$output .= <<<HTML
<noscript>
<div>
<div style="width: 302px; height: 422px; position: relative;">
<div style="width: 302px; height: 422px; position: absolute;">
<iframe src="https://www.google.com/recaptcha/api/fallback?k={$htmlUrlencoded}&hl={$lang}"
frameborder="0" scrolling="no"
style="width: 302px; height:422px; border-style: none;">
</iframe>
</div>
</div>
<div style="width: 300px; height: 60px; border-style: none;
bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;
background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;">
<textarea id="g-recaptcha-response" name="g-recaptcha-response"
class="g-recaptcha-response"
style="width: 250px; height: 40px; border: 1px solid #c1c1c1;
margin: 10px 25px; padding: 0px; resize: none;" >
</textarea>
</div>
</div>
</noscript>
HTML;
return $output;
}
}

View file

@ -1,4 +1,7 @@
<?php
use MediaWiki\Auth\AuthenticationRequest;
class ReCaptchaNoCaptcha extends SimpleCaptcha {
// used for renocaptcha-edit, renocaptcha-addurl, renocaptcha-badlogin, renocaptcha-createaccount,
// renocaptcha-create, renocaptcha-sendemail via getMessage()
@ -30,7 +33,7 @@ class ReCaptchaNoCaptcha extends SimpleCaptcha {
$htmlUrlencoded = htmlspecialchars( urlencode( $wgReCaptchaSiteKey ) );
$output .= <<<HTML
<noscript>
<div style="width: 302px; height: 422px;">
<div>
<div style="width: 302px; height: 422px; position: relative;">
<div style="width: 302px; height: 422px; position: absolute;">
<iframe src="https://www.google.com/recaptcha/api/fallback?k={$htmlUrlencoded}&hl={$lang}"
@ -38,15 +41,15 @@ class ReCaptchaNoCaptcha extends SimpleCaptcha {
style="width: 302px; height:422px; border-style: none;">
</iframe>
</div>
<div style="width: 300px; height: 60px; border-style: none;
bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;
background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;">
<textarea id="g-recaptcha-response" name="g-recaptcha-response"
class="g-recaptcha-response"
style="width: 250px; height: 40px; border: 1px solid #c1c1c1;
margin: 10px 25px; padding: 0px; resize: none;" >
</textarea>
</div>
</div>
<div style="width: 300px; height: 60px; border-style: none;
bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;
background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;">
<textarea id="g-recaptcha-response" name="g-recaptcha-response"
class="g-recaptcha-response"
style="width: 250px; height: 40px; border: 1px solid #c1c1c1;
margin: 10px 25px; padding: 0px; resize: none;" >
</textarea>
</div>
</div>
</noscript>
@ -67,22 +70,31 @@ HTML;
wfDebugLog( 'captcha', 'Unable to validate response: ' . $error );
}
public function passCaptchaLimitedFromRequest( WebRequest $request, User $user ) {
$index = 'not used'; // ReCaptchaNoCaptcha combines captcha ID + solution into a single value
// API is hardwired to return captchaWord, so use that if the standard isempty
$response = $request->getVal( 'g-recaptcha-response', $request->getVal( 'captchaWord' ) );
return $this->passCaptchaLimited( $index, $response, $user );
}
/**
* Check, if the user solved the captcha.
*
* Based on reference implementation:
* https://github.com/google/recaptcha#php
*
* @param $_ mixed Not used (ReCaptcha v2 puts index and solution in a single string)
* @param $word string captcha solution
* @return boolean
*/
function passCaptcha( $_, $__ ) {
function passCaptcha( $_, $word ) {
global $wgRequest, $wgReCaptchaSecretKey, $wgReCaptchaSendRemoteIP;
$url = 'https://www.google.com/recaptcha/api/siteverify';
// Build data to append to request
$data = [
'secret' => $wgReCaptchaSecretKey,
'response' => $wgRequest->getVal( 'g-recaptcha-response' ),
'response' => $word,
];
if ( $wgReCaptchaSendRemoteIP ) {
$data['remoteip'] = $wgRequest->getIP();
@ -164,4 +176,53 @@ HTML;
return true;
}
public function getError() {
return $this->error;
}
public function storeCaptcha( $info ) {
// ReCaptcha is stored by Google; the ID will be generated at that time as well, and
// the one returned here won't be used. Just pretend this worked.
return 'not used';
}
public function retrieveCaptcha( $index ) {
// just pretend it worked
return [ 'index' => $index ];
}
public function getCaptcha() {
// ReCaptcha is handled by frontend code + an external provider; nothing to do here.
return [];
}
public function getCaptchaInfo( $captchaData, $id ) {
return wfMessage( 'renocaptcha-info' );
}
public function createAuthenticationRequest() {
return new ReCaptchaNoCaptchaAuthenticationRequest();
}
public function onAuthChangeFormFields(
array $requests, array $fieldInfo, array &$formDescriptor, $action
) {
global $wgReCaptchaSiteKey;
$req = AuthenticationRequest::getRequestByClass( $requests,
CaptchaAuthenticationRequest::class, true );
if ( !$req ) {
return;
}
// ugly way to retrieve error information
$captcha = ConfirmEditHooks::getInstance();
$formDescriptor['captchaWord'] = [
'class' => HTMLReCaptchaNoCaptchaField::class,
'key' => $wgReCaptchaSiteKey,
'error' => $captcha->getError(),
] + $formDescriptor['captchaWord'];
}
}

View file

@ -0,0 +1,30 @@
<?php
use MediaWiki\Auth\AuthenticationRequest;
/**
* Authentication request for ReCaptcha v2. Unlike the parent class, no session storage is used
* and there is no ID; Google provides a single proof string after successfully solving a captcha.
*/
class ReCaptchaNoCaptchaAuthenticationRequest extends CaptchaAuthenticationRequest {
public function __construct() {
parent::__construct( null, null );
}
public function loadFromSubmission( array $data ) {
// unhack the hack in parent
return AuthenticationRequest::loadFromSubmission( $data );
}
public function getFieldInfo() {
$fieldInfo = parent::getFieldInfo();
return [
'captchaWord' => [
'type' => 'string',
'label' => $fieldInfo['captchaInfo']['label'],
'help' => wfMessage( 'renocaptcha-help' ),
],
];
}
}

View file

@ -6,7 +6,9 @@
]
},
"AutoloadClasses": {
"ReCaptchaNoCaptcha": "ReCaptchaNoCaptcha.class.php"
"ReCaptchaNoCaptcha": "ReCaptchaNoCaptcha.class.php",
"HTMLReCaptchaNoCaptchaField": "HTMLReCaptchaNoCaptchaField.php",
"ReCaptchaNoCaptchaAuthenticationRequest": "ReCaptchaNoCaptchaAuthenticationRequest.php"
},
"config": {
"CaptchaClass": "ReCaptchaNoCaptcha",

View file

@ -9,5 +9,6 @@
"renocaptcha-createaccount-fail": "It seems you haven't solved the CAPTCHA.",
"renocaptcha-create": "To protect the wiki against automated page creation, we kindly ask you to solve the following CAPTCHA:",
"renocaptcha-noscript": "Unhappily you have disabled JavaScript, so we can't recognize automatically, if you're a human or not. Please solve the CAPTCHA above and copy the resulting text into the following textarea:",
"renocaptcha-help": "Please solve a ReCaptcha NoCaptcha challenge and return the response value as captchaWord.",
"renocaptcha-apihelp-param-g-recaptcha-response": "Field from the ReCaptcha widget."
}

View file

@ -7,5 +7,6 @@
"renocaptcha-createaccount-fail": "Error message, when the CAPTCHA isn't solved correctly.",
"renocaptcha-create": "Message above the CAPTCHA for create (user creates a new page) action.",
"renocaptcha-noscript": "This messages is warning you have javascript disabled so you have to manualy input the text into the textbox.",
"renocaptcha-help": "Explanation of how to solve the CAPTCHA for API clients.",
"renocaptcha-apihelp-param-g-recaptcha-response": "{{doc-apihelp-param|description=the \"g-recaptcha-response\" parameter added by [[mw:Extension:ConfirmEdit]]|noseealso=1}}"
}

View file

@ -27,6 +27,15 @@ class SimpleCaptcha {
$this->trigger = $trigger;
}
/**
* Return the error from the last passCaptcha* call.
* Not implemented but needed by some child classes.
* @return
*/
public function getError() {
return null;
}
/**
* Returns an array with 'question' and 'answer' keys.
* Subclasses might use different structure.
@ -1279,6 +1288,15 @@ class SimpleCaptcha {
return true;
}
/**
* @return CaptchaAuthenticationRequest
*/
public function createAuthenticationRequest() {
$captchaData = $this->getCaptcha();
$id = $this->storeCaptcha( $captchaData );
return new CaptchaAuthenticationRequest( $id, $captchaData );
}
/**
* Modify the apprearance of the captcha field
* @param AuthenticationRequest[] $requests

View file

@ -19,6 +19,7 @@
"captcha-sendemail": "To protect the wiki against automated spamming, we kindly ask you to solve the simple sum below and enter the answer in the box ([[Special:Captcha/help|more info]]):",
"captcha-sendemail-fail": "Incorrect or missing CAPTCHA.",
"captcha-disabledinapi": "This action requires a CAPTCHA, so it cannot be performed through the API.",
"captcha-error": "CAPTCHA verification failed due to internal error: $1",
"captchahelp-title": "CAPTCHA help",
"captchahelp-cookies-needed": "You will need to have cookies enabled in your browser for this to work.",
"captchahelp-text": "Web sites that accept postings from the public, like this wiki, are often abused by spammers who use automated tools to post their links to many sites.\nWhile these spam links can be removed, they are a significant nuisance.\n\nSometimes, especially when adding new web links to a page, the wiki may show you an image of colored or distorted text and ask you to type the words shown.\nSince this is a task that's hard to automate, it will allow most real humans to make their posts while stopping most spammers and other robotic attackers.\n\nUnfortunately this may inconvenience users with limited vision or using text-based or speech-based browsers.\nAt the moment we do not have an audio alternative available.\nPlease contact the [[Special:ListAdmins|site administrators]] for assistance if this is unexpectedly preventing you from making legitimate actions.\n\nHit the \"back\" button in your browser to return to the page editor.",

View file

@ -31,6 +31,7 @@
"captcha-sendemail": "Used as footer text.\n{{Related|ConfirmEdit-sendemail}}",
"captcha-sendemail-fail": "Used as failure message.\n\nSee also:\n* {{msg-mw|Captcha-createaccount-fail}}",
"captcha-disabledinapi": "Used as error message when in the API mode.",
"captcha-error": "Error message shown when a CAPTCHA check failed for reasons the user cannot do anything about (e.g. the server could not contact the captcha provider). $1 is the error message.",
"captchahelp-title": "The page title of [[Special:Captcha/help]]",
"captchahelp-cookies-needed": "The page title for this message is {{msg-mw|Captchahelp-title}}.\n\nThis message follows the following help message:\n* {{msg-mw|Captchahelp-text}}.",
"captchahelp-text": "This is the help text shown on [[Special:Captcha/help]].",

View file

@ -25,12 +25,9 @@ class ConfirmEditHooks {
* Registers conditional hooks.
*/
public static function onRegistration() {
global $wgDisableAuthManager, $wgAuthManagerAutoConfig, $wgCaptchaClass;
global $wgDisableAuthManager, $wgAuthManagerAutoConfig;
$supportsAuthManager = in_array( $wgCaptchaClass, [ SimpleCaptcha::class,
QuestyCaptcha::class, MathCaptcha::class, FancyCaptcha::class ], true );
if ( class_exists( AuthManager::class ) && !$wgDisableAuthManager && $supportsAuthManager ) {
if ( class_exists( AuthManager::class ) && !$wgDisableAuthManager ) {
$wgAuthManagerAutoConfig['preauth'][CaptchaPreAuthenticationProvider::class] = [
'class' => CaptchaPreAuthenticationProvider::class,
'sort'=> 10, // run after preauth providers not requiring user input

View file

@ -3,6 +3,10 @@
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthManager;
/**
* Generic captcha authentication request class. A captcha consist some data stored in the session
* (e.g. a question and its answer), an ID that references the data, and a solution.
*/
class CaptchaAuthenticationRequest extends AuthenticationRequest {
/** @var string Identifier of the captcha. Used internally to remember which captcha was used. */
public $captchaId;

View file

@ -57,7 +57,7 @@ class CaptchaPreAuthenticationProvider extends AbstractPreAuthenticationProvider
}
if ( $needed ) {
return [ $this->createRequest( $captcha, $action ) ];
return [ $captcha->createAuthenticationRequest() ];
} else {
return [];
}
@ -88,7 +88,7 @@ class CaptchaPreAuthenticationProvider extends AbstractPreAuthenticationProvider
// Make brute force attacks harder by not telling whether the password or the
// captcha failed.
return $success ? Status::newGood() : Status::newFatal( 'wrongpassword' );
return $success ? Status::newGood() : $this->makeError( 'wrongpassword', $captcha );
}
public function testForAccountCreation( $user, $creator, array $reqs ) {
@ -104,7 +104,9 @@ class CaptchaPreAuthenticationProvider extends AbstractPreAuthenticationProvider
'type' => 'accountcreation',
'successful' => $success,
] );
return $success ? Status::newGood() : Status::newFatal( 'captcha-createaccount-fail' );
if ( !$success ) {
return $this->makeError( 'captcha-createaccount-fail', $captcha );
}
}
return Status::newGood();
}
@ -124,17 +126,6 @@ class CaptchaPreAuthenticationProvider extends AbstractPreAuthenticationProvider
}
}
/**
* @param SimpleCaptcha $captcha
* @param string $action One of the AuthManager::ACTION_* constants.
* @return CaptchaAuthenticationRequest
*/
protected function createRequest( SimpleCaptcha $captcha, $action ) {
$captchaData = $captcha->getCaptcha();
$id = $captcha->storeCaptcha( $captchaData );
return new CaptchaAuthenticationRequest( $id, $captchaData );
}
/**
* Verify submitted captcha.
* Assumes that the user has to pass the capctha (permission checks are caller's responsibility).
@ -145,10 +136,24 @@ class CaptchaPreAuthenticationProvider extends AbstractPreAuthenticationProvider
*/
protected function verifyCaptcha( SimpleCaptcha $captcha, array $reqs, User $user ) {
/** @var CaptchaAuthenticationRequest $req */
$req = AuthenticationRequest::getRequestByClass( $reqs, CaptchaAuthenticationRequest::class );
$req = AuthenticationRequest::getRequestByClass( $reqs,
CaptchaAuthenticationRequest::class, true );
if ( !$req ) {
return false;
}
return $captcha->passCaptchaLimited( $req->captchaId, $req->captchaWord, $user );
}
/**
* @param string $message Message key
* @param SimpleCaptcha $captcha
* @return Status
*/
protected function makeError( $message, SimpleCaptcha $captcha ) {
$error = $captcha->getError();
if ( $error ) {
return Status::newFatal( wfMessage( 'captcha-error', $error ) );
}
return Status::newFatal( $message );
}
}

View file

@ -0,0 +1,23 @@
<?php
require_once __DIR__ . '/../ReCaptcha/HTMLReCaptchaField.php';
class HTMLReCaptchaFieldTest extends PHPUnit_Framework_TestCase {
public function testSubmit() {
$form = new HTMLForm( [
'foo' => [
'class' => HTMLReCaptchaField::class,
'key' => '123',
'theme' => 'x',
],
] );
$mockClosure = $this->getMockBuilder( 'object' )->setMethods( [ '__invoke' ] )->getMock();
$mockClosure->expects( $this->once() )->method( '__invoke' )
->with( [] )->willReturn( true );
$form->setTitle( Title::newFromText( 'Title' ) );
$form->setSubmitCallback( $mockClosure );
$form->prepareForm();
$form->trySubmit();
}
}

View file

@ -0,0 +1,29 @@
<?php
require_once __DIR__ . '/../ReCaptchaNoCaptcha/HTMLReCaptchaNoCaptchaField.php';
class HTMLReCaptchaNoCaptchaFieldTest extends PHPUnit_Framework_TestCase {
public function testSubmit() {
$form = new HTMLForm( [
'foo' => [
'class' => HTMLReCaptchaNoCaptchaField::class,
'key' => '123',
],
] );
$request = new FauxRequest( [
'foo' => 'abc',
'g-recaptcha-response' => 'def',
], true );
$mockClosure = $this->getMockBuilder( 'object' )->setMethods( [ '__invoke' ] )->getMock();
$mockClosure->expects( $this->once() )->method( '__invoke' )
->with( [ 'foo' => 'def' ] )->willReturn( true );
$context = new DerivativeContext( RequestContext::getMain() );
$context->setRequest( $request );
$form->setTitle( Title::newFromText( 'Title' ) );
$form->setContext( $context );
$form->setSubmitCallback( $mockClosure );
$form->prepareForm();
$form->trySubmit();
}
}

View file

@ -0,0 +1,29 @@
<?php
require_once __DIR__ . '/../ReCaptcha/HTMLSubmittedValueField.php';
class HTMLSubmittedValueFieldTest extends PHPUnit_Framework_TestCase {
public function testSubmit() {
$form = new HTMLForm( [
'foo' => [
'class' => HTMLSubmittedValueField::class,
'name' => 'bar',
],
] );
$request = new FauxRequest( [
'foo' => '123',
'bar' => '456',
], true );
$mockClosure = $this->getMockBuilder( 'object' )->setMethods( [ '__invoke' ] )->getMock();
$mockClosure->expects( $this->once() )->method( '__invoke' )
->with( [ 'foo' => '456' ] )->willReturn( true );
$context = new DerivativeContext( RequestContext::getMain() );
$context->setRequest( $request );
$form->setTitle( Title::newFromText( 'Title' ) );
$form->setContext( $context );
$form->setSubmitCallback( $mockClosure );
$form->prepareForm();
$form->trySubmit();
}
}

View file

@ -0,0 +1,20 @@
<?php
use MediaWiki\Auth\AuthenticationRequestTestCase;
require_once __DIR__ . '/../ReCaptcha/ReCaptchaAuthenticationRequest.php';
class ReCaptchaAuthenticationRequestTest extends AuthenticationRequestTestCase {
protected function getInstance( array $args = [] ) {
return new ReCaptchaAuthenticationRequest();
}
public function provideLoadFromSubmission() {
return [
'no challange id' => [ [], [ 'captchaWord' => 'abc' ], false ],
'no solution' => [ [], [ 'captchaId' => '123' ], false ],
'normal' => [ [], [ 'captchaId' => '123', 'captchaWord' => 'abc' ],
[ 'captchaId' => '123', 'captchaWord' => 'abc' ] ],
];
}
}

View file

@ -0,0 +1,18 @@
<?php
use MediaWiki\Auth\AuthenticationRequestTestCase;
require_once __DIR__ . '/../ReCaptchaNoCaptcha/ReCaptchaNoCaptchaAuthenticationRequest.php';
class ReCaptchaNoCaptchaAuthenticationRequestTest extends AuthenticationRequestTestCase {
protected function getInstance( array $args = [] ) {
return new ReCaptchaNoCaptchaAuthenticationRequest();
}
public function provideLoadFromSubmission() {
return [
'no proof' => [ [], [], false ],
'normal' => [ [], [ 'captchaWord' => 'abc' ], [ 'captchaWord' => 'abc' ] ],
];
}
}