Issues (186)

includes/Helpers/OAuthUserHelper.php (7 issues)

Severity
1
<?php
2
/******************************************************************************
3
 * Wikipedia Account Creation Assistance tool                                 *
4
 * ACC Development Team. Please see team.json for a list of contributors.     *
5
 *                                                                            *
6
 * This is free and unencumbered software released into the public domain.    *
7
 * Please see LICENSE.md for the full licencing statement.                    *
8
 ******************************************************************************/
9
10
namespace Waca\Helpers;
11
12
use DateTimeImmutable;
13
use MediaWiki\OAuthClient\Exception;
14
use PDOStatement;
15
use Waca\DataObjects\OAuthIdentity;
16
use Waca\DataObjects\OAuthToken;
17
use Waca\DataObjects\User;
18
use Waca\Exceptions\ApplicationLogicException;
19
use Waca\Exceptions\CurlException;
20
use Waca\Exceptions\OAuthException;
21
use Waca\Exceptions\OptimisticLockFailedException;
22
use Waca\Helpers\Interfaces\IMediaWikiClient;
23
use Waca\Helpers\Interfaces\IOAuthProtocolHelper;
24
use Waca\PdoDatabase;
25
use Waca\SiteConfiguration;
26
27
class OAuthUserHelper implements IMediaWikiClient
28
{
29
    const TOKEN_REQUEST = 'request';
30
    const TOKEN_ACCESS = 'access';
31
    /** @var PDOStatement */
32
    private static $tokenCountStatement = null;
33
    /** @var PDOStatement */
34
    private $getTokenStatement;
35
    /**
36
     * @var User
37
     */
38
    private $user;
39
    /**
40
     * @var PdoDatabase
41
     */
42
    private $database;
43
    /**
44
     * @var IOAuthProtocolHelper
45
     */
46
    private $oauthProtocolHelper;
47
    /**
48
     * @var bool|null Is the user linked to OAuth
49
     */
50
    private $linked;
51
    private $partiallyLinked;
52
    /** @var OAuthToken */
53
    private $accessToken;
54
    /** @var bool */
55
    private $accessTokenLoaded = false;
56
    /**
57
     * @var OAuthIdentity
58
     */
59
    private $identity = null;
60
    /**
61
     * @var bool
62
     */
63
    private $identityLoaded = false;
64
    /**
65
     * @var SiteConfiguration
66
     */
67
    private $siteConfiguration;
68
69
    private $legacyTokens;
70
71
    #region Static methods
72
73
    public static function findUserByRequestToken($requestToken, PdoDatabase $database)
74
    {
75
        $statement = $database->prepare(<<<'SQL'
76
            SELECT u.* FROM user u 
77
            INNER JOIN oauthtoken t ON t.user = u.id 
78
            WHERE t.type = :type AND t.token = :token
79
SQL
80
        );
81
        $statement->execute(array(':type' => self::TOKEN_REQUEST, ':token' => $requestToken));
82
83
        /** @var User $user */
84
        $user = $statement->fetchObject(User::class);
85
        $statement->closeCursor();
86
87
        if ($user === false) {
0 ignored issues
show
The condition $user === false is always false.
Loading history...
88
            throw new ApplicationLogicException('Token not found in store, please try again');
89
        }
90
91
        $user->setDatabase($database);
92
93
        return $user;
94
    }
95
96
    private static function userIsFullyLinked(User $user, PdoDatabase $database = null)
97
    {
98
        if (self::$tokenCountStatement === null && $database === null) {
99
            throw new ApplicationLogicException('Static link request without initialised statement');
100
        }
101
102
        return self::runTokenCount($user->getId(), $database, self::TOKEN_ACCESS);
103
    }
104
105
    private static function userIsPartiallyLinked(User $user, PdoDatabase $database = null)
106
    {
107
        if (self::$tokenCountStatement === null && $database === null) {
108
            throw new ApplicationLogicException('Static link request without initialised statement');
109
        }
110
111
        if (self::userIsFullyLinked($user, $database)) {
112
            return false;
113
        }
114
115
        return self::runTokenCount($user->getId(), $database, self::TOKEN_REQUEST)
116
            || $user->getOnWikiName() == null;
117
    }
118
119
    /**
120
     * @param PdoDatabase $database
121
     */
122
    private static function prepareTokenCountStatement(PdoDatabase $database)
123
    {
124
        if (self::$tokenCountStatement === null) {
125
            self::$tokenCountStatement = $database->prepare('SELECT COUNT(*) FROM oauthtoken WHERE user = :user AND type = :type');
126
        }
127
    }
128
129
    private static function runTokenCount($userId, $database, $tokenType)
130
    {
131
        if (self::$tokenCountStatement === null) {
132
            self::prepareTokenCountStatement($database);
133
        }
134
135
        self::$tokenCountStatement->execute(array(
136
            ':user' => $userId,
137
            ':type' => $tokenType,
138
        ));
139
140
        $tokenCount = self::$tokenCountStatement->fetchColumn();
141
        $linked = $tokenCount > 0;
142
        self::$tokenCountStatement->closeCursor();
143
144
        return $linked;
145
    }
146
147
    #endregion Static methods
148
149
    /**
150
     * OAuthUserHelper constructor.
151
     *
152
     * @param User                 $user
153
     * @param PdoDatabase          $database
154
     * @param IOAuthProtocolHelper $oauthProtocolHelper
155
     * @param SiteConfiguration    $siteConfiguration
156
     */
157
    public function __construct(
158
        User $user,
159
        PdoDatabase $database,
160
        IOAuthProtocolHelper $oauthProtocolHelper,
161
        SiteConfiguration $siteConfiguration
162
    ) {
163
        $this->user = $user;
164
        $this->database = $database;
165
        $this->oauthProtocolHelper = $oauthProtocolHelper;
166
167
        $this->linked = null;
168
        $this->partiallyLinked = null;
169
        $this->siteConfiguration = $siteConfiguration;
170
171
        self::prepareTokenCountStatement($database);
172
        $this->getTokenStatement = $this->database->prepare('SELECT * FROM oauthtoken WHERE user = :user AND type = :type');
173
174
        $this->legacyTokens = $this->siteConfiguration->getOauthLegacyConsumerTokens();
175
    }
176
177
    /**
178
     * Determines if the user is fully connected to OAuth.
179
     *
180
     * @return bool
181
     */
182
    public function isFullyLinked()
183
    {
184
        if ($this->linked === null) {
185
            $this->linked = self::userIsFullyLinked($this->user, $this->database);
186
        }
187
188
        return $this->linked;
189
    }
190
191
    /**
192
     * Attempts to figure out if a user is partially linked to OAuth, and therefore needs to complete the OAuth
193
     * procedure before configuring.
194
     * @return bool
195
     */
196
    public function isPartiallyLinked()
197
    {
198
        if ($this->partiallyLinked === null) {
199
            $this->partiallyLinked = self::userIsPartiallyLinked($this->user, $this->database);
200
        }
201
202
        return $this->partiallyLinked;
203
    }
204
205
    public function canCreateAccount()
206
    {
207
        return $this->isFullyLinked()
208
            && $this->getIdentity(true)->getGrantBasic()
209
            && $this->getIdentity(true)->getGrantHighVolume()
210
            && $this->getIdentity(true)->getGrantCreateAccount();
211
    }
212
213
    public function canWelcome()
214
    {
215
        return $this->isFullyLinked()
216
            && $this->getIdentity(true)->getGrantBasic()
217
            && $this->getIdentity(true)->getGrantHighVolume()
218
            && $this->getIdentity(true)->getGrantCreateEditMovePage();
219
    }
220
221
    /**
222
     * @throws OAuthException
223
     * @throws CurlException
224
     * @throws OptimisticLockFailedException
225
     * @throws Exception
226
     */
227
    public function refreshIdentity()
228
    {
229
        $this->loadIdentity();
230
231
        if ($this->identity === null) {
232
            $this->identity = new OAuthIdentity();
233
            $this->identity->setUserId($this->user->getId());
234
            $this->identity->setDatabase($this->database);
235
        }
236
237
        $token = $this->loadAccessToken();
238
239
        try {
240
            $rawTicket = $this->oauthProtocolHelper->getIdentityTicket($token->getToken(), $token->getSecret());
241
        }
242
        catch (Exception $ex) {
243
            if (strpos($ex->getMessage(), "mwoauthdatastore-access-token-not-found") !== false) {
244
                throw new OAuthException('No approved grants for this access token.', -1, $ex);
245
            }
246
247
            throw $ex;
248
        }
249
250
        $this->identity->populate($rawTicket);
251
252
        if (!$this->identityIsValid()) {
253
            throw new OAuthException('Identity ticket is not valid!');
254
        }
255
256
        $this->identity->save();
257
258
        $this->user->setOnWikiName($this->identity->getUsername());
259
        $this->user->save();
260
    }
261
262
    /**
263
     * @return string
264
     * @throws CurlException
265
     */
266
    public function getRequestToken()
267
    {
268
        $token = $this->oauthProtocolHelper->getRequestToken();
269
270
        $this->partiallyLinked = true;
271
        $this->linked = false;
272
273
        $this->database
274
            ->prepare('DELETE FROM oauthtoken WHERE user = :user AND type = :type')
275
            ->execute(array(':user' => $this->user->getId(), ':type' => self::TOKEN_REQUEST));
276
277
        $this->database
278
            ->prepare('INSERT INTO oauthtoken (user, type, token, secret, expiry) VALUES (:user, :type, :token, :secret, DATE_ADD(NOW(), INTERVAL 1 DAY))')
279
            ->execute(array(
280
                ':user'   => $this->user->getId(),
281
                ':type'   => self::TOKEN_REQUEST,
282
                ':token'  => $token->key,
283
                ':secret' => $token->secret,
284
            ));
285
286
        return $this->oauthProtocolHelper->getAuthoriseUrl($token->key);
287
    }
288
289
    /**
290
     * @param $verificationToken
291
     *
292
     * @throws ApplicationLogicException
293
     * @throws CurlException
294
     * @throws OAuthException
295
     * @throws OptimisticLockFailedException
296
     */
297
    public function completeHandshake($verificationToken)
298
    {
299
        $this->getTokenStatement->execute(array(':user' => $this->user->getId(), ':type' => self::TOKEN_REQUEST));
300
301
        /** @var OAuthToken $token */
302
        $token = $this->getTokenStatement->fetchObject(OAuthToken::class);
303
        $this->getTokenStatement->closeCursor();
304
305
        if ($token === false) {
0 ignored issues
show
The condition $token === false is always false.
Loading history...
306
            throw new ApplicationLogicException('Cannot find request token');
307
        }
308
309
        $token->setDatabase($this->database);
310
311
        $accessToken = $this->oauthProtocolHelper->callbackCompleted($token->getToken(), $token->getSecret(),
312
            $verificationToken);
313
314
        $clearStatement = $this->database->prepare('DELETE FROM oauthtoken WHERE user = :u AND type = :t');
315
        $clearStatement->execute(array(':u' => $this->user->getId(), ':t' => self::TOKEN_ACCESS));
316
317
        $token->setToken($accessToken->key);
318
        $token->setSecret($accessToken->secret);
319
        $token->setType(self::TOKEN_ACCESS);
320
        $token->setExpiry(null);
321
        $token->save();
322
323
        $this->partiallyLinked = false;
324
        $this->linked = true;
325
326
        $this->refreshIdentity();
327
    }
328
329
    public function detach()
330
    {
331
        $this->loadIdentity();
332
333
        $this->identity->delete();
334
        $statement = $this->database->prepare('DELETE FROM oauthtoken WHERE user = :user');
335
        $statement->execute(array(':user' => $this->user->getId()));
336
337
        $this->identity = null;
338
        $this->linked = false;
339
        $this->partiallyLinked = false;
340
    }
341
342
    /**
343
     * @param bool $expiredOk
344
     *
345
     * @return OAuthIdentity
346
     * @throws OAuthException
347
     */
348
    public function getIdentity($expiredOk = false)
349
    {
350
        $this->loadIdentity();
351
352
        if (!$this->identityIsValid($expiredOk)) {
353
            throw new OAuthException('Stored identity is not valid.');
354
        }
355
356
        return $this->identity;
357
    }
358
359
    public function doApiCall($params, $method)
360
    {
361
        // Ensure we're logged in
362
        $params['assert'] = 'user';
363
364
        $token = $this->loadAccessToken();
365
        return $this->oauthProtocolHelper->apiCall($params, $token->getToken(), $token->getSecret(), $method);
366
    }
367
368
    /**
369
     * @param bool $expiredOk
370
     *
371
     * @return bool
372
     */
373
    private function identityIsValid($expiredOk = false)
374
    {
375
        $this->loadIdentity();
376
377
        if ($this->identity === null) {
378
            return false;
379
        }
380
381
        if ($this->identity->getIssuedAtTime() === false
382
            || $this->identity->getExpirationTime() === false
0 ignored issues
show
The condition $this->identity->getExpirationTime() === false is always false.
Loading history...
383
            || $this->identity->getAudience() === false
0 ignored issues
show
The condition $this->identity->getAudience() === false is always false.
Loading history...
384
            || $this->identity->getIssuer() === false
0 ignored issues
show
The condition $this->identity->getIssuer() === false is always false.
Loading history...
385
        ) {
386
            // this isn't populated properly.
387
            return false;
388
        }
389
390
        $issue = DateTimeImmutable::createFromFormat("U", $this->identity->getIssuedAtTime());
391
        $now = new DateTimeImmutable();
392
393
        if ($issue > $now) {
394
            // wat.
395
            return false;
396
        }
397
398
        if ($this->identityExpired() && !$expiredOk) {
399
            // soz.
400
            return false;
401
        }
402
403
        if ($this->identity->getAudience() !== $this->siteConfiguration->getOAuthConsumerToken()) {
404
            // token not issued for us
405
406
            // we allow cases where the cache is expired and the cache is for a legacy token
407
            if (!($expiredOk && in_array($this->identity->getAudience(), $this->legacyTokens))) {
408
                return false;
409
            }
410
        }
411
412
        if ($this->identity->getIssuer() !== $this->siteConfiguration->getOauthMediaWikiCanonicalServer()) {
413
            // token not issued by the right person
414
            return false;
415
        }
416
417
        // can't find a reason to not trust it
418
        return true;
419
    }
420
421
    /**
422
     * @return bool
423
     */
424
    public function identityExpired()
425
    {
426
        // allowed max age
427
        $gracePeriod = $this->siteConfiguration->getOauthIdentityGraceTime();
428
429
        $expiry = DateTimeImmutable::createFromFormat("U", $this->identity->getExpirationTime());
430
        $graceExpiry = $expiry->modify($gracePeriod);
431
        $now = new DateTimeImmutable();
432
433
        return $graceExpiry < $now;
434
    }
435
436
    /**
437
     * Loads the OAuth identity from the database for the current user.
438
     */
439
    private function loadIdentity()
440
    {
441
        if ($this->identityLoaded) {
442
            return;
443
        }
444
445
        $statement = $this->database->prepare('SELECT * FROM oauthidentity WHERE user = :user');
446
        $statement->execute(array(':user' => $this->user->getId()));
447
        /** @var OAuthIdentity $obj */
448
        $obj = $statement->fetchObject(OAuthIdentity::class);
449
450
        if ($obj === false) {
0 ignored issues
show
The condition $obj === false is always false.
Loading history...
451
            // failed to load identity.
452
            $this->identityLoaded = true;
453
            $this->identity = null;
454
455
            return;
456
        }
457
458
        $obj->setDatabase($this->database);
459
        $this->identityLoaded = true;
460
        $this->identity = $obj;
461
    }
462
463
    /**
464
     * @return OAuthToken
465
     * @throws OAuthException
466
     */
467
    private function loadAccessToken()
468
    {
469
        if (!$this->accessTokenLoaded) {
470
            $this->getTokenStatement->execute(array(':user' => $this->user->getId(), ':type' => self::TOKEN_ACCESS));
471
            /** @var OAuthToken $token */
472
            $token = $this->getTokenStatement->fetchObject(OAuthToken::class);
473
            $this->getTokenStatement->closeCursor();
474
475
            if ($token === false) {
0 ignored issues
show
The condition $token === false is always false.
Loading history...
476
                throw new OAuthException('Access token not found!');
477
            }
478
479
            $this->accessToken = $token;
480
            $this->accessTokenLoaded = true;
481
        }
482
483
        return $this->accessToken;
484
    }
485
}
486