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