Passed
Pull Request — master (#20)
by
unknown
02:34
created

SQL2::connect()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 47
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 29
c 1
b 0
f 0
nc 6
nop 1
dl 0
loc 47
rs 8.8337
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\Error;
11
use SimpleSAML\Logger;
12
use SimpleSAML\Module\core\Auth\UserPassBase;
13
14
/**
15
 * An authentication source source that uses (potentially multiple) SQL databases.
16
 *
17
 * This class is an example authentication source which authenticates an user
18
 * against a SQL database.
19
 *
20
 * @package SimpleSAMLphp
21
 */
22
23
class SQL2 extends UserPassBase
24
{
25
    /**
26
     * List of one or more databases that are used by auth and attribute queries.
27
     * Each database must have a unique name, and the name is used to refer to
28
     * the database in auth and attribute queries.
29
     *
30
     * @var array
31
     */
32
    private array $databases = [];
33
34
    /**
35
     * List of one or more authentication queries. The first query that returns a result
36
     * is considered to have authenticated the user (and termed "winning").
37
     *
38
     * @var array
39
     */
40
    private array $authQueries = [];
41
42
    /**
43
     * List of zero or more attribute queries, which can optionally be limited to run only
44
     * for certain "winning" authentication queries.
45
     *
46
     * @var array
47
     */
48
    private array $attributesQueries = [];
49
50
51
    /**
52
     * Constructor for this authentication source.
53
     *
54
     * @param array $info  Information about this authentication source.
55
     * @param array $config  Configuration.
56
     */
57
    public function __construct(array $info, array $config)
58
    {
59
        // Call the parent constructor first, as required by the interface
60
        parent::__construct($info, $config);
61
62
        // Check databases configuration that all required parameters are present
63
        if (array_key_exists('databases', $config)) {
64
            if (!is_array($config['databases'])) {
65
                throw new Exception('Required parameter \'databases\' for authentication source ' .
66
                    $this->authId . ' was provided and is expected to be an array. Instead it was: ' .
67
                    var_export($config['databases'], true));
68
            }
69
70
            if (empty($config['databases'])) {
71
                throw new Exception('Required parameter \'databases\' for authentication source ' .
72
                    $this->authId . ' was provided but is an empty array.');
73
            }
74
75
            foreach ($config['databases'] as $dbname => $dbConfig) {
76
                if (!is_array($dbConfig)) {
77
                    throw new Exception('Each entry in the ' .
78
                        $dbname . ' \'databases\' parameter for authentication source ' .
79
                        $this->authId . ' is expected to be an array. Instead it was: ' .
80
                        var_export($dbConfig, true));
81
                }
82
                foreach (['dsn', 'username', 'password'] as $param) {
83
                    if (!array_key_exists($param, $dbConfig)) {
84
                        throw new Exception('Database ' .
85
                            $dbname . ' is missing required attribute \'' .
86
                            $param . '\' for authentication source ' .
87
                            $this->authId);
88
                    }
89
                    if (!is_string($dbConfig[$param])) {
90
                        throw new Exception('Expected parameter \'' . $param .
91
                            '\' for authentication source ' . $this->authId .
92
                            ' to be a string. Instead it was: ' .
93
                            var_export($config[$param], true));
94
                    }
95
                }
96
97
                if (array_key_exists('options', $dbConfig) && !is_array($dbConfig['options'])) {
98
                    throw new Exception('Optional parameter \'options\' for authentication source ' .
99
                        $this->authId . ' was provided and is expected to be an array. Instead it was: ' .
100
                        var_export($dbConfig['options'], true));
101
                }
102
103
                $this->databases[$dbname] = [
104
                    '_pdo' => null, // Will hold the PDO connection when connected
105
                    'dsn' => $dbConfig['dsn'],
106
                    'username' => $dbConfig['username'],
107
                    'password' => $dbConfig['password'],
108
                    'options' => $dbConfig['options'] ?? [],
109
                ];
110
            }
111
        } else {
112
            throw new Exception('Missing required attribute \'databases\' for authentication source ' . $this->authId);
113
        }
114
115
        // Check auth_queries configuration that all required parameters are present
116
        if (array_key_exists('auth_queries', $config)) {
117
            if (!is_array($config['auth_queries'])) {
118
                throw new Exception('Required parameter \'auth_queries\' for authentication source ' .
119
                    $this->authId . ' was provided and is expected to be an array. Instead it was: ' .
120
                    var_export($config['auth_queries'], true));
121
            }
122
123
            if (empty($config['auth_queries'])) {
124
                throw new Exception('Required parameter \'auth_queries\' for authentication source ' .
125
                    $this->authId . ' was provided but is an empty array.');
126
            }
127
128
            foreach ($config['auth_queries'] as $authQueryName => $authQueryConfig) {
129
                if (!is_array($authQueryConfig)) {
130
                    throw new Exception('Each entry in the ' .
131
                        $authQueryName . ' \'auth_queries\' parameter for authentication source ' .
132
                        $this->authId . ' is expected to be an array. Instead it was: ' .
133
                        var_export($authQueryConfig, true));
134
                }
135
136
                foreach (['database', 'query'] as $param) {
137
                    if (!array_key_exists($param, $authQueryConfig)) {
138
                        throw new Exception('Auth query ' .
139
                            $authQueryName . ' is missing required attribute \'' .
140
                            $param . '\' for authentication source ' .
141
                            $this->authId);
142
                    }
143
                    if (!is_string($authQueryConfig[$param])) {
144
                        throw new Exception('Expected parameter \'' . $param .
145
                            '\' for authentication source \'' . $this->authId . '\'' .
146
                            ' to be a string. Instead it was: ' .
147
                            var_export($authQueryConfig[$param], true));
148
                    }
149
                }
150
151
                if (!array_key_exists($authQueryConfig['database'], $this->databases)) {
152
                    throw new Exception('Auth query ' .
153
                        $authQueryName . ' references unknown database \'' .
154
                        $authQueryConfig['database'] . '\' for authentication source ' .
155
                        $this->authId);
156
                }
157
158
                $this->authQueries[$authQueryName] = [
159
                    // Will be set to true for the query that successfully authenticated the user
160
                    '_winning_auth_query' => false,
161
162
                    // Will hold the value of the attribute named by 'extract_userid_from'
163
                    // if specified and authentication succeeds
164
                    '_extracted_userid' => null,
165
166
                    'database' => $authQueryConfig['database'],
167
                    'query' => $authQueryConfig['query'],
168
                ];
169
170
                if (array_key_exists('username_regex', $authQueryConfig)) {
171
                    if (!is_string($authQueryConfig['username_regex'])) {
172
                        throw new Exception('Optional parameter \'username_regex\' for authentication source ' .
173
                            $this->authId . ' was provided and is expected to be a string. Instead it was: ' .
174
                            var_export($authQueryConfig['username_regex'], true));
175
                    }
176
                    $this->authQueries[$authQueryName]['username_regex'] = $authQueryConfig['username_regex'];
177
                }
178
179
                if (array_key_exists('extract_userid_from', $authQueryConfig)) {
180
                    if (!is_string($authQueryConfig['extract_userid_from'])) {
181
                        throw new Exception('Optional parameter \'extract_userid_from\' for authentication source ' .
182
                            $this->authId . ' was provided and is expected to be a string. Instead it was: ' .
183
                            var_export($authQueryConfig['extract_userid_from'], true));
184
                    }
185
                    $this->authQueries[$authQueryName]['extract_userid_from'] = $authQueryConfig['extract_userid_from'];
186
                }
187
188
                if (array_key_exists('password_verify_hash_column', $authQueryConfig)) {
189
                    if (!is_string($authQueryConfig['password_verify_hash_column'])) {
190
                        throw new Exception(
191
                            'Optional parameter \'password_verify_hash_column\' for authentication source ' .
192
                            $this->authId . ' was provided and is expected to be a string. Instead it was: ' .
193
                            var_export($authQueryConfig['password_verify_hash_column'], true),
194
                        );
195
                    }
196
                    $this->authQueries[$authQueryName]['password_verify_hash_column'] =
197
                        $authQueryConfig['password_verify_hash_column'];
198
                }
199
            }
200
        } else {
201
            throw new Exception(
202
                'Missing required attribute \'auth_queries\' for authentication source ' .
203
                $this->authId,
204
            );
205
        }
206
207
        // attr_queries is optional, but if specified, we need to check the parameters
208
        if (array_key_exists('attr_queries', $config)) {
209
            if (!is_array($config['attr_queries'])) {
210
                throw new Exception('Optional parameter \'attr_queries\' for authentication source ' .
211
                    $this->authId . ' was provided and is expected to be an array. Instead it was: ' .
212
                    var_export($config['attr_queries'], true));
213
            }
214
215
            foreach ($config['attr_queries'] as $attrQueryConfig) {
216
                if (!is_array($attrQueryConfig)) {
217
                    throw new Exception('\'attr_queries\' parameter for authentication source ' .
218
                        $this->authId . ' is expected to be an array. Instead it was: ' .
219
                        var_export($attrQueryConfig, true));
220
                }
221
222
                foreach (['database', 'query'] as $param) {
223
                    if (!array_key_exists($param, $attrQueryConfig)) {
224
                        throw new Exception('Attribute query is missing required attribute \'' .
225
                            $param . '\' for authentication source ' .
226
                            $this->authId);
227
                    }
228
                    if (!is_string($attrQueryConfig[$param])) {
229
                        throw new Exception('Expected parameter \'' . $param .
230
                            '\' for authentication source \'' . $this->authId . '\'' .
231
                            ' to be a string. Instead it was: ' .
232
                            var_export($attrQueryConfig[$param], true));
233
                    }
234
                }
235
236
                $currentAttributeQuery = [
237
                    'database' => $attrQueryConfig['database'],
238
                    'query' => $attrQueryConfig['query'],
239
                ];
240
241
                if (!array_key_exists($attrQueryConfig['database'], $this->databases)) {
242
                    throw new Exception('Attribute query references unknown database \'' .
243
                        $attrQueryConfig['database'] . '\' for authentication source ' .
244
                        $this->authId);
245
                }
246
247
                if (array_key_exists('only_for_auth', $attrQueryConfig)) {
248
                    if (!is_array($attrQueryConfig['only_for_auth'])) {
249
                        throw new Exception('Optional parameter \'only_for_auth\' for authentication source ' .
250
                            $this->authId . ' was provided and is expected to be an array. Instead it was: ' .
251
                            var_export($attrQueryConfig['only_for_auth'], true));
252
                    }
253
                    foreach ($attrQueryConfig['only_for_auth'] as $authQueryName) {
254
                        if (!is_string($authQueryName)) {
255
                            throw new Exception('Each entry in the \'only_for_auth\' array for authentication source ' .
256
                                $this->authId . ' is expected to be a string. Instead it was: ' .
257
                                var_export($authQueryName, true));
258
                        }
259
                        if (!array_key_exists($authQueryName, $this->authQueries)) {
260
                            throw new Exception('Attribute query references unknown auth query \'' .
261
                                $authQueryName . '\' for authentication source ' .
262
                                $this->authId);
263
                        }
264
                    }
265
                    $currentAttributeQuery['only_for_auth'] = $attrQueryConfig['only_for_auth'];
266
                }
267
268
                $this->attributesQueries[] = $currentAttributeQuery;
269
            }
270
        }
271
    }
272
273
274
    /**
275
     * Create a database connection.
276
     *
277
     * @return \PDO  The database connection.
278
     */
279
    protected function connect(string $dbname): PDO
280
    {
281
        if (!array_key_exists($dbname, $this->databases)) {
282
            throw new Exception('sqlauth:' . $this->authId . ': Attempt to connect to unknown database \'' .
283
                $dbname . '\'');
284
        }
285
        if ($this->databases[$dbname]['_pdo'] !== null) {
286
            // Already connected
287
            return $this->databases[$dbname]['_pdo'];
288
        }
289
290
        try {
291
            $db = new PDO(
292
                $this->databases[$dbname]['dsn'],
293
                $this->databases[$dbname]['username'],
294
                $this->databases[$dbname]['password'],
295
                $this->databases[$dbname]['options'],
296
            );
297
        } catch (PDOException $e) {
298
            // Obfuscate the password if it's part of the dsn
299
            $obfuscated_dsn =
300
                preg_replace('/(user|password)=(.*?([;]|$))/', '${1}=***', $this->databases[$dbname]['dsn']);
301
302
            throw new Exception('sqlauth:' . $this->authId . ': - Failed to connect to \'' .
303
                $obfuscated_dsn . '\': ' . $e->getMessage());
304
        }
305
306
        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
307
308
        $driver = explode(':', $this->databases[$dbname]['dsn'], 2);
309
        $driver = strtolower($driver[0]);
310
311
        // Driver specific initialization
312
        switch ($driver) {
313
            case 'mysql':
314
                // Use UTF-8
315
                $db->exec("SET NAMES 'utf8mb4'");
316
                break;
317
            case 'pgsql':
318
                // Use UTF-8
319
                $db->exec("SET NAMES 'UTF8'");
320
                break;
321
        }
322
323
        Logger::debug('sqlauth:' . $this->authId . ': Connected to database ' . $dbname);
324
        $this->databases[$dbname]['_pdo'] = $db;
325
        return $db;
326
    }
327
328
329
    /**
330
     * Extract SQL columns into SAML attribute array
331
     *
332
     * @param array $attributes output place to store extracted attributes
333
     * @param array  $data  Associative array from database in the format of PDO fetchAll
334
     * @param array  $forbiddenAttributes An array of attributes to never return
335
     * @return array &$attributes
336
     */
337
    protected function extractAttributes(array &$attributes, array $data, array $forbiddenAttributes = []): array
338
    {
339
        foreach ($data as $row) {
340
            foreach ($row as $name => $value) {
341
                if ($value === null) {
342
                    continue;
343
                }
344
                if (in_array($name, $forbiddenAttributes)) {
345
                    continue;
346
                }
347
348
                $value = (string) $value;
349
350
                if (!array_key_exists($name, $attributes)) {
351
                    $attributes[$name] = [];
352
                }
353
354
                if (in_array($value, $attributes[$name], true)) {
355
                    // Value already exists in attribute
356
                    continue;
357
                }
358
359
                $attributes[$name][] = $value;
360
            }
361
        }
362
        return $attributes;
363
    }
364
365
366
    /**
367
     * Execute the query with given parameters and return the tuples that result.
368
     *
369
     * @param string $query  SQL to execute
370
     * @param array $params parameters to the SQL query
371
     * @return array tuples that result
372
     */
373
    protected function executeQuery(PDO $db, string $query, array $params): array
374
    {
375
        try {
376
            $sth = $db->prepare($query);
377
        } catch (PDOException $e) {
378
            throw new Exception('sqlauth:' . $this->authId .
379
                                ': - Failed to prepare query: ' . $e->getMessage());
380
        }
381
382
        try {
383
            $sth->execute($params);
384
        } catch (PDOException $e) {
385
            throw new Exception('sqlauth:' . $this->authId .
386
                                ': - Failed to execute query: ' . $e->getMessage());
387
        }
388
389
        try {
390
            $data = $sth->fetchAll(PDO::FETCH_ASSOC);
391
            return $data;
392
        } catch (PDOException $e) {
393
            throw new Exception('sqlauth:' . $this->authId .
394
                                ': - Failed to fetch result set: ' . $e->getMessage());
395
        }
396
    }
397
398
399
    /**
400
     * Attempt to log in using the given username and password.
401
     *
402
     * On a successful login, this function should return the users attributes. On failure,
403
     * it should throw an exception. If the error was caused by the user entering the wrong
404
     * username or password, a \SimpleSAML\Error\Error('WRONGUSERPASS') should be thrown.
405
     *
406
     * Note that both the username and the password are UTF-8 encoded.
407
     *
408
     * @param string $username  The username the user wrote.
409
     * @param string $password  The password the user wrote.
410
     * @return array  Associative array with the users attributes.
411
     */
412
    protected function login(
413
        string $username,
414
        #[\SensitiveParameter]
415
        string $password,
416
    ): array {
417
418
        $attributes = [];
419
        $winning_auth_query = null;
420
421
        // Run authentication queries in order until one succeeds.
422
        foreach ($this->authQueries as $queryname => &$queryConfig) {
423
            // Check if the username matches the username_regex for this query
424
            if (
425
                array_key_exists('username_regex', $queryConfig) &&
426
                !preg_match($queryConfig['username_regex'], $username)
427
            ) {
428
                Logger::debug('sqlauth:' . $this->authId . ': Skipping auth query ' . $queryname .
429
                             ' because username ' . $username . ' does not match username_regex ' .
430
                             $queryConfig['username_regex']);
431
                continue;
432
            }
433
434
            Logger::debug('sqlauth:' . $this->authId . ': Trying auth query ' . $queryname);
435
436
            $db = $this->connect($queryConfig['database']);
437
438
            try {
439
                $sqlParams = ['username' => $username];
440
                if (!array_key_exists('password_verify_hash_column', $queryConfig)) {
441
                    // If we are not using password_verify(), pass the password to the query
442
                    $sqlParams['password'] = $password;
443
                }
444
                $data = $this->executeQuery($db, $queryConfig['query'], $sqlParams);
445
            } catch (PDOException $e) {
446
                Logger::error('sqlauth:' . $this->authId . ': Auth query ' . $queryname .
447
                              ' failed with error: ' . $e->getMessage());
448
                continue;
449
            }
450
451
            // If we got any rows, the authentication succeeded. If not, try the next query.
452
            if (count($data) > 0) {
453
                /* This is where we need to run password_verify() if we are using password_verify() to
454
                 * authenticate hashed passwords that are only stored in the database. */
455
                if (array_key_exists('password_verify_hash_column', $queryConfig)) {
456
                    $hashColumn = $queryConfig['password_verify_hash_column'];
457
                    if (!array_key_exists($hashColumn, $data[0])) {
458
                        Logger::error('sqlauth:' . $this->authId . ': Auth query ' . $queryname .
459
                                     ' did not return expected hash column \'' . $hashColumn . '\'');
460
                        throw new Error\Error('WRONGUSERPASS');
461
                    }
462
463
                    $validPasswordHashFound = false;
464
                    $passwordHash = null;
465
                    foreach ($data as $row) {
466
                        if ((!array_key_exists($hashColumn, $row)) || is_null($row[$hashColumn])) {
467
                            Logger::error(sprintf(
468
                                'sqlauth:%s: column `%s` must be in every result tuple.',
469
                                $this->authId,
470
                                $hashColumn,
471
                            ));
472
                            throw new Error\Error('WRONGUSERPASS');
473
                        }
474
                        if (($passwordHash === null) && (strlen($row[$hashColumn]) > 0)) {
475
                            $passwordHash = $row[$hashColumn];
476
                            $validPasswordHashFound = true;
477
                        } elseif ($passwordHash != $row[$hashColumn]) {
478
                            Logger::error(sprintf(
479
                                'sqlauth:%s: column %s must be THE SAME in every result tuple.',
480
                                $this->authId,
481
                                $hashColumn,
482
                            ));
483
                            throw new Error\Error('WRONGUSERPASS');
484
                        } elseif (strlen($row[$hashColumn]) === 0) {
485
                            Logger::error(sprintf(
486
                                'sqlauth:%s: column `%s` must contain a valid password hash.',
487
                                $this->authId,
488
                                $hashColumn,
489
                            ));
490
                            throw new Error\Error('WRONGUSERPASS');
491
                        }
492
                    }
493
494
                    if ((!$validPasswordHashFound) || (!password_verify($password, $passwordHash))) {
495
                        Logger::error('sqlauth:' . $this->authId . ': Auth query ' . $queryname .
496
                                     ' password verification failed');
497
                        /* Authentication with verify_password() failed, however that only means that
498
                         * this auth query did not succeed. We should try the next auth query if any. */
499
                        continue;
500
                    }
501
502
                    Logger::debug('sqlauth:' . $this->authId . ': Auth query ' . $queryname .
503
                                 ' password verification using password_verify() succeeded');
504
                }
505
506
                Logger::debug('sqlauth:' . $this->authId . ': Auth query ' . $queryname .
507
                             ' succeeded with ' . count($data) . ' rows');
508
                $queryConfig['_winning_auth_query'] = true;
509
510
                if (array_key_exists('extract_userid_from', $queryConfig)) {
511
                    $queryConfig['_extracted_userid'] = $data[0][$queryConfig['extract_userid_from']];
512
                }
513
                $winning_auth_query = $queryname;
514
515
                $forbiddenAttributes = [];
516
                if (array_key_exists('password_verify_hash_column', $queryConfig)) {
517
                    $forbiddenAttributes[] = $queryConfig['password_verify_hash_column'];
518
                }
519
                $this->extractAttributes($attributes, $data, $forbiddenAttributes);
520
521
                // The first auth query that succeeds is the winning one, so we can stop here.
522
                break;
523
            } else {
524
                Logger::debug('sqlauth:' . $this->authId . ': Auth query ' . $queryname .
525
                             ' returned no rows, trying next auth query if any');
526
            }
527
        }
528
529
        if (empty($attributes)) {
530
            // No auth query succeeded
531
            Logger::error('sqlauth:' . $this->authId . ': No auth query succeeded. Probably wrong username/password.');
532
            throw new Error\Error('WRONGUSERPASS');
533
        }
534
535
        // Run attribute queries. Each attribute query can specify which auth queries it applies to.
536
        foreach ($this->attributesQueries as $attrQueryConfig) {
537
            // If the attribute query is limited to certain auth queries, check if the winning auth query
538
            // is one of those.
539
            Logger::debug(
540
                'sqlauth:' . $this->authId . ': ' .
541
                'Considering attribute query ' . $attrQueryConfig['query'] .
542
                ' for winning auth query ' . $winning_auth_query .
543
                ' with only_for_auth ' . implode(',', $attrQueryConfig['only_for_auth'] ?? []),
544
            );
545
546
            if (
547
                (!array_key_exists('only_for_auth', $attrQueryConfig)) ||
548
                in_array($winning_auth_query, $attrQueryConfig['only_for_auth'], true)
549
            ) {
550
                Logger::debug('sqlauth:' . $this->authId . ': Running attribute query ' . $attrQueryConfig['query'] .
551
                             ' for winning auth query ' . $winning_auth_query);
552
553
                $db = $this->connect($attrQueryConfig['database']);
554
555
                try {
556
                    $params = ($this->authQueries[$winning_auth_query]['_extracted_userid'] !== null) ?
557
                        ['userid' => $this->authQueries[$winning_auth_query]['_extracted_userid']] :
558
                        ['username' => $username];
559
                    $data = $this->executeQuery($db, $attrQueryConfig['query'], $params);
560
                } catch (PDOException $e) {
561
                    Logger::error('sqlauth:' . $this->authId . ': Attribute query ' . $attrQueryConfig['query'] .
562
                                  ' failed with error: ' . $e->getMessage());
563
                    continue;
564
                }
565
566
                Logger::debug('sqlauth:' . $this->authId . ': Attribute query ' . $attrQueryConfig['query'] .
567
                             ' returned ' . count($data) . ' rows');
568
569
                $this->extractAttributes($attributes, $data, []);
570
            } else {
571
                Logger::debug('sqlauth:' . $this->authId . ': Skipping attribute query ' . $attrQueryConfig['query'] .
572
                             ' because it does not apply to winning auth query ' . $winning_auth_query);
573
            }
574
        }
575
576
        // At the end, disconnect from all databases
577
        $db = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $db is dead and can be removed.
Loading history...
578
        foreach ($this->databases as $dbname => $dbConfig) {
579
            if ($dbConfig['_pdo'] !== null) {
580
                $this->databases[$dbname]['_pdo'] = null;
581
                Logger::debug('sqlauth:' . $this->authId . ': Disconnected from database ' . $dbname);
582
            }
583
        }
584
585
        Logger::info('sqlauth:' . $this->authId . ': Attributes: ' . implode(',', array_keys($attributes)));
586
587
        return $attributes;
588
    }
589
}
590