Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
52.16% |
217 / 416 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
ConsumerSubmitControl | |
52.16% |
217 / 416 |
|
50.00% |
4 / 8 |
1779.11 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getRequiredFields | |
84.91% |
90 / 106 |
|
0.00% |
0 / 1 |
28.32 | |||
checkBasePermissions | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
9.36 | |||
processAction | |
28.74% |
71 / 247 |
|
0.00% |
0 / 1 |
2055.12 | |||
getLogTitle | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
makeLogEntry | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
1 | |||
notify | |
81.25% |
13 / 16 |
|
0.00% |
0 / 1 |
4.11 | |||
isSecureContext | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
9 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\OAuth\Control; |
4 | |
5 | use ApiMessage; |
6 | use Composer\Semver\VersionParser; |
7 | use Exception; |
8 | use ExtensionRegistry; |
9 | use FormatJson; |
10 | use IContextSource; |
11 | use LogicException; |
12 | use ManualLogEntry; |
13 | use MediaWiki\Extension\Notifications\Model\Event; |
14 | use MediaWiki\Extension\OAuth\Backend\Consumer; |
15 | use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance; |
16 | use MediaWiki\Extension\OAuth\Backend\MWOAuthDataStore; |
17 | use MediaWiki\Extension\OAuth\Backend\Utils; |
18 | use MediaWiki\Extension\OAuth\Entity\ClientEntity; |
19 | use MediaWiki\Extension\OAuth\OAuthServices; |
20 | use MediaWiki\Logger\LoggerFactory; |
21 | use MediaWiki\MediaWikiServices; |
22 | use MediaWiki\Parser\Sanitizer; |
23 | use MediaWiki\SpecialPage\SpecialPage; |
24 | use MediaWiki\Title\Title; |
25 | use MediaWiki\User\User; |
26 | use MediaWiki\WikiMap\WikiMap; |
27 | use MWCryptRand; |
28 | use MWException; |
29 | use StatusValue; |
30 | use UnexpectedValueException; |
31 | use Wikimedia\Rdbms\IDatabase; |
32 | |
33 | /** |
34 | * (c) Aaron Schulz 2013, GPL |
35 | * |
36 | * This program is free software; you can redistribute it and/or modify |
37 | * it under the terms of the GNU General Public License as published by |
38 | * the Free Software Foundation; either version 2 of the License, or |
39 | * (at your option) any later version. |
40 | * |
41 | * This program is distributed in the hope that it will be useful, |
42 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
43 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
44 | * GNU General Public License for more details. |
45 | * |
46 | * You should have received a copy of the GNU General Public License along |
47 | * with this program; if not, write to the Free Software Foundation, Inc., |
48 | * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. |
49 | * http://www.gnu.org/copyleft/gpl.html |
50 | */ |
51 | |
52 | /** |
53 | * This handles the core logic of approving/disabling consumers |
54 | * from using particular user accounts |
55 | * |
56 | * This control can only be used on the management wiki |
57 | * |
58 | * @TODO: improve error messages |
59 | */ |
60 | class ConsumerSubmitControl extends SubmitControl { |
61 | /** |
62 | * Names of the actions that can be performed on a consumer. These are the same as the |
63 | * options in getRequiredFields(). |
64 | * @var string[] |
65 | */ |
66 | public static $actions = [ 'propose', 'update', 'approve', 'reject', 'disable', 'reenable' ]; |
67 | |
68 | /** @var IDatabase */ |
69 | protected $dbw; |
70 | |
71 | /** |
72 | * MySQL Blob Size is 2^16 - 1 = 65535 as per "L + 2 bytes, where L < 216" on |
73 | * https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html |
74 | */ |
75 | private const BLOB_SIZE = 65535; |
76 | |
77 | /** |
78 | * @param IContextSource $context |
79 | * @param array $params |
80 | * @param IDatabase $dbw Result of Utils::getCentralDB( DB_PRIMARY ) |
81 | */ |
82 | public function __construct( IContextSource $context, array $params, IDatabase $dbw ) { |
83 | parent::__construct( $context, $params ); |
84 | $this->dbw = $dbw; |
85 | } |
86 | |
87 | protected function getRequiredFields() { |
88 | $validateRsaKey = static function ( $s ) { |
89 | if ( trim( $s ) === '' ) { |
90 | return true; |
91 | } |
92 | if ( strlen( $s ) > self::BLOB_SIZE ) { |
93 | return false; |
94 | } |
95 | $key = openssl_pkey_get_public( $s ); |
96 | if ( $key === false ) { |
97 | return false; |
98 | } |
99 | $info = openssl_pkey_get_details( $key ); |
100 | |
101 | return ( $info['type'] === OPENSSL_KEYTYPE_RSA ); |
102 | }; |
103 | |
104 | $suppress = [ 'suppress' => '/^[01]$/' ]; |
105 | $base = [ |
106 | 'consumerKey' => '/^[0-9a-f]{32}$/', |
107 | 'reason' => '/^.{0,255}$/', |
108 | 'changeToken' => '/^[0-9a-f]{40}$/' |
109 | ]; |
110 | |
111 | $validateBlobSize = static function ( $s ) { |
112 | return strlen( $s ?? '' ) < self::BLOB_SIZE; |
113 | }; |
114 | |
115 | return [ |
116 | // Proposer (application administrator) actions: |
117 | 'propose' => [ |
118 | 'name' => '/^.{1,128}$/', |
119 | 'version' => static function ( $s ) { |
120 | if ( strlen( $s ) > 32 ) { |
121 | return false; |
122 | } |
123 | $parser = new VersionParser(); |
124 | try { |
125 | $parser->normalize( $s ); |
126 | return true; |
127 | } catch ( UnexpectedValueException $e ) { |
128 | return false; |
129 | } |
130 | }, |
131 | 'oauthVersion' => static function ( $i ) { |
132 | return in_array( $i, [ Consumer::OAUTH_VERSION_1, Consumer::OAUTH_VERSION_2 ] ); |
133 | }, |
134 | 'callbackUrl' => static function ( $s, $vals ) { |
135 | $isOAuth1 = (int)$vals['oauthVersion'] === Consumer::OAUTH_VERSION_1; |
136 | $isOAuth2 = !$isOAuth1; |
137 | |
138 | if ( strlen( $s ?? '' ) > 2000 ) { |
139 | return false; |
140 | } elseif ( $vals['ownerOnly'] ) { |
141 | return true; |
142 | } |
143 | |
144 | $urlParts = wfParseUrl( $s ); |
145 | if ( !$urlParts ) { |
146 | return false; |
147 | } elseif ( $isOAuth2 && !self::isSecureContext( $urlParts ) ) { |
148 | return StatusValue::newFatal( |
149 | new ApiMessage( 'mwoauth-error-callback-url-must-be-https', 'invalid_callback_url' ) |
150 | ); |
151 | } elseif ( ( $isOAuth1 || $vals['oauth2IsConfidential'] ) |
152 | && WikiMap::getWikiFromUrl( $s ) |
153 | ) { |
154 | return StatusValue::newGood()->warning( |
155 | new ApiMessage( 'mwoauth-error-callback-server-url', 'invalid_callback_url' ) |
156 | ); |
157 | } elseif ( ( $isOAuth2 || !$vals['callbackIsPrefix'] ) |
158 | && in_array( $urlParts['path'] ?? '', [ '', '/' ], true ) |
159 | && !( $urlParts['query'] ?? false ) |
160 | && !( $urlParts['fragment'] ?? false ) |
161 | ) { |
162 | $message = $isOAuth1 |
163 | ? 'mwoauth-error-callback-bare-domain-oauth1' |
164 | : 'mwoauth-error-callback-bare-domain-oauth2'; |
165 | return StatusValue::newGood()->warning( |
166 | new ApiMessage( $message, 'invalid_callback_url' ) |
167 | ); |
168 | } |
169 | return true; |
170 | }, |
171 | 'description' => $validateBlobSize, |
172 | 'email' => static function ( $s ) { |
173 | return Sanitizer::validateEmail( $s ); |
174 | }, |
175 | 'wiki' => static function ( $s ) { |
176 | global $wgConf; |
177 | return ( $s === '*' |
178 | || in_array( $s, $wgConf->getLocalDatabases() ) |
179 | || in_array( $s, Utils::getAllWikiNames() ) |
180 | ); |
181 | }, |
182 | 'oauth2GrantTypes' => static function ( $a, $vals ) { |
183 | if ( $vals['oauthVersion'] == Consumer::OAUTH_VERSION_1 ) { |
184 | return true; |
185 | } |
186 | |
187 | // OAuth 2 apps must have at least one grant type |
188 | return count( $a ) > 0 && strlen( FormatJson::encode( $a ) ) <= self::BLOB_SIZE; |
189 | }, |
190 | 'granttype' => '/^(authonly|authonlyprivate|normal)$/', |
191 | 'grants' => static function ( $s ) { |
192 | if ( strlen( $s ) > self::BLOB_SIZE ) { |
193 | return false; |
194 | } |
195 | $grants = FormatJson::decode( $s, true ); |
196 | return is_array( $grants ) && Utils::grantsAreValid( $grants ); |
197 | }, |
198 | 'restrictions' => $validateBlobSize, |
199 | 'rsaKey' => $validateRsaKey, |
200 | 'agreement' => static function ( $s ) { |
201 | return ( $s == true ); |
202 | }, |
203 | ], |
204 | 'update' => array_merge( $base, [ |
205 | 'restrictions' => $validateBlobSize, |
206 | 'rsaKey' => $validateRsaKey, |
207 | 'resetSecret' => static function ( $s ) { |
208 | return is_bool( $s ); |
209 | }, |
210 | ] ), |
211 | // Approver (project administrator) actions: |
212 | 'approve' => $base, |
213 | 'reject' => array_merge( $base, $suppress ), |
214 | 'disable' => array_merge( $base, $suppress ), |
215 | 'reenable' => $base |
216 | ]; |
217 | } |
218 | |
219 | protected function checkBasePermissions() { |
220 | global $wgBlockDisablesLogin; |
221 | $user = $this->getUser(); |
222 | $readOnlyMode = MediaWikiServices::getInstance()->getReadOnlyMode(); |
223 | if ( !$user->getId() ) { |
224 | return $this->failure( 'not_logged_in', 'badaccess-group0' ); |
225 | } elseif ( $user->isLocked() || ( $wgBlockDisablesLogin && $user->getBlock() ) ) { |
226 | return $this->failure( 'user_blocked', 'badaccess-group0' ); |
227 | } elseif ( $readOnlyMode->isReadOnly() ) { |
228 | return $this->failure( 'readonly', 'readonlytext', $readOnlyMode->getReason() ); |
229 | } elseif ( !Utils::isCentralWiki() ) { |
230 | // This logs consumer changes to the local logging table on the central wiki |
231 | throw new LogicException( "This can only be used from the OAuth management wiki." ); |
232 | } |
233 | return $this->success(); |
234 | } |
235 | |
236 | protected function processAction( $action ) { |
237 | $context = $this->getContext(); |
238 | // proposer or admin |
239 | $user = $this->getUser(); |
240 | $dbw = $this->dbw; |
241 | |
242 | $centralUserId = Utils::getCentralIdFromLocalUser( $user ); |
243 | if ( !$centralUserId ) { |
244 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
245 | } |
246 | |
247 | $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); |
248 | |
249 | switch ( $action ) { |
250 | case 'propose': |
251 | if ( !$permissionManager->userHasRight( $user, 'mwoauthproposeconsumer' ) ) { |
252 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
253 | } elseif ( !$user->isEmailConfirmed() ) { |
254 | return $this->failure( 'email_not_confirmed', 'mwoauth-consumer-email-unconfirmed' ); |
255 | } elseif ( $user->getEmail() !== $this->vals['email'] ) { |
256 | // @TODO: allow any email and don't set emailAuthenticated below |
257 | return $this->failure( 'email_mismatched', 'mwoauth-consumer-email-mismatched' ); |
258 | } |
259 | |
260 | if ( Consumer::newFromNameVersionUser( |
261 | $dbw, $this->vals['name'], $this->vals['version'], $centralUserId |
262 | ) ) { |
263 | return $this->failure( 'consumer_exists', 'mwoauth-consumer-alreadyexists' ); |
264 | } |
265 | |
266 | $wikiNames = Utils::getAllWikiNames(); |
267 | $dbKey = array_search( $this->vals['wiki'], $wikiNames ); |
268 | if ( $dbKey !== false ) { |
269 | $this->vals['wiki'] = $dbKey; |
270 | } |
271 | |
272 | $curVer = $dbw->selectField( 'oauth_registered_consumer', |
273 | 'oarc_version', |
274 | [ 'oarc_name' => $this->vals['name'], 'oarc_user_id' => $centralUserId ], |
275 | __METHOD__, |
276 | [ 'ORDER BY' => 'oarc_registration DESC', 'FOR UPDATE' ] |
277 | ); |
278 | if ( $curVer !== false && version_compare( $curVer, $this->vals['version'], '>=' ) ) { |
279 | return $this->failure( 'consumer_exists', |
280 | 'mwoauth-consumer-alreadyexistsversion', $curVer ); |
281 | } |
282 | |
283 | // Handle owner-only mode |
284 | if ( $this->vals['ownerOnly'] ) { |
285 | $this->vals['callbackUrl'] = SpecialPage::getTitleFor( 'OAuth', 'verified' ) |
286 | ->getLocalURL(); |
287 | $this->vals['callbackIsPrefix'] = ''; |
288 | $stage = Consumer::STAGE_APPROVED; |
289 | } else { |
290 | $stage = Consumer::STAGE_PROPOSED; |
291 | } |
292 | |
293 | // Handle grant types |
294 | $grants = []; |
295 | switch ( $this->vals['granttype'] ) { |
296 | case 'authonly': |
297 | $grants = [ 'mwoauth-authonly' ]; |
298 | break; |
299 | case 'authonlyprivate': |
300 | $grants = [ 'mwoauth-authonlyprivate' ]; |
301 | break; |
302 | case 'normal': |
303 | $grants = array_unique( array_merge( |
304 | // implied grants |
305 | MediaWikiServices::getInstance() |
306 | ->getGrantsInfo() |
307 | ->getHiddenGrants(), |
308 | FormatJson::decode( $this->vals['grants'], true ) |
309 | ) ); |
310 | break; |
311 | } |
312 | |
313 | $now = wfTimestampNow(); |
314 | $cmr = Consumer::newFromArray( |
315 | [ |
316 | 'id' => null, |
317 | 'consumerKey' => MWCryptRand::generateHex( 32 ), |
318 | 'userId' => $centralUserId, |
319 | 'email' => $user->getEmail(), |
320 | 'emailAuthenticated' => $now, |
321 | 'developerAgreement' => 1, |
322 | 'secretKey' => MWCryptRand::generateHex( 32 ), |
323 | 'registration' => $now, |
324 | 'stage' => $stage, |
325 | 'stageTimestamp' => $now, |
326 | 'grants' => $grants, |
327 | 'restrictions' => $this->vals['restrictions'], |
328 | 'deleted' => 0 |
329 | ] + $this->vals |
330 | ); |
331 | |
332 | $logAction = 'propose'; |
333 | $oauthServices = OAuthServices::wrap( MediaWikiServices::getInstance() ); |
334 | $workflow = $oauthServices->getWorkflow(); |
335 | $autoApproved = $workflow->consumerCanBeAutoApproved( $cmr ); |
336 | if ( $cmr->getOwnerOnly() ) { |
337 | // FIXME the stage is set a few dozen lines earlier - should simplify this |
338 | $logAction = 'create-owner-only'; |
339 | } elseif ( $autoApproved ) { |
340 | $cmr->setField( 'stage', Consumer::STAGE_APPROVED ); |
341 | $logAction = 'propose-autoapproved'; |
342 | } |
343 | |
344 | $cmr->save( $dbw ); |
345 | $this->makeLogEntry( $dbw, $cmr, $logAction, $user, $this->vals['description'] ); |
346 | if ( !$cmr->getOwnerOnly() && !$autoApproved ) { |
347 | // Notify admins if the consumer needs to be approved. |
348 | if ( $cmr->getStage() === Consumer::STAGE_PROPOSED ) { |
349 | $this->notify( $cmr, $user, $action, '' ); |
350 | } |
351 | } |
352 | |
353 | // If it's owner-only, automatically accept it for the user too. |
354 | $accessToken = null; |
355 | if ( $cmr->getOwnerOnly() ) { |
356 | $accessToken = MWOAuthDataStore::newToken(); |
357 | $cmra = ConsumerAcceptance::newFromArray( [ |
358 | 'id' => null, |
359 | 'wiki' => $cmr->getWiki(), |
360 | 'userId' => $centralUserId, |
361 | 'consumerId' => $cmr->getId(), |
362 | 'accessToken' => $accessToken->key, |
363 | 'accessSecret' => $accessToken->secret, |
364 | 'grants' => $cmr->getGrants(), |
365 | 'accepted' => $now, |
366 | 'oauth_version' => $cmr->getOAuthVersion() |
367 | ] ); |
368 | $cmra->save( $dbw ); |
369 | if ( $cmr instanceof ClientEntity ) { |
370 | // OAuth2 client |
371 | try { |
372 | $accessToken = $cmr->getOwnerOnlyAccessToken( $cmra ); |
373 | } catch ( Exception $ex ) { |
374 | return $this->failure( |
375 | 'unable_to_retrieve_access_token', |
376 | 'mwoauth-oauth2-unable-to-retrieve-access-token', |
377 | $ex->getMessage() |
378 | ); |
379 | } |
380 | } |
381 | } |
382 | |
383 | return $this->success( [ 'consumer' => $cmr, 'accessToken' => $accessToken ] ); |
384 | case 'update': |
385 | if ( !$permissionManager->userHasRight( $user, 'mwoauthupdateownconsumer' ) ) { |
386 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
387 | } |
388 | |
389 | $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); |
390 | if ( !$cmr ) { |
391 | return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); |
392 | } elseif ( $cmr->getUserId() !== $centralUserId ) { |
393 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
394 | } elseif ( |
395 | $cmr->getStage() !== Consumer::STAGE_APPROVED |
396 | && $cmr->getStage() !== Consumer::STAGE_PROPOSED |
397 | ) { |
398 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
399 | } elseif ( $cmr->getDeleted() |
400 | && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) { |
401 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
402 | } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { |
403 | return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); |
404 | } |
405 | |
406 | $cmr->setFields( [ |
407 | 'rsaKey' => $this->vals['rsaKey'], |
408 | 'restrictions' => $this->vals['restrictions'], |
409 | 'secretKey' => $this->vals['resetSecret'] |
410 | ? MWCryptRand::generateHex( 32 ) |
411 | : $cmr->getSecretKey(), |
412 | ] ); |
413 | |
414 | // Log if something actually changed |
415 | if ( $cmr->save( $dbw ) ) { |
416 | $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); |
417 | $this->notify( $cmr, $user, $action, $this->vals['reason'] ); |
418 | } |
419 | |
420 | $accessToken = null; |
421 | if ( $cmr->getOwnerOnly() && $this->vals['resetSecret'] ) { |
422 | $cmra = $cmr->getCurrentAuthorization( $user, WikiMap::getCurrentWikiId() ); |
423 | $accessToken = MWOAuthDataStore::newToken(); |
424 | $fields = [ |
425 | 'wiki' => $cmr->getWiki(), |
426 | 'userId' => $centralUserId, |
427 | 'consumerId' => $cmr->getId(), |
428 | 'accessSecret' => $accessToken->secret, |
429 | 'grants' => $cmr->getGrants(), |
430 | ]; |
431 | |
432 | if ( $cmra ) { |
433 | $accessToken->key = $cmra->getAccessToken(); |
434 | $cmra->setFields( $fields ); |
435 | } else { |
436 | $cmra = ConsumerAcceptance::newFromArray( $fields + [ |
437 | 'id' => null, |
438 | 'accessToken' => $accessToken->key, |
439 | 'accepted' => wfTimestampNow(), |
440 | ] ); |
441 | } |
442 | $cmra->save( $dbw ); |
443 | if ( $cmr instanceof ClientEntity ) { |
444 | $accessToken = $cmr->getOwnerOnlyAccessToken( $cmra, true ); |
445 | } |
446 | } |
447 | |
448 | return $this->success( [ 'consumer' => $cmr, 'accessToken' => $accessToken ] ); |
449 | case 'approve': |
450 | if ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) { |
451 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
452 | } |
453 | |
454 | $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); |
455 | if ( !$cmr ) { |
456 | return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); |
457 | } elseif ( !in_array( $cmr->getStage(), [ |
458 | Consumer::STAGE_PROPOSED, |
459 | Consumer::STAGE_EXPIRED, |
460 | Consumer::STAGE_REJECTED, |
461 | ] ) ) { |
462 | return $this->failure( 'not_proposed', 'mwoauth-consumer-not-proposed' ); |
463 | } elseif ( $cmr->getDeleted() && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) { |
464 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
465 | } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { |
466 | return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); |
467 | } |
468 | |
469 | $cmr->setFields( [ |
470 | 'stage' => Consumer::STAGE_APPROVED, |
471 | 'stageTimestamp' => wfTimestampNow(), |
472 | 'deleted' => 0 ] ); |
473 | |
474 | // Log if something actually changed |
475 | if ( $cmr->save( $dbw ) ) { |
476 | $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); |
477 | $this->notify( $cmr, $user, $action, $this->vals['reason'] ); |
478 | } |
479 | |
480 | return $this->success( $cmr ); |
481 | case 'reject': |
482 | if ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) { |
483 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
484 | } |
485 | |
486 | $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); |
487 | if ( !$cmr ) { |
488 | return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); |
489 | } elseif ( $cmr->getStage() !== Consumer::STAGE_PROPOSED ) { |
490 | return $this->failure( 'not_proposed', 'mwoauth-consumer-not-proposed' ); |
491 | } elseif ( $cmr->getDeleted() && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) { |
492 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
493 | } elseif ( $this->vals['suppress'] && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) { |
494 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
495 | } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { |
496 | return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); |
497 | } |
498 | |
499 | $cmr->setFields( [ |
500 | 'stage' => Consumer::STAGE_REJECTED, |
501 | 'stageTimestamp' => wfTimestampNow(), |
502 | 'deleted' => $this->vals['suppress'] ] ); |
503 | |
504 | // Log if something actually changed |
505 | if ( $cmr->save( $dbw ) ) { |
506 | $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); |
507 | $this->notify( $cmr, $user, $action, $this->vals['reason'] ); |
508 | } |
509 | |
510 | return $this->success( $cmr ); |
511 | case 'disable': |
512 | if ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) { |
513 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
514 | } elseif ( $this->vals['suppress'] && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) { |
515 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
516 | } |
517 | |
518 | $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); |
519 | if ( !$cmr ) { |
520 | return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); |
521 | } elseif ( $cmr->getStage() !== Consumer::STAGE_APPROVED |
522 | && $cmr->getDeleted() == $this->vals['suppress'] |
523 | ) { |
524 | return $this->failure( 'not_approved', 'mwoauth-consumer-not-approved' ); |
525 | } elseif ( $cmr->getDeleted() && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) { |
526 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
527 | } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { |
528 | return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); |
529 | } |
530 | |
531 | $cmr->setFields( [ |
532 | 'stage' => Consumer::STAGE_DISABLED, |
533 | 'stageTimestamp' => wfTimestampNow(), |
534 | 'deleted' => $this->vals['suppress'] ] ); |
535 | |
536 | // Log if something actually changed |
537 | if ( $cmr->save( $dbw ) ) { |
538 | $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); |
539 | $this->notify( $cmr, $user, $action, $this->vals['reason'] ); |
540 | } |
541 | |
542 | return $this->success( $cmr ); |
543 | case 'reenable': |
544 | if ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) { |
545 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
546 | } |
547 | |
548 | $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); |
549 | if ( !$cmr ) { |
550 | return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); |
551 | } elseif ( $cmr->getStage() !== Consumer::STAGE_DISABLED ) { |
552 | return $this->failure( 'not_disabled', 'mwoauth-consumer-not-disabled' ); |
553 | } elseif ( $cmr->getDeleted() && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) { |
554 | return $this->failure( 'permission_denied', 'badaccess-group0' ); |
555 | } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { |
556 | return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); |
557 | } |
558 | |
559 | $cmr->setFields( [ |
560 | 'stage' => Consumer::STAGE_APPROVED, |
561 | 'stageTimestamp' => wfTimestampNow(), |
562 | 'deleted' => 0 ] ); |
563 | |
564 | // Log if something actually changed |
565 | if ( $cmr->save( $dbw ) ) { |
566 | $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); |
567 | $this->notify( $cmr, $user, $action, $this->vals['reason'] ); |
568 | } |
569 | |
570 | return $this->success( $cmr ); |
571 | } |
572 | } |
573 | |
574 | /** |
575 | * @param IDatabase $db |
576 | * @param int $userId |
577 | * @return Title |
578 | */ |
579 | protected function getLogTitle( IDatabase $db, $userId ) { |
580 | $name = Utils::getCentralUserNameFromId( $userId ); |
581 | return Title::makeTitleSafe( NS_USER, $name ); |
582 | } |
583 | |
584 | /** |
585 | * @param IDatabase $dbw |
586 | * @param Consumer $cmr |
587 | * @param string $action |
588 | * @param User $performer |
589 | * @param string $comment |
590 | */ |
591 | protected function makeLogEntry( |
592 | $dbw, Consumer $cmr, $action, User $performer, $comment |
593 | ) { |
594 | $logEntry = new ManualLogEntry( 'mwoauthconsumer', $action ); |
595 | $logEntry->setPerformer( $performer ); |
596 | $target = $this->getLogTitle( $dbw, $cmr->getUserId() ); |
597 | $logEntry->setTarget( $target ); |
598 | $logEntry->setComment( $comment ); |
599 | $logEntry->setParameters( [ '4:consumer' => $cmr->getConsumerKey() ] ); |
600 | $logEntry->setRelations( [ |
601 | 'OAuthConsumer' => [ $cmr->getConsumerKey() ] |
602 | ] ); |
603 | $logEntry->insert( $dbw ); |
604 | |
605 | LoggerFactory::getInstance( 'OAuth' )->info( |
606 | '{user} performed action {action} on consumer {consumer}', [ |
607 | 'action' => $action, |
608 | 'user' => $performer->getName(), |
609 | 'consumer' => $cmr->getConsumerKey(), |
610 | 'target' => $target->getText(), |
611 | 'comment' => $comment, |
612 | 'clientip' => $this->getContext()->getRequest()->getIP(), |
613 | ] |
614 | ); |
615 | } |
616 | |
617 | /** |
618 | * @param Consumer $cmr Consumer which was the subject of the action |
619 | * @param User $user User who performed the action |
620 | * @param string $actionType |
621 | * @param string $comment |
622 | */ |
623 | protected function notify( $cmr, $user, $actionType, $comment ) { |
624 | if ( !in_array( $actionType, self::$actions, true ) ) { |
625 | throw new MWException( "Invalid action type: $actionType" ); |
626 | } elseif ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
627 | return; |
628 | } elseif ( !Utils::isCentralWiki() ) { |
629 | # sanity; should never get here on a replica wiki |
630 | return; |
631 | } |
632 | |
633 | Event::create( [ |
634 | 'type' => 'oauth-app-' . $actionType, |
635 | 'agent' => $user, |
636 | 'extra' => [ |
637 | 'action' => $actionType, |
638 | 'app-key' => $cmr->getConsumerKey(), |
639 | 'owner-id' => $cmr->getUserId(), |
640 | 'comment' => $comment, |
641 | ], |
642 | ] ); |
643 | } |
644 | |
645 | /** |
646 | * Decide whether the given (parsed) URL corresponds to a secure context. |
647 | * (This is only an approximation of the algorithm browsers use, |
648 | * since some considerations such as frames don't apply here.) |
649 | * |
650 | * @see https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts |
651 | * |
652 | * @param array $urlParts As returned by {@link wfParseUrl()}. |
653 | * @return bool |
654 | */ |
655 | protected static function isSecureContext( array $urlParts ): bool { |
656 | if ( $urlParts['scheme'] === 'https' ) { |
657 | return true; |
658 | } |
659 | |
660 | $host = $urlParts['host']; |
661 | if ( $host === 'localhost' |
662 | || $host === '127.0.0.1' |
663 | || $host === '[::1]' |
664 | || str_ends_with( $host, '.localhost' ) |
665 | // The wmftest.{com,net,org} domains hosted by the Wikimedia |
666 | // Foundation include a '*.local IN A 127.0.0.1' that is used in |
667 | // some local development environments. |
668 | || str_ends_with( $host, '.local.wmftest.com' ) |
669 | || str_ends_with( $host, '.local.wmftest.net' ) |
670 | || str_ends_with( $host, '.local.wmftest.org' ) |
671 | ) { |
672 | return true; |
673 | } |
674 | |
675 | return false; |
676 | } |
677 | } |