Database::execute()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 25
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 15
nc 4
nop 2
dl 0
loc 25
rs 9.7666
c 0
b 0
f 0
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
    private array $options = [];
54
55
    /**
56
     * Table with consent.
57
     */
58
    private string $table;
59
60
    /**
61
     * The timeout of the database connection.
62
     *
63
     * @var int|null
64
     */
65
    private ?int $timeout = null;
66
67
    /**
68
     * Database handle.
69
     *
70
     * This variable can't be serialized.
71
     */
72
    private ?PDO $db = null;
73
74
75
    /**
76
     * Parse configuration.
77
     *
78
     * This constructor parses the configuration.
79
     *
80
     * @param array $config Configuration for database consent store.
81
     *
82
     * @throws \Exception in case of a configuration error.
83
     */
84
    public function __construct(array $config)
85
    {
86
        parent::__construct($config);
87
88
        if (!array_key_exists('dsn', $config)) {
89
            throw new Exception('consent:Database - Missing required option \'dsn\'.');
90
        }
91
        if (!is_string($config['dsn'])) {
92
            throw new Exception('consent:Database - \'dsn\' is supposed to be a string.');
93
        }
94
95
        $this->dsn = $config['dsn'];
96
        $this->dateTime = (0 === strpos($this->dsn, 'sqlite:')) ? 'DATETIME("NOW")' : 'NOW()';
97
98
        if (array_key_exists('username', $config)) {
99
            if (!is_string($config['username'])) {
100
                throw new Exception('consent:Database - \'username\' is supposed to be a string.');
101
            }
102
            $this->username = $config['username'];
103
        }
104
105
        if (array_key_exists('password', $config)) {
106
            if (!is_string($config['password'])) {
107
                throw new Exception('consent:Database - \'password\' is supposed to be a string.');
108
            }
109
            $this->password = $config['password'];
110
        }
111
112
        if (array_key_exists('options', $config)) {
113
            if (!is_array($config['options'])) {
114
                throw new Exception('consent:Database - \'options\' is supposed to be an array.');
115
            }
116
            $this->options = $config['options'];
117
        } else {
118
            $this->options = [];
119
        }
120
121
        if (array_key_exists('table', $config)) {
122
            if (!is_string($config['table'])) {
123
                throw new Exception('consent:Database - \'table\' is supposed to be a string.');
124
            }
125
            $this->table = $config['table'];
126
        } else {
127
            $this->table = 'consent';
128
        }
129
130
        if (isset($config['timeout'])) {
131
            if (!is_int($config['timeout'])) {
132
                throw new Exception('consent:Database - \'timeout\' is supposed to be an integer.');
133
            }
134
            $this->timeout = $config['timeout'];
135
        }
136
    }
137
138
139
    /**
140
     * Called before serialization.
141
     *
142
     * @return array The variables which should be serialized.
143
     */
144
    public function __sleep(): array
145
    {
146
        return [
147
            'dsn',
148
            'dateTime',
149
            'username',
150
            'password',
151
            'table',
152
            'timeout',
153
        ];
154
    }
155
156
157
    /**
158
     * Check for consent.
159
     *
160
     * This function checks whether a given user has authorized the release of
161
     * the attributes identified by $attributeSet from $source to $destination.
162
     *
163
     * @param string $userId        The hash identifying the user at an IdP.
164
     * @param string $destinationId A string which identifies the destination.
165
     * @param string $attributeSet  A hash which identifies the attributes.
166
     *
167
     * @return bool True if the user has given consent earlier, false if not
168
     *              (or on error).
169
     */
170
    public function hasConsent(string $userId, string $destinationId, string $attributeSet): bool
171
    {
172
        $st = $this->execute(
173
            'UPDATE ' . $this->table . ' ' .
174
            'SET usage_date = ' . $this->dateTime . ' ' .
175
            'WHERE hashed_user_id = ? AND service_id = ? AND attribute = ?',
176
            [$userId, $destinationId, $attributeSet],
177
        );
178
179
        if ($st === false) {
180
            return false;
181
        }
182
183
        $rowCount = $st->rowCount();
184
        if ($rowCount === 0) {
185
            Logger::debug('consent:Database - No consent found.');
186
            return false;
187
        } else {
188
            Logger::debug('consent:Database - Consent found.');
189
            return true;
190
        }
191
    }
192
193
194
    /**
195
     * Save consent.
196
     *
197
     * Called when the user asks for the consent to be saved. If consent information
198
     * for the given user and destination already exists, it should be overwritten.
199
     *
200
     * @param string $userId        The hash identifying the user at an IdP.
201
     * @param string $destinationId A string which identifies the destination.
202
     * @param string $attributeSet  A hash which identifies the attributes.
203
     *
204
     * @return bool True if consent is deleted, false otherwise.
205
     */
206
    public function saveConsent(string $userId, string $destinationId, string $attributeSet): bool
207
    {
208
        // Check for old consent (with different attribute set)
209
        $st = $this->execute(
210
            'UPDATE ' . $this->table . ' ' .
211
            'SET consent_date = ' . $this->dateTime . ', usage_date = ' . $this->dateTime . ', attribute = ? ' .
212
            'WHERE hashed_user_id = ? AND service_id = ?',
213
            [$attributeSet, $userId, $destinationId],
214
        );
215
216
        if ($st === false) {
217
            return false;
218
        }
219
220
        if ($st->rowCount() > 0) {
221
            // Consent has already been stored in the database
222
            Logger::debug('consent:Database - Updated old consent.');
223
            return false;
224
        }
225
226
        // Add new consent
227
        $st = $this->execute(
228
            'INSERT INTO ' . $this->table . ' (' . 'consent_date, usage_date, hashed_user_id, service_id, attribute' .
229
            ') ' . 'VALUES (' . $this->dateTime . ', ' . $this->dateTime . ', ?, ?, ?)',
230
            [$userId, $destinationId, $attributeSet],
231
        );
232
233
        if ($st !== false) {
234
            Logger::debug('consent:Database - Saved new consent.');
235
        }
236
        return true;
237
    }
238
239
240
    /**
241
     * Delete consent.
242
     *
243
     * Called when a user revokes consent for a given destination.
244
     *
245
     * @param string $userId        The hash identifying the user at an IdP.
246
     * @param string $destinationId A string which identifies the destination.
247
     *
248
     * @return int Number of consents deleted
249
     */
250
    public function deleteConsent(string $userId, string $destinationId): int
251
    {
252
        $st = $this->execute(
253
            'DELETE FROM ' . $this->table . ' WHERE hashed_user_id = ? AND service_id = ?;',
254
            [$userId, $destinationId],
255
        );
256
257
        if ($st === false) {
258
            return 0;
259
        }
260
261
        if ($st->rowCount() > 0) {
262
            Logger::debug('consent:Database - Deleted consent.');
263
            return $st->rowCount();
264
        }
265
266
        Logger::warning('consent:Database - Attempted to delete nonexistent consent');
267
        return 0;
268
    }
269
270
271
    /**
272
     * Delete all consents.
273
     *
274
     * @param string $userId The hash identifying the user at an IdP.
275
     *
276
     * @return int Number of consents deleted
277
     */
278
    public function deleteAllConsents(string $userId): int
279
    {
280
        $st = $this->execute(
281
            'DELETE FROM ' . $this->table . ' WHERE hashed_user_id = ?',
282
            [$userId],
283
        );
284
285
        if ($st === false) {
286
            return 0;
287
        }
288
289
        if ($st->rowCount() > 0) {
290
            Logger::debug('consent:Database - Deleted (' . $st->rowCount() . ') consent(s) . ');
291
            return $st->rowCount();
292
        }
293
294
        Logger::warning('consent:Database - Attempted to delete nonexistent consent');
295
        return 0;
296
    }
297
298
299
    /**
300
     * Retrieve consents.
301
     *
302
     * This function should return a list of consents the user has saved.
303
     *
304
     * @param string $userId The hash identifying the user at an IdP.
305
     *
306
     * @return array Array of all destination ids the user has given consent for.
307
     */
308
    public function getConsents(string $userId): array
309
    {
310
        $ret = [];
311
312
        $st = $this->execute(
313
            'SELECT service_id, attribute, consent_date, usage_date FROM ' . $this->table .
314
            ' WHERE hashed_user_id = ?',
315
            [$userId],
316
        );
317
318
        if ($st === false) {
319
            return [];
320
        }
321
322
        while ($row = $st->fetch(PDO::FETCH_NUM)) {
323
            $ret[] = $row;
324
        }
325
326
        return $ret;
327
    }
328
329
330
    /**
331
     * Prepare and execute statement.
332
     *
333
     * This function prepares and executes a statement. On error, false will be
334
     * returned.
335
     *
336
     * @param string $statement  The statement which should be executed.
337
     * @param array  $parameters Parameters for the statement.
338
     *
339
     * @return \PDOStatement|false  The statement, or false if execution failed.
340
     */
341
    private function execute(string $statement, array $parameters)
342
    {
343
        $db = $this->getDB();
344
        if ($db === false) {
0 ignored issues
show
introduced by
The condition $db === false is always false.
Loading history...
345
            return false;
346
        }
347
348
        $st = $db->prepare($statement);
349
        if ($st === false) {
350
            Logger::error(
351
                'consent:Database - Error preparing statement \'' .
352
                $statement . '\': ' . self::formatError($db->errorInfo()),
353
            );
354
            return false;
355
        }
356
357
        if ($st->execute($parameters) !== true) {
358
            Logger::error(
359
                'consent:Database - Error executing statement \'' .
360
                $statement . '\': ' . self::formatError($st->errorInfo()),
361
            );
362
            return false;
363
        }
364
365
        return $st;
366
    }
367
368
369
    /**
370
     * Get statistics from the database
371
     *
372
     * The returned array contains 3 entries
373
     * - total: The total number of consents
374
     * - users: Total number of uses that have given consent
375
     * ' services: Total number of services that has been given consent to
376
     *
377
     * @return array Array containing the statistics
378
     */
379
    public function getStatistics(): array
380
    {
381
        $ret = [];
382
383
        // Get total number of consents
384
        $st = $this->execute('SELECT COUNT(*) AS no FROM ' . $this->table, []);
385
386
        if ($st === false) {
387
            return [];
388
        }
389
390
        if ($row = $st->fetch(PDO::FETCH_NUM)) {
391
            $ret['total'] = $row[0];
392
        }
393
394
        // Get total number of users that has given consent
395
        $st = $this->execute(
396
            'SELECT COUNT(*) AS no ' .
397
            'FROM (SELECT DISTINCT hashed_user_id FROM ' . $this->table . ' ) AS foo',
398
            [],
399
        );
400
401
        if ($st === false) {
402
            return [];
403
        }
404
405
        if ($row = $st->fetch(PDO::FETCH_NUM)) {
406
            $ret['users'] = $row[0];
407
        }
408
409
        // Get total number of services that has been given consent to
410
        $st = $this->execute(
411
            'SELECT COUNT(*) AS no FROM (SELECT DISTINCT service_id FROM ' . $this->table . ') AS foo',
412
            [],
413
        );
414
415
        if ($st === false) {
416
            return [];
417
        }
418
419
        if ($row = $st->fetch(PDO::FETCH_NUM)) {
420
            $ret['services'] = $row[0];
421
        }
422
423
        return $ret;
424
    }
425
426
427
    /**
428
     * Get database handle.
429
     *
430
     * @return \PDO|false Database handle, or false if we fail to connect.
431
     */
432
    private function getDB()
433
    {
434
        if ($this->db !== null) {
435
            return $this->db;
436
        }
437
438
        $driver_options = [];
439
        if (isset($this->timeout)) {
440
            $driver_options[PDO::ATTR_TIMEOUT] = $this->timeout;
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;
451
    }
452
453
454
    /**
455
     * Format PDO error.
456
     *
457
     * This function formats a PDO error, as returned from errorInfo.
458
     *
459
     * @param array $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