Passed
Push — oauth-migration-tweaks ( b7dcb7 )
by Simon
08:27
created

OAuthUserHelper   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 455
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 59
eloc 181
c 2
b 0
f 0
dl 0
loc 455
rs 4.08

20 Methods

Rating   Name   Duplication   Size   Complexity  
A completeHandshake() 0 30 2
A userIsFullyLinked() 0 7 3
A canCreateAccount() 0 5 4
A __construct() 0 18 1
A canWelcome() 0 5 4
C identityIsValid() 0 46 13
A getRequestToken() 0 21 1
A runTokenCount() 0 16 2
A userIsPartiallyLinked() 0 12 5
A isPartiallyLinked() 0 7 2
A findUserByRequestToken() 0 21 2
A refreshIdentity() 0 33 5
A prepareTokenCountStatement() 0 4 2
A getIdentity() 0 9 2
A loadIdentity() 0 22 3
A detach() 0 11 1
A doApiCall() 0 7 1
A loadAccessToken() 0 17 3
A identityExpired() 0 10 1
A isFullyLinked() 0 7 2

How to fix   Complexity   

Complex Class

Complex classes like OAuthUserHelper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use OAuthUserHelper, and based on these observations, apply Extract Interface, too.

1
<?php
2
/******************************************************************************
3
 * Wikipedia Account Creation Assistance tool                                 *
4
 *                                                                            *
5
 * All code in this file is released into the public domain by the ACC        *
6
 * Development Team. Please see team.json for a list of contributors.         *
7
 ******************************************************************************/
8
9
namespace Waca\Helpers;
10
11
use DateTimeImmutable;
12
use MediaWiki\OAuthClient\Exception;
13
use PDOStatement;
14
use Waca\DataObjects\OAuthIdentity;
15
use Waca\DataObjects\OAuthToken;
16
use Waca\DataObjects\User;
17
use Waca\Exceptions\ApplicationLogicException;
18
use Waca\Exceptions\CurlException;
19
use Waca\Exceptions\OAuthException;
20
use Waca\Exceptions\OptimisticLockFailedException;
21
use Waca\Helpers\Interfaces\IMediaWikiClient;
22
use Waca\Helpers\Interfaces\IOAuthProtocolHelper;
23
use Waca\PdoDatabase;
24
use Waca\SiteConfiguration;
0 ignored issues
show
Bug introduced by
The type Waca\SiteConfiguration was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
25
26
class OAuthUserHelper implements IMediaWikiClient
27
{
28
    const TOKEN_REQUEST = 'request';
29
    const TOKEN_ACCESS = 'access';
30
    /** @var PDOStatement */
31
    private static $tokenCountStatement = null;
32
    /** @var PDOStatement */
33
    private $getTokenStatement;
34
    /**
35
     * @var User
36
     */
37
    private $user;
38
    /**
39
     * @var PdoDatabase
40
     */
41
    private $database;
42
    /**
43
     * @var IOAuthProtocolHelper
44
     */
45
    private $oauthProtocolHelper;
46
    /**
47
     * @var bool|null Is the user linked to OAuth
48
     */
49
    private $linked;
50
    private $partiallyLinked;
51
    /** @var OAuthToken */
52
    private $accessToken;
53
    /** @var bool */
54
    private $accessTokenLoaded = false;
55
    /**
56
     * @var OAuthIdentity
57
     */
58
    private $identity = null;
59
    /**
60
     * @var bool
61
     */
62
    private $identityLoaded = false;
63
    /**
64
     * @var SiteConfiguration
65
     */
66
    private $siteConfiguration;
67
68
    private $legacyTokens;
69
70
    #region Static methods
71
72
    public static function findUserByRequestToken($requestToken, PdoDatabase $database)
73
    {
74
        $statement = $database->prepare(<<<'SQL'
75
            SELECT u.* FROM user u 
76
            INNER JOIN oauthtoken t ON t.user = u.id 
77
            WHERE t.type = :type AND t.token = :token
78
SQL
79
        );
80
        $statement->execute(array(':type' => self::TOKEN_REQUEST, ':token' => $requestToken));
81
82
        /** @var User $user */
83
        $user = $statement->fetchObject(User::class);
84
        $statement->closeCursor();
85
86
        if ($user === false) {
0 ignored issues
show
introduced by
The condition $user === false is always false.
Loading history...
87
            throw new ApplicationLogicException('Token not found in store, please try again');
88
        }
89
90
        $user->setDatabase($database);
91
92
        return $user;
93
    }
94
95
    public static function userIsFullyLinked(User $user, PdoDatabase $database = null)
96
    {
97
        if (self::$tokenCountStatement === null && $database === null) {
98
            throw new ApplicationLogicException('Static link request without initialised statement');
99
        }
100
101
        return self::runTokenCount($user->getId(), $database, self::TOKEN_ACCESS);
102
    }
103
104
    public static function userIsPartiallyLinked(User $user, PdoDatabase $database = null)
105
    {
106
        if (self::$tokenCountStatement === null && $database === null) {
107
            throw new ApplicationLogicException('Static link request without initialised statement');
108
        }
109
110
        if (self::userIsFullyLinked($user, $database)) {
111
            return false;
112
        }
113
114
        return self::runTokenCount($user->getId(), $database, self::TOKEN_REQUEST)
115
            || $user->getOnWikiName() == null;
116
    }
117
118
    /**
119
     * @param PdoDatabase $database
120
     */
121
    public static function prepareTokenCountStatement(PdoDatabase $database)
122
    {
123
        if (self::$tokenCountStatement === null) {
124
            self::$tokenCountStatement = $database->prepare('SELECT COUNT(*) FROM oauthtoken WHERE user = :user AND type = :type');
0 ignored issues
show
Documentation Bug introduced by
It seems like $database->prepare('SELE...user AND type = :type') can also be of type boolean. However, the property $tokenCountStatement is declared as type PDOStatement. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
125
        }
126
    }
127
128
    private static function runTokenCount($userId, $database, $tokenType)
129
    {
130
        if (self::$tokenCountStatement === null) {
131
            self::prepareTokenCountStatement($database);
132
        }
133
134
        self::$tokenCountStatement->execute(array(
135
            ':user' => $userId,
136
            ':type' => $tokenType,
137
        ));
138
139
        $tokenCount = self::$tokenCountStatement->fetchColumn();
140
        $linked = $tokenCount > 0;
141
        self::$tokenCountStatement->closeCursor();
142
143
        return $linked;
144
    }
145
146
    #endregion Static methods
147
148
    /**
149
     * OAuthUserHelper constructor.
150
     *
151
     * @param User                 $user
152
     * @param PdoDatabase          $database
153
     * @param IOAuthProtocolHelper $oauthProtocolHelper
154
     * @param SiteConfiguration    $siteConfiguration
155
     */
156
    public function __construct(
157
        User $user,
158
        PdoDatabase $database,
159
        IOAuthProtocolHelper $oauthProtocolHelper,
160
        SiteConfiguration $siteConfiguration
161
    ) {
162
        $this->user = $user;
163
        $this->database = $database;
164
        $this->oauthProtocolHelper = $oauthProtocolHelper;
165
166
        $this->linked = null;
167
        $this->partiallyLinked = null;
168
        $this->siteConfiguration = $siteConfiguration;
169
170
        self::prepareTokenCountStatement($database);
171
        $this->getTokenStatement = $this->database->prepare('SELECT * FROM oauthtoken WHERE user = :user AND type = :type');
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->database->prepare...user AND type = :type') can also be of type boolean. However, the property $getTokenStatement is declared as type PDOStatement. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
172
173
        $this->legacyTokens = $this->siteConfiguration->getOauthLegacyConsumerTokens();
174
    }
175
176
    /**
177
     * Determines if the user is fully connected to OAuth.
178
     *
179
     * @return bool
180
     */
181
    public function isFullyLinked()
182
    {
183
        if ($this->linked === null) {
184
            $this->linked = self::userIsFullyLinked($this->user, $this->database);
185
        }
186
187
        return $this->linked;
188
    }
189
190
    /**
191
     * Attempts to figure out if a user is partially linked to OAuth, and therefore needs to complete the OAuth
192
     * procedure before configuring.
193
     * @return bool
194
     */
195
    public function isPartiallyLinked()
196
    {
197
        if ($this->partiallyLinked === null) {
198
            $this->partiallyLinked = self::userIsPartiallyLinked($this->user, $this->database);
199
        }
200
201
        return $this->partiallyLinked;
202
    }
203
204
    public function canCreateAccount() {
205
        return $this->isFullyLinked()
206
            && $this->getIdentity(true)->getGrantBasic()
207
            && $this->getIdentity(true)->getGrantHighVolume()
208
            && $this->getIdentity(true)->getGrantCreateAccount();
209
    }
210
211
    public function canWelcome() {
212
        return $this->isFullyLinked()
213
            && $this->getIdentity(true)->getGrantBasic()
214
            && $this->getIdentity(true)->getGrantHighVolume()
215
            && $this->getIdentity(true)->getGrantCreateEditMovePage();
216
    }
217
218
    /**
219
     * @throws OAuthException
220
     * @throws CurlException
221
     * @throws OptimisticLockFailedException
222
     * @throws Exception
223
     */
224
    public function refreshIdentity()
225
    {
226
        $this->loadIdentity();
227
228
        if ($this->identity === null) {
229
            $this->identity = new OAuthIdentity();
230
            $this->identity->setUserId($this->user->getId());
231
            $this->identity->setDatabase($this->database);
232
        }
233
234
        $token = $this->loadAccessToken();
235
236
        try {
237
            $rawTicket = $this->oauthProtocolHelper->getIdentityTicket($token->getToken(), $token->getSecret());
238
        }
239
        catch (Exception $ex) {
240
            if (strpos($ex->getMessage(), "mwoauthdatastore-access-token-not-found") !== false) {
241
                throw new OAuthException('No approved grants for this access token.', -1, $ex);
242
            }
243
244
            throw $ex;
245
        }
246
247
        $this->identity->populate($rawTicket);
248
249
        if (!$this->identityIsValid()) {
250
            throw new OAuthException('Identity ticket is not valid!');
251
        }
252
253
        $this->identity->save();
254
255
        $this->user->setOnWikiName($this->identity->getUsername());
256
        $this->user->save();
257
    }
258
259
    /**
260
     * @return string
261
     * @throws CurlException
262
     */
263
    public function getRequestToken()
264
    {
265
        $token = $this->oauthProtocolHelper->getRequestToken();
266
267
        $this->partiallyLinked = true;
268
        $this->linked = false;
269
270
        $this->database
271
            ->prepare('DELETE FROM oauthtoken WHERE user = :user AND type = :type')
272
            ->execute(array(':user' => $this->user->getId(), ':type' => self::TOKEN_REQUEST));
273
274
        $this->database
275
            ->prepare('INSERT INTO oauthtoken (user, type, token, secret, expiry) VALUES (:user, :type, :token, :secret, DATE_ADD(NOW(), INTERVAL 1 DAY))')
276
            ->execute(array(
277
                ':user'   => $this->user->getId(),
278
                ':type'   => self::TOKEN_REQUEST,
279
                ':token'  => $token->key,
280
                ':secret' => $token->secret,
281
            ));
282
283
        return $this->oauthProtocolHelper->getAuthoriseUrl($token->key);
284
    }
285
286
    /**
287
     * @param $verificationToken
288
     *
289
     * @throws ApplicationLogicException
290
     * @throws CurlException
291
     * @throws OAuthException
292
     * @throws OptimisticLockFailedException
293
     */
294
    public function completeHandshake($verificationToken)
295
    {
296
        $this->getTokenStatement->execute(array(':user' => $this->user->getId(), ':type' => self::TOKEN_REQUEST));
297
298
        /** @var OAuthToken $token */
299
        $token = $this->getTokenStatement->fetchObject(OAuthToken::class);
300
        $this->getTokenStatement->closeCursor();
301
302
        if ($token === false) {
0 ignored issues
show
introduced by
The condition $token === false is always false.
Loading history...
303
            throw new ApplicationLogicException('Cannot find request token');
304
        }
305
306
        $token->setDatabase($this->database);
307
308
        $accessToken = $this->oauthProtocolHelper->callbackCompleted($token->getToken(), $token->getSecret(),
309
            $verificationToken);
310
311
        $clearStatement = $this->database->prepare('DELETE FROM oauthtoken WHERE user = :u AND type = :t');
312
        $clearStatement->execute(array(':u' => $this->user->getId(), ':t' => self::TOKEN_ACCESS));
313
314
        $token->setToken($accessToken->key);
315
        $token->setSecret($accessToken->secret);
316
        $token->setType(self::TOKEN_ACCESS);
317
        $token->setExpiry(null);
318
        $token->save();
319
320
        $this->partiallyLinked = false;
321
        $this->linked = true;
322
323
        $this->refreshIdentity();
324
    }
325
326
    public function detach()
327
    {
328
        $this->loadIdentity();
329
330
        $this->identity->delete();
331
        $statement = $this->database->prepare('DELETE FROM oauthtoken WHERE user = :user');
332
        $statement->execute(array(':user' => $this->user->getId()));
333
334
        $this->identity = null;
335
        $this->linked = false;
336
        $this->partiallyLinked = false;
337
    }
338
339
    /**
340
     * @param bool $expiredOk
341
     *
342
     * @return OAuthIdentity
343
     * @throws OAuthException
344
     */
345
    public function getIdentity($expiredOk = false)
346
    {
347
        $this->loadIdentity();
348
349
        if (!$this->identityIsValid($expiredOk)) {
350
            throw new OAuthException('Stored identity is not valid.');
351
        }
352
353
        return $this->identity;
354
    }
355
356
    public function doApiCall($params, $method)
357
    {
358
        // Ensure we're logged in
359
        $params['assert'] = 'user';
360
361
        $token = $this->loadAccessToken();
362
        return $this->oauthProtocolHelper->apiCall($params, $token->getToken(), $token->getSecret(), $method);
363
    }
364
365
    /**
366
     * @param bool $expiredOk
367
     *
368
     * @return bool
369
     */
370
    private function identityIsValid($expiredOk = false)
371
    {
372
        $this->loadIdentity();
373
374
        if ($this->identity === null) {
375
            return false;
376
        }
377
378
        if ($this->identity->getIssuedAtTime() === false
379
            || $this->identity->getExpirationTime() === false
0 ignored issues
show
introduced by
The condition $this->identity->getExpirationTime() === false is always false.
Loading history...
380
            || $this->identity->getAudience() === false
0 ignored issues
show
introduced by
The condition $this->identity->getAudience() === false is always false.
Loading history...
381
            || $this->identity->getIssuer() === false
0 ignored issues
show
introduced by
The condition $this->identity->getIssuer() === false is always false.
Loading history...
382
        ) {
383
            // this isn't populated properly.
384
            return false;
385
        }
386
387
        $issue = DateTimeImmutable::createFromFormat("U", $this->identity->getIssuedAtTime());
388
        $now = new DateTimeImmutable();
389
390
        if ($issue > $now) {
391
            // wat.
392
            return false;
393
        }
394
395
        if ($this->identityExpired() && !$expiredOk) {
396
            // soz.
397
            return false;
398
        }
399
400
        if ($this->identity->getAudience() !== $this->siteConfiguration->getOAuthConsumerToken()) {
401
            // token not issued for us
402
403
            // we allow cases where the cache is expired and the cache is for a legacy token
404
            if (!($expiredOk && in_array($this->identity->getAudience(), $this->legacyTokens))) {
405
                return false;
406
            }
407
        }
408
409
        if ($this->identity->getIssuer() !== $this->siteConfiguration->getOauthMediaWikiCanonicalServer()) {
410
            // token not issued by the right person
411
            return false;
412
        }
413
414
        // can't find a reason to not trust it
415
        return true;
416
    }
417
418
    /**
419
     * @return bool
420
     */
421
    public function identityExpired()
422
    {
423
        // allowed max age
424
        $gracePeriod = $this->siteConfiguration->getOauthIdentityGraceTime();
425
426
        $expiry = DateTimeImmutable::createFromFormat("U", $this->identity->getExpirationTime());
427
        $graceExpiry = $expiry->modify($gracePeriod);
428
        $now = new DateTimeImmutable();
429
430
        return $graceExpiry < $now;
431
    }
432
433
    /**
434
     * Loads the OAuth identity from the database for the current user.
435
     */
436
    private function loadIdentity()
437
    {
438
        if ($this->identityLoaded) {
439
            return;
440
        }
441
442
        $statement = $this->database->prepare('SELECT * FROM oauthidentity WHERE user = :user');
443
        $statement->execute(array(':user' => $this->user->getId()));
444
        /** @var OAuthIdentity $obj */
445
        $obj = $statement->fetchObject(OAuthIdentity::class);
446
447
        if ($obj === false) {
0 ignored issues
show
introduced by
The condition $obj === false is always false.
Loading history...
448
            // failed to load identity.
449
            $this->identityLoaded = true;
450
            $this->identity = null;
451
452
            return;
453
        }
454
455
        $obj->setDatabase($this->database);
456
        $this->identityLoaded = true;
457
        $this->identity = $obj;
458
    }
459
460
    /**
461
     * @return OAuthToken
462
     * @throws OAuthException
463
     */
464
    private function loadAccessToken()
465
    {
466
        if (!$this->accessTokenLoaded) {
467
            $this->getTokenStatement->execute(array(':user' => $this->user->getId(), ':type' => self::TOKEN_ACCESS));
468
            /** @var OAuthToken $token */
469
            $token = $this->getTokenStatement->fetchObject(OAuthToken::class);
470
            $this->getTokenStatement->closeCursor();
471
472
            if ($token === false) {
0 ignored issues
show
introduced by
The condition $token === false is always false.
Loading history...
473
                throw new OAuthException('Access token not found!');
474
            }
475
476
            $this->accessToken = $token;
477
            $this->accessTokenLoaded = true;
478
        }
479
480
        return $this->accessToken;
481
    }
482
}
483