Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Turnstile
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 15
650
0.00% covered (danger)
0.00%
0 / 1
 getFormInformation
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 getCSPUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logCheckError
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getCaptchaParamsFromRequest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 passCaptcha
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 addCaptchaAPI
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 describeCaptchaType
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 apiGetAllowedParams
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storeCaptcha
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 retrieveCaptcha
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCaptcha
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createAuthenticationRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAuthChangeFormFields
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\ConfirmEdit\Turnstile;
4
5use MediaWiki\Api\ApiBase;
6use MediaWiki\Auth\AuthenticationRequest;
7use MediaWiki\Extension\ConfirmEdit\Auth\CaptchaAuthenticationRequest;
8use MediaWiki\Extension\ConfirmEdit\Hooks;
9use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha;
10use MediaWiki\Html\Html;
11use MediaWiki\Json\FormatJson;
12use MediaWiki\Language\RawMessage;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Message\Message;
15use MediaWiki\Output\OutputPage;
16use MediaWiki\Request\WebRequest;
17use MediaWiki\Status\Status;
18use MediaWiki\User\UserIdentity;
19
20class Turnstile extends SimpleCaptcha {
21    /**
22     * @var string used for turnstile-edit, turnstile-addurl, turnstile-badlogin, turnstile-createaccount,
23     * turnstile-create, turnstile-sendemail via getMessage()
24     */
25    protected static $messagePrefix = 'turnstile';
26
27    /** @var string|null */
28    private $error = null;
29
30    /** @inheritDoc */
31    public function getFormInformation( $tabIndex = 1, ?OutputPage $out = null ) {
32        global $wgTurnstileSiteKey, $wgLang;
33        $lang = htmlspecialchars( urlencode( $wgLang->getCode() ) );
34
35        $output = Html::element( 'div', [
36            'class' => [
37                'cf-turnstile',
38                'mw-confirmedit-captcha-fail' => (bool)$this->error,
39            ],
40            'data-sitekey' => $wgTurnstileSiteKey
41        ] );
42        return [
43            'html' => $output,
44            'headitems' => [
45                // Insert the Turnstile script, in display language, if available.
46                // Language falls back to the browser's display language.
47                // See https://developers.cloudflare.com/turnstile/reference/supported-languages/
48                "<script src=\"https://challenges.cloudflare.com/turnstile/v0/api.js?language={$lang}\" async defer>
49                </script>"
50            ]
51        ];
52    }
53
54    /** @inheritDoc */
55    public static function getCSPUrls() {
56        return [ 'https://challenges.cloudflare.com/turnstile/v0/api.js' ];
57    }
58
59    /**
60     * @param Status|array|string $info
61     */
62    protected function logCheckError( $info ) {
63        if ( $info instanceof Status ) {
64            $errors = $info->getErrorsArray();
65            $error = $errors[0][0];
66        } elseif ( is_array( $info ) ) {
67            $error = implode( ',', $info );
68        } else {
69            $error = $info;
70        }
71
72        wfDebugLog( 'captcha', 'Unable to validate response: ' . $error );
73    }
74
75    /**
76     * @param WebRequest $request
77     * @return array
78     */
79    protected function getCaptchaParamsFromRequest( WebRequest $request ) {
80        // Turnstile combines captcha ID + solution into a single value
81        // API is hardwired to return captchaWord, so use that if the standard isempty
82        // "captchaWord" is sent as "captchaword" by visual editor
83        $index = 'not used';
84        $response = $request->getVal(
85            'cf-turnstile-response',
86            $request->getVal( 'captchaWord', $request->getVal( 'captchaword' ) )
87        );
88        return [ $index, $response ];
89    }
90
91    /**
92     * Check if the user solved the captcha.
93     *
94     * Based on reference implementation:
95     * https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
96     *
97     * @param mixed $_ Not used
98     * @param string $word captcha solution
99     * @param UserIdentity $user
100     * @return bool
101     */
102    protected function passCaptcha( $_, $word, $user ) {
103        global $wgRequest, $wgTurnstileSecretKey, $wgTurnstileSendRemoteIP;
104
105        $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
106        // Build data to append to request
107        $data = [
108            'secret' => $wgTurnstileSecretKey,
109            'response' => $word,
110        ];
111        if ( $wgTurnstileSendRemoteIP ) {
112            $data['remoteip'] = $wgRequest->getIP();
113        }
114        $request = MediaWikiServices::getInstance()->getHttpRequestFactory()
115            ->create( $url, [ 'method' => 'POST' ], __METHOD__ );
116        $request->setData( $data );
117        $status = $request->execute();
118        if ( !$status->isOK() ) {
119            $this->error = 'http';
120            $this->logCheckError( $status );
121            return false;
122        }
123        $response = FormatJson::decode( $request->getContent(), true );
124        if ( !$response ) {
125            $this->error = 'json';
126            $this->logCheckError( $this->error );
127            return false;
128        }
129        // Turnstile always returns the "error-codes" array, so we should just
130        // check whether it is empty or not.
131        if ( !empty( $response['error-codes'] ) ) {
132            $this->error = 'turnstile-api';
133            $this->logCheckError( $response['error-codes'] );
134            return false;
135        }
136
137        return $response['success'];
138    }
139
140    /** @inheritDoc */
141    protected function addCaptchaAPI( &$resultArr ) {
142        $resultArr['captcha'] = $this->describeCaptchaType( $this->action );
143        $resultArr['captcha']['error'] = $this->error;
144    }
145
146    /** @inheritDoc */
147    public function describeCaptchaType( ?string $action = null ) {
148        global $wgTurnstileSiteKey;
149        return [
150            'type' => 'turnstile',
151            'mime' => 'application/javascript',
152            'key' => $wgTurnstileSiteKey,
153        ];
154    }
155
156    /**
157     * Show a message asking the user to enter a captcha on edit
158     * The result will be treated as wiki text
159     *
160     * @param string $action Action being performed
161     * @return Message
162     */
163    public function getMessage( $action ) {
164        $msg = parent::getMessage( $action );
165        if ( $this->error ) {
166            $msg = new RawMessage( '<div class="error">$1</div>', [ $msg ] );
167        }
168        return $msg;
169    }
170
171    /**
172     * @param ApiBase $module
173     * @param array &$params
174     * @param int $flags
175     * @return bool
176     */
177    public function apiGetAllowedParams( ApiBase $module, &$params, $flags ) {
178        if ( $flags && $this->isAPICaptchaModule( $module ) ) {
179            $params['cf-turnstile-response'] = [
180                ApiBase::PARAM_HELP_MSG => 'turnstile-apihelp-param-cf-turnstile-response',
181            ];
182        }
183
184        return true;
185    }
186
187    /** @inheritDoc */
188    public function getError() {
189        return $this->error;
190    }
191
192    /** @inheritDoc */
193    public function storeCaptcha( $info ) {
194        return 'not used';
195    }
196
197    /** @inheritDoc */
198    public function retrieveCaptcha( $index ) {
199        // Pretend it worked
200        return [ 'index' => $index ];
201    }
202
203    /** @inheritDoc */
204    public function getCaptcha() {
205        // Turnstile is handled by frontend code + an external provider; nothing to do here.
206        return [];
207    }
208
209    /**
210     * @return TurnstileAuthenticationRequest
211     */
212    public function createAuthenticationRequest() {
213        return new TurnstileAuthenticationRequest();
214    }
215
216    /**
217     * @param array $requests
218     * @param array $fieldInfo
219     * @param array &$formDescriptor
220     * @param string $action
221     */
222    public function onAuthChangeFormFields(
223        array $requests, array $fieldInfo, array &$formDescriptor, $action
224    ) {
225        global $wgTurnstileSiteKey;
226
227        /** @var CaptchaAuthenticationRequest $req */
228        $req = AuthenticationRequest::getRequestByClass(
229            $requests,
230            CaptchaAuthenticationRequest::class,
231            true
232        );
233        if ( !$req ) {
234            return;
235        }
236
237        // ugly way to retrieve error information
238        $captcha = Hooks::getInstance( $req->getAction() );
239
240        $formDescriptor['captchaWord'] = [
241            'class' => HTMLTurnstileField::class,
242            'key' => $wgTurnstileSiteKey,
243            'error' => $captcha->getError(),
244        ] + $formDescriptor['captchaWord'];
245    }
246}