Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 313 |
|
0.00% |
0 / 44 |
CRAP | |
0.00% |
0 / 1 |
Consumer | |
0.00% |
0 / 313 |
|
0.00% |
0 / 44 |
7140 | |
0.00% |
0 / 1 |
getSchema | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
2 | |||
getFieldPermissionChecks | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
2 | |||
getConsumerClass | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
isOAuth2 | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
newFromKey | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
newFromNameVersionUser | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
newGrants | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAllStages | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getConsumerKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUserId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getVersion | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCallbackUrl | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCallbackIsPrefix | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEmail | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEmailAuthenticated | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDeveloperAgreement | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getOwnerOnly | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getOAuthVersion | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getWiki | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGrants | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRegistration | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSecretKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRsaKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRestrictions | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getStage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getStageTimestamp | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDeleted | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLocalUserId | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
generateCallbackUrl | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getCurrentAuthorization | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
20 | |||
authorize | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
conductAuthorizationChecks | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
56 | |||
saveAuthorization | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
30 | |||
isUsableBy | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
42 | |||
normalizeValues | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
encodeRow | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
decodeRow | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
__get | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
userCanSee | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
userCanSeePrivate | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
userCanSeeEmail | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
userCanSeeSecurity | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
userCanSeeSecret | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * (c) Aaron Schulz 2013, GPL |
5 | * |
6 | * This program is free software; you can redistribute it and/or modify |
7 | * it under the terms of the GNU General Public License as published by |
8 | * the Free Software Foundation; either version 2 of the License, or |
9 | * (at your option) any later version. |
10 | * |
11 | * This program is distributed in the hope that it will be useful, |
12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
14 | * GNU General Public License for more details. |
15 | * |
16 | * You should have received a copy of the GNU General Public License along |
17 | * with this program; if not, write to the Free Software Foundation, Inc., |
18 | * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. |
19 | * http://www.gnu.org/copyleft/gpl.html |
20 | */ |
21 | |
22 | namespace MediaWiki\Extension\OAuth\Backend; |
23 | |
24 | use FormatJson; |
25 | use IContextSource; |
26 | use IDBAccessObject; |
27 | use LogicException; |
28 | use MediaWiki\Extension\OAuth\Entity\ClientEntity as OAuth2Client; |
29 | use MediaWiki\Linker\Linker; |
30 | use MediaWiki\MediaWikiServices; |
31 | use MediaWiki\SpecialPage\SpecialPage; |
32 | use MediaWiki\User\User; |
33 | use MediaWiki\WikiMap\WikiMap; |
34 | use Message; |
35 | use MWRestrictions; |
36 | use Wikimedia\Rdbms\IDatabase; |
37 | |
38 | /** |
39 | * Representation of an OAuth consumer. |
40 | */ |
41 | abstract class Consumer extends MWOAuthDAO { |
42 | public const OAUTH_VERSION_1 = 1; |
43 | public const OAUTH_VERSION_2 = 2; |
44 | |
45 | /** @var array Backwards-compatibility grant mappings */ |
46 | public static $mapBackCompatGrants = [ |
47 | 'useoauth' => 'basic', |
48 | 'authonly' => 'mwoauth-authonly', |
49 | 'authonlyprivate' => 'mwoauth-authonlyprivate', |
50 | ]; |
51 | |
52 | /** @var int|null Unique ID */ |
53 | protected $id; |
54 | /** @var string Hex token */ |
55 | protected $consumerKey; |
56 | /** @var string Name of connected application */ |
57 | protected $name; |
58 | /** @var int Publisher's central user ID. $wgMWOAuthSharedUserIDs defines which central ID |
59 | * provider to use. |
60 | */ |
61 | protected $userId; |
62 | /** @var string Version used for handshake breaking changes */ |
63 | protected $version; |
64 | /** @var string OAuth callback URL for authorization step */ |
65 | protected $callbackUrl; |
66 | /** |
67 | * @var bool OAuth callback URL is a prefix and we allow all URLs which |
68 | * have callbackUrl as the prefix |
69 | */ |
70 | protected $callbackIsPrefix; |
71 | /** @var string Application description */ |
72 | protected $description; |
73 | /** @var string Publisher email address */ |
74 | protected $email; |
75 | /** @var string|null TS_MW timestamp of when email address was confirmed */ |
76 | protected $emailAuthenticated; |
77 | /** @var bool User accepted the developer agreement */ |
78 | protected $developerAgreement; |
79 | /** @var bool Consumer is for use by the owner only */ |
80 | protected $ownerOnly; |
81 | /** @var int Version of the OAuth protocol */ |
82 | protected $oauthVersion; |
83 | /** @var string Wiki ID the application can be used on (or "*" for all) */ |
84 | protected $wiki; |
85 | /** @var string TS_MW timestamp of proposal */ |
86 | protected $registration; |
87 | /** @var string Secret HMAC key */ |
88 | protected $secretKey; |
89 | /** @var string Public RSA key */ |
90 | protected $rsaKey; |
91 | /** @var array List of grants */ |
92 | protected $grants; |
93 | /** @var MWRestrictions IP restrictions */ |
94 | protected $restrictions; |
95 | /** @var int MWOAuthConsumer::STAGE_* constant */ |
96 | protected $stage; |
97 | /** @var string TS_MW timestamp of last stage change */ |
98 | protected $stageTimestamp; |
99 | /** @var bool Indicates this consumer's information is suppressed */ |
100 | protected $deleted; |
101 | /** @var bool Indicates whether the client (consumer) is able to keep the secret */ |
102 | protected $oauth2IsConfidential; |
103 | /** @var array OAuth2 grant types available to the client */ |
104 | protected $oauth2GrantTypes; |
105 | |
106 | /* Stages that registered consumer takes (stored in DB) */ |
107 | public const STAGE_PROPOSED = 0; |
108 | public const STAGE_APPROVED = 1; |
109 | public const STAGE_REJECTED = 2; |
110 | public const STAGE_EXPIRED = 3; |
111 | public const STAGE_DISABLED = 4; |
112 | |
113 | /** @var int|false|null Cache for local ID looked up from $userId */ |
114 | protected $localUserId; |
115 | |
116 | /** |
117 | * Maps stage ids to human-readable names which describe them as a state |
118 | * @var array<int,string> |
119 | */ |
120 | public static $stageNames = [ |
121 | self::STAGE_PROPOSED => 'proposed', |
122 | self::STAGE_REJECTED => 'rejected', |
123 | self::STAGE_EXPIRED => 'expired', |
124 | self::STAGE_APPROVED => 'approved', |
125 | self::STAGE_DISABLED => 'disabled', |
126 | ]; |
127 | |
128 | /** |
129 | * Maps stage ids to human-readable names which describe them as an action (which would result |
130 | * in that stage) |
131 | * @var array<int,string> |
132 | */ |
133 | public static $stageActionNames = [ |
134 | self::STAGE_PROPOSED => 'propose', |
135 | self::STAGE_REJECTED => 'reject', |
136 | self::STAGE_EXPIRED => 'propose', |
137 | self::STAGE_APPROVED => 'approve', |
138 | self::STAGE_DISABLED => 'disable', |
139 | ]; |
140 | |
141 | /** |
142 | * Get member => db field mapping |
143 | * Loads all fields to avoid unnecessary querying |
144 | * |
145 | * @return array |
146 | */ |
147 | protected static function getSchema() { |
148 | return [ |
149 | 'table' => 'oauth_registered_consumer', |
150 | 'fieldColumnMap' => [ |
151 | 'id' => 'oarc_id', |
152 | 'consumerKey' => 'oarc_consumer_key', |
153 | 'name' => 'oarc_name', |
154 | 'userId' => 'oarc_user_id', |
155 | 'version' => 'oarc_version', |
156 | 'callbackUrl' => 'oarc_callback_url', |
157 | 'callbackIsPrefix' => 'oarc_callback_is_prefix', |
158 | 'description' => 'oarc_description', |
159 | 'email' => 'oarc_email', |
160 | 'emailAuthenticated' => 'oarc_email_authenticated', |
161 | 'oauthVersion' => 'oarc_oauth_version', |
162 | 'developerAgreement' => 'oarc_developer_agreement', |
163 | 'ownerOnly' => 'oarc_owner_only', |
164 | 'wiki' => 'oarc_wiki', |
165 | 'grants' => 'oarc_grants', |
166 | 'registration' => 'oarc_registration', |
167 | 'secretKey' => 'oarc_secret_key', |
168 | 'rsaKey' => 'oarc_rsa_key', |
169 | 'restrictions' => 'oarc_restrictions', |
170 | 'stage' => 'oarc_stage', |
171 | 'stageTimestamp' => 'oarc_stage_timestamp', |
172 | 'deleted' => 'oarc_deleted', |
173 | 'oauth2IsConfidential' => 'oarc_oauth2_is_confidential', |
174 | 'oauth2GrantTypes' => 'oarc_oauth2_allowed_grants', |
175 | ], |
176 | 'idField' => 'id', |
177 | 'autoIncrField' => 'id', |
178 | ]; |
179 | } |
180 | |
181 | protected static function getFieldPermissionChecks() { |
182 | return [ |
183 | 'name' => 'userCanSee', |
184 | 'userId' => 'userCanSee', |
185 | 'version' => 'userCanSee', |
186 | 'callbackUrl' => 'userCanSee', |
187 | 'callbackIsPrefix' => 'userCanSee', |
188 | 'description' => 'userCanSee', |
189 | 'rsaKey' => 'userCanSee', |
190 | 'email' => 'userCanSeeEmail', |
191 | 'secretKey' => 'userCanSeeSecret', |
192 | 'restrictions' => 'userCanSeeSecurity', |
193 | ]; |
194 | } |
195 | |
196 | /** |
197 | * @param array $data |
198 | * @return string |
199 | */ |
200 | protected static function getConsumerClass( array $data ) { |
201 | return static::isOAuth2( $data ) ? |
202 | OAuth2Client::class : |
203 | OAuth1Consumer::class; |
204 | } |
205 | |
206 | /** |
207 | * @param array $data |
208 | * @return bool |
209 | */ |
210 | protected static function isOAuth2( array $data = [] ) { |
211 | $oauthVersion = $data['oarc_oauth_version'] ?? $data['oauthVersion']; |
212 | return (int)$oauthVersion === self::OAUTH_VERSION_2; |
213 | } |
214 | |
215 | /** |
216 | * @param IDatabase $db |
217 | * @param string|null $key |
218 | * @param int $flags IDBAccessObject::READ_* bitfield |
219 | * @return Consumer|false |
220 | */ |
221 | public static function newFromKey( IDatabase $db, $key, $flags = 0 ) { |
222 | $row = $db->selectRow( static::getTable(), |
223 | array_values( static::getFieldColumnMap() ), |
224 | [ 'oarc_consumer_key' => (string)$key ], |
225 | __METHOD__, |
226 | ( $flags & IDBAccessObject::READ_LOCKING ) ? [ 'FOR UPDATE' ] : [] |
227 | ); |
228 | |
229 | if ( $row ) { |
230 | return static::newFromRow( $db, $row ); |
231 | } else { |
232 | return false; |
233 | } |
234 | } |
235 | |
236 | /** |
237 | * @param IDatabase $db |
238 | * @param string $name |
239 | * @param string $version |
240 | * @param int $userId Central user ID |
241 | * @param int $flags IDBAccessObject::READ_* bitfield |
242 | * @return Consumer|bool |
243 | */ |
244 | public static function newFromNameVersionUser( |
245 | IDatabase $db, $name, $version, $userId, $flags = 0 |
246 | ) { |
247 | $row = $db->selectRow( static::getTable(), |
248 | array_values( static::getFieldColumnMap() ), |
249 | [ |
250 | 'oarc_name' => (string)$name, |
251 | 'oarc_version' => (string)$version, |
252 | 'oarc_user_id' => (int)$userId |
253 | ], |
254 | __METHOD__, |
255 | ( $flags & IDBAccessObject::READ_LOCKING ) ? [ 'FOR UPDATE' ] : [] |
256 | ); |
257 | |
258 | if ( $row ) { |
259 | return static::newFromRow( $db, $row ); |
260 | } else { |
261 | return false; |
262 | } |
263 | } |
264 | |
265 | /** |
266 | * @return string[] |
267 | */ |
268 | public static function newGrants() { |
269 | return []; |
270 | } |
271 | |
272 | /** |
273 | * @return int[] |
274 | */ |
275 | public static function getAllStages() { |
276 | return [ |
277 | self::STAGE_PROPOSED, |
278 | self::STAGE_REJECTED, |
279 | self::STAGE_EXPIRED, |
280 | self::STAGE_APPROVED, |
281 | self::STAGE_DISABLED, |
282 | ]; |
283 | } |
284 | |
285 | /** |
286 | * Internal ID (DB primary key). |
287 | * @return int |
288 | */ |
289 | public function getId() { |
290 | return $this->get( 'id' ); |
291 | } |
292 | |
293 | /** |
294 | * Consumer key (32-character hexadecimal string that's used in the OAuth protocol |
295 | * and in URLs). This is used as the consumer ID for most external purposes. |
296 | * @return string |
297 | */ |
298 | public function getConsumerKey() { |
299 | return $this->get( 'consumerKey' ); |
300 | } |
301 | |
302 | /** |
303 | * Name of the consumer. |
304 | * @return string |
305 | */ |
306 | public function getName() { |
307 | return $this->get( 'name' ); |
308 | } |
309 | |
310 | /** |
311 | * Central ID of the owner. |
312 | * @return int |
313 | */ |
314 | public function getUserId() { |
315 | return $this->get( 'userId' ); |
316 | } |
317 | |
318 | /** |
319 | * Consumer version. This is mostly meant for humans: different versions of the same |
320 | * application have different keys and are handled as different consumers internally. |
321 | * @return string |
322 | */ |
323 | public function getVersion() { |
324 | return $this->get( 'version' ); |
325 | } |
326 | |
327 | /** |
328 | * Callback URL (or prefix). The browser will be redirected to this URL at the end of |
329 | * an OAuth handshake. See getCallbackIsPrefix() for the interpretation of this field. |
330 | * @return string |
331 | */ |
332 | public function getCallbackUrl() { |
333 | return $this->get( 'callbackUrl' ); |
334 | } |
335 | |
336 | /** |
337 | * When false, the callback URL will be determined by getCallbackUrl(). When true, |
338 | * getCallbackUrl() returns a prefix; the callback URL must be provided by the caller |
339 | * and must match the prefix. For the exact definition of "match", see |
340 | * MWOAuthServer::checkCallback(). |
341 | * @return bool |
342 | */ |
343 | public function getCallbackIsPrefix() { |
344 | return $this->get( 'callbackIsPrefix' ); |
345 | } |
346 | |
347 | /** |
348 | * Description of the consumer. Currently interpreted as plain text; might change to wikitext |
349 | * in the future. |
350 | * @return string |
351 | */ |
352 | public function getDescription() { |
353 | return $this->get( 'description' ); |
354 | } |
355 | |
356 | /** |
357 | * Email address of the owner. |
358 | * @return string |
359 | */ |
360 | public function getEmail() { |
361 | return $this->get( 'email' ); |
362 | } |
363 | |
364 | /** |
365 | * Date of verifying the email, in TS_MW format. In practice this will be the same as |
366 | * getRegistration(). |
367 | * @return string|null |
368 | */ |
369 | public function getEmailAuthenticated() { |
370 | return $this->get( 'emailAuthenticated' ); |
371 | } |
372 | |
373 | /** |
374 | * Did the user accept the developer agreement (the terms of use checkbox at the bottom of the |
375 | * registration form)? Except for very old users, always true. |
376 | * @return bool |
377 | */ |
378 | public function getDeveloperAgreement() { |
379 | return $this->get( 'developerAgreement' ); |
380 | } |
381 | |
382 | /** |
383 | * Owner-only consumers will use one-legged flow instead of three-legged (see |
384 | * https://github.com/Mashape/mashape-oauth/blob/master/FLOWS.md#oauth-10a-one-legged ); there |
385 | * is only one user (who is the same as the owner) and they learn the access token at |
386 | * consumer registration time. |
387 | * @return bool |
388 | */ |
389 | public function getOwnerOnly() { |
390 | return $this->get( 'ownerOnly' ); |
391 | } |
392 | |
393 | /** |
394 | * @return int |
395 | */ |
396 | abstract public function getOAuthVersion(); |
397 | |
398 | /** |
399 | * The wiki on which the consumer is allowed to access user accounts. A wiki ID or '*' for all. |
400 | * @return string |
401 | */ |
402 | public function getWiki() { |
403 | return $this->get( 'wiki' ); |
404 | } |
405 | |
406 | /** |
407 | * The list of grants required by this application. |
408 | * @return string[] |
409 | */ |
410 | public function getGrants() { |
411 | return $this->get( 'grants' ); |
412 | } |
413 | |
414 | /** |
415 | * Consumer registration date in TS_MW format. |
416 | * @return string |
417 | */ |
418 | public function getRegistration() { |
419 | return $this->get( 'registration' ); |
420 | } |
421 | |
422 | /** |
423 | * Secret key used to derive the consumer secret for HMAC-SHA1 signed OAuth requests. |
424 | * The actual consumer secret will be calculated via Utils::hmacDBSecret() to mitigate |
425 | * DB leaks. |
426 | * @return string |
427 | */ |
428 | public function getSecretKey() { |
429 | return $this->get( 'secretKey' ); |
430 | } |
431 | |
432 | /** |
433 | * Public RSA key for RSA-SHA1 signed OAuth requests. |
434 | * @return string |
435 | */ |
436 | public function getRsaKey() { |
437 | return $this->get( 'rsaKey' ); |
438 | } |
439 | |
440 | /** |
441 | * Application restrictions (such as allowed IPs). |
442 | * @return MWRestrictions |
443 | */ |
444 | public function getRestrictions() { |
445 | return $this->get( 'restrictions' ); |
446 | } |
447 | |
448 | /** |
449 | * Stage at which the consumer is in the review workflow (proposed, approved etc). |
450 | * @return int One of the STAGE_* constants |
451 | */ |
452 | public function getStage() { |
453 | return $this->get( 'stage' ); |
454 | } |
455 | |
456 | /** |
457 | * Date at which the consumer was moved to the current stage, in TS_MW format. |
458 | * @return string |
459 | */ |
460 | public function getStageTimestamp() { |
461 | return $this->get( 'stageTimestamp' ); |
462 | } |
463 | |
464 | /** |
465 | * Is the consumer suppressed? (There is no plain deletion; the closest equivalent is the |
466 | * rejected/disabled stage.) |
467 | * @return bool |
468 | */ |
469 | public function getDeleted() { |
470 | return $this->get( 'deleted' ); |
471 | } |
472 | |
473 | /** |
474 | * Local ID of the owner (or false if there is no local account). |
475 | * @return int|false |
476 | */ |
477 | public function getLocalUserId() { |
478 | if ( $this->localUserId === null ) { |
479 | $user = Utils::getLocalUserFromCentralId( $this->getUserId() ); |
480 | if ( $user ) { |
481 | $this->localUserId = $user->getId(); |
482 | } else { |
483 | $this->localUserId = false; |
484 | } |
485 | } |
486 | return $this->localUserId; |
487 | } |
488 | |
489 | /** |
490 | * @param MWOAuthDataStore $dataStore |
491 | * @param string $verifyCode verification code |
492 | * @param string $requestKey original request key from /initiate |
493 | * @return string the url for redirection |
494 | */ |
495 | public function generateCallbackUrl( $dataStore, $verifyCode, $requestKey ) { |
496 | $callback = $dataStore->getCallbackUrl( $this->key, $requestKey ); |
497 | |
498 | if ( $callback === 'oob' ) { |
499 | $callback = $this->getCallbackUrl(); |
500 | } |
501 | |
502 | return wfAppendQuery( $callback, [ |
503 | 'oauth_verifier' => $verifyCode, |
504 | 'oauth_token' => $requestKey |
505 | ] ); |
506 | } |
507 | |
508 | /** |
509 | * Attempts to find an authorization by this user for this consumer. Since a user can |
510 | * accept a consumer multiple times (once for "*" and once for each specific wiki), |
511 | * there can several access tokens per-wiki (with varying grants) for a consumer. |
512 | * This will choose the most wiki-specific access token. The precedence is: |
513 | * a) The acceptance for wiki X if the consumer is applicable only to wiki X |
514 | * b) The acceptance for wiki $wikiId (if the consumer is applicable to it) |
515 | * c) The acceptance for wikis "*" (all wikis) |
516 | * |
517 | * Users might want more grants on some wikis than on "*". Note that the reverse would not |
518 | * make sense, since the consumer could just use the "*" acceptance if it has more grants. |
519 | * |
520 | * @param User $mwUser (local wiki user) User who may or may not have authorizations |
521 | * @param string $wikiId |
522 | * @throws MWOAuthException |
523 | * @return ConsumerAcceptance|bool |
524 | */ |
525 | public function getCurrentAuthorization( User $mwUser, $wikiId ) { |
526 | $dbr = Utils::getCentralDB( DB_REPLICA ); |
527 | |
528 | $centralUserId = Utils::getCentralIdFromLocalUser( $mwUser ); |
529 | if ( !$centralUserId ) { |
530 | throw new MWOAuthException( |
531 | 'mwoauthserver-invalid-user', |
532 | [ |
533 | 'consumer_name' => $this->getName(), |
534 | Message::rawParam( Linker::makeExternalLink( |
535 | 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008', |
536 | 'E008', |
537 | true |
538 | ) ), |
539 | 'consumer' => $this->getConsumerKey(), |
540 | ] |
541 | ); |
542 | } |
543 | |
544 | $checkWiki = $this->getWiki() !== '*' ? $this->getWiki() : $wikiId; |
545 | |
546 | $cmra = ConsumerAcceptance::newFromUserConsumerWiki( |
547 | $dbr, |
548 | $centralUserId, |
549 | $this, |
550 | $checkWiki, |
551 | 0, |
552 | $this->getOAuthVersion() |
553 | ); |
554 | if ( !$cmra ) { |
555 | $cmra = ConsumerAcceptance::newFromUserConsumerWiki( |
556 | $dbr, |
557 | $centralUserId, |
558 | $this, |
559 | '*', |
560 | 0, |
561 | $this->getOAuthVersion() |
562 | ); |
563 | } |
564 | return $cmra; |
565 | } |
566 | |
567 | /** |
568 | * @param User $mwUser |
569 | * @param bool $update |
570 | * @param array $grants |
571 | * @param string|null $requestTokenKey |
572 | * @return mixed |
573 | */ |
574 | abstract public function authorize( User $mwUser, $update, $grants, $requestTokenKey = null ); |
575 | |
576 | /** |
577 | * Verify that this user can authorize this consumer |
578 | * |
579 | * @param User $mwUser |
580 | * @throws MWOAuthException |
581 | */ |
582 | protected function conductAuthorizationChecks( User $mwUser ) { |
583 | global $wgBlockDisablesLogin; |
584 | |
585 | // Check that user and consumer are in good standing |
586 | if ( $mwUser->isLocked() || ( $wgBlockDisablesLogin && $mwUser->getBlock() ) ) { |
587 | throw new MWOAuthException( 'mwoauthserver-insufficient-rights', [ |
588 | Message::rawParam( Linker::makeExternalLink( |
589 | 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E007', |
590 | 'E007', |
591 | true |
592 | ) ), |
593 | 'consumer' => $this->getConsumerKey(), |
594 | 'consumer_name' => $this->getName(), |
595 | ] ); |
596 | } |
597 | |
598 | if ( $this->getDeleted() ) { |
599 | throw new MWOAuthException( 'mwoauthserver-bad-consumer-key', [ |
600 | Message::rawParam( Linker::makeExternalLink( |
601 | 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E006', |
602 | 'E006', |
603 | true |
604 | ) ), |
605 | 'consumer' => $this->getConsumerKey(), |
606 | 'consumer_name' => $this->getName(), |
607 | ] ); |
608 | } elseif ( !$this->isUsableBy( $mwUser ) ) { |
609 | $owner = Utils::getCentralUserNameFromId( |
610 | $this->getUserId(), |
611 | $mwUser |
612 | ); |
613 | throw new MWOAuthException( |
614 | 'mwoauthserver-bad-consumer', |
615 | [ |
616 | 'consumer_name' => $this->getName(), |
617 | 'talkpage' => Utils::getCentralUserTalk( $owner ), |
618 | Message::rawParam( Linker::makeExternalLink( |
619 | 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E005', |
620 | 'E005', |
621 | true |
622 | ) ), |
623 | 'consumer' => $this->getConsumerKey(), |
624 | ] |
625 | ); |
626 | } elseif ( $this->getOwnerOnly() ) { |
627 | throw new MWOAuthException( 'mwoauthserver-consumer-owner-only', [ |
628 | 'consumer_name' => $this->getName(), |
629 | SpecialPage::getTitleFor( |
630 | 'OAuthConsumerRegistration', 'update/' . $this->getConsumerKey() |
631 | ), |
632 | Message::rawParam( Linker::makeExternalLink( |
633 | 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E010', |
634 | 'E010', |
635 | true |
636 | ) ), |
637 | 'consumer' => $this->getConsumerKey(), |
638 | ] ); |
639 | } |
640 | } |
641 | |
642 | /** |
643 | * @param User $mwUser |
644 | * @param bool $update |
645 | * @param string[] $grants |
646 | * @return ConsumerAcceptance |
647 | * @throws MWOAuthException |
648 | */ |
649 | protected function saveAuthorization( User $mwUser, $update, $grants ) { |
650 | // CentralAuth may abort here if there is no global account for this user |
651 | $centralUserId = Utils::getCentralIdFromLocalUser( $mwUser ); |
652 | if ( !$centralUserId ) { |
653 | throw new MWOAuthException( |
654 | 'mwoauthserver-invalid-user', |
655 | [ |
656 | 'consumer_name' => $this->getName(), |
657 | Message::rawParam( Linker::makeExternalLink( |
658 | 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008', |
659 | 'E008', |
660 | true |
661 | ) ), |
662 | 'consumer' => $this->getConsumerKey(), |
663 | ] |
664 | ); |
665 | } |
666 | |
667 | $dbw = Utils::getCentralDB( DB_PRIMARY ); |
668 | // Check if this authorization exists |
669 | $cmra = $this->getCurrentAuthorization( $mwUser, WikiMap::getCurrentWikiId() ); |
670 | |
671 | if ( $update ) { |
672 | // This should be an update to an existing authorization |
673 | if ( !$cmra ) { |
674 | // update requested, but no existing key |
675 | throw new MWOAuthException( 'mwoauthserver-invalid-request', [ |
676 | 'consumer' => $this->getConsumerKey(), |
677 | 'consumer_name' => $this->getName(), |
678 | ] ); |
679 | } |
680 | $cmra->setFields( [ |
681 | 'wiki' => $this->getWiki(), |
682 | 'grants' => $grants |
683 | ] ); |
684 | $cmra->save( $dbw ); |
685 | } elseif ( !$cmra ) { |
686 | // Add the Authorization to the database |
687 | $accessToken = MWOAuthDataStore::newToken(); |
688 | $cmra = ConsumerAcceptance::newFromArray( [ |
689 | 'id' => null, |
690 | 'wiki' => $this->getWiki(), |
691 | 'userId' => $centralUserId, |
692 | 'consumerId' => $this->getId(), |
693 | 'accessToken' => $accessToken->key, |
694 | 'accessSecret' => $accessToken->secret, |
695 | 'grants' => $grants, |
696 | 'accepted' => wfTimestampNow(), |
697 | 'oauth_version' => $this->getOAuthVersion() |
698 | ] ); |
699 | $cmra->save( $dbw ); |
700 | } |
701 | |
702 | return $cmra; |
703 | } |
704 | |
705 | /** |
706 | * Check if the consumer is usable by $user |
707 | * |
708 | * "Usable by $user" includes: |
709 | * - Approved for multi-user use |
710 | * - Approved for owner-only use and is owned by $user |
711 | * - Still pending approval and is owned by $user |
712 | * |
713 | * @param User $user |
714 | * @return bool |
715 | */ |
716 | public function isUsableBy( User $user ) { |
717 | if ( $this->stage === self::STAGE_APPROVED && !$this->getOwnerOnly() ) { |
718 | return true; |
719 | } elseif ( $this->stage === self::STAGE_PROPOSED || $this->stage === self::STAGE_APPROVED ) { |
720 | $centralId = Utils::getCentralIdFromLocalUser( $user ); |
721 | return ( $centralId && $this->userId === $centralId ); |
722 | } |
723 | |
724 | return false; |
725 | } |
726 | |
727 | protected function normalizeValues() { |
728 | // Keep null values since we're constructing w/ them to auto-increment |
729 | $this->id = $this->id === null ? null : (int)$this->id; |
730 | $this->userId = (int)$this->userId; |
731 | $this->registration = wfTimestamp( TS_MW, $this->registration ); |
732 | $this->stage = (int)$this->stage; |
733 | $this->stageTimestamp = wfTimestamp( TS_MW, $this->stageTimestamp ); |
734 | $this->emailAuthenticated = wfTimestampOrNull( TS_MW, $this->emailAuthenticated ); |
735 | $this->grants = (array)$this->grants; |
736 | $this->callbackIsPrefix = (bool)$this->callbackIsPrefix; |
737 | $this->ownerOnly = (bool)$this->ownerOnly; |
738 | $this->oauthVersion = (int)$this->oauthVersion; |
739 | $this->developerAgreement = (bool)$this->developerAgreement; |
740 | $this->deleted = (bool)$this->deleted; |
741 | $this->oauth2IsConfidential = (bool)$this->oauth2IsConfidential; |
742 | } |
743 | |
744 | protected function encodeRow( IDatabase $db, $row ) { |
745 | // For compatibility with other wikis in the farm, un-remap some grants |
746 | foreach ( self::$mapBackCompatGrants as $old => $new ) { |
747 | while ( ( $i = array_search( $new, $row['oarc_grants'], true ) ) !== false ) { |
748 | $row['oarc_grants'][$i] = $old; |
749 | } |
750 | } |
751 | |
752 | $row['oarc_registration'] = $db->timestamp( $row['oarc_registration'] ); |
753 | $row['oarc_stage_timestamp'] = $db->timestamp( $row['oarc_stage_timestamp'] ); |
754 | $row['oarc_restrictions'] = $row['oarc_restrictions']->toJson(); |
755 | $row['oarc_grants'] = FormatJson::encode( $row['oarc_grants'] ); |
756 | $row['oarc_email_authenticated'] = |
757 | $db->timestampOrNull( $row['oarc_email_authenticated'] ); |
758 | $row['oarc_oauth2_allowed_grants'] = FormatJson::encode( |
759 | $row['oarc_oauth2_allowed_grants'] |
760 | ); |
761 | return $row; |
762 | } |
763 | |
764 | protected function decodeRow( IDatabase $db, $row ) { |
765 | $row['oarc_registration'] = wfTimestamp( TS_MW, $row['oarc_registration'] ); |
766 | $row['oarc_stage'] = (int)$row['oarc_stage']; |
767 | $row['oarc_stage_timestamp'] = wfTimestamp( TS_MW, $row['oarc_stage_timestamp'] ); |
768 | $row['oarc_restrictions'] = MWRestrictions::newFromJson( $row['oarc_restrictions'] ); |
769 | $row['oarc_grants'] = FormatJson::decode( $row['oarc_grants'], true ); |
770 | $row['oarc_user_id'] = (int)$row['oarc_user_id']; |
771 | $row['oarc_email_authenticated'] = |
772 | wfTimestampOrNull( TS_MW, $row['oarc_email_authenticated'] ); |
773 | $row['oarc_oauth2_allowed_grants'] = FormatJson::decode( |
774 | $row['oarc_oauth2_allowed_grants'] ?? 'null', true |
775 | ); |
776 | |
777 | // For backwards compatibility, remap some grants |
778 | foreach ( self::$mapBackCompatGrants as $old => $new ) { |
779 | while ( ( $i = array_search( $old, $row['oarc_grants'], true ) ) !== false ) { |
780 | $row['oarc_grants'][$i] = $new; |
781 | } |
782 | } |
783 | |
784 | return $row; |
785 | } |
786 | |
787 | /** |
788 | * Magic method so that fields like $consumer->secret and $consumer->key work. |
789 | * This allows MWOAuthConsumer to be a replacement for OAuthConsumer |
790 | * in lib/OAuth.php without inheriting. |
791 | * @param mixed $prop |
792 | * @return mixed |
793 | */ |
794 | public function __get( $prop ) { |
795 | if ( $prop === 'key' ) { |
796 | return $this->consumerKey; |
797 | } elseif ( $prop === 'secret' ) { |
798 | return Utils::hmacDBSecret( $this->secretKey ); |
799 | } elseif ( $prop === 'callback_url' ) { |
800 | return $this->callbackUrl; |
801 | } else { |
802 | throw new LogicException( 'Direct property access attempt: ' . $prop ); |
803 | } |
804 | } |
805 | |
806 | /** |
807 | * Can the user see a field with "standard" visibility? |
808 | * @param string $name Field name |
809 | * @param IContextSource $context |
810 | * @return true|Message True if allowed, error message otherwise. |
811 | */ |
812 | protected function userCanSee( $name, IContextSource $context ) { |
813 | $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); |
814 | |
815 | if ( $this->getDeleted() |
816 | && !$permissionManager->userHasRight( $context->getUser(), 'mwoauthviewsuppressed' ) |
817 | ) { |
818 | return $context->msg( 'mwoauth-field-hidden' ); |
819 | } else { |
820 | return true; |
821 | } |
822 | } |
823 | |
824 | /** |
825 | * Can the user see a private field? |
826 | * @param string $name Field name |
827 | * @param IContextSource $context |
828 | * @return true|Message True if allowed, error message otherwise. |
829 | */ |
830 | protected function userCanSeePrivate( $name, IContextSource $context ) { |
831 | $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); |
832 | |
833 | if ( !$permissionManager->userHasRight( $context->getUser(), 'mwoauthviewprivate' ) ) { |
834 | return $context->msg( 'mwoauth-field-private' ); |
835 | } else { |
836 | return $this->userCanSee( $name, $context ); |
837 | } |
838 | } |
839 | |
840 | /** |
841 | * Can the user see the app owner's email? |
842 | * @param string $name Field name |
843 | * @param IContextSource $context |
844 | * @return true|Message True if allowed, error message otherwise. |
845 | */ |
846 | protected function userCanSeeEmail( $name, IContextSource $context ) { |
847 | // although email is not a security-related field, it's handled the same way |
848 | return $this->userCanSeeSecurity( $name, $context ); |
849 | } |
850 | |
851 | /** |
852 | * Can the user see a field that relates to how the app's owner manages application |
853 | * security? |
854 | * @param string $name Field name |
855 | * @param IContextSource $context |
856 | * @return true|Message True if allowed, error message otherwise. |
857 | */ |
858 | protected function userCanSeeSecurity( $name, IContextSource $context ) { |
859 | $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); |
860 | $user = $context->getUser(); |
861 | |
862 | if ( $user->getId() === $this->getLocalUserId() ) { |
863 | // owners can always see the details of their apps, unless the app got deleted-suppressed |
864 | return $this->userCanSee( $name, $context ); |
865 | } elseif ( $this->getOwnerOnly() ) { |
866 | // owner-only apps are essentially personal API tokens, nobody else's business |
867 | return $context->msg( 'mwoauth-field-private' ); |
868 | } elseif ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) { |
869 | // if you are not the owner or an admin you definitely shouldn't see security details |
870 | return $context->msg( 'mwoauth-field-private' ); |
871 | } else { |
872 | // OAuth admin looking at non-owner-only app. Just need to check suppression. |
873 | return $this->userCanSee( $name, $context ); |
874 | } |
875 | } |
876 | |
877 | /** |
878 | * Can the user see a given field containing credentials? (No.) |
879 | * @param string $name Field name |
880 | * @param IContextSource $context |
881 | * @return true|Message True if allowed, error message otherwise. |
882 | */ |
883 | protected function userCanSeeSecret( $name, IContextSource $context ) { |
884 | return $context->msg( 'mwoauth-field-private' ); |
885 | } |
886 | } |