Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 515 |
|
0.00% |
0 / 17 |
CRAP | |
0.00% |
0 / 1 |
SpecialMWOAuth | |
0.00% |
0 / 515 |
|
0.00% |
0 / 17 |
9506 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLocalName | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
execute | |
0.00% |
0 / 202 |
|
0.00% |
0 / 1 |
1640 | |||
showCancelPage | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
outputJWT | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
handleAuthorizationForm | |
0.00% |
0 / 132 |
|
0.00% |
0 / 1 |
552 | |||
redirectToREST | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
6 | |||
getRequestValidators | |
0.00% |
0 / 52 |
|
0.00% |
0 / 1 |
12 | |||
getRequestedGrants | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
showError | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
returnToken | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
20 | |||
showResponse | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
30 | |||
useRealNames | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
determineOAuthVersion | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
assertOAuthVersion | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
makePrivacyLink | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace 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 | |
24 | use Firebase\JWT\JWT; |
25 | use FormatJson; |
26 | use HTMLForm; |
27 | use IContextSource; |
28 | use MediaWiki\Extension\OAuth\Backend\Consumer; |
29 | use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance; |
30 | use MediaWiki\Extension\OAuth\Backend\MWOAuthException; |
31 | use MediaWiki\Extension\OAuth\Backend\MWOAuthRequest; |
32 | use MediaWiki\Extension\OAuth\Backend\MWOAuthToken; |
33 | use MediaWiki\Extension\OAuth\Backend\Utils; |
34 | use MediaWiki\Extension\OAuth\Control\ConsumerAcceptanceSubmitControl; |
35 | use MediaWiki\Extension\OAuth\Control\ConsumerAccessControl; |
36 | use MediaWiki\Extension\OAuth\Lib\OAuthException; |
37 | use MediaWiki\Extension\OAuth\Lib\OAuthToken; |
38 | use MediaWiki\Extension\OAuth\Lib\OAuthUtil; |
39 | use MediaWiki\Extension\OAuth\UserStatementProvider; |
40 | use MediaWiki\Html\Html; |
41 | use MediaWiki\Linker\Linker; |
42 | use MediaWiki\Logger\LoggerFactory; |
43 | use MediaWiki\Permissions\GrantsLocalization; |
44 | use MediaWiki\Request\WebRequest; |
45 | use MediaWiki\SpecialPage\SpecialPage; |
46 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
47 | use MediaWiki\Status\Status; |
48 | use MediaWiki\Title\Title; |
49 | use MediaWiki\User\User; |
50 | use MediaWiki\WikiMap\WikiMap; |
51 | use Message; |
52 | use MWException; |
53 | use OOUI; |
54 | use OOUI\HtmlSnippet; |
55 | use Psr\Log\LoggerInterface; |
56 | |
57 | /** |
58 | * Page that handles OAuth consumer authorization and token exchange |
59 | */ |
60 | class 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 | } |