Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 185
0.00% covered (danger)
0.00%
0 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Utils
0.00% covered (danger)
0.00%
0 / 185
0.00% covered (danger)
0.00%
0 / 25
4830
0.00% covered (danger)
0.00%
0 / 1
 isCentralWiki
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCentralWiki
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCentralDB
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getSessionCache
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getNonceCache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getConsumerStateCounts
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getHeaders
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 hasOAuthHeaders
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 runAutoMaintenance
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 getWikiIdName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getAllWikiNames
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 newMWOAuthServer
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 newMWOAuthDataStore
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCentralUserNameFromId
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 getLocalUserFromCentralId
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getCentralIdFromLocalUser
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 getCentralIdFromUserName
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 hmacDBSecret
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getCentralUserTalk
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 grantsAreValid
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 locateUsersToNotify
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
 getTagName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isReservedTagName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOAuthAdmins
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\OAuth\Backend;
4
5use BagOStuff;
6use MediaWiki\Deferred\AutoCommitUpdate;
7use MediaWiki\Deferred\DeferredUpdates;
8use MediaWiki\Extension\Notifications\Model\Event;
9use MediaWiki\Extension\OAuth\Lib\OAuthSignatureMethod_HMAC_SHA1;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Request\WebRequest;
12use MediaWiki\Title\Title;
13use MediaWiki\User\CentralId\CentralIdLookup;
14use MediaWiki\User\User;
15use MediaWiki\WikiMap\WikiMap;
16use MWException;
17use ObjectCache;
18use RequestContext;
19use Wikimedia\Rdbms\IDatabase;
20
21/**
22 * Static utility functions for OAuth
23 *
24 * @file
25 * @ingroup OAuth
26 */
27class Utils {
28    /**
29     * @return bool
30     */
31    public static function isCentralWiki() {
32        global $wgMWOAuthCentralWiki;
33
34        return ( WikiMap::getCurrentWikiId() === $wgMWOAuthCentralWiki );
35    }
36
37    /**
38     * @return string|bool
39     */
40    public static function getCentralWiki() {
41        global $wgMWOAuthCentralWiki;
42
43        return $wgMWOAuthCentralWiki;
44    }
45
46    /**
47     * @param int $index DB_PRIMARY/DB_REPLICA
48     * @return IDatabase
49     */
50    public static function getCentralDB( $index ) {
51        $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
52
53        // T244415: Use the primary database if there were changes
54        if ( $index === DB_REPLICA && $lbFactory->hasOrMadeRecentPrimaryChanges() ) {
55            $index = DB_PRIMARY;
56        }
57        $wikiId = self::getCentralWiki();
58        if ( WikiMap::isCurrentWikiId( $wikiId ) ) {
59            $wikiId = false;
60        }
61
62        return $lbFactory->getMainLB( $wikiId )->getConnection(
63            $index, [], $wikiId );
64    }
65
66    /**
67     * @return BagOStuff
68     */
69    public static function getSessionCache() {
70        global $wgMWOAuthSessionCacheType;
71        global $wgSessionCacheType;
72
73        $sessionCacheType = $wgMWOAuthSessionCacheType ?? $wgSessionCacheType;
74        return ObjectCache::getInstance( $sessionCacheType );
75    }
76
77    /**
78     * Get the cache type for OAuth 1.0 nonces
79     * @return BagOStuff
80     */
81    public static function getNonceCache() {
82        global $wgMWOAuthNonceCacheType, $wgMWOAuthSessionCacheType, $wgSessionCacheType;
83
84        $cacheType = $wgMWOAuthNonceCacheType
85            ?? $wgMWOAuthSessionCacheType ?? $wgSessionCacheType;
86        return ObjectCache::getInstance( $cacheType );
87    }
88
89    /**
90     * @param IDatabase $db
91     * @return int[]
92     */
93    public static function getConsumerStateCounts( IDatabase $db ) {
94        $res = $db->newSelectQueryBuilder()
95            ->select( [ 'oarc_stage', 'count' => 'COUNT(*)' ] )
96            ->from( 'oauth_registered_consumer' )
97            ->groupBy( 'oarc_stage' )
98            ->caller( __METHOD__ )
99            ->fetchResultSet();
100        $table = [
101            Consumer::STAGE_APPROVED => 0,
102            Consumer::STAGE_DISABLED => 0,
103            Consumer::STAGE_EXPIRED  => 0,
104            Consumer::STAGE_PROPOSED => 0,
105            Consumer::STAGE_REJECTED => 0,
106        ];
107        foreach ( $res as $row ) {
108            $table[(int)$row->oarc_stage] = (int)$row->count;
109        }
110        return $table;
111    }
112
113    /**
114     * Get request headers.
115     * Sanitizes the output of apache_request_headers because
116     * we always want the keys to be Cased-Like-This and arh()
117     * returns the headers in the same case as they are in the
118     * request
119     * @return array Header name => value
120     */
121    public static function getHeaders() {
122        $request = RequestContext::getMain()->getRequest();
123        $headers = $request->getAllHeaders();
124
125        $out = [];
126        foreach ( $headers as $key => $value ) {
127            $key = str_replace(
128                " ",
129                "-",
130                ucwords( strtolower( str_replace( "-", " ", $key ) ) )
131            );
132            $out[$key] = $value;
133        }
134        return $out;
135    }
136
137    /**
138     * Test this request for an OAuth Authorization header
139     * @param WebRequest $request the MediaWiki request
140     * @return bool true if a header was found
141     */
142    public static function hasOAuthHeaders( WebRequest $request ) {
143        $header = $request->getHeader( 'Authorization' );
144
145        return $header !== false && strpos( $header, 'OAuth ' ) === 0;
146    }
147
148    /**
149     * Make a cache key for the given arguments, that (hopefully) won't clash with
150     * anything else in your cache
151     * @param string ...$args
152     * @return string
153     */
154    public static function getCacheKey( ...$args ) {
155        global $wgMWOAuthCentralWiki;
156
157        return "OAUTH:$wgMWOAuthCentralWiki:" . implode( ':', $args );
158    }
159
160    /**
161     * @param IDatabase $dbw
162     * @return void
163     */
164    public static function runAutoMaintenance( IDatabase $dbw ) {
165        global $wgMWOAuthRequestExpirationAge;
166
167        if ( $wgMWOAuthRequestExpirationAge <= 0 ) {
168            return;
169        }
170
171        $cutoff = time() - $wgMWOAuthRequestExpirationAge;
172        $fname = __METHOD__;
173        DeferredUpdates::addUpdate(
174            new AutoCommitUpdate(
175                $dbw,
176                __METHOD__,
177                static function ( IDatabase $dbw ) use ( $cutoff, $fname ) {
178                    $dbw->newUpdateQueryBuilder()
179                        ->update( 'oauth_registered_consumer' )
180                        ->set( [
181                            'oarc_stage' => Consumer::STAGE_EXPIRED,
182                            'oarc_stage_timestamp' => $dbw->timestamp()
183                        ] )
184                        ->where( [
185                            'oarc_stage' => Consumer::STAGE_PROPOSED,
186                            $dbw->expr( 'oarc_stage_timestamp', '<', $dbw->timestamp( $cutoff ) )
187                        ] )
188                        ->caller( $fname )
189                        ->execute();
190                }
191            )
192        );
193    }
194
195    /**
196     * Get the pretty name of an OAuth wiki ID restriction value
197     *
198     * @param string $wikiId A wiki ID or '*'
199     * @return string
200     */
201    public static function getWikiIdName( $wikiId ) {
202        if ( $wikiId === '*' ) {
203            return wfMessage( 'mwoauth-consumer-allwikis' )->text();
204        }
205
206        $host = WikiMap::getWikiName( $wikiId );
207        if ( strpos( $host, '.' ) ) {
208            // e.g. "en.wikipedia.org"
209            return $host;
210        }
211
212        return $wikiId;
213    }
214
215    /**
216     * Get the pretty names of all local wikis
217     *
218     * @return string[] associative array of local wiki names indexed by wiki ID
219     */
220    public static function getAllWikiNames() {
221        global $wgConf;
222        $wikiNames = [];
223        foreach ( $wgConf->getLocalDatabases() as $dbname ) {
224            $name = self::getWikiIdName( $dbname );
225            if ( $name != $dbname ) {
226                $wikiNames[$dbname] = $name;
227            }
228        }
229        return $wikiNames;
230    }
231
232    /**
233     * Quickly get a new server with all the default configurations
234     *
235     * @return MWOAuthServer with default configurations
236     */
237    public static function newMWOAuthServer() {
238        $store = static::newMWOAuthDataStore();
239        $server = new MWOAuthServer( $store );
240        $server->add_signature_method( new OAuthSignatureMethod_HMAC_SHA1() );
241        $server->add_signature_method( new MWOAuthSignatureMethod_RSA_SHA1( $store ) );
242
243        return $server;
244    }
245
246    public static function newMWOAuthDataStore() {
247        $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
248        $dbr = self::getCentralDB( DB_REPLICA );
249        $dbw = $lb->getServerCount() > 1 ? self::getCentralDB( DB_PRIMARY ) : null;
250        return new MWOAuthDataStore( $dbr, $dbw, self::getSessionCache(), self::getNonceCache() );
251    }
252
253    /**
254     * Given a central wiki user ID, get a central username
255     *
256     * @param int $userId
257     * @param bool|User|string $audience show hidden names based on this user, or false for public
258     * @throws MWException
259     * @return string|bool Username, false if not found, empty string if name is hidden
260     */
261    public static function getCentralUserNameFromId( $userId, $audience = false ) {
262        global $wgMWOAuthSharedUserIDs, $wgMWOAuthSharedUserSource;
263
264        // global ID required via hook
265        if ( $wgMWOAuthSharedUserIDs ) {
266            $lookup = MediaWikiServices::getInstance()
267                ->getCentralIdLookupFactory()
268                ->getLookup( $wgMWOAuthSharedUserSource );
269            $name = $lookup->nameFromCentralId(
270                $userId,
271                $audience === 'raw'
272                    ? CentralIdLookup::AUDIENCE_RAW
273                    : ( $audience ?: CentralIdLookup::AUDIENCE_PUBLIC )
274            );
275            if ( $name === null ) {
276                $name = false;
277            }
278        } else {
279            $name = '';
280            $user = User::newFromId( $userId );
281            $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
282
283            if ( $audience === 'raw'
284                || !$user->isHidden()
285                || ( $audience instanceof User && $permissionManager->userHasRight( $audience, 'hideuser' ) )
286            ) {
287                $name = $user->getName();
288            }
289        }
290
291        return $name;
292    }
293
294    /**
295     * Given a central wiki user ID, get a local User object
296     *
297     * @param int $userId
298     * @return User|false False if not found
299     */
300    public static function getLocalUserFromCentralId( $userId ) {
301        global $wgMWOAuthSharedUserIDs, $wgMWOAuthSharedUserSource;
302
303        // global ID required via hook
304        if ( $wgMWOAuthSharedUserIDs ) {
305            $lookup = MediaWikiServices::getInstance()
306                ->getCentralIdLookupFactory()
307                ->getLookup( $wgMWOAuthSharedUserSource );
308            $user = $lookup->localUserFromCentralId( $userId );
309            if ( $user === null || !$lookup->isAttached( $user ) ) {
310                return false;
311            }
312            return User::newFromIdentity( $user );
313        }
314
315        return User::newFromId( $userId );
316    }
317
318    /**
319     * Given a local User object, get the user ID for that user on the central wiki
320     *
321     * @param User $user
322     * @return int|bool ID or false if not found
323     */
324    public static function getCentralIdFromLocalUser( User $user ) {
325        global $wgMWOAuthSharedUserIDs, $wgMWOAuthSharedUserSource;
326
327        // global ID required via hook
328        if ( $wgMWOAuthSharedUserIDs ) {
329            // T227688 do not rely on array auto-creation for non-stdClass
330            if ( !isset( $user->oAuthUserData ) ) {
331                $user->oAuthUserData = [];
332            }
333
334            if ( isset( $user->oAuthUserData['centralId'] ) ) {
335                $id = $user->oAuthUserData['centralId'];
336            } else {
337                $lookup = MediaWikiServices::getInstance()
338                    ->getCentralIdLookupFactory()
339                    ->getLookup( $wgMWOAuthSharedUserSource );
340                if ( !$lookup->isAttached( $user ) ) {
341                    $id = false;
342                } else {
343                    $id = $lookup->centralIdFromLocalUser( $user );
344                    if ( $id === 0 ) {
345                        $id = false;
346                    }
347                }
348                // Process cache the result to avoid queries
349                $user->oAuthUserData['centralId'] = $id;
350            }
351        } else {
352            $id = $user->getId();
353        }
354
355        return $id;
356    }
357
358    /**
359     * Given a username, get the user ID for that user on the central wiki.
360     * @param string $username
361     * @return int|bool ID or false if not found
362     */
363    public static function getCentralIdFromUserName( $username ) {
364        global $wgMWOAuthSharedUserIDs, $wgMWOAuthSharedUserSource;
365
366        // global ID required via hook
367        if ( $wgMWOAuthSharedUserIDs ) {
368            $lookup = MediaWikiServices::getInstance()
369                ->getCentralIdLookupFactory()
370                ->getLookup( $wgMWOAuthSharedUserSource );
371            $id = $lookup->centralIdFromName( $username );
372            if ( $id === 0 ) {
373                $id = false;
374            }
375        } else {
376            $id = false;
377            $user = User::newFromName( $username );
378            if ( $user instanceof User && $user->getId() > 0 ) {
379                $id = $user->getId();
380            }
381        }
382
383        return $id;
384    }
385
386    /**
387     * Get the effective secret key/token to use for OAuth purposes.
388     *
389     * For example, the "secret key" and "access secret" values that are
390     * used for authenticating request should be the result of applying this
391     * function to the respective values stored in the DB. This means that
392     * a leak of DB values is not enough to impersonate consumers.
393     *
394     * @param string $secret
395     * @return string
396     */
397    public static function hmacDBSecret( $secret ) {
398        global $wgOAuthSecretKey, $wgSecretKey;
399
400        $secretKey = $wgOAuthSecretKey ?? $wgSecretKey;
401
402        return $secretKey ? hash_hmac( 'sha1', $secret, $secretKey ) : $secret;
403    }
404
405    /**
406     * Get a link to the central wiki's user talk page of a user.
407     *
408     * @param string $username the username of the User Talk link
409     * @return string the (proto-relative, urlencoded) url of the central wiki's user talk page
410     */
411    public static function getCentralUserTalk( $username ) {
412        global $wgMWOAuthCentralWiki, $wgMWOAuthSharedUserIDs;
413
414        if ( $wgMWOAuthSharedUserIDs ) {
415            $url = WikiMap::getForeignURL(
416                $wgMWOAuthCentralWiki,
417                "User_talk:$username"
418            );
419        } else {
420            $url = Title::makeTitleSafe( NS_USER_TALK, $username )->getFullURL();
421        }
422        return $url;
423    }
424
425    /**
426     * @param array $grants
427     * @return bool
428     */
429    public static function grantsAreValid( array $grants ) {
430        // Remove our special grants before calling the core method
431        $grants = array_diff( $grants, [ 'mwoauth-authonly', 'mwoauth-authonlyprivate' ] );
432        return MediaWikiServices::getInstance()
433            ->getGrantsInfo()
434            ->grantsAreValid( $grants );
435    }
436
437    /**
438     * Given an OAuth consumer stage change event, find out who needs to be notified.
439     * Will be used as an EchoAttributeManager::ATTR_LOCATORS callback.
440     * @param Event $event
441     * @return User[]
442     */
443    public static function locateUsersToNotify( Event $event ) {
444        $agent = $event->getAgent();
445        $owner = self::getLocalUserFromCentralId( $event->getExtraParam( 'owner-id' ) );
446
447        $users = [];
448        switch ( $event->getType() ) {
449            case 'oauth-app-propose':
450                // notify OAuth admins about new proposed apps
451                $oauthAdmins = self::getOAuthAdmins();
452                foreach ( $oauthAdmins as $admin ) {
453                    if ( $admin->equals( $owner ) ) {
454                        continue;
455                    }
456                    $users[$admin->getId()] = $admin;
457                }
458                break;
459            case 'oauth-app-update':
460            case 'oauth-app-approve':
461            case 'oauth-app-reject':
462            case 'oauth-app-disable':
463            case 'oauth-app-reenable':
464                // notify owner if someone else changed the status of the app
465                if ( !$owner->equals( $agent ) ) {
466                    $users[$owner->getId()] = $owner;
467                }
468                break;
469        }
470        return $users;
471    }
472
473    /**
474     * Get the change tag name for a given consumer.
475     * @param int $consumerId
476     * @return string
477     */
478    public static function getTagName( $consumerId ) {
479        return 'OAuth CID: ' . (int)$consumerId;
480    }
481
482    /**
483     * Check if a given change tag name should be reserved for this extension.
484     * @param string $tagName
485     * @return bool
486     */
487    public static function isReservedTagName( $tagName ) {
488        return stripos( $tagName, 'oauth cid:' ) === 0;
489    }
490
491    /**
492     * Return a list of all OAuth admins (or the first 5000 in the unlikely case that there is more
493     * than that).
494     * Should be called on the central OAuth wiki.
495     * @return User[]
496     */
497    protected static function getOAuthAdmins() {
498        global $wgOAuthGroupsToNotify;
499
500        if ( !$wgOAuthGroupsToNotify ) {
501            return [];
502        }
503
504        return iterator_to_array( User::findUsersByGroup( $wgOAuthGroupsToNotify ) );
505    }
506}