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
Bug
introduced
by
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
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
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
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
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
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 |