Issues (281)

Branch: master

src/Backend/Core/Engine/Authentication.php (1 issue)

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);
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();
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);
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);
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