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