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