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