Support hCaptcha for VisualEditor

Extend Captcha save error handler to verify user using
hCaptcha.

Bug: T264684
Change-Id: I88928e291a2ecd6edd74af62575e8704c0a2ee13
(cherry picked from commit 0993a43a29)
This commit is contained in:
alistair3149 2021-03-25 11:13:51 -04:00 committed by Reedy
parent c6c0316abb
commit 7850e41f03
5 changed files with 186 additions and 17 deletions

View file

@ -9,7 +9,7 @@
"license-name": "GPL-2.0-or-later",
"type": "antispam",
"MessagesDirs": {
"hNoCaptcha": [
"HCaptcha": [
"i18n"
]
},
@ -37,5 +37,36 @@
"value": false
}
},
"ConfigRegistry": {
"hcaptcha": "GlobalVarConfig::newInstance"
},
"ResourceFileModulePaths": {
"localBasePath": "resources",
"remoteExtPath": "ConfirmEdit/hCaptcha/resources"
},
"ResourceModules": {
"ext.confirmEdit.hCaptcha.visualEditor": {
"scripts": "ve-confirmedit-hCaptcha/ve.init.mw.HCaptchaSaveErrorHandler.js",
"targets": [
"desktop",
"mobile"
]
}
},
"Hooks": {
"ResourceLoaderGetConfigVars": "resourceloader"
},
"HookHandlers": {
"resourceloader": {
"class": "MediaWiki\\Extensions\\ConfirmEdit\\hCaptcha\\Hooks\\ResourceLoaderHooks"
}
},
"attributes": {
"VisualEditor": {
"PluginModules": [
"ext.confirmEdit.hCaptcha.visualEditor"
]
}
},
"manifest_version": 2
}

View file

@ -5,12 +5,14 @@ namespace MediaWiki\Extensions\ConfirmEdit\hCaptcha;
use ApiBase;
use CaptchaAuthenticationRequest;
use ConfirmEditHooks;
use ContentSecurityPolicy;
use FormatJson;
use Html;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\MediaWikiServices;
use Message;
use RawMessage;
use RequestContext;
use SimpleCaptcha;
use Status;
use WebRequest;
@ -20,7 +22,17 @@ class HCaptcha extends SimpleCaptcha {
// hcaptcha-create, hcaptcha-sendemail via getMessage()
protected static $messagePrefix = 'hcaptcha-';
private $error;
private $error = null;
private $hCaptchaConfig;
private $siteKey;
public function __construct() {
$this->hCaptchaConfig = MediaWikiServices::getInstance()->getConfigFactory()
->makeConfig( 'hcaptcha' );
$this->siteKey = $this->hCaptchaConfig->get( 'HCaptchaSiteKey' );
}
/**
* Get the captcha form.
@ -28,14 +40,12 @@ class HCaptcha extends SimpleCaptcha {
* @return array
*/
public function getFormInformation( $tabIndex = 1 ) {
global $wgHCaptchaSiteKey;
$output = Html::element( 'div', [
'class' => [
'h-captcha',
'mw-confirmedit-captcha-fail' => (bool)$this->error,
],
'data-sitekey' => $wgHCaptchaSiteKey
'data-sitekey' => $this->siteKey
] );
return [
@ -53,6 +63,22 @@ class HCaptcha extends SimpleCaptcha {
return [ 'https://hcaptcha.com', 'https://*.hcaptcha.com' ];
}
/**
* Adds the CSP policies necessary for the captcha module to work in a CSP enforced
* setup.
*
* @param ContentSecurityPolicy $csp The CSP instance to add the policies to, usually
* obtained from {@link OutputPage::getCSP()}
*/
public static function addCSPSources( ContentSecurityPolicy $csp ) {
foreach ( static::getCSPUrls() as $src ) {
// Since frame-src is not supported
$csp->addDefaultSrc( $src );
$csp->addScriptSrc( $src );
$csp->addStyleSrc( $src );
}
}
/**
* @param Status|array|string $info
*/
@ -74,7 +100,10 @@ class HCaptcha extends SimpleCaptcha {
* @return array
*/
protected function getCaptchaParamsFromRequest( WebRequest $request ) {
$response = $request->getVal( 'h-captcha-response' );
$response = $request->getVal(
'h-captcha-response',
$request->getVal( 'captchaWord', $request->getVal( 'captchaword' ) )
);
return [ '', $response ];
}
@ -89,15 +118,19 @@ class HCaptcha extends SimpleCaptcha {
* @return bool
*/
protected function passCaptcha( $_, $token ) {
global $wgRequest, $wgHCaptchaSecretKey, $wgHCaptchaSendRemoteIP, $wgHCaptchaProxy;
$webRequest = RequestContext::getMain()->getRequest();
$secretKey = $this->hCaptchaConfig->get( 'HCaptchaSecretKey' );
$sendRemoteIp = $this->hCaptchaConfig->get( 'HCaptchaSendRemoteIP' );
$proxy = $this->hCaptchaConfig->get( 'HCaptchaProxy' );
$url = 'https://hcaptcha.com/siteverify';
$data = [
'secret' => $wgHCaptchaSecretKey,
'secret' => $secretKey,
'response' => $token,
];
if ( $wgHCaptchaSendRemoteIP ) {
$data['remoteip'] = $wgRequest->getIP();
if ( $sendRemoteIp ) {
$data['remoteip'] = $webRequest->getIP();
}
$options = [
@ -105,8 +138,8 @@ class HCaptcha extends SimpleCaptcha {
'postData' => $data,
];
if ( $wgHCaptchaProxy ) {
$options['proxy'] = $wgHCaptchaProxy;
if ( $proxy ) {
$options['proxy'] = $proxy;
}
$request = MediaWikiServices::getInstance()->getHttpRequestFactory()
@ -137,17 +170,18 @@ class HCaptcha extends SimpleCaptcha {
* @param array &$resultArr
*/
protected function addCaptchaAPI( &$resultArr ) {
$resultArr['captcha'] = $this->describeCaptchaType();
$resultArr['captcha']['error'] = $this->error;
}
/**
* @return array
*/
public function describeCaptchaType() {
global $wgHCaptchaSiteKey;
return [
'type' => 'hcaptcha',
'mime' => 'application/javascript',
'key' => $wgHCaptchaSiteKey,
'key' => $this->siteKey,
];
}
@ -187,6 +221,8 @@ class HCaptcha extends SimpleCaptcha {
* @inheritDoc
*/
public function storeCaptcha( $info ) {
// hCaptcha is stored externally, 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';
}
@ -202,6 +238,7 @@ class HCaptcha extends SimpleCaptcha {
* @inheritDoc
*/
public function getCaptcha() {
// hCaptcha is handled by frontend code + an external provider; nothing to do here.
return [];
}
@ -221,8 +258,6 @@ class HCaptcha extends SimpleCaptcha {
public function onAuthChangeFormFields(
array $requests, array $fieldInfo, array &$formDescriptor, $action
) {
global $wgHCaptchaSiteKey;
$req = AuthenticationRequest::getRequestByClass(
$requests,
CaptchaAuthenticationRequest::class,
@ -237,7 +272,7 @@ class HCaptcha extends SimpleCaptcha {
$formDescriptor['captchaWord'] = [
'class' => HTMLHCaptchaField::class,
'key' => $wgHCaptchaSiteKey,
'key' => $this->siteKey,
'error' => $captcha->getError(),
] + $formDescriptor['captchaWord'];
}

View file

@ -0,0 +1,29 @@
<?php
declare( strict_types=1 );
namespace MediaWiki\Extensions\ConfirmEdit\hCaptcha\Hooks;
use Config;
use MediaWiki\MediaWikiServices;
use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
class ResourceLoaderHooks implements ResourceLoaderGetConfigVarsHook {
/**
* Adds extra variables to the global config
*
* @param array &$vars Global variables object
* @param string $skin
* @param Config $config
* @return void
*/
public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ) : void {
$hCaptchaConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'hcaptcha' );
if ( $hCaptchaConfig->get( 'CaptchaClass' ) === 'MediaWiki\\Extensions\\ConfirmEdit\\hCaptcha\\HCaptcha' ) {
$vars['wgConfirmEditConfig'] = [
'hCaptchaSiteKey' => $hCaptchaConfig->get( 'HCaptchaSiteKey' ),
'hCaptchaScriptURL' => 'https://hcaptcha.com/1/api.js',
];
}
}
}

View file

@ -0,0 +1,6 @@
{
"globals": {
"ve": false,
"OO": false
}
}

View file

@ -0,0 +1,68 @@
mw.loader.using( 'ext.visualEditor.targetLoader' ).then( function () {
mw.libs.ve.targetLoader.addPlugin( function () {
ve.init.mw.HCaptchaSaveErrorHandler = function () {};
OO.inheritClass( ve.init.mw.HCaptchaSaveErrorHandler, ve.init.mw.SaveErrorHandler );
ve.init.mw.HCaptchaSaveErrorHandler.static.name = 'confirmEditHCaptcha';
ve.init.mw.HCaptchaSaveErrorHandler.static.getReadyPromise = function () {
var onLoadFn = 'onHcaptchaLoadCallback' + Date.now(),
deferred, config, scriptURL, params;
if ( !this.readyPromise ) {
deferred = $.Deferred();
config = mw.config.get( 'wgConfirmEditConfig' );
scriptURL = new mw.Uri( config.hCaptchaScriptURL );
params = { onload: onLoadFn, render: 'explicit' };
scriptURL.query = $.extend( scriptURL.query, params );
this.readyPromise = deferred.promise();
window[ onLoadFn ] = deferred.resolve;
mw.loader.load( scriptURL.toString() );
}
return this.readyPromise;
};
ve.init.mw.HCaptchaSaveErrorHandler.static.matchFunction = function ( data ) {
var captchaData = ve.getProp( data, 'visualeditoredit', 'edit', 'captcha' );
return !!( captchaData && captchaData.type === 'hcaptcha' );
};
ve.init.mw.HCaptchaSaveErrorHandler.static.process = function ( data, target ) {
var self = this,
config = mw.config.get( 'wgConfirmEditConfig' ),
siteKey = config.hCaptchaSiteKey,
$container = $( '<div>' );
// Register extra fields
target.saveFields.wpCaptchaWord = function () {
// eslint-disable-next-line no-jquery/no-global-selector
return $( '[name=h-captcha-response]' ).val();
};
this.getReadyPromise()
.then( function () {
// ProcessDialog's error system isn't great for this yet.
target.saveDialog.clearMessage( 'api-save-error' );
target.saveDialog.showMessage( 'api-save-error', $container, { wrap: false } );
self.widgetId = window.hcaptcha.render( $container[ 0 ], {
sitekey: siteKey,
callback: function () {
target.saveDialog.executeAction( 'save' );
},
'expired-callback': function () {},
'error-callback': function () {}
} );
target.saveDialog.popPending();
target.saveDialog.updateSize();
target.emit( 'saveErrorCaptcha' );
} );
};
ve.init.mw.saveErrorHandlerFactory.register( ve.init.mw.HCaptchaSaveErrorHandler );
} );
} );