Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 515
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMWOAuth
0.00% covered (danger)
0.00%
0 / 515
0.00% covered (danger)
0.00%
0 / 17
9506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLocalName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 202
0.00% covered (danger)
0.00%
0 / 1
1640
 showCancelPage
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 outputJWT
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 handleAuthorizationForm
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 1
552
 redirectToREST
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 getRequestValidators
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
12
 getRequestedGrants
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 showError
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 returnToken
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 showResponse
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 useRealNames
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 determineOAuthVersion
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 assertOAuthVersion
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 makePrivacyLink
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\OAuth\Frontend\SpecialPages;
4
5/**
6 * (c) Chris Steipp, Aaron Schulz 2013, GPL
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation; either version 2 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License along
19 * with this program; if not, write to the Free Software Foundation, Inc.,
20 * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
21 * http://www.gnu.org/copyleft/gpl.html
22 */
23
24use Firebase\JWT\JWT;
25use FormatJson;
26use HTMLForm;
27use IContextSource;
28use MediaWiki\Extension\OAuth\Backend\Consumer;
29use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance;
30use MediaWiki\Extension\OAuth\Backend\MWOAuthException;
31use MediaWiki\Extension\OAuth\Backend\MWOAuthRequest;
32use MediaWiki\Extension\OAuth\Backend\MWOAuthToken;
33use MediaWiki\Extension\OAuth\Backend\Utils;
34use MediaWiki\Extension\OAuth\Control\ConsumerAcceptanceSubmitControl;
35use MediaWiki\Extension\OAuth\Control\ConsumerAccessControl;
36use MediaWiki\Extension\OAuth\Lib\OAuthException;
37use MediaWiki\Extension\OAuth\Lib\OAuthToken;
38use MediaWiki\Extension\OAuth\Lib\OAuthUtil;
39use MediaWiki\Extension\OAuth\UserStatementProvider;
40use MediaWiki\Html\Html;
41use MediaWiki\Linker\Linker;
42use MediaWiki\Logger\LoggerFactory;
43use MediaWiki\Permissions\GrantsLocalization;
44use MediaWiki\Request\WebRequest;
45use MediaWiki\SpecialPage\SpecialPage;
46use MediaWiki\SpecialPage\UnlistedSpecialPage;
47use MediaWiki\Status\Status;
48use MediaWiki\Title\Title;
49use MediaWiki\User\User;
50use MediaWiki\WikiMap\WikiMap;
51use Message;
52use MWException;
53use OOUI;
54use OOUI\HtmlSnippet;
55use Psr\Log\LoggerInterface;
56
57/**
58 * Page that handles OAuth consumer authorization and token exchange
59 */
60class SpecialMWOAuth extends UnlistedSpecialPage {
61    /** @var LoggerInterface */
62    protected $logger;
63
64    /** @var GrantsLocalization */
65    private $grantsLocalization;
66
67    /** @var int Defaults to OAuth1 */
68    protected $oauthVersion = Consumer::OAUTH_VERSION_1;
69
70    /**
71     * @param GrantsLocalization $grantsLocalization
72     */
73    public function __construct( GrantsLocalization $grantsLocalization ) {
74        parent::__construct( 'OAuth' );
75        $this->logger = LoggerFactory::getInstance( 'OAuth' );
76        $this->grantsLocalization = $grantsLocalization;
77    }
78
79    public function doesWrites() {
80        return true;
81    }
82
83    public function getLocalName() {
84        // Force the canonical name when OAuth headers are present,
85        // otherwise SpecialPageFactory redirects and breaks the signature.
86        if ( Utils::hasOAuthHeaders( $this->getRequest() ) ) {
87            return $this->getName();
88        }
89        return parent::getLocalName();
90    }
91
92    public function execute( $subpage ) {
93        $this->setHeaders();
94
95        $user = $this->getUser();
96        $request = $this->getRequest();
97
98        $output = $this->getOutput();
99        $output->disallowUserJs();
100
101        $config = $this->getConfig();
102
103        // 'raw' for plaintext, 'html' or 'json'.
104        // For the initiate and token endpoints, 'raw' also handles the formatting required by
105        // https://oauth.net/core/1.0a/#response_parameters
106        // Other than that, there is no reason to use anything but JSON, but the others are
107        // supported for B/C.
108        $format = $request->getVal( 'format', 'raw' );
109        '@phan-var string $format';
110
111        try {
112            if ( $config->get( 'MWOAuthReadOnly' ) &&
113                !in_array( $subpage, [ 'verified', 'grants', 'identify' ] )
114            ) {
115                throw new MWOAuthException( 'mwoauth-db-readonly' );
116            }
117
118            $this->determineOAuthVersion( $request );
119            switch ( $subpage ) {
120                // Return a request token (first leg of 3-legged OAuth 1 handshake).
121                case 'initiate':
122                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
123                    $oauthServer = Utils::newMWOAuthServer();
124                    $oauthRequest = MWOAuthRequest::fromRequest( $request );
125                    $this->logger->debug( __METHOD__ . ": Getting temporary credentials" );
126                    // fetch_request_token does the version, freshness, and sig checks
127                    $token = $oauthServer->fetch_request_token( $oauthRequest );
128                    $this->returnToken( $token, $format );
129                    break;
130
131                // Show an authorization dialog to the user where they can grant permissions
132                // to the app. Used in OAuth 2, by the oauth2/authorize REST endpoint.
133                case 'approve':
134                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_2 );
135                    $format = 'html';
136                    $clientId = $request->getVal( 'client_id', '' );
137                    $this->logger->debug( __METHOD__ . ": doing '$subpage' for OAuth2 with " .
138                        "client_id '$clientId' for '{$user->getName()}'" );
139                    if ( !$user->isNamed() ) {
140                        // Should not happen, as user login status will already be checked at this point
141                        // Just redirect back to REST, it will then redirect to login
142                        $this->redirectToREST();
143                        return;
144                    }
145                    if ( $request->wasPosted() && $request->getCheck( 'cancel' ) ) {
146                        $this->showCancelPage( $clientId );
147                    } else {
148                        $this->handleAuthorizationForm(
149                            null, $clientId, true
150                        );
151                    }
152
153                    break;
154
155                // Show an authorization dialog to the user where they can grant permissions
156                // to the app (second leg of 3-legged OAuth 1 handshake).
157                // 'authenticate' might skip user interaction if the grants are
158                // limited and the user already authorized in the past.
159                case 'authorize':
160                case 'authenticate':
161                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
162                    $format = 'html';
163
164                    $requestToken = $request->getVal( 'requestToken',
165                        $request->getVal( 'oauth_token' ) );
166                    $consumerKey = $request->getVal( 'consumerKey',
167                        $request->getVal( 'oauth_consumer_key' ) );
168                    $this->logger->debug( __METHOD__ . ": doing '$subpage' with " .
169                        "'$requestToken' '$consumerKey' for '{$user->getName()}'" );
170
171                    $this->requireNamedUser( 'mwoauth-named-account-required-reason' );
172
173                    // TODO? Test that $requestToken exists in memcache
174                    if ( $request->wasPosted() && $request->getCheck( 'cancel' ) ) {
175                        // Show acceptance cancellation confirmation
176                        $this->showCancelPage( $consumerKey );
177                    } else {
178                        // Show form and redirect on submission for authorization
179                        $this->handleAuthorizationForm(
180                            $requestToken, $consumerKey, $subpage === 'authenticate'
181                        );
182                    }
183                    break;
184
185                // Return an access token (third leg of 3-legged OAuth 1 handshake).
186                case 'token':
187                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
188                    $oauthServer = Utils::newMWOAuthServer();
189                    $oauthRequest = MWOAuthRequest::fromRequest( $request );
190
191                    $isRsa = $oauthRequest->get_parameter( "oauth_signature_method" ) === 'RSA-SHA1';
192
193                    // We want to use HTTPS when returning the credentials. But
194                    // for RSA we don't need to return a token secret, so HTTP is ok.
195                    if ( $config->get( 'MWOAuthSecureTokenTransfer' ) && !$isRsa
196                        && $request->detectProtocol() == 'http'
197                        && substr( wfExpandUrl( '/', PROTO_HTTPS ), 0, 8 ) === 'https://'
198                    ) {
199                        $redirUrl = str_replace(
200                            'http://', 'https://', $request->getFullRequestURL()
201                        );
202                        $output->redirect( $redirUrl );
203                        $output->addVaryHeader( 'X-Forwarded-Proto' );
204                        break;
205                    }
206
207                    $token = $oauthServer->fetch_access_token( $oauthRequest );
208                    if ( $isRsa ) {
209                        // RSA doesn't use the token secret, so don't return one.
210                        $token->secret = '__unused__';
211                    }
212                    $this->returnToken( $token, $format );
213                    break;
214
215                // Can be used as a return URL for non-web-based OAuth 1 applications which cannot
216                // provide their own return URL. It just displays the parameters that would
217                // normally be passed to the application via the return URL.
218                case 'verified':
219                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
220                    $format = 'html';
221                    $verifier = $request->getVal( 'oauth_verifier' );
222                    $requestToken = $request->getVal( 'oauth_token' );
223                    if ( !$verifier || !$requestToken ) {
224                        throw new MWOAuthException( 'mwoauth-bad-request-missing-params', [
225                            Message::rawParam( Linker::makeExternalLink(
226                                'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E001',
227                                'E001',
228                                true
229                            ) )
230                        ] );
231                    }
232                    $output->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() );
233                    $this->showResponse(
234                        $this->msg( 'mwoauth-verified',
235                            wfEscapeWikiText( $verifier ),
236                            wfEscapeWikiText( $requestToken )
237                        )->parse(),
238                        $format
239                    );
240                    break;
241
242                // Was used to list grants and their descriptions. With grants now in core,
243                // it just redirects to Special:ListGrants.
244                case 'grants':
245                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
246                    // Backwards compatibility
247                    $listGrants = SpecialPage::getTitleFor( 'ListGrants' );
248                    $output->redirect( $listGrants->getFullURL() );
249                    break;
250
251                // Return a JWT with the identity of the user, based on the OAuth signature.
252                // Our homegrown OIDC equivalent for OAuth 1.
253                case 'identify':
254                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
255                    // we only return JWT, so we assume json
256                    $format = 'json';
257                    $server = Utils::newMWOAuthServer();
258                    $oauthRequest = MWOAuthRequest::fromRequest( $request );
259                    // verify_request throws an exception if anything isn't verified
260                    [ $consumer, $token ] = $server->verify_request( $oauthRequest );
261                    /** @var Consumer $consumer */
262                    /** @var MWOAuthToken $token */
263
264                    $wiki = WikiMap::getCurrentWikiId();
265                    $dbr = Utils::getCentralDB( DB_REPLICA );
266                    $access = ConsumerAcceptance::newFromToken( $dbr, $token->key );
267                    $localUser = Utils::getLocalUserFromCentralId( $access->getUserId() );
268                    if ( !$localUser || !$localUser->isNamed() ) {
269                        throw new MWOAuthException( 'mwoauth-invalid-authorization-invalid-user', [
270                            Message::rawParam( Linker::makeExternalLink(
271                                'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008',
272                                'E008',
273                                true
274                            ) ),
275                            'consumer' => $consumer->getConsumerKey(),
276                            'consumer_name' => $consumer->getName(),
277                            'cmra_id' => $access->getId(),
278                        ] );
279                    } elseif ( $localUser->isLocked() ||
280                        ( $config->get( 'BlockDisablesLogin' ) && $localUser->getBlock() )
281                    ) {
282                        throw new MWOAuthException( 'mwoauth-invalid-authorization-blocked-user', [
283                            'consumer' => $consumer->getConsumerKey(),
284                            'consumer_name' => $consumer->getName(),
285                            'user_name' => $localUser->getName(),
286                        ] );
287                    }
288                    // Access token is for this wiki
289                    if ( $access->getWiki() !== '*' && $access->getWiki() !== $wiki ) {
290                        throw new MWOAuthException(
291                            'mwoauth-invalid-authorization-wrong-wiki',
292                            [
293                                'request_wiki' => $wiki,
294                                'consumer' => $consumer->getConsumerKey(),
295                                'consumer_name' => $consumer->getName(),
296                                'consumer_wiki' => $access->getWiki(),
297                            ]
298                        );
299                    } elseif ( !$consumer->isUsableBy( $localUser ) ) {
300                        throw new MWOAuthException( 'mwoauth-invalid-authorization-not-approved', [
301                            'consumer_name' => $consumer->getName(),
302                            'consumer' => $consumer->getConsumerKey(),
303                            'user_name' => $localUser->getName(),
304                        ] );
305                    }
306
307                    // We know the identity of the user who granted the authorization
308                    $this->outputJWT( $localUser, $consumer, $oauthRequest, $format, $access );
309                    break;
310
311                // Redirect to the REST API. A hack to reuse the OAuth 1 codebase and entry
312                // point for OAuth 2 workflows.
313                case 'rest_redirect':
314                    $query = $this->getRequest()->getQueryValues();
315                    if ( !array_key_exists( 'rest_url', $query ) ) {
316                        throw new OAuthException( 'Invalid redirect' );
317                    }
318                    $restUrl = $query['rest_url'];
319                    // make sure there's no way to change the domain
320                    if ( $restUrl[0] !== '/' ) {
321                        $restUrl = '/' . $restUrl;
322                    }
323                    $target = wfGetServerUrl( PROTO_CURRENT ) . $restUrl;
324
325                    $output->redirect( $target );
326                    break;
327
328                case '':
329                    $this->addHelpLink( 'Help:OAuth' );
330                    $output->addWikiMsg( 'mwoauth-nosubpage-explanation' );
331                    break;
332
333                // Typo in app code? Show an error.
334                default:
335                    $format = $request->getVal( 'format', 'html' );
336                    '@phan-var string $format';
337                    $dbr = Utils::getCentralDB( DB_REPLICA );
338                    $cmrAc = ConsumerAccessControl::wrap(
339                        Consumer::newFromKey(
340                            $dbr,
341                            $request->getVal( 'oauth_consumer_key', null )
342                        ),
343                        $this->getContext()
344                    );
345
346                    if ( !$cmrAc || !$cmrAc->userCanAccess( 'userId' ) ) {
347                        $this->showError(
348                            $this->msg( 'mwoauth-bad-request-invalid-action' )->rawParams(
349                                Linker::makeExternalLink(
350                                    'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E002',
351                                    'E002',
352                                    true
353                                )
354                            ),
355                            $format
356                        );
357                    } else {
358                        $owner = $cmrAc->getUserName( $this->getUser() );
359                        $this->showError(
360                            $this->msg( 'mwoauth-bad-request-invalid-action-contact',
361                                Utils::getCentralUserTalk( $owner )
362                            )->rawParams( Linker::makeExternalLink(
363                                'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E003',
364                                'E003',
365                                true
366                            ) ),
367                            $format
368                        );
369                    }
370            }
371        } catch ( MWOAuthException $exception ) {
372            $this->logger->warning( __METHOD__ . ": Exception " . $exception->getNormalizedMessage(),
373                [ 'exception' => $exception ] + $exception->getMessageContext() );
374            $this->showError( $this->msg( $exception->getMessageObject() ), $format );
375        } catch ( OAuthException $exception ) {
376            $this->logger->warning( __METHOD__ . ": Exception " . $exception->getMessage(),
377                [ 'exception' => $exception ] );
378            $this->showError(
379                $this->msg( 'mwoauth-oauth-exception', $exception->getMessage() ),
380                $format
381            );
382        }
383
384        $output->addModuleStyles( 'ext.MWOAuth.styles' );
385    }
386
387    /**
388     * @param string $consumerKey
389     * @throws MWOAuthException
390     */
391    protected function showCancelPage( $consumerKey ) {
392        $dbr = Utils::getCentralDB( DB_REPLICA );
393        $cmrAc = ConsumerAccessControl::wrap(
394            Consumer::newFromKey( $dbr, $consumerKey ),
395            $this->getContext()
396        );
397        if ( !$cmrAc ) {
398            throw new MWOAuthException( 'mwoauth-invalid-consumer-key', [
399                'consumer' => $consumerKey,
400            ] );
401        }
402
403        if ( $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) {
404            // Respond to client with user approval denied error
405            $this->redirectToREST( [
406                'approval_cancel' => 1
407            ] );
408            return;
409        }
410
411        $output = $this->getOutput();
412
413        $output->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() );
414        $output->addWikiMsg(
415            'mwoauth-acceptance-cancelled',
416            $cmrAc->getName()
417        );
418        $output->addReturnTo( Title::newMainPage() );
419    }
420
421    /**
422     * Make statements about the user, and sign the json with
423     * a key shared with the Consumer.
424     * @param User $user the user who is the subject of this request
425     * @param Consumer $consumer
426     * @param MWOAuthRequest $request
427     * @param string $format the format of the response: raw, json, or html
428     * @param ConsumerAcceptance $access
429     */
430    protected function outputJWT( $user, $consumer, $request, $format, $access ) {
431        $grants = $access->getGrants();
432        $userStatementProvider = UserStatementProvider::factory( $user, $consumer, $grants );
433
434        $statement = $userStatementProvider->getUserStatement();
435        // String value used to associate a Client session with an ID Token, and to mitigate
436        // replay attacks. The value is passed through unmodified from the Authorization Request.
437        $statement['nonce'] = $request->get_parameter( 'oauth_nonce' );
438        $JWT = JWT::encode( $statement, $consumer->secret, 'HS256' );
439        $this->showResponse( $JWT, $format );
440    }
441
442    /**
443     * @param string|null $requestToken
444     * @param string|null $consumerKey
445     * @param bool $authenticate
446     */
447    protected function handleAuthorizationForm( $requestToken, $consumerKey, $authenticate ) {
448        $output = $this->getOutput();
449
450        $output->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() );
451        $user = $this->getUser();
452
453        $oauthServer = Utils::newMWOAuthServer();
454
455        if ( !$consumerKey && $requestToken && $this->oauthVersion === Consumer::OAUTH_VERSION_1 ) {
456            $consumerKey = $oauthServer->getConsumerKey( $requestToken );
457        }
458
459        $cmrAc = ConsumerAccessControl::wrap(
460            Consumer::newFromKey( Utils::getCentralDB( DB_REPLICA ), $consumerKey ),
461            $this->getContext()
462        );
463
464        if ( !$cmrAc || !$cmrAc->userCanAccess( [ 'name', 'userId', 'grants' ] ) ) {
465            throw new MWOAuthException( 'mwoauthserver-bad-consumer-key', [
466                Message::rawParam( Linker::makeExternalLink(
467                    'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E006',
468                    'E006',
469                    true
470                ) ),
471                'consumer' => $consumerKey,
472            ] );
473        } elseif ( $cmrAc->getDAO()->getOAuthVersion() !== $this->oauthVersion ) {
474            throw new MWOAuthException(
475                'mwoauthserver-bad-consumer-version',
476                [
477                    Utils::getCentralUserTalk( $cmrAc->getUserName() ),
478                    \Message::rawParam( Linker::makeExternalLink(
479                        'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E012',
480                        'E012',
481                        true
482                    ) )
483                ]
484            );
485        } elseif ( !$cmrAc->getDAO()->isUsableBy( $user ) ) {
486            throw new MWOAuthException(
487                'mwoauthserver-bad-consumer',
488                [
489                    $cmrAc->getName(),
490                    Utils::getCentralUserTalk( $cmrAc->getUserName() ),
491                ]
492            );
493        }
494
495        $existing = $cmrAc->getDAO()->getCurrentAuthorization( $user, WikiMap::getCurrentWikiId() );
496
497        // If only authentication was requested, and the existing authorization
498        // matches, and the only grants are 'mwoauth-authonly' or 'mwoauth-authonlyprivate',
499        // then don't bother prompting the user about it.
500        if ( $existing && $authenticate &&
501            $existing->getWiki() === $cmrAc->getDAO()->getWiki() &&
502            $existing->getGrants() === $cmrAc->getDAO()->getGrants() &&
503             !array_diff( $existing->getGrants(), [ 'mwoauth-authonly', 'mwoauth-authonlyprivate' ] )
504        ) {
505            if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) {
506                $this->redirectToREST( [
507                    'approval_pass' => true
508                ] );
509            } else {
510                $callback = $cmrAc->getDAO()->authorize(
511                    $user, false, $cmrAc->getDAO()->getGrants(), $requestToken
512                );
513                $output->redirect( $callback );
514            }
515            return;
516        }
517
518        $control = new ConsumerAcceptanceSubmitControl(
519            $this->getContext(), [], Utils::getCentralDB( DB_PRIMARY ), $this->oauthVersion
520        );
521
522        $form = HTMLForm::factory( 'ooui',
523            $control->registerValidators( $this->getRequestValidators( [
524                'existing' => $existing,
525                'consumerKey' => $consumerKey,
526                'requestToken' => $requestToken
527            ] ) ),
528            $this->getContext()
529        );
530        $form->setSubmitCallback(
531            static function ( array $data, IContextSource $context ) use ( $control ) {
532                if ( $context->getRequest()->getCheck( 'cancel' ) ) {
533                    throw new MWException( 'Received request for a form cancellation.' );
534                }
535                $control->setInputParameters( $data );
536                return $control->submit();
537            }
538        );
539        $form->setId( 'mw-mwoauth-authorize-form' );
540
541        // Possible messages are:
542        // * mwoauth-form-description-allwikis
543        // * mwoauth-form-description-onewiki
544        // * mwoauth-form-description-allwikis-nogrants
545        // * mwoauth-form-description-onewiki-nogrants
546        // * mwoauth-form-description-allwikis-privateinfo
547        // * mwoauth-form-description-onewiki-privateinfo
548        // * mwoauth-form-description-allwikis-privateinfo-norealname
549        // * mwoauth-form-description-onewiki-privateinfo-norealname
550        $msgKey = 'mwoauth-form-description';
551        $params = [
552            $this->getUser()->getName(),
553            $cmrAc->getName(),
554            $cmrAc->getUserName(),
555        ];
556        if ( $cmrAc->getWiki() === '*' ) {
557            $msgKey .= '-allwikis';
558        } else {
559            $msgKey .= '-onewiki';
560            $params[] = $cmrAc->getWikiName();
561        }
562        $grants = $cmrAc->getGrants();
563        if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) {
564            $grants = $this->getRequestedGrants( $cmrAc );
565        }
566
567        $grantsText = $this->grantsLocalization->getGrantsWikiText( $grants, $this->getLanguage() );
568        if ( $grantsText === "\n" ) {
569            if ( in_array( 'mwoauth-authonlyprivate', $cmrAc->getGrants(), true ) ) {
570                $msgKey .= '-privateinfo';
571                if ( !$this->useRealNames() ) {
572                    // If the wiki does not use real names, don't mention them in the authorization
573                    // dialog to avoid scaring users. The wiki where the authorization dialog is
574                    // shown and the wiki where the user is actually identified might be different;
575                    // there's not much we can do about that here so it is left to the wiki
576                    // administrator to set up the farm in a non-misleading way.
577                    $msgKey .= '-norealname';
578                }
579            } else {
580                $msgKey .= '-nogrants';
581            }
582        } else {
583            $params[] = $grantsText;
584        }
585        $form->addHeaderHtml( $this->msg( $msgKey, $params )->parseAsBlock() );
586        $form->addHeaderHtml( $this->msg( 'mwoauth-form-legal' )->text() );
587
588        $form->suppressDefaultSubmit();
589        $form->addButton( [
590            'name' => 'accept',
591            'value' => $this->msg( 'mwoauth-form-button-approve' )->text(),
592            'id' => 'mw-mwoauth-accept',
593            'attribs' => [
594                'class' => 'mw-mwoauth-authorize-button'
595            ],
596            'flags' => [ 'primary', 'progressive' ],
597        ] );
598        $form->addButton( [
599            'name' => 'cancel',
600            'value' => $this->msg( 'mwoauth-form-button-cancel' )->text(),
601            'attribs' => [
602                'class' => 'mw-mwoauth-authorize-button'
603            ],
604            'framed' => false,
605        ] );
606
607        $form->addFooterHtml( $this->makePrivacyLink() );
608
609        $out = $this->getOutput();
610        $out->enableOOUI();
611        $out->addModuleStyles( 'ext.MWOAuth.AuthorizeForm' );
612        $out->addModules( 'ext.MWOAuth.AuthorizeDialog' );
613
614        $form->prepareForm();
615        $status = $form->tryAuthorizedSubmit();
616
617        $out->addHtml( new OOUI\PanelLayout( [
618            'id' => 'mw-mwoauth-authorize-panel',
619            'expanded' => false,
620            'content' => new HtmlSnippet( $form->getHTML( $status ) ),
621        ] ) );
622
623        if ( $status instanceof Status && $status->isOK() ) {
624            if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) {
625                $this->redirectToREST( [
626                    'approval_pass' => true
627                ] );
628            } else {
629                // Redirect to callback url
630                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
631                $output->redirect( $status->value['result']['callbackUrl'] );
632            }
633        }
634    }
635
636    private function redirectToREST( $queryAppend = [] ) {
637        $redirectParams = [
638            'returnto' => $this->getRequest()->getText(
639                'returnto', $this->getRequest()->getText( 'returnto' )
640            ),
641            'returntoquery' => wfCgiToArray(
642                $this->getRequest()->getText(
643                    'returntoquery', $this->getRequest()->getText( 'returntoquery' )
644                )
645            )
646        ];
647
648        $expanded = wfExpandUrl( $redirectParams['returnto'] );
649        if ( !$expanded ) {
650            return;
651        }
652
653        $returnToQuery = array_merge(
654            $redirectParams['returntoquery'],
655            $queryAppend
656        );
657        $returnToQuery = wfArrayToCgi( $returnToQuery );
658
659        $output = $this->getOutput();
660        $output->disable();
661        $output->getRequest()->response()->header(
662            'Location: ' . "$expanded?{$returnToQuery}"
663        );
664    }
665
666    /**
667     * @param array $data
668     * @return array[]
669     */
670    private function getRequestValidators( $data = [] ) {
671        $validators = [
672            'action' => [
673                'type'    => 'hidden',
674                'default' => 'accept',
675            ],
676            'confirmUpdate' => [
677                'type'    => 'hidden',
678                'default' => $data['existing'] ? 1 : 0,
679            ],
680            'oauth_version' => [
681                'name' => 'oauth_version',
682                'type' => 'hidden',
683                'default' => $this->oauthVersion
684            ],
685        ];
686        if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) {
687            $validators += [
688                'client_id' => [
689                    'name' => 'client_id',
690                    'type' => 'hidden',
691                    'default' => $this->getRequest()->getText( 'client_id' )
692                ],
693                'scope' => [
694                    'name' => 'scope',
695                    'type' => 'hidden',
696                    'default' => $this->getRequest()->getText( 'scope' )
697                ],
698                'returnto' => [
699                    'name' => 'returnto',
700                    'type'    => 'hidden',
701                    'default' => $this->getRequest()->getText( 'returnto' )
702                ],
703                'returntoquery' => [
704                    'name'    => 'returntoquery',
705                    'type'    => 'hidden',
706                    'default' => $this->getRequest()->getText( 'returntoquery' )
707                ],
708            ];
709        } else {
710            $validators += [
711                'consumerKey' => [
712                    'name'    => 'consumerKey',
713                    'type'    => 'hidden',
714                    'default' => $data['consumerKey']
715                ],
716                'requestToken' => [
717                    'name'    => 'requestToken',
718                    'type'    => 'hidden',
719                    'default' => $data['requestToken'],
720                ],
721            ];
722        }
723
724        return $validators;
725    }
726
727    /**
728     * OAuth 2.0 only
729     * Get only the grants (scopes) that were actually requested (and are allowed)
730     *
731     * @param ConsumerAccessControl $cmrAc
732     * @return string[]
733     */
734    private function getRequestedGrants( $cmrAc ) {
735        $allowed = $cmrAc->getGrants();
736        $requested = explode( ' ', $this->getRequest()->getText( 'scope', '' ) );
737
738        return array_intersect( $requested, $allowed );
739    }
740
741    /**
742     * @param Message $message to return to the user
743     * @param string $format the format of the response: html, raw, or json
744     */
745    private function showError( $message, $format ) {
746        if ( $format == 'raw' ) {
747            $this->showResponse( 'Error: ' . $message->escaped(), 'raw' );
748        } elseif ( $format == 'json' ) {
749            $error = FormatJson::encode( [
750                'error' => $message->getKey(),
751                'message' => $message->text(),
752            ] );
753            $this->showResponse( $error, 'json' );
754        } elseif ( $format == 'html' ) {
755            $this->getOutput()->showErrorPage( 'mwoauth-error', $message );
756        }
757    }
758
759    /**
760     * @param OAuthToken $token
761     * @param string $format the format of the response: html, raw, or json
762     */
763    private function returnToken( OAuthToken $token, $format ) {
764        if ( $format == 'raw' ) {
765            $return = 'oauth_token=' . OAuthUtil::urlencode_rfc3986( $token->key );
766            $return .= '&oauth_token_secret=' . OAuthUtil::urlencode_rfc3986( $token->secret );
767            $return .= '&oauth_callback_confirmed=true';
768            $this->showResponse( $return, 'raw' );
769        } elseif ( $format == 'json' ) {
770            $this->showResponse( FormatJson::encode( $token ), 'json' );
771        } elseif ( $format == 'html' ) {
772            $html = Html::element(
773                'li',
774                [],
775                'oauth_token = ' . OAuthUtil::urlencode_rfc3986( $token->key )
776            );
777            $html .= Html::element(
778                'li',
779                [],
780                'oauth_token_secret = ' . OAuthUtil::urlencode_rfc3986( $token->secret )
781            );
782            $html .= Html::element(
783                'li',
784                [],
785                'oauth_callback_confirmed = true'
786            );
787            $html = Html::rawElement( 'ul', [], $html );
788            $this->showResponse( $html, 'html' );
789        }
790    }
791
792    /**
793     * @param string $data html or string to pass back to the user. Already escaped.
794     * @param string $format the format of the response: raw, json, or html
795     * @param-taint $data escaped
796     */
797    private function showResponse( $data, $format ) {
798        $out = $this->getOutput();
799        if ( $format == 'raw' || $format == 'json' ) {
800            $out->disable();
801            // Cancel output buffering and gzipping if set
802            wfResetOutputBuffers();
803            // We must not allow the output to be Squid cached
804            $response = $this->getRequest()->response();
805            $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
806            $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
807            $response->header( 'Pragma: no-cache' );
808            $response->header( 'Content-length: ' . strlen( $data ) );
809            if ( $format == 'json' ) {
810                $response->header( 'Content-type: application/json' );
811            } else {
812                $response->header( 'Content-type: text/plain' );
813            }
814            print $data;
815        } elseif ( $format == 'html' ) {
816            $out->addHTML( $data );
817        }
818    }
819
820    /**
821     * Check whether the wiki is configured to use/show real names.
822     * We assume that either all or none of the OAuth wikis in a farm use real names.
823     * @return bool
824     */
825    private function useRealNames() {
826        $config = $this->getContext()->getConfig();
827        return !in_array( 'realname', $config->get( 'HiddenPrefs' ), true );
828    }
829
830    /**
831     * Get the requested OAuth version from the request
832     *
833     * @param WebRequest $request
834     * @return int
835     */
836    private function determineOAuthVersion( WebRequest $request ) {
837        $this->oauthVersion = $request->getInt( 'oauth_version', Consumer::OAUTH_VERSION_1 );
838
839        return $this->oauthVersion;
840    }
841
842    /**
843     * @param int $allowed Allowed version
844     * @throws MWOAuthException
845     */
846    private function assertOAuthVersion( $allowed ) {
847        if ( $this->oauthVersion !== $allowed ) {
848            throw new MWOAuthException(
849                'mwoauth-oauth-unsupported-version',
850                [ $this->oauthVersion ]
851            );
852        }
853    }
854
855    private function makePrivacyLink() {
856        // If the link description has been disabled in the default language,
857        if ( $this->msg( 'privacy' )->inContentLanguage()->isDisabled() ) {
858            // then it is disabled, for all languages.
859            $title = null;
860        } else {
861            // Otherwise, we display the link for the user, described in their
862            // language (which may or may not be the same as the default language),
863            // but we make the link target be the one site-wide page.
864            $title = Title::newFromText( $this->msg( 'privacypage' )->inContentLanguage()->text() );
865        }
866
867        if ( !$title ) {
868            return '';
869        }
870
871        return $this->getLinkRenderer()->makeKnownLink(
872            $title,
873            $this->msg( 'privacy' )->text()
874        );
875    }
876}