SQL::verifyUserNameWithRegex()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 3
nop 1
dl 0
loc 7
rs 10
c 0
b 0
f 0
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
use function array_key_exists;
15
use function array_keys;
16
use function explode;
17
use function implode;
18
use function in_array;
19
use function is_string;
20
use function preg_replace;
21
use function strtolower;
22
use function var_export;
23
24
/**
25
 * Simple SQL authentication source
26
 *
27
 * This class is an example authentication source which authenticates an user
28
 * against a SQL database.
29
 *
30
 * @package SimpleSAMLphp
31
 */
32
33
class SQL extends UserPassBase
34
{
35
    /**
36
     * The DSN we should connect to.
37
     * @var string
38
     */
39
    private string $dsn;
40
41
    /**
42
     * The username we should connect to the database with.
43
     * @var string
44
     */
45
    private string $username;
46
47
    /**
48
     * The password we should connect to the database with.
49
     * @var string
50
     */
51
    private string $password;
52
53
    /**
54
     * An optional regex that the username should match.
55
     * @var string
56
     */
57
    protected ?string $username_regex;
58
59
    /**
60
     * The options that we should connect to the database with.
61
     * @var array
62
     */
63
    private array $options = [];
64
65
    /**
66
     * The query or queries we should use to retrieve the attributes for the user.
67
     *
68
     * The username and password will be available as :username and :password.
69
     * @var array
70
     */
71
    protected array $query;
72
73
74
    /**
75
     * Constructor for this authentication source.
76
     *
77
     * @param array $info  Information about this authentication source.
78
     * @param array $config  Configuration.
79
     */
80
    public function __construct(array $info, array $config)
81
    {
82
        // Call the parent constructor first, as required by the interface
83
        parent::__construct($info, $config);
84
85
        // Make sure that all required parameters are present.
86
        foreach (['dsn', 'username', 'password'] as $param) {
87
            if (!array_key_exists($param, $config)) {
88
                throw new Exception('Missing required attribute \'' . $param .
89
                    '\' for authentication source ' . $this->authId);
90
            }
91
92
            if (!is_string($config[$param])) {
93
                throw new Exception('Expected parameter \'' . $param .
94
                    '\' for authentication source ' . $this->authId .
95
                    ' to be a string. Instead it was: ' .
96
                    var_export($config[$param], true));
97
            }
98
        }
99
100
        // Query can be a single query or an array of queries.
101
        if (!array_key_exists('query', $config)) {
102
            throw new Exception('Missing required attribute \'query\' ' .
103
                'for authentication source ' . $this->authId);
104
        } elseif (is_array($config['query']) && (count($config['query']) < 1)) {
105
            throw new Exception('Required attribute \'query\' is an empty ' .
106
                'list of queries for authentication source ' . $this->authId);
107
        }
108
109
        $this->dsn = $config['dsn'];
110
        $this->username = $config['username'];
111
        $this->password = $config['password'];
112
        $this->query = is_string($config['query']) ? [$config['query']] : $config['query'];
113
        if (isset($config['options'])) {
114
            $this->options = $config['options'];
115
        }
116
117
        // Optional "username_regex" parameter
118
        $this->username_regex = array_key_exists('username_regex', $config) ? $config['username_regex'] : null;
119
    }
120
121
122
    /**
123
     * Create a database connection.
124
     *
125
     * @return \PDO  The database connection.
126
     */
127
    protected function connect(): PDO
128
    {
129
        try {
130
            $db = new PDO($this->dsn, $this->username, $this->password, $this->options);
131
        } catch (PDOException $e) {
132
            // Obfuscate the password if it's part of the dsn
133
            $obfuscated_dsn =  preg_replace('/(user|password)=(.*?([;]|$))/', '${1}=***', $this->dsn);
134
135
            throw new Exception('sqlauth:' . $this->authId . ': - Failed to connect to \'' .
136
                $obfuscated_dsn . '\': ' . $e->getMessage());
137
        }
138
139
        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
140
141
        $driver = explode(':', $this->dsn, 2);
142
        $driver = strtolower($driver[0]);
143
144
        // Driver specific initialization
145
        switch ($driver) {
146
            case 'mysql':
147
                // Use UTF-8
148
                $db->exec("SET NAMES 'utf8mb4'");
149
                break;
150
            case 'pgsql':
151
                // Use UTF-8
152
                $db->exec("SET NAMES 'UTF8'");
153
                break;
154
        }
155
156
        return $db;
157
    }
158
159
160
    /**
161
     * Extract SQL columns into SAML attribute array
162
     *
163
     * @param array $attributes output place to store extracted attributes
164
     * @param array  $data  Associative array from database in the format of PDO fetchAll
165
     * @param array  $forbiddenAttributes An array of attributes to never return
166
     * @return array &$attributes
167
     */
168
    protected function extractAttributes(array &$attributes, array $data, array $forbiddenAttributes = []): array
169
    {
170
        foreach ($data as $row) {
171
            foreach ($row as $name => $value) {
172
                if ($value === null) {
173
                    continue;
174
                }
175
                if (in_array($name, $forbiddenAttributes)) {
176
                    continue;
177
                }
178
179
                $value = (string) $value;
180
181
                if (!array_key_exists($name, $attributes)) {
182
                    $attributes[$name] = [];
183
                }
184
185
                if (in_array($value, $attributes[$name], true)) {
186
                    // Value already exists in attribute
187
                    continue;
188
                }
189
190
                $attributes[$name][] = $value;
191
            }
192
        }
193
        return $attributes;
194
    }
195
196
197
    /**
198
     * Execute the query with given parameters and return the tuples that result.
199
     *
200
     * @param string $query  SQL to execute
201
     * @param array $params parameters to the SQL query
202
     * @return array tuples that result
203
     */
204
    protected function executeQuery(PDO $db, string $query, array $params): array
205
    {
206
        try {
207
            $sth = $db->prepare($query);
208
        } catch (PDOException $e) {
209
            throw new Exception('sqlauth:' . $this->authId .
210
                                ': - Failed to prepare query: ' . $e->getMessage());
211
        }
212
213
        try {
214
            $sth->execute($params);
215
        } catch (PDOException $e) {
216
            throw new Exception('sqlauth:' . $this->authId .
217
                                ': - Failed to execute query: ' . $e->getMessage());
218
        }
219
220
        try {
221
            $data = $sth->fetchAll(PDO::FETCH_ASSOC);
222
            return $data;
223
        } catch (PDOException $e) {
224
            throw new Exception('sqlauth:' . $this->authId .
225
                                ': - Failed to fetch result set: ' . $e->getMessage());
226
        }
227
    }
228
229
230
    /**
231
     * If there is a username_regex then verify the passed username against it and
232
     * throw an exception if it fails.
233
     *
234
     * @param string $username  The username the user wrote.
235
     */
236
    protected function verifyUserNameWithRegex(string $username): void
237
    {
238
        if ($this->username_regex !== null) {
239
            if (!preg_match($this->username_regex, $username)) {
240
                Logger::error('sqlauth:' . $this->authId .
241
                    ": Username doesn't match username_regex.");
242
                throw new Error\Error('WRONGUSERPASS');
243
            }
244
        }
245
    }
246
247
248
    /**
249
     * Attempt to log in using the given username and password.
250
     *
251
     * On a successful login, this function should return the users attributes. On failure,
252
     * it should throw an exception. If the error was caused by the user entering the wrong
253
     * username or password, a \SimpleSAML\Error\Error('WRONGUSERPASS') should be thrown.
254
     *
255
     * Note that both the username and the password are UTF-8 encoded.
256
     *
257
     * @param string $username  The username the user wrote.
258
     * @param string $password  The password the user wrote.
259
     * @return array  Associative array with the users attributes.
260
     */
261
    protected function login(
262
        string $username,
263
        #[\SensitiveParameter]
264
        string $password,
265
    ): array {
266
        $this->verifyUserNameWithRegex($username);
267
268
        $db = $this->connect();
269
        $params = ['username' => $username, 'password' => $password];
270
        $attributes = [];
271
272
        $numQueries = count($this->query);
273
        for ($x = 0; $x < $numQueries; $x++) {
274
            $data = $this->executeQuery($db, $this->query[$x], $params);
275
276
            Logger::info('sqlauth:' . $this->authId . ': Got ' . count($data) .
277
                ' rows from database');
278
279
            if ($x === 0) {
280
                if (count($data) === 0) {
281
                    // No rows returned from first query - invalid username/password
282
                    Logger::error('sqlauth:' . $this->authId .
283
                        ': No rows in result set. Probably wrong username/password.');
284
                    throw new Error\Error('WRONGUSERPASS');
285
                }
286
                /* Only the first query should be passed the password, as that is the only
287
                 * one used for authentication. Subsequent queries are only used for
288
                 * getting attribute lists, so only need the username. */
289
                unset($params['password']);
290
            }
291
292
            /* Extract attributes. We allow the resultset to consist of multiple rows. Attributes
293
            * which are present in more than one row will become multivalued. null values and
294
            * duplicate values will be skipped. All values will be converted to strings.
295
             */
296
            $this->extractAttributes($attributes, $data, []);
297
        }
298
299
        Logger::info('sqlauth:' . $this->authId . ': Attributes: ' . implode(',', array_keys($attributes)));
300
301
        return $attributes;
302
    }
303
}
304