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 ApiBase;
6use ApiMessage;
7use Exception;
8use GuzzleHttp\Psr7\ServerRequest;
9use InvalidArgumentException;
10use MediaWiki\Api\Hook\ApiCheckCanExecuteHook;
11use MediaWiki\Extension\OAuth\Backend\Consumer;
12use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance;
13use MediaWiki\Extension\OAuth\Backend\MWOAuthException;
14use MediaWiki\Extension\OAuth\Backend\MWOAuthRequest;
15use MediaWiki\Extension\OAuth\Backend\Utils;
16use MediaWiki\Extension\OAuth\Repository\AccessTokenRepository;
17use MediaWiki\Hook\MarkPatrolledHook;
18use MediaWiki\Hook\RecentChange_saveHook;
19use MediaWiki\Linker\Linker;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Request\WebRequest;
22use MediaWiki\Session\ImmutableSessionProviderWithCookie;
23use MediaWiki\Session\SessionBackend;
24use MediaWiki\Session\SessionInfo;
25use MediaWiki\Session\SessionManager;
26use MediaWiki\Session\UserInfo;
27use MediaWiki\Title\Title;
28use MediaWiki\User\User;
29use MediaWiki\User\UserIdentity;
30use MediaWiki\WikiMap\WikiMap;
31use Message;
32use MWRestrictions;
33use RecentChange;
34use RequestContext;
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    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}