Passed
Push — statistics ( 40505d...a159ce )
by Simon
19:10 queued 15:20
created

PasswordCredentialProvider   A

Complexity

Total Complexity 20

Size/Duplication

Total Lines 133
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
wmc 20
eloc 46
c 3
b 0
f 0
dl 0
loc 133
ccs 0
cts 71
cp 0
rs 10

6 Methods

Rating   Name   Duplication   Size   Complexity  
B authenticate() 0 47 9
A reallySetCredential() 0 12 2
A setCredential() 0 24 5
A revokePasswordResetTokens() 0 9 2
A deleteCredential() 0 3 1
A __construct() 0 3 1
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\Security\CredentialProviders;
10
11
use Waca\DataObjects\Credential;
12
use Waca\DataObjects\User;
13
use Waca\Exceptions\ApplicationLogicException;
14
use Waca\Exceptions\OptimisticLockFailedException;
15
use Waca\PdoDatabase;
16
use Waca\SessionAlert;
17
use Waca\SiteConfiguration;
18
use Wikimedia\CommonPasswords\CommonPasswords;
19
use ZxcvbnPhp\Zxcvbn;
20
21
class PasswordCredentialProvider extends CredentialProviderBase
22
{
23
    const PASSWORD_COST = 10;
24
    const PASSWORD_ALGO = PASSWORD_BCRYPT;
25
26
    public function __construct(PdoDatabase $database, SiteConfiguration $configuration)
27
    {
28
        parent::__construct($database, $configuration, 'password');
29
    }
30
31
    public function authenticate(User $user, $data)
32
    {
33
        $storedData = $this->getCredentialData($user->getId());
34
        if($storedData === null)
35
        {
36
            // No available credential matching these parameters
37
            return false;
38
        }
39
40
        if($storedData->getVersion() !== 2) {
41
            // Non-2 versions are not supported.
42
            return false;
43
        }
44
45
        if (!password_verify($data, $storedData->getData())) {
46
            return false;
47
        }
48
49
        if (password_needs_rehash($storedData->getData(), self::PASSWORD_ALGO,
50
            array('cost' => self::PASSWORD_COST))) {
51
            try {
52
                $this->reallySetCredential($user, $storedData->getFactor(), $data);
53
            }
54
            catch (OptimisticLockFailedException $e) {
55
                // optimistic lock failed, but no biggie. We'll catch it on the next login.
56
            }
57
        }
58
59
        $strengthTester = new Zxcvbn();
60
        $strength = $strengthTester->passwordStrength($data, [$user->getUsername(), $user->getOnWikiName(), $user->getEmail()]);
61
62
        /*  0 means the password is extremely guessable (within 10^3 guesses), dictionary words like 'password' or 'mother' score a 0
63
            1 is still very guessable (guesses < 10^6), an extra character on a dictionary word can score a 1
64
            2 is somewhat guessable (guesses < 10^8), provides some protection from unthrottled online attacks
65
            3 is safely unguessable (guesses < 10^10), offers moderate protection from offline slow-hash scenario
66
            4 is very unguessable (guesses >= 10^10) and provides strong protection from offline slow-hash scenario         */
67
68
        if ($strength['score'] <= 1 || CommonPasswords::isCommon($data) || mb_strlen($data) < 8) {
69
            // prevent login for extremely weak passwords
70
            // at this point the user has authenticated via password, so they *know* it's weak.
71
            SessionAlert::error('Your password is too weak to permit login. Please choose the "forgotten your password" option below and set a new one.', null);
72
            return false;
73
        }
74
75
        $this->revokePasswordResetTokens($user->getId());
76
77
        return true;
78
    }
79
80
    /**
81
     * @param User   $user
82
     * @param int    $factor
83
     * @param string $password
84
     *
85
     * @throws OptimisticLockFailedException
86
     */
87
    private function reallySetCredential(User $user, int $factor, string $password) : void {
88
        $storedData = $this->getCredentialData($user->getId());
89
90
        if ($storedData === null) {
91
            $storedData = $this->createNewCredential($user);
92
        }
93
94
        $storedData->setData(password_hash($password, self::PASSWORD_ALGO, array('cost' => self::PASSWORD_COST)));
95
        $storedData->setFactor($factor);
96
        $storedData->setVersion(2);
97
98
        $storedData->save();
99
    }
100
101
    /**
102
     * @param User   $user
103
     * @param int    $factor
104
     * @param string $password
105
     *
106
     * @throws ApplicationLogicException
107
     * @throws OptimisticLockFailedException
108
     */
109
    public function setCredential(User $user, $factor, $password)
110
    {
111
        if (CommonPasswords::isCommon($password)) {
112
            throw new ApplicationLogicException("Your new password is listed in the top 100,000 passwords. Please choose a stronger one.", null);
113
        }
114
115
        $strengthTester = new Zxcvbn();
116
        $strength = $strengthTester->passwordStrength($password, [$user->getUsername(), $user->getOnWikiName(), $user->getEmail()]);
117
118
        /*  0 means the password is extremely guessable (within 10^3 guesses), dictionary words like 'password' or 'mother' score a 0
119
            1 is still very guessable (guesses < 10^6), an extra character on a dictionary word can score a 1
120
            2 is somewhat guessable (guesses < 10^8), provides some protection from unthrottled online attacks
121
            3 is safely unguessable (guesses < 10^10), offers moderate protection from offline slow-hash scenario
122
            4 is very unguessable (guesses >= 10^10) and provides strong protection from offline slow-hash scenario         */
123
124
        if ($strength['score'] <= 2 || mb_strlen($password) < 8) {
125
            throw new ApplicationLogicException("Your new password is too weak. Please choose a stronger one.", null);
126
        }
127
128
        if ($strength['score'] <= 3) {
129
            SessionAlert::warning("Your new password is not as strong as it could be. Consider replacing it with a stronger password.", null);
130
        }
131
132
        $this->reallySetCredential($user, $factor, $password);
133
    }
134
135
    /**
136
     * @param User $user
137
     *
138
     * @throws ApplicationLogicException
139
     */
140
    public function deleteCredential(User $user)
141
    {
142
        throw new ApplicationLogicException('Deletion of password credential is not allowed.');
143
    }
144
145
    private function revokePasswordResetTokens(int $userId)
146
    {
147
        $statement = $this->getDatabase()->prepare("SELECT * FROM credential WHERE type = 'reset' AND user = :user;");
148
        $statement->execute([':user' => $userId]);
149
        $existing = $statement->fetchAll(PdoDatabase::FETCH_CLASS, Credential::class);
150
151
        foreach ($existing as $c) {
152
            $c->setDatabase($this->getDatabase());
153
            $c->delete();
154
        }
155
    }
156
}
157