Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
7.14% covered (danger)
7.14%
16 / 224
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SessionProvider
7.14% covered (danger)
7.14%
16 / 224
26.67% covered (danger)
26.67%
4 / 15
3139.72
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 postInitSetup
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 provideSessionInfo
2.46% covered (danger)
2.46%
3 / 122
0.00% covered (danger)
0.00%
0 / 1
703.53
 getOAuthVersionFromRequest
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 verifyOAuth2Request
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 preventSessionsForUser
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 getVaryHeaders
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionData
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getAllowedUserRights
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getRestrictions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 onApiCheckCanExecute
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 onRecentChange_save
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getPublicConsumerId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onMarkPatrolled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 safeAgainstCsrf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\OAuth;
4
5use Exception;
6use GuzzleHttp\Psr7\ServerRequest;
7use InvalidArgumentException;
8use MediaWiki\Api\ApiBase;
9use MediaWiki\Api\ApiMessage;
10use MediaWiki\Api\Hook\ApiCheckCanExecuteHook;
11use MediaWiki\Context\RequestContext;
12use MediaWiki\Extension\OAuth\Backend\Consumer;
13use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance;
14use MediaWiki\Extension\OAuth\Backend\MWOAuthException;
15use MediaWiki\Extension\OAuth\Backend\MWOAuthRequest;
16use MediaWiki\Extension\OAuth\Backend\Utils;
17use MediaWiki\Extension\OAuth\Repository\AccessTokenRepository;
18use MediaWiki\Hook\MarkPatrolledHook;
19use MediaWiki\Hook\RecentChange_saveHook;
20use MediaWiki\Linker\Linker;
21use MediaWiki\MediaWikiServices;
22use MediaWiki\Message\Message;
23use MediaWiki\RecentChanges\RecentChange;
24use MediaWiki\Request\WebRequest;
25use MediaWiki\Session\ImmutableSessionProviderWithCookie;
26use MediaWiki\Session\SessionBackend;
27use MediaWiki\Session\SessionInfo;
28use MediaWiki\Session\SessionManager;
29use MediaWiki\Session\UserInfo;
30use MediaWiki\Title\Title;
31use MediaWiki\User\User;
32use MediaWiki\User\UserIdentity;
33use MediaWiki\WikiMap\WikiMap;
34use MWRestrictions;
35use 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 */
51class 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}