Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
7.14% |
16 / 224 |
|
26.67% |
4 / 15 |
CRAP | |
0.00% |
0 / 1 |
SessionProvider | |
7.14% |
16 / 224 |
|
26.67% |
4 / 15 |
3139.72 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
postInitSetup | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
provideSessionInfo | |
2.46% |
3 / 122 |
|
0.00% |
0 / 1 |
703.53 | |||
getOAuthVersionFromRequest | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
3.58 | |||
verifyOAuth2Request | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
preventSessionsForUser | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
6 | |||
getVaryHeaders | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getSessionData | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
getAllowedUserRights | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getRestrictions | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
onApiCheckCanExecute | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
onRecentChange_save | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getPublicConsumerId | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
onMarkPatrolled | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
safeAgainstCsrf | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\OAuth; |
4 | |
5 | use Exception; |
6 | use GuzzleHttp\Psr7\ServerRequest; |
7 | use InvalidArgumentException; |
8 | use MediaWiki\Api\ApiBase; |
9 | use MediaWiki\Api\ApiMessage; |
10 | use MediaWiki\Api\Hook\ApiCheckCanExecuteHook; |
11 | use MediaWiki\Context\RequestContext; |
12 | use MediaWiki\Extension\OAuth\Backend\Consumer; |
13 | use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance; |
14 | use MediaWiki\Extension\OAuth\Backend\MWOAuthException; |
15 | use MediaWiki\Extension\OAuth\Backend\MWOAuthRequest; |
16 | use MediaWiki\Extension\OAuth\Backend\Utils; |
17 | use MediaWiki\Extension\OAuth\Repository\AccessTokenRepository; |
18 | use MediaWiki\Hook\MarkPatrolledHook; |
19 | use MediaWiki\Hook\RecentChange_saveHook; |
20 | use MediaWiki\Linker\Linker; |
21 | use MediaWiki\MediaWikiServices; |
22 | use MediaWiki\Message\Message; |
23 | use MediaWiki\RecentChanges\RecentChange; |
24 | use MediaWiki\Request\WebRequest; |
25 | use MediaWiki\Session\ImmutableSessionProviderWithCookie; |
26 | use MediaWiki\Session\SessionBackend; |
27 | use MediaWiki\Session\SessionInfo; |
28 | use MediaWiki\Session\SessionManager; |
29 | use MediaWiki\Session\UserInfo; |
30 | use MediaWiki\Title\Title; |
31 | use MediaWiki\User\User; |
32 | use MediaWiki\User\UserIdentity; |
33 | use MediaWiki\WikiMap\WikiMap; |
34 | use MWRestrictions; |
35 | use Wikimedia\Rdbms\DBError; |
36 | |
37 | /** |
38 | * Session provider for OAuth |
39 | * |
40 | * This is a fairly standard ImmutableSessionProviderWithCookie implementation: |
41 | * the user identity is determined by the OAuth headers included in the |
42 | * request. But since we want to make sure to fail the request when OAuth |
43 | * headers are present but invalid, this takes the somewhat unusual step of |
44 | * returning a bogus SessionInfo and then hooking ApiBeforeMain to throw a |
45 | * fatal exception after MediaWiki is ready to handle it. |
46 | * |
47 | * It also takes advantage of the getAllowedUserRights() method for authz |
48 | * purposes (limiting the rights to those included in the grant), and |
49 | * registers some hooks to tag actions made via the provider. |
50 | */ |
51 | class SessionProvider |
52 | extends ImmutableSessionProviderWithCookie |
53 | implements ApiCheckCanExecuteHook, RecentChange_saveHook, MarkPatrolledHook |
54 | { |
55 | |
56 | public function __construct( array $params = [] ) { |
57 | parent::__construct( $params ); |
58 | } |
59 | |
60 | protected function postInitSetup() { |
61 | $hookContainer = MediaWikiServices::getInstance()->getHookContainer(); |
62 | |
63 | $hookContainer->register( 'ApiCheckCanExecute', $this ); |
64 | $hookContainer->register( 'RecentChange_save', $this ); |
65 | $hookContainer->register( 'MarkPatrolled', $this ); |
66 | } |
67 | |
68 | /** @inheritDoc */ |
69 | public function provideSessionInfo( WebRequest $request ) { |
70 | $oauthVersion = $this->getOAuthVersionFromRequest( $request ); |
71 | if ( $oauthVersion === null ) { |
72 | // Not an OAuth request |
73 | return null; |
74 | } |
75 | |
76 | // OAuth is restricted to be API-only. |
77 | if ( !defined( 'MW_API' ) && !defined( 'MW_REST_API' ) ) { |
78 | $globalRequest = RequestContext::getMain()->getRequest(); |
79 | if ( $request !== $globalRequest ) { |
80 | // We are looking at something other than the global request. No easy way to |
81 | // find out the title, and showing an error should be handled in the global |
82 | // request anyway. Bail out. |
83 | return null; |
84 | } |
85 | // The global Title object is not set up yet. |
86 | $title = Title::newFromText( $request->getText( 'title' ) ); |
87 | if ( $title && $title->isSpecial( 'OAuth' ) ) { |
88 | // Some Special:OAuth subpages expect an OAuth request header, but process it |
89 | // manually, not via SessionManager. We mustn't break those. |
90 | // TODO: this can probably be limited to /token and /identify |
91 | return null; |
92 | } |
93 | |
94 | return $this->makeException( 'mwoauth-not-api' ); |
95 | } |
96 | |
97 | $logData = [ |
98 | 'clientip' => $request->getIP(), |
99 | 'user' => false, |
100 | 'consumer' => '', |
101 | 'result' => 'fail', |
102 | ]; |
103 | |
104 | $dbr = Utils::getCentralDB( DB_REPLICA ); |
105 | $access = null; |
106 | try { |
107 | if ( $oauthVersion === Consumer::OAUTH_VERSION_2 ) { |
108 | $resourceServer = ResourceServer::factory(); |
109 | $accessTokenKey = $this->verifyOAuth2Request( $resourceServer, $request ); |
110 | $accessTokenRepo = new AccessTokenRepository( $this->config->get( 'CanonicalServer' ) ); |
111 | $accessId = $accessTokenRepo->getApprovalId( $accessTokenKey ); |
112 | if ( $accessId === 0 ) { |
113 | if ( |
114 | $resourceServer->getUser()->getId() === 0 && |
115 | $resourceServer->getClient()->getOwnerOnly() === false |
116 | ) { |
117 | // This tell us, with good degree of certainty, that the AT |
118 | // was issued to a machine and represents no particular user |
119 | $access = ConsumerAcceptance::newFromArray( [ |
120 | 'id' => null, |
121 | 'wiki' => $resourceServer->getClient()->getWiki(), |
122 | 'userId' => 0, |
123 | 'consumerId' => $resourceServer->getClient()->getId(), |
124 | 'accessToken' => '', |
125 | 'accessSecret' => '', |
126 | 'grants' => $resourceServer->getClient()->getGrants(), |
127 | 'accepted' => wfTimestampNow(), |
128 | 'oauth_version' => Consumer::OAUTH_VERSION_2 |
129 | ] ); |
130 | } |
131 | } else { |
132 | $access = ConsumerAcceptance::newFromId( |
133 | Utils::getCentralDB( DB_REPLICA ), $accessId |
134 | ); |
135 | } |
136 | if ( !$access ) { |
137 | $logData['consumer'] = $resourceServer->getClient()->getConsumerKey(); |
138 | throw new MWOAuthException( 'mwoauth-oauth2-error-create-at-no-user-approval' ); |
139 | } |
140 | |
141 | // Set the scopes that are verified for this request |
142 | $access->setField( 'grants', array_keys( $resourceServer->getScopes() ) ); |
143 | } else { |
144 | $server = Utils::newMWOAuthServer(); |
145 | $oauthRequest = MWOAuthRequest::fromRequest( $request ); |
146 | $logData['consumer'] = $oauthRequest->getConsumerKey(); |
147 | [ , $accessToken ] = $server->verify_request( $oauthRequest ); |
148 | $accessTokenKey = $accessToken->key; |
149 | $access = ConsumerAcceptance::newFromToken( $dbr, $accessTokenKey ); |
150 | } |
151 | } catch ( Exception $ex ) { |
152 | $this->logger->info( 'Bad OAuth request from {ip}', $logData + [ 'exception' => $ex ] ); |
153 | return $this->makeException( 'mwoauth-invalid-authorization', $ex->getMessage() ); |
154 | } |
155 | |
156 | $logData['user'] = Utils::getCentralUserNameFromId( $access->getUserId(), 'raw' ); |
157 | |
158 | $wiki = WikiMap::getCurrentWikiId(); |
159 | // Access token is for this wiki |
160 | if ( $access->getWiki() !== '*' && $access->getWiki() !== $wiki ) { |
161 | $this->logger->debug( 'OAuth request for wrong wiki from user {user}', $logData ); |
162 | return $this->makeException( 'mwoauth-invalid-authorization-wrong-wiki', $wiki ); |
163 | } |
164 | |
165 | // There exists a local user |
166 | $localUser = Utils::getLocalUserFromCentralId( $access->getUserId() ); |
167 | if ( !$localUser ) { |
168 | $localUser = User::newFromId( 0 ); |
169 | } |
170 | // If there is an actual approval, but user bound to it does not exist |
171 | if ( $access->getId() > 0 && $localUser->getId() === 0 ) { |
172 | $this->logger->debug( 'OAuth request for invalid or non-local user {user}', $logData ); |
173 | return $this->makeException( 'mwoauth-invalid-authorization-invalid-user', |
174 | Message::rawParam( Linker::makeExternalLink( |
175 | 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008', |
176 | 'E008', |
177 | true |
178 | ) ) |
179 | ); |
180 | } |
181 | if ( $localUser->isLocked() ) { |
182 | $this->logger->debug( 'OAuth request for locked user {user}', $logData ); |
183 | return $this->makeException( 'mwoauth-invalid-authorization-blocked-user' ); |
184 | } |
185 | if ( $this->config->get( 'BlockDisablesLogin' ) ) { |
186 | $block = MediaWikiServices::getInstance()->getBlockManager() |
187 | ->getBlock( $localUser, null ); |
188 | if ( $block && $block->isSitewide() ) { |
189 | $this->logger->debug( 'OAuth request for blocked user {user}', $logData ); |
190 | return $this->makeException( 'mwoauth-invalid-authorization-blocked-user' ); |
191 | } |
192 | } |
193 | |
194 | // The consumer is approved or owned by $localUser, and is for this wiki. |
195 | $consumer = Consumer::newFromId( $dbr, $access->getConsumerId() ); |
196 | if ( !$consumer->isUsableBy( $localUser ) ) { |
197 | $this->logger->debug( |
198 | 'OAuth request for consumer {consumer} not approved by user {user}', $logData |
199 | ); |
200 | return $this->makeException( 'mwoauth-invalid-authorization-not-approved', |
201 | $consumer->getName() ); |
202 | } elseif ( $consumer->getWiki() !== '*' && $consumer->getWiki() !== $wiki ) { |
203 | $this->logger->debug( 'OAuth request for consumer {consumer} to incorrect wiki', $logData ); |
204 | return $this->makeException( 'mwoauth-invalid-authorization-wrong-wiki', $wiki ); |
205 | } |
206 | |
207 | // Ok, use this user! |
208 | if ( $this->sessionCookieName === null ) { |
209 | // We're not configured to use cookies, so concatenate some of the |
210 | // internal consumer-acceptance state to generate an ID. |
211 | $id = $this->hashToSessionId( implode( "\n", [ |
212 | $access->getId(), |
213 | $access->getWiki(), |
214 | $access->getUserId(), |
215 | $access->getConsumerId(), |
216 | $access->getAccepted(), |
217 | $wiki, |
218 | ] ) ); |
219 | $persisted = false; |
220 | $forceUse = true; |
221 | } else { |
222 | $id = $this->getSessionIdFromCookie( $request ); |
223 | $persisted = $id !== null; |
224 | $forceUse = false; |
225 | } |
226 | |
227 | $logData['result'] = 'success'; |
228 | $this->logger->debug( 'OAuth request for consumer {consumer} by user {user}', $logData ); |
229 | |
230 | return new SessionInfo( SessionInfo::MAX_PRIORITY, [ |
231 | 'provider' => $this, |
232 | 'id' => $id, |
233 | 'userInfo' => UserInfo::newFromUser( $localUser, true ), |
234 | 'persisted' => $persisted, |
235 | 'forceUse' => $forceUse, |
236 | 'metadata' => [ |
237 | 'oauthVersion' => $oauthVersion, |
238 | 'consumerId' => $consumer->getOwnerOnly() ? null : $consumer->getId(), |
239 | 'key' => $accessTokenKey, |
240 | 'rights' => MediaWikiServices::getInstance() |
241 | ->getGrantsInfo() |
242 | ->getGrantRights( $access->getGrants() ), |
243 | 'restrictions' => $consumer->getRestrictions()->toJson(), |
244 | ], |
245 | ] ); |
246 | } |
247 | |
248 | /** |
249 | * Determine OAuth version of the request |
250 | * |
251 | * @param WebRequest $request |
252 | * @return int|null if request is not using OAuth header |
253 | */ |
254 | private function getOAuthVersionFromRequest( WebRequest $request ) { |
255 | if ( Utils::hasOAuthHeaders( $request ) ) { |
256 | return Consumer::OAUTH_VERSION_1; |
257 | } |
258 | if ( ResourceServer::isOAuth2Request( $request ) ) { |
259 | return Consumer::OAUTH_VERSION_2; |
260 | } |
261 | |
262 | return null; |
263 | } |
264 | |
265 | /** |
266 | * @param ResourceServer &$resourceServer |
267 | * @param WebRequest $request |
268 | * @return string |
269 | * @throws MWOAuthException |
270 | */ |
271 | private function verifyOAuth2Request( ResourceServer &$resourceServer, WebRequest $request ) { |
272 | $request = ServerRequest::fromGlobals()->withHeader( |
273 | 'authorization', |
274 | $request->getHeader( 'authorization' ) |
275 | ); |
276 | |
277 | $response = new Response(); |
278 | $valid = false; |
279 | $resourceServer->verify( |
280 | $request, |
281 | $response, |
282 | static function ( $request, $response ) use ( &$valid ) { |
283 | $valid = true; |
284 | } |
285 | ); |
286 | |
287 | if ( $valid ) { |
288 | return $resourceServer->getAccessTokenId(); |
289 | } |
290 | |
291 | throw new MWOAuthException( 'mwoauth-oauth2-invalid-access-token' ); |
292 | } |
293 | |
294 | /** @inheritDoc */ |
295 | public function preventSessionsForUser( $username ) { |
296 | $id = Utils::getCentralIdFromUserName( $username ); |
297 | $dbw = Utils::getCentralDB( DB_PRIMARY ); |
298 | |
299 | $dbw->startAtomic( __METHOD__ ); |
300 | try { |
301 | // Remove any approvals for the user's consumers before deleting them |
302 | $dbw->deleteJoin( |
303 | 'oauth_accepted_consumer', |
304 | 'oauth_registered_consumer', |
305 | 'oaac_consumer_id', |
306 | 'oarc_id', |
307 | [ 'oarc_user_id' => $id ], |
308 | __METHOD__ |
309 | ); |
310 | $dbw->newDeleteQueryBuilder() |
311 | ->deleteFrom( 'oauth_registered_consumer' ) |
312 | ->where( [ 'oarc_user_id' => $id ] ) |
313 | ->caller( __METHOD__ ) |
314 | ->execute(); |
315 | |
316 | // Remove any approvals by this user, too |
317 | $dbw->newDeleteQueryBuilder() |
318 | ->deleteFrom( 'oauth_accepted_consumer' ) |
319 | ->where( [ 'oaac_user_id' => $id ] ) |
320 | ->caller( __METHOD__ ) |
321 | ->execute(); |
322 | } catch ( DBError $e ) { |
323 | $dbw->rollback( __METHOD__ ); |
324 | throw $e; |
325 | } |
326 | $dbw->endAtomic( __METHOD__ ); |
327 | } |
328 | |
329 | /** @inheritDoc */ |
330 | public function getVaryHeaders() { |
331 | return [ |
332 | 'Authorization' => null, |
333 | ]; |
334 | } |
335 | |
336 | /** |
337 | * Fetch the access data, if any, for this user-session |
338 | * @param UserIdentity|null $userIdentity |
339 | * @return array|null |
340 | */ |
341 | private function getSessionData( ?UserIdentity $userIdentity = null ) { |
342 | if ( $userIdentity ) { |
343 | $user = User::newFromIdentity( $userIdentity ); |
344 | $session = $user->getRequest()->getSession(); |
345 | if ( $session->getProvider() === $this && |
346 | $user->equals( $session->getUser() ) |
347 | ) { |
348 | return $session->getProviderMetadata(); |
349 | } |
350 | } else { |
351 | $session = SessionManager::getGlobalSession(); |
352 | if ( $session->getProvider() === $this ) { |
353 | return $session->getProviderMetadata(); |
354 | } |
355 | } |
356 | |
357 | return null; |
358 | } |
359 | |
360 | /** @inheritDoc */ |
361 | public function getAllowedUserRights( SessionBackend $backend ) { |
362 | if ( $backend->getProvider() !== $this ) { |
363 | throw new InvalidArgumentException( 'Backend\'s provider isn\'t $this' ); |
364 | } |
365 | $data = $backend->getProviderMetadata(); |
366 | if ( $data ) { |
367 | return $data['rights']; |
368 | } |
369 | |
370 | // Should never happen |
371 | $this->logger->debug( __METHOD__ . ': No provider metadata, returning no rights allowed' ); |
372 | return []; |
373 | } |
374 | |
375 | public function getRestrictions( ?array $data ): ?MWRestrictions { |
376 | if ( $data && isset( $data['restrictions'] ) && is_string( $data['restrictions'] ) ) { |
377 | try { |
378 | return MWRestrictions::newFromJson( $data['restrictions'] ); |
379 | } catch ( \InvalidArgumentException $e ) { |
380 | $this->logger->warning( __METHOD__ . ': Failed to parse restrictions: {restrictions}', [ |
381 | 'restrictions' => $data['restrictions'] |
382 | ] ); |
383 | return null; |
384 | } |
385 | } |
386 | return null; |
387 | } |
388 | |
389 | /** |
390 | * Disable certain API modules when used with OAuth |
391 | * |
392 | * @param ApiBase $module |
393 | * @param UserIdentity $userIdentity |
394 | * @param string|array &$message |
395 | * @return bool |
396 | */ |
397 | public function onApiCheckCanExecute( $module, $userIdentity, &$message ) { |
398 | global $wgMWOauthDisabledApiModules; |
399 | if ( !$this->getSessionData( $userIdentity ) ) { |
400 | return true; |
401 | } |
402 | |
403 | foreach ( $wgMWOauthDisabledApiModules as $badModule ) { |
404 | if ( $module instanceof $badModule ) { |
405 | $message = ApiMessage::create( |
406 | [ 'mwoauth-api-module-disabled', $module->getModuleName() ], |
407 | 'mwoauth-api-module-disabled' |
408 | ); |
409 | return false; |
410 | } |
411 | } |
412 | |
413 | return true; |
414 | } |
415 | |
416 | /** |
417 | * Record the fact that OAuth was used for anything added to RecentChanges. |
418 | * |
419 | * @param RecentChange $rc |
420 | * @return bool true |
421 | */ |
422 | public function onRecentChange_save( $rc ) { |
423 | $consumerId = $this->getPublicConsumerId( $rc->getPerformerIdentity() ); |
424 | if ( $consumerId !== null ) { |
425 | $rc->addTags( Utils::getTagName( $consumerId ) ); |
426 | } |
427 | return true; |
428 | } |
429 | |
430 | /** |
431 | * Get the consumer ID of the non-owner-only OAuth consumer associated with this user, or null. |
432 | * @param UserIdentity|null $userIdentity |
433 | * @return int|null |
434 | */ |
435 | protected function getPublicConsumerId( ?UserIdentity $userIdentity = null ) { |
436 | $data = $this->getSessionData( $userIdentity ); |
437 | if ( $data && isset( $data['consumerId'] ) ) { |
438 | return $data['consumerId']; |
439 | } |
440 | return null; |
441 | } |
442 | |
443 | /** |
444 | * Record the fact that OAuth was used for marking an existing RecentChange as patrolled. |
445 | * (RecentChange::doMarkPatrolled() does not use RecentChange::save() |
446 | * and therefore bypasses the above hook handler.) |
447 | * |
448 | * @param int $rcid |
449 | * @param User $user |
450 | * @param bool $wcOnlySysopsCanPatrol |
451 | * @param bool $auto |
452 | * @param string[] &$tags |
453 | * |
454 | * @return bool true |
455 | */ |
456 | public function onMarkPatrolled( |
457 | $rcid, |
458 | $user, |
459 | $wcOnlySysopsCanPatrol, |
460 | $auto, |
461 | &$tags |
462 | ) { |
463 | $consumerId = $this->getPublicConsumerId( $user ); |
464 | if ( $consumerId !== null ) { |
465 | $tags[] = Utils::getTagName( $consumerId ); |
466 | } |
467 | return true; |
468 | } |
469 | |
470 | /** |
471 | * OAuth tokens already protect against CSRF. CSRF tokens are not required. |
472 | * |
473 | * @return bool true |
474 | */ |
475 | public function safeAgainstCsrf() { |
476 | return true; |
477 | } |
478 | } |