Passed
Push — master ( eebc59...c9d0f2 )
by jelmer
35:34 queued 28:57
created

Authentication::clearUserSessionsForId()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
rs 10
c 0
b 0
f 0
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 RuntimeException;
10
11
/**
12
 * The class below will handle all authentication stuff. It will handle module-access, action-access, ...
13
 */
14
class Authentication
15
{
16
    /**
17
     * All allowed modules
18
     *
19
     * @var array
20
     */
21
    private static $allowedActions = [];
22
23
    /**
24
     * All allowed modules
25
     *
26
     * @var array
27
     */
28
    private static $allowedModules = [];
29
30
    /**
31
     * A user object for the current authenticated user
32
     *
33
     * @var User
34
     */
35
    private static $user;
36
37
    /**
38
     * This is used to prevent logging out multiple times (less queries)
39
     *
40
     * @var bool
41
     */
42
    private static $alreadyLoggedOut = false;
43
44
    /**
45
     * Check the strength of the password
46
     *
47
     * @param string $password The password.
48
     *
49
     * @return string
50
     */
51
    public static function checkPassword(string $password): string
52
    {
53
        return PasswordStrengthChecker::checkPassword($password);
54
    }
55
56 40
    /**
57
     * Cleanup sessions for the current user and sessions that are invalid
58
     */
59 40
    public static function cleanupOldSessions(): void
60 40
    {
61
        // remove all sessions that are invalid (older then 30 min)
62
        BackendModel::get('database')->delete('users_sessions', 'date <= DATE_SUB(NOW(), INTERVAL 30 MINUTE)');
63
    }
64
65
    /**
66
     * Encrypt the password with PHP password_hash function.
67
     *
68
     * @param string $password
69 1
     *
70
     * @return string
71 1
     */
72
    public static function encryptPassword(string $password): string
73
    {
74
        return password_hash($password, PASSWORD_DEFAULT);
75
    }
76
77
    /**
78
     * Verify the password with PHP password_verify function.
79
     *
80
     * @param string $email
81
     * @param string $password
82 41
     *
83
     * @return bool
84 41
     */
85
    public static function verifyPassword(string $email, string $password): bool
86 41
    {
87
        $encryptedPassword = BackendUsersModel::getEncryptedPassword($email);
88
89
        return password_verify($password, $encryptedPassword);
0 ignored issues
show
Bug introduced by
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

89
        return password_verify($password, /** @scrutinizer ignore-type */ $encryptedPassword);
Loading history...
90
    }
91
92
    /**
93
     * Returns a string encrypted like sha1(md5($salt) . md5($string))
94
     *    The salt is an optional extra string you can strengthen your encryption with
95
     *
96
     * @param string $string The string to encrypt.
97
     * @param string $salt The salt to use.
98 40
     *
99
     * @return string
100 40
     */
101
    public static function getEncryptedString(string $string, string $salt = null): string
102
    {
103
        return (string) sha1(md5($salt) . md5($string));
0 ignored issues
show
Bug introduced by
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

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

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

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

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