Issues (281)

Branch: master

src/Backend/Core/Engine/Authentication.php (5 issues)

1
<?php
2
3
namespace Backend\Core\Engine;
4
5
use Backend\Core\Engine\Model as BackendModel;
6
use Backend\Modules\Users\Engine\Model as BackendUsersModel;
7
use Common\Events\ForkEvents;
8
use Common\Events\ForkSessionIdChangedEvent;
9
use DateTime;
10
use DateTimeZone;
11
use RuntimeException;
12
13
/**
14
 * The class below will handle all authentication stuff. It will handle module-access, action-access, ...
15
 */
16
class Authentication
17
{
18
    /**
19
     * All allowed modules
20
     *
21
     * @var array
22
     */
23
    private static $allowedActions = [];
24
25
    /**
26
     * All allowed modules
27
     *
28
     * @var array
29
     */
30
    private static $allowedModules = [];
31
32
    /**
33
     * A user object for the current authenticated user
34
     *
35
     * @var User
36
     */
37
    private static $user;
38
39
    /**
40
     * This is used to prevent logging out multiple times (less queries)
41
     *
42
     * @var bool
43
     */
44
    private static $alreadyLoggedOut = false;
45
46
    /**
47
     * Check the strength of the password
48
     *
49
     * @param string $password The password.
50
     *
51
     * @return string
52
     */
53
    public static function checkPassword(string $password): string
54
    {
55
        return PasswordStrengthChecker::checkPassword($password);
56 40
    }
57
58
    /**
59 40
     * Cleanup sessions for the current user and sessions that are invalid
60 40
     */
61
    public static function cleanupOldSessions(): void
62
    {
63
        $deleteIfOlderThan = (new DateTime('- 30 minutes', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
64
        BackendModel::get('database')->delete('users_sessions', 'date <= ?', [$deleteIfOlderThan]);
65
    }
66
67
    /**
68
     * Encrypt the password with PHP password_hash function.
69 1
     *
70
     * @param string $password
71 1
     *
72
     * @return string
73
     */
74
    public static function encryptPassword(string $password): string
75
    {
76
        return password_hash($password, PASSWORD_DEFAULT);
77
    }
78
79
    /**
80
     * Verify the password with PHP password_verify function.
81
     *
82 41
     * @param string $email
83
     * @param string $password
84 41
     *
85
     * @return bool
86 41
     */
87
    public static function verifyPassword(string $email, string $password): bool
88
    {
89
        $encryptedPassword = BackendUsersModel::getEncryptedPassword($email);
90
91
        return password_verify($password, $encryptedPassword);
0 ignored issues
show
It seems like $encryptedPassword can also be of type null; however, parameter $hash of password_verify() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

91
        return password_verify($password, /** @scrutinizer ignore-type */ $encryptedPassword);
Loading history...
92
    }
93
94
    /**
95
     * Returns a string encrypted like sha1(md5($salt) . md5($string))
96
     *    The salt is an optional extra string you can strengthen your encryption with
97
     *
98 40
     * @param string $string The string to encrypt.
99
     * @param string $salt The salt to use.
100 40
     *
101
     * @return string
102
     */
103
    public static function getEncryptedString(string $string, string $salt = null): string
104
    {
105
        return (string) sha1(md5($salt) . md5($string));
0 ignored issues
show
It seems like $salt can also be of type null; however, parameter $string of md5() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

105
        return (string) sha1(md5(/** @scrutinizer ignore-type */ $salt) . md5($string));
Loading history...
106
    }
107
108 131
    /**
109
     * Returns the current authenticated user
110
     *
111 131
     * @return User
112 131
     */
113
    public static function getUser(): User
114
    {
115 131
        // if the user-object doesn't exist create a new one
116
        if (self::$user === null) {
117
            self::$user = new User();
118
        }
119
120
        return self::$user;
121 71
    }
122
123 71
    /**
124 16
     * @deprecated this will become a private method in Fork 6
125
     */
126
    public static function getAllowedActions(): array
127 71
    {
128 71
        if (!empty(self::$allowedActions)) {
129
            return self::$allowedActions;
130
        }
131
132
        $allowedActionsRows = (array) BackendModel::get('database')->getRecords(
133
            'SELECT gra.module, gra.action, MAX(gra.level) AS level
134
            FROM users_sessions AS us
135 71
            INNER JOIN users AS u ON us.user_id = u.id
136
            INNER JOIN users_groups AS ug ON u.id = ug.user_id
137
            INNER JOIN groups_rights_actions AS gra ON ug.group_id = gra.group_id
138
            WHERE us.session_id = ? AND us.secret_key = ?
139 71
            GROUP BY gra.module, gra.action',
140 71
            [BackendModel::getSession()->getId(), BackendModel::getSession()->get('backend_secret_key')]
141
        );
142 38
143 38
        // add all actions and their level
144
        $modules = BackendModel::getModules();
145
        foreach ($allowedActionsRows as $row) {
146
            // add if the module is installed
147 71
            if (in_array($row['module'], $modules, true)) {
148
                self::$allowedActions[$row['module']][$row['action']] = (int) $row['level'];
149
            }
150
        }
151
152
        return self::$allowedActions;
153
    }
154
155
    /**
156
     * Is the given action allowed for the current user
157
     *
158 131
     * @param string $action The action to check for.
159
     * @param string $module The module wherein the action is located.
160 131
     *
161
     * @return bool
162
     */
163
    public static function isAllowedAction(string $action = null, string $module = null): bool
164 131
    {
165 131
        $alwaysAllowed = self::getAlwaysAllowed();
166
167
        // The url should only be taken from the container if the action and or module isn't set
168 131
        // This way we can use the command also in the a console command
169 131
        $action = $action ?: BackendModel::get('url')->getAction();
170
        $module = \SpoonFilter::toCamelCase($module ?: BackendModel::get('url')->getModule());
171
172
        // is this action an action that doesn't require authentication?
173 40
        if (isset($alwaysAllowed[$module][$action])) {
174
            return true;
175
        }
176
177
        // users that aren't logged in can only access always allowed items
178 40
        if (!self::isLoggedIn()) {
179 38
            return false;
180
        }
181
182 2
        // module exists and God user is enough to be allowed
183
        if (in_array($module, BackendModel::getModules(), true) && self::getUser()->isGod()) {
184
            return true;
185 2
        }
186
187 2
        $allowedActions = self::getAllowedActions();
0 ignored issues
show
Deprecated Code introduced by
The function Backend\Core\Engine\Auth...on::getAllowedActions() has been deprecated: this will become a private method in Fork 6 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

187
        $allowedActions = /** @scrutinizer ignore-deprecated */ self::getAllowedActions();

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
188 2
189
        // do we know a level for this action
190
        if (isset($allowedActions[$module][$action])) {
191
            // is the level greater than zero? aka: do we have access?
192 2
            if ((int) $allowedActions[$module][$action] > 0) {
193
                return true;
194
            }
195 131
        }
196
197
        return false;
198 131
    }
199
200
    private static function getAlwaysAllowed(): array
201
    {
202
        return [
203
            'Core' => ['GenerateUrl' => 7, 'ContentCss' => 7, 'Templates' => 7],
204
            'Error' => ['Index' => 7],
205
            'Authentication' => ['Index' => 7, 'ResetPassword' => 7, 'Logout' => 7],
206
        ];
207
    }
208
209
    /**
210
     * Is the given module allowed for the current user
211 131
     *
212
     * @param string $module The module to check for.
213 131
     *
214 131
     * @return bool
215 131
     */
216
    public static function isAllowedModule(string $module): bool
217
    {
218 131
        $modules = BackendModel::getModules();
219 131
        $alwaysAllowed = array_keys(self::getAlwaysAllowed());
220
        $module = \SpoonFilter::toCamelCase($module);
221
222
        // is this module a module that doesn't require user level authentication?
223 131
        if (in_array($module, $alwaysAllowed, true)) {
224 131
            return true;
225
        }
226
227
        // users that aren't logged in can only access always allowed items
228 40
        if (!self::isLoggedIn()) {
229 38
            return false;
230
        }
231
232
        // module is active and God user, good enough
233 2
        if (in_array($module, $modules, true) && self::getUser()->isGod()) {
234 2
            return true;
235
        }
236
237 2
        // do we already know something?
238 2
        if (empty(self::$allowedModules)) {
239
            $database = BackendModel::get('database');
240
241
            // get allowed modules
242
            $allowedModules = (array) $database->getColumn(
243
                'SELECT DISTINCT grm.module
244 2
                 FROM users_sessions AS us
245
                 INNER JOIN users AS u ON us.user_id = u.id
246
                 INNER JOIN users_groups AS ug ON u.id = ug.user_id
247 2
                 INNER JOIN groups_rights_modules AS grm ON ug.group_id = grm.group_id
248 2
                 WHERE us.session_id = ? AND us.secret_key = ?',
249
                [BackendModel::getSession()->getId(), BackendModel::getSession()->get('backend_secret_key')]
250
            );
251
252 2
            foreach ($allowedModules as $row) {
253
                self::$allowedModules[$row] = true;
254
            }
255
        }
256
257
        return isset(self::$allowedModules[$module]) ?? false;
258
    }
259
260 131
    /**
261
     * Is the current user logged in?
262 131
     *
263 40
     * @return bool
264
     */
265
    public static function isLoggedIn(): bool
266
    {
267 131
        if (BackendModel::getContainer()->has('logged_in')) {
268 131
            return (bool) BackendModel::getContainer()->get('logged_in');
269 131
        }
270
271 131
        // check if all needed values are set in the session
272
        if (!(bool) BackendModel::getSession()->get('backend_logged_in')
273
            || (string) BackendModel::getSession()->get('backend_secret_key') === '') {
274 40
            self::logout();
275
276
            return false;
277 40
        }
278 40
279
        $database = BackendModel::get('database');
280
281
        // get the row from the tables
282 40
        $sessionData = $database->getRecord(
283
            'SELECT us.id, us.user_id
284
             FROM users_sessions AS us
285
             WHERE us.session_id = ? AND us.secret_key = ?
286 40
             LIMIT 1',
287
            [BackendModel::getSession()->getId(), BackendModel::getSession()->get('backend_secret_key')]
288 40
        );
289 40
290 40
        // if we found a matching row, we know the user is logged in, so we update his session
291 40
        if ($sessionData !== null) {
292 40
            // update the session in the table
293
            $database->update(
294
                'users_sessions',
295
                ['date' => BackendModel::getUTCDate()],
296 40
                'id = ?',
297
                (int) $sessionData['id']
298
            );
299 40
300
            // create a user object, it will handle stuff related to the current authenticated user
301 40
            self::$user = new User($sessionData['user_id']);
302
303
            // the user is logged on
304
            BackendModel::getContainer()->set('logged_in', true);
0 ignored issues
show
true of type true is incompatible with the type null|object expected by parameter $service of Symfony\Component\Depend...ntainerInterface::set(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

304
            BackendModel::getContainer()->set('logged_in', /** @scrutinizer ignore-type */ true);
Loading history...
305
306
            return true;
307
        }
308
309
        self::logout();
310
311
        return false;
312
    }
313
314
    /**
315
     * Login the user with the given credentials.
316
     * Will return a boolean that indicates if the user is logged in.
317
     *
318 41
     * @param string $login The users login.
319
     * @param string $password The password provided by the user.
320 41
     *
321
     * @return bool
322 41
     */
323
    public static function loginUser(string $login, string $password): bool
324
    {
325 41
        self::$alreadyLoggedOut = false;
326 1
327
        $database = BackendModel::get('database');
328
329
        // check password
330 40
        if (!static::verifyPassword($login, $password)) {
331 40
            return false;
332
        }
333
334
        // check in database (is the user active and not deleted, are the email and password correct?)
335 40
        $userId = (int) $database->getVar(
336
            'SELECT u.id
337
             FROM users AS u
338 40
             WHERE u.email = ? AND u.active = ? AND u.deleted = ?
339
             LIMIT 1',
340
            [$login, true, false]
341
        );
342
343
        if ($userId === 0) {
344
            // userId 0 will not exist, so it means that this isn't a valid combination
345
            // reset values for invalid users. We can't destroy the session
346
            // because session-data can be used on the site.
347
            self::logout();
348 40
349
            return false;
350
        }
351
352 40
        // cleanup old sessions
353 40
        self::cleanupOldSessions();
354 40
355 40
        $session = BackendModel::getSession();
356
        $oldSession = $session->getId();
357
358
        // create a new session for safety reasons
359 40
        if (!$session->migrate(true)) {
360
            throw new RuntimeException(
361
                'For safety reasons the session should be regenerated. But apparently it failed.'
362 40
            );
363 40
        }
364
365
        // build the session array (will be stored in the database)
366 40
        $userSession = [
367 40
            'user_id' => $userId,
368
            'secret_key' => static::getEncryptedString($session->getId(), $userId),
369 40
            'session_id' => $session->getId(),
370
            'date' => BackendModel::getUTCDate(),
371
        ];
372
373
        // insert a new row in the session-table
374
        $database->insert('users_sessions', $userSession);
375 131
376
        // store some values in the session
377 131
        $session->set('backend_logged_in', true);
378 131
        $session->set('backend_secret_key', $userSession['secret_key']);
379
380
        // trigger changed session ID
381
        BackendModel::get('event_dispatcher')->dispatch(
382 131
            ForkEvents::FORK_EVENTS_SESSION_ID_CHANGED,
383
            new ForkSessionIdChangedEvent($oldSession, $session->getId())
384
        );
385 131
386 131
        // update/instantiate the value for the logged_in container.
387 131
        BackendModel::getContainer()->set('logged_in', true);
0 ignored issues
show
true of type true is incompatible with the type null|object expected by parameter $service of Symfony\Component\Depend...ntainerInterface::set(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

387
        BackendModel::getContainer()->set('logged_in', /** @scrutinizer ignore-type */ true);
Loading history...
388
        self::$user = new User($userId);
389 131
390 131
        return true;
391
    }
392
393
    /**
394
     * Logout the current user
395
     */
396
    public static function logout(): void
397
    {
398 131
        if (self::$alreadyLoggedOut) {
399
            return;
400 131
        }
401 131
402 131
        $session = BackendModel::getSession();
403 131
        $oldSession = $session->getId();
404
405
        // remove all rows owned by the current user
406
        BackendModel::get('database')->delete('users_sessions', 'session_id = ?', $session->getId());
407
408
        // reset values. We can't destroy the session because session-data can be used on the site.
409
        $session->set('backend_logged_in', false);
410
        $session->set('backend_secret_key', '');
411
        $session->set('csrf_token', '');
412
413
        // create a new session for safety reasons
414
        if (!$session->migrate(true)) {
415
            throw new RuntimeException(
416
                'For safety reasons the session should be regenerated. But apparently it failed.'
417
            );
418
        }
419
420
        // trigger changed session ID
421
        BackendModel::get('event_dispatcher')->dispatch(
422
            ForkEvents::FORK_EVENTS_SESSION_ID_CHANGED,
423
            new ForkSessionIdChangedEvent($oldSession, $session->getId())
424
        );
425
426
        self::$alreadyLoggedOut = true;
427
    }
428
429
    /**
430
     * Reset our class to make sure no contamination from previous
431
     * authentications persists. This signifies a deeper issue with
432
     * this class. Solving the issue would be preferable to introducting
433
     * another method. This currently only exists to serve the test.
434
     */
435
    public static function tearDown(): void
436
    {
437
        self::$allowedActions = [];
438
        self::$allowedModules = [];
439
        self::$user = null;
440
    }
441
442
    public static function clearUserSessionsForId(int $userId): void
443
    {
444
        BackendModel::get('database')->delete('users_sessions', 'user_id = ?', $userId);
445
    }
446
}
447