Passed
Pull Request — master (#6)
by Tim
01:52
created

PasswordVerify::__construct()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 3
eloc 5
c 2
b 0
f 0
nc 4
nop 2
dl 0
loc 11
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\sqlauth\Auth\Source;
6
7
use Exception;
8
use PDO;
9
use PDOException;
10
use SimpleSAML\Assert\Assert;
11
use SimpleSAML\Error;
12
use SimpleSAML\Logger;
13
use SimpleSAML\Module\sqlauth\Auth\Source\SQL;
14
15
use function array_key_exists;
16
use function array_keys;
17
use function count;
18
use function implode;
19
use function in_array;
20
use function is_null;
21
use function password_verify;
22
use function sprintf;
23
use function strval;
24
25
/**
26
 * Simple SQL authentication source
27
 *
28
 * This class is very much like the SQL class. The major difference is that
29
 * instead of using SHA2 and other functions in the database we use the PHP
30
 * password_verify() function to allow for example PASSWORD_ARGON2ID to be used
31
 * for verification.
32
 *
33
 * While this class has a query parameter as the SQL class does the meaning 
34
 * is different. The query for this class should return at least a column 
35
 * called passwordhash containing the hashed password which was generated 
36
 * for example using
37
 *    password_hash('hello', PASSWORD_ARGON2ID );
38
 *
39
 * Auth only passes if the PHP code below returns true.
40
 *   password_verify($password, row['passwordhash'] );
41
 *
42
 * Unlike the SQL class the username is the only parameter passed to the SQL query,
43
 * the query can not perform password checks, they are performed by the PHP code 
44
 * in this class using password_verify().
45
 *
46
 * If there are other columns in the returned data they are assumed to be attributes
47
 * you would like to be returned through SAML.
48
 *
49
 * @package SimpleSAMLphp
50
 */
51
52
class PasswordVerify extends SQL
53
{
54
    /**
55
     * The column in the result set containing the passwordhash.
56
     */
57
    protected ?string $passwordhashcolumn = null;
58
59
    /**
60
     * Constructor for this authentication source.
61
     *
62
     * @param array $info  Information about this authentication source.
63
     * @param array $config  Configuration.
64
     */
65
    public function __construct(array $info, array $config)    
66
    {
67
        // Call the parent constructor first, as required by the interface
68
        parent::__construct($info, $config);
69
70
        if (array_key_exists('passwordhashcolumn', $config)) {
71
            $this->passwordhashcolumn = $config['passwordhashcolumn'];
72
        }
73
74
        if ($this->passwordhashcolumn === null) {
75
            $this->passwordhashcolumn = 'passwordhash';
76
        }
77
    }
78
79
80
    /**
81
     * Extract SQL columns into SAML attribute array
82
     *
83
     * @param array  $data  Associative array from database in the format of PDO fetchAll
84
     * @param array  $forbiddenAttributes An array of attributes to never return
85
     * @return array  Associative array with the users attributes.
86
     */
87
    protected function extractAttributes($data, $forbiddenAttributes = array()): array
88
    {
89
        $attributes = [];
90
        foreach ($data as $row) {
91
            foreach ($row as $name => $value) {
92
                if ($value === null) {
93
                    continue;
94
                }
95
                if (in_array($name, $forbiddenAttributes)) {
96
                    continue;
97
                }
98
               
99
100
                $value = strval($value);
101
102
                if (!array_key_exists($name, $attributes)) {
103
                    $attributes[$name] = [];
104
                }
105
106
                if (in_array($value, $attributes[$name], true)) {
107
                    // Value already exists in attribute
108
                    continue;
109
                }
110
111
                $attributes[$name][] = $value;
112
            }
113
        }
114
        
115
        return $attributes;
116
    }
117
118
119
    /**
120
     * Attempt to log in using the given username and password.
121
     *
122
     * On a successful login, this function should return the users attributes. On failure,
123
     * it should throw an exception. If the error was caused by the user entering the wrong
124
     * username or password, a \SimpleSAML\Error\Error('WRONGUSERPASS') should be thrown.
125
     *
126
     * Note that both the username and the password are UTF-8 encoded.
127
     *
128
     * @param string $username  The username the user wrote.
129
     * @param string $password  The password the user wrote.
130
     * @return array  Associative array with the users attributes.
131
     */
132
    protected function login(string $username, string $password): array
133
    {
134
        $db = $this->connect();
135
        
136
        try {
137
            $sth = $db->prepare($this->query);
138
        } catch (PDOException $e) {
139
            throw new Exception(sprintf(
140
                'sqlauth:%s: - Failed to prepare query: %s',
141
                $this->authId,
142
                $e->getMessage(),
143
            ));
144
        }
145
146
147
        try {
148
            $sth->execute(['username' => $username]);
149
        } catch (PDOException $e) {
150
            throw new Exception(sprintf(
151
                'sqlauth:%s: - Failed to execute sql: %s query: %s',
152
                $this->authId,
153
                $this->query,
154
                $e->getMessage(),
155
            ));
156
        }
157
158
        try {
159
            $data = $sth->fetchAll(PDO::FETCH_ASSOC);
160
        } catch (PDOException $e) {
161
            throw new Exception(sprintf(
162
                'sqlauth:%s: - Failed to fetch result set: %s',
163
                $this->authId,
164
                $e->getMessage(),
165
            ));
166
        }
167
168
        Logger::info(sprintf(
169
            'sqlauth:%s : Got %d rows from database',
170
            $this->authId,
171
            count($data),
172
        ));
173
174
        if (count($data) === 0) {
175
            // No rows returned - invalid username/password
176
            Logger::error(sprintf(
177
                'sqlauth:%s: No rows in result set. Probably wrong username/password.',
178
                $this->authId,
179
            ));
180
            throw new Error\Error('WRONGUSERPASS');
181
        }
182
183
        /**
184
         * Sanity check, passwordhash must be in each resulting tuple and must have
185
         * the same value in every tuple.
186
         * 
187
         * Note that $pwhash will contain the passwordhash value after this loop.
188
         */
189
        $pwhash = null;
190
        foreach ($data as $row) {
191
            if (!array_key_exists($this->passwordhashcolumn, $row)
192
                || is_null($row[$this->passwordhashcolumn]))
193
            {
194
                Logger::error(sprintf(
195
                    'sqlauth:%s: column %s must be in every result tuple.',
196
                    $this->authId,
197
                    $this->passwordhashcolumn,
198
                ));
199
                throw new Error\Error('WRONGUSERPASS');
200
            }
201
            if ($pwhash) {
202
                if ($pwhash !== $row[$this->passwordhashcolumn]) {
203
                    Logger::error(sprintf(
204
                        'sqlauth:%s: column %s must be THE SAME in every result tuple.',
205
                        $this->authId,
206
                        $this->passwordhashcolumn,
207
                    ));
208
                    throw new Error\Error('WRONGUSERPASS');
209
                }
210
            }
211
            $pwhash = $row[$this->passwordhashcolumn];
212
        }
213
        /**
214
         * This should never happen as the count(data) test above would have already thrown.
215
         * But checking twice doesn't hurt.
216
         */
217
        if (is_null($pwhash)) {
218
            if ($pwhash !== $row[$this->passwordhashcolumn]) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $row seems to be defined by a foreach iteration on line 190. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
219
                Logger::error(sprintf(
220
                    'sqlauth:%s: column %s does not contain a password hash.',
221
                    $this->authId,
222
                    $this->passwordhashcolumn,
223
                ));
224
                throw new Error\Error('WRONGUSERPASS');
225
            }
226
        }
227
228
        /**
229
         * VERIFICATION!
230
         * Now to check if the password the user supplied is actually valid
231
         */
232
        if (!password_verify($password, $pwhash)) {
233
            Logger::error(sprintf('sqlauth:%s: password is incorrect.', $this->authId));
234
            throw new Error\Error('WRONGUSERPASS');
235
        }
236
237
        
238
        $attributes = $this->extractAttributes($data, [$this->passwordhashcolumn]);
239
240
        Logger::info(sprintf(
241
            'sqlauth:%s: Attributes: %s',
242
            $this->authId,
243
            implode(',', array_keys($attributes)),
244
        ));
245
246
        return $attributes;
247
    }
248
}
249