Completed
Push — master ( 6dcf56...c56c6f )
by Tim
32s queued 30s
created

SQL::extractAttributes()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 26
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 13
nc 8
nop 3
dl 0
loc 26
rs 8.8333
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
     * Constructor for this authentication source.
75
     *
76
     * @param array $info  Information about this authentication source.
77
     * @param array $config  Configuration.
78
     */
79
    public function __construct(array $info, array $config)
80
    {
81
        // Call the parent constructor first, as required by the interface
82
        parent::__construct($info, $config);
83
84
        // Make sure that all required parameters are present.
85
        foreach (['dsn', 'username', 'password'] as $param) {
86
            if (!array_key_exists($param, $config)) {
87
                throw new Exception('Missing required attribute \'' . $param .
88
                    '\' for authentication source ' . $this->authId);
89
            }
90
91
            if (!is_string($config[$param])) {
92
                throw new Exception('Expected parameter \'' . $param .
93
                    '\' for authentication source ' . $this->authId .
94
                    ' to be a string. Instead it was: ' .
95
                    var_export($config[$param], true));
96
            }
97
        }
98
99
        // Query can be a single query or an array of queries.
100
        if (!array_key_exists('query', $config)) {
101
            throw new Exception('Missing required attribute \'query\' ' .
102
                'for authentication source ' . $this->authId);
103
        } elseif (is_array($config['query']) && (count($config['query']) < 1)) {
104
            throw new Exception('Required attribute \'query\' is an empty ' .
105
                'list of queries for authentication source ' . $this->authId);
106
        }
107
108
        $this->dsn = $config['dsn'];
109
        $this->username = $config['username'];
110
        $this->password = $config['password'];
111
        $this->query = is_string($config['query']) ? [$config['query']] : $config['query'];
112
        if (isset($config['options'])) {
113
            $this->options = $config['options'];
114
        }
115
116
        // Optional "username_regex" parameter
117
        $this->username_regex = array_key_exists('username_regex', $config) ? $config['username_regex'] : null;
118
    }
119
120
121
    /**
122
     * Create a database connection.
123
     *
124
     * @return \PDO  The database connection.
125
     */
126
    protected function connect(): PDO
127
    {
128
        try {
129
            $db = new PDO($this->dsn, $this->username, $this->password, $this->options);
130
        } catch (PDOException $e) {
131
            // Obfuscate the password if it's part of the dsn
132
            $obfuscated_dsn =  preg_replace('/(user|password)=(.*?([;]|$))/', '${1}=***', $this->dsn);
133
134
            throw new Exception('sqlauth:' . $this->authId . ': - Failed to connect to \'' .
135
                $obfuscated_dsn . '\': ' . $e->getMessage());
136
        }
137
138
        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
139
140
        $driver = explode(':', $this->dsn, 2);
141
        $driver = strtolower($driver[0]);
142
143
        // Driver specific initialization
144
        switch ($driver) {
145
            case 'mysql':
146
                // Use UTF-8
147
                $db->exec("SET NAMES 'utf8mb4'");
148
                break;
149
            case 'pgsql':
150
                // Use UTF-8
151
                $db->exec("SET NAMES 'UTF8'");
152
                break;
153
        }
154
155
        return $db;
156
    }
157
158
    /**
159
     * Extract SQL columns into SAML attribute array
160
     *
161
     * @param $attributes output place to store extracted attributes
0 ignored issues
show
Bug introduced by
The type SimpleSAML\Module\sqlauth\Auth\Source\output was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
162
     * @param array  $data  Associative array from database in the format of PDO fetchAll
163
     * @param array  $forbiddenAttributes An array of attributes to never return
164
     * @return $attributes
0 ignored issues
show
Documentation Bug introduced by
The doc comment $attributes at position 0 could not be parsed: Unknown type name '$attributes' at position 0 in $attributes.
Loading history...
165
     */
166
    protected function extractAttributes(&$attributes, $data, $forbiddenAttributes = [])
167
    {
168
        foreach ($data as $row) {
169
            foreach ($row as $name => $value) {
170
                if ($value === null) {
171
                    continue;
172
                }
173
                if (in_array($name, $forbiddenAttributes)) {
174
                    continue;
175
                }
176
177
                $value = (string) $value;
178
179
                if (!array_key_exists($name, $attributes)) {
180
                    $attributes[$name] = [];
181
                }
182
183
                if (in_array($value, $attributes[$name], true)) {
184
                    // Value already exists in attribute
185
                    continue;
186
                }
187
188
                $attributes[$name][] = $value;
189
            }
190
        }
191
        return $attributes;
192
    }
193
194
    /**
195
     * Execute the query with given parameters and return the tuples that result.
196
     *
197
     * @param string $query  SQL to execute
198
     * @param array  $params parameters to the SQL query
199
     * @return tuples that result
0 ignored issues
show
Bug introduced by
The type SimpleSAML\Module\sqlauth\Auth\Source\tuples was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
200
     */
201
    protected function executeQuery(PDO $db, string $query, array $params): array
202
    {
203
        try {
204
            $sth = $db->prepare($query);
205
        } catch (PDOException $e) {
206
            throw new Exception('sqlauth:' . $this->authId .
207
                                ': - Failed to prepare query: ' . $e->getMessage());
208
        }
209
210
        try {
211
            $sth->execute($params);
212
        } catch (PDOException $e) {
213
            throw new Exception('sqlauth:' . $this->authId .
214
                                ': - Failed to execute query: ' . $e->getMessage());
215
        }
216
217
        try {
218
            $data = $sth->fetchAll(PDO::FETCH_ASSOC);
219
            return $data;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $data returns the type array which is incompatible with the documented return type SimpleSAML\Module\sqlauth\Auth\Source\tuples.
Loading history...
220
        } catch (PDOException $e) {
221
            throw new Exception('sqlauth:' . $this->authId .
222
                                ': - Failed to fetch result set: ' . $e->getMessage());
223
        }
224
    }
225
226
    /**
227
     * If there is a username_regex then verify the passed username against it and
228
     * throw an exception if it fails.
229
     *
230
     * @param string $username  The username the user wrote.
231
     */
232
    protected function verifyUserNameWithRegex(string $username): void
233
    {
234
        if ($this->username_regex !== null) {
235
            if (!preg_match($this->username_regex, $username)) {
236
                Logger::error('sqlauth:' . $this->authId .
237
                    ": Username doesn't match username_regex.");
238
                throw new Error\Error('WRONGUSERPASS');
239
            }
240
        }
241
    }
242
243
    /**
244
     * Attempt to log in using the given username and password.
245
     *
246
     * On a successful login, this function should return the users attributes. On failure,
247
     * it should throw an exception. If the error was caused by the user entering the wrong
248
     * username or password, a \SimpleSAML\Error\Error('WRONGUSERPASS') should be thrown.
249
     *
250
     * Note that both the username and the password are UTF-8 encoded.
251
     *
252
     * @param string $username  The username the user wrote.
253
     * @param string $password  The password the user wrote.
254
     * @return array  Associative array with the users attributes.
255
     */
256
    protected function login(string $username, string $password): array
257
    {
258
        $this->verifyUserNameWithRegex($username);
259
260
        $db = $this->connect();
261
        $params = ['username' => $username, 'password' => $password];
262
        $attributes = [];
263
264
        $numQueries = count($this->query);
265
        for ($x = 0; $x < $numQueries; $x++) {
266
            $data = $this->executeQuery($db, $this->query[$x], $params);
267
268
            Logger::info('sqlauth:' . $this->authId . ': Got ' . count($data) .
269
                ' rows from database');
270
271
            if ($x === 0) {
272
                if (count($data) === 0) {
273
                    // No rows returned from first query - invalid username/password
274
                    Logger::error('sqlauth:' . $this->authId .
275
                        ': No rows in result set. Probably wrong username/password.');
276
                    throw new Error\Error('WRONGUSERPASS');
277
                }
278
                /* Only the first query should be passed the password, as that is the only
279
                 * one used for authentication. Subsequent queries are only used for
280
                 * getting attribute lists, so only need the username. */
281
                unset($params['password']);
282
            }
283
284
            /* Extract attributes. We allow the resultset to consist of multiple rows. Attributes
285
            * which are present in more than one row will become multivalued. null values and
286
            * duplicate values will be skipped. All values will be converted to strings.
287
             */
288
            $this->extractAttributes($attributes, $data, []);
289
        }
290
291
        Logger::info('sqlauth:' . $this->authId . ': Attributes: ' . implode(',', array_keys($attributes)));
292
293
        return $attributes;
294
    }
295
}
296