Database   B
last analyzed

Complexity

Total Complexity 48

Size/Duplication

Total Lines 460
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 160
dl 0
loc 460
rs 8.5599
c 0
b 0
f 0
wmc 48

12 Methods

Rating   Name   Duplication   Size   Complexity  
A selftest() 0 12 2
A formatError() 0 5 1
A saveConsent() 0 31 4
A hasConsent() 0 20 3
A execute() 0 22 3
A deleteConsent() 0 18 3
A deleteAllConsents() 0 18 3
B getStatistics() 0 45 7
A getConsents() 0 19 3
A __sleep() 0 9 1
A getDB() 0 20 4
C __construct() 0 51 14

How to fix   Complexity   

Complex Class

Complex classes like Database 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 Database, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\consent\Consent\Store;
6
7
use Exception;
8
use PDO;
9
use SimpleSAML\Assert\Assert;
10
use SimpleSAML\Logger;
11
12
/**
13
 * Store consent in database.
14
 *
15
 * This class implements a consent store which stores the consent information in a database. It is tested, and should
16
 * work against MySQL, PostgreSQL and SQLite.
17
 *
18
 * It has the following options:
19
 * - dsn: The DSN which should be used to connect to the database server. See the PHP Manual for supported drivers and
20
 *   DSN formats.
21
 * - username: The username used for database connection.
22
 * - password: The password used for database connection.
23
 * - table: The name of the table used. Optional, defaults to 'consent'.
24
 *
25
 * @package SimpleSAMLphp
26
 */
27
28
class Database extends \SimpleSAML\Module\consent\Store
29
{
30
    /**
31
     * DSN for the database.
32
     */
33
    private string $dsn;
34
35
    /**
36
     * The DATETIME SQL function to use
37
     */
38
    private string $dateTime;
39
40
    /**
41
     * Username for the database.
42
     */
43
    private ?string $username = null;
44
45
    /**
46
     * Password for the database;
47
     */
48
    private ?string $password = null;
49
50
    /**
51
     * Options for the database;
52
     *
53
     * @var array<mixed>
54
     */
55
    private array $options = [];
56
57
    /**
58
     * Table with consent.
59
     */
60
    private string $table;
61
62
    /**
63
     * The timeout of the database connection.
64
     *
65
     * @var int|null
66
     */
67
    private ?int $timeout = null;
68
69
    /**
70
     * Database handle.
71
     *
72
     * This variable can't be serialized.
73
     */
74
    private ?PDO $db = null;
75
76
77
    /**
78
     * Parse configuration.
79
     *
80
     * This constructor parses the configuration.
81
     *
82
     * @param array<mixed> $config Configuration for database consent store.
83
     *
84
     * @throws \Exception in case of a configuration error.
85
     */
86
    public function __construct(array $config)
87
    {
88
        parent::__construct($config);
89
90
        if (!array_key_exists('dsn', $config)) {
91
            throw new Exception('consent:Database - Missing required option \'dsn\'.');
92
        }
93
        if (!is_string($config['dsn'])) {
94
            throw new Exception('consent:Database - \'dsn\' is supposed to be a string.');
95
        }
96
97
        $this->dsn = $config['dsn'];
98
        $this->dateTime = (0 === strpos($this->dsn, 'sqlite:')) ? 'DATETIME("NOW")' : 'NOW()';
99
100
        if (array_key_exists('username', $config)) {
101
            if (!is_string($config['username'])) {
102
                throw new Exception('consent:Database - \'username\' is supposed to be a string.');
103
            }
104
            $this->username = $config['username'];
105
        }
106
107
        if (array_key_exists('password', $config)) {
108
            if (!is_string($config['password'])) {
109
                throw new Exception('consent:Database - \'password\' is supposed to be a string.');
110
            }
111
            $this->password = $config['password'];
112
        }
113
114
        if (array_key_exists('options', $config)) {
115
            if (!is_array($config['options'])) {
116
                throw new Exception('consent:Database - \'options\' is supposed to be an array.');
117
            }
118
            $this->options = $config['options'];
119
        } else {
120
            $this->options = [];
121
        }
122
123
        if (array_key_exists('table', $config)) {
124
            if (!is_string($config['table'])) {
125
                throw new Exception('consent:Database - \'table\' is supposed to be a string.');
126
            }
127
            $this->table = $config['table'];
128
        } else {
129
            $this->table = 'consent';
130
        }
131
132
        if (isset($config['timeout'])) {
133
            if (!is_int($config['timeout'])) {
134
                throw new Exception('consent:Database - \'timeout\' is supposed to be an integer.');
135
            }
136
            $this->timeout = $config['timeout'];
137
        }
138
    }
139
140
141
    /**
142
     * Called before serialization.
143
     *
144
     * @return string[] The variables which should be serialized.
145
     */
146
    public function __sleep(): array
147
    {
148
        return [
149
            'dsn',
150
            'dateTime',
151
            'username',
152
            'password',
153
            'table',
154
            'timeout',
155
        ];
156
    }
157
158
159
    /**
160
     * Check for consent.
161
     *
162
     * This function checks whether a given user has authorized the release of
163
     * the attributes identified by $attributeSet from $source to $destination.
164
     *
165
     * @param string $userId        The hash identifying the user at an IdP.
166
     * @param string $destinationId A string which identifies the destination.
167
     * @param string $attributeSet  A hash which identifies the attributes.
168
     *
169
     * @return bool True if the user has given consent earlier, false if not
170
     *              (or on error).
171
     */
172
    public function hasConsent(string $userId, string $destinationId, string $attributeSet): bool
173
    {
174
        $st = $this->execute(
175
            'UPDATE ' . $this->table . ' ' .
176
            'SET usage_date = ' . $this->dateTime . ' ' .
177
            'WHERE hashed_user_id = ? AND service_id = ? AND attribute = ?',
178
            [$userId, $destinationId, $attributeSet],
179
        );
180
181
        if ($st === false) {
182
            return false;
183
        }
184
185
        $rowCount = $st->rowCount();
186
        if ($rowCount === 0) {
187
            Logger::debug('consent:Database - No consent found.');
188
            return false;
189
        } else {
190
            Logger::debug('consent:Database - Consent found.');
191
            return true;
192
        }
193
    }
194
195
196
    /**
197
     * Save consent.
198
     *
199
     * Called when the user asks for the consent to be saved. If consent information
200
     * for the given user and destination already exists, it should be overwritten.
201
     *
202
     * @param string $userId        The hash identifying the user at an IdP.
203
     * @param string $destinationId A string which identifies the destination.
204
     * @param string $attributeSet  A hash which identifies the attributes.
205
     *
206
     * @return bool True if consent is deleted, false otherwise.
207
     */
208
    public function saveConsent(string $userId, string $destinationId, string $attributeSet): bool
209
    {
210
        // Check for old consent (with different attribute set)
211
        $st = $this->execute(
212
            'UPDATE ' . $this->table . ' ' .
213
            'SET consent_date = ' . $this->dateTime . ', usage_date = ' . $this->dateTime . ', attribute = ? ' .
214
            'WHERE hashed_user_id = ? AND service_id = ?',
215
            [$attributeSet, $userId, $destinationId],
216
        );
217
218
        if ($st === false) {
219
            return false;
220
        }
221
222
        if ($st->rowCount() > 0) {
223
            // Consent has already been stored in the database
224
            Logger::debug('consent:Database - Updated old consent.');
225
            return false;
226
        }
227
228
        // Add new consent
229
        $st = $this->execute(
230
            'INSERT INTO ' . $this->table . ' (' . 'consent_date, usage_date, hashed_user_id, service_id, attribute' .
231
            ') ' . 'VALUES (' . $this->dateTime . ', ' . $this->dateTime . ', ?, ?, ?)',
232
            [$userId, $destinationId, $attributeSet],
233
        );
234
235
        if ($st !== false) {
236
            Logger::debug('consent:Database - Saved new consent.');
237
        }
238
        return true;
239
    }
240
241
242
    /**
243
     * Delete consent.
244
     *
245
     * Called when a user revokes consent for a given destination.
246
     *
247
     * @param string $userId        The hash identifying the user at an IdP.
248
     * @param string $destinationId A string which identifies the destination.
249
     *
250
     * @return int Number of consents deleted
251
     */
252
    public function deleteConsent(string $userId, string $destinationId): int
253
    {
254
        $st = $this->execute(
255
            'DELETE FROM ' . $this->table . ' WHERE hashed_user_id = ? AND service_id = ?;',
256
            [$userId, $destinationId],
257
        );
258
259
        if ($st === false) {
260
            return 0;
261
        }
262
263
        if ($st->rowCount() > 0) {
264
            Logger::debug('consent:Database - Deleted consent.');
265
            return $st->rowCount();
266
        }
267
268
        Logger::warning('consent:Database - Attempted to delete nonexistent consent');
269
        return 0;
270
    }
271
272
273
    /**
274
     * Delete all consents.
275
     *
276
     * @param string $userId The hash identifying the user at an IdP.
277
     *
278
     * @return int Number of consents deleted
279
     */
280
    public function deleteAllConsents(string $userId): int
281
    {
282
        $st = $this->execute(
283
            'DELETE FROM ' . $this->table . ' WHERE hashed_user_id = ?',
284
            [$userId],
285
        );
286
287
        if ($st === false) {
288
            return 0;
289
        }
290
291
        if ($st->rowCount() > 0) {
292
            Logger::debug('consent:Database - Deleted (' . $st->rowCount() . ') consent(s) . ');
293
            return $st->rowCount();
294
        }
295
296
        Logger::warning('consent:Database - Attempted to delete nonexistent consent');
297
        return 0;
298
    }
299
300
301
    /**
302
     * Retrieve consents.
303
     *
304
     * This function should return a list of consents the user has saved.
305
     *
306
     * @param string $userId The hash identifying the user at an IdP.
307
     *
308
     * @return string[] Array of all destination ids the user has given consent for.
309
     */
310
    public function getConsents(string $userId): array
311
    {
312
        $ret = [];
313
314
        $st = $this->execute(
315
            'SELECT service_id, attribute, consent_date, usage_date FROM ' . $this->table .
316
            ' WHERE hashed_user_id = ?',
317
            [$userId],
318
        );
319
320
        if ($st === false) {
321
            return [];
322
        }
323
324
        while ($row = $st->fetch(PDO::FETCH_NUM)) {
325
            $ret[] = $row;
326
        }
327
328
        return $ret;
329
    }
330
331
332
    /**
333
     * Prepare and execute statement.
334
     *
335
     * This function prepares and executes a statement. On error, false will be
336
     * returned.
337
     *
338
     * @param string $statement  The statement which should be executed.
339
     * @param array<mixed>  $parameters Parameters for the statement.
340
     *
341
     * @return \PDOStatement|false  The statement, or false if execution failed.
342
     */
343
    private function execute(string $statement, array $parameters)
344
    {
345
        $db = $this->getDB();
346
347
        $st = $db->prepare($statement);
348
        if ($st === false) {
349
            Logger::error(
350
                'consent:Database - Error preparing statement \'' .
351
                $statement . '\': ' . self::formatError($db->errorInfo()),
352
            );
353
            return false;
354
        }
355
356
        if ($st->execute($parameters) !== true) {
357
            Logger::error(
358
                'consent:Database - Error executing statement \'' .
359
                $statement . '\': ' . self::formatError($st->errorInfo()),
360
            );
361
            return false;
362
        }
363
364
        return $st;
365
    }
366
367
368
    /**
369
     * Get statistics from the database
370
     *
371
     * The returned array contains 3 entries
372
     * - total: The total number of consents
373
     * - users: Total number of uses that have given consent
374
     * ' services: Total number of services that has been given consent to
375
     *
376
     * @return array<mixed> Array containing the statistics
377
     */
378
    public function getStatistics(): array
379
    {
380
        $ret = [];
381
382
        // Get total number of consents
383
        $st = $this->execute('SELECT COUNT(*) AS no FROM ' . $this->table, []);
384
385
        if ($st === false) {
386
            return [];
387
        }
388
389
        if ($row = $st->fetch(PDO::FETCH_NUM)) {
390
            $ret['total'] = $row[0];
391
        }
392
393
        // Get total number of users that has given consent
394
        $st = $this->execute(
395
            'SELECT COUNT(*) AS no ' .
396
            'FROM (SELECT DISTINCT hashed_user_id FROM ' . $this->table . ' ) AS foo',
397
            [],
398
        );
399
400
        if ($st === false) {
401
            return [];
402
        }
403
404
        if ($row = $st->fetch(PDO::FETCH_NUM)) {
405
            $ret['users'] = $row[0];
406
        }
407
408
        // Get total number of services that has been given consent to
409
        $st = $this->execute(
410
            'SELECT COUNT(*) AS no FROM (SELECT DISTINCT service_id FROM ' . $this->table . ') AS foo',
411
            [],
412
        );
413
414
        if ($st === false) {
415
            return [];
416
        }
417
418
        if ($row = $st->fetch(PDO::FETCH_NUM)) {
419
            $ret['services'] = $row[0];
420
        }
421
422
        return $ret;
423
    }
424
425
426
    /**
427
     * Get database handle.
428
     *
429
     * @return \PDO Database handle, or false if we fail to connect.
430
     */
431
    private function getDB(): PDO
432
    {
433
        if ($this->db !== null) {
434
            return $this->db;
435
        }
436
437
        $driver_options = [];
438
        if (isset($this->timeout)) {
439
            $driver_options[PDO::ATTR_TIMEOUT] = $this->timeout;
440
        }
441
442
        if (!empty($this->options)) {
443
            $this->options = array_merge($driver_options, $this->options);
444
        } else {
445
            $this->options = $driver_options;
446
        }
447
448
        $this->db = new PDO($this->dsn, $this->username, $this->password, $this->options);
449
450
        return $this->db;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->db returns the type null which is incompatible with the type-hinted return PDO.
Loading history...
451
    }
452
453
454
    /**
455
     * Format PDO error.
456
     *
457
     * This function formats a PDO error, as returned from errorInfo.
458
     *
459
     * @param string[] $error The error information.
460
     *
461
     * @return string Error text.
462
     */
463
    private static function formatError(array $error): string
464
    {
465
        Assert::greaterThanEq(count($error), 3);
466
467
        return $error[0] . ' - ' . $error[2] . ' (' . $error[1] . ')';
468
    }
469
470
471
    /**
472
     * A quick selftest of the consent database.
473
     *
474
     * @return boolean True if OK, false if not. Will throw an exception on connection errors.
475
     */
476
    public function selftest(): bool
477
    {
478
        $st = $this->execute(
479
            'SELECT * FROM ' . $this->table . ' WHERE hashed_user_id = ? AND service_id = ? AND attribute = ?',
480
            ['test', 'test', 'test'],
481
        );
482
483
        if ($st === false) {
484
            // normally, the test will fail by an exception, so we won't reach this code
485
            return false;
486
        }
487
        return true;
488
    }
489
}
490