Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.27% covered (success)
98.27%
227 / 231
92.00% covered (success)
92.00%
23 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthUtils
98.27% covered (success)
98.27%
227 / 231
92.00% covered (success)
92.00%
23 / 25
93
0.00% covered (danger)
0.00%
0 / 1
 authenticate
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 validateAccessToken
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
7
 resolveUsernameOrEmail
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 hasPermission
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 hasUserPermission
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getUserPermissionMap
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 hasRolePermission
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getRolePermissionMap
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 getCurrentUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCurrentAuthUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTokenUser
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 getSessionUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSessionUser
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
7
 getSessionAuthUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSessionAuthUser
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getUserByUsername
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getAuthenticatedRoles
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isRoleIdAuthenticated
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 hasRoleEditPermission
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
7.01
 isUsernameAllowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isUsernameUnique
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 isPasswordAllowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserAvatar
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 hashPassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verifyPassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Olz\Utils;
4
5use Olz\Entity\AccessToken;
6use Olz\Entity\AuthRequest;
7use Olz\Entity\Roles\Role;
8use Olz\Entity\Users\User;
9use Olz\Exceptions\AuthBlockedException;
10use Olz\Exceptions\InvalidCredentialsException;
11
12class AuthUtils {
13    use WithUtilsTrait;
14
15    public const MAX_LOOP = 5;
16
17    /** @var array<int, array<string, bool>> */
18    protected array $cached_permission_map_by_user = [];
19    /** @var array<int, array<string, bool>> */
20    protected array $cached_permission_map_by_role = [];
21    /** @var array<string, User> */
22    protected array $cached_users = [];
23
24    public function authenticate(string $username_or_email, string $password): User {
25        $ip_address = $this->server()['REMOTE_ADDR'];
26        $auth_request_repo = $this->entityManager()->getRepository(AuthRequest::class);
27
28        // If there are invalid credentials provided too often, we block.
29        $num_remaining_attempts = $auth_request_repo->numRemainingAttempts($ip_address);
30        if ($num_remaining_attempts <= 0) {
31            $message = "Login attempt from blocked IP: {$ip_address} (user: {$username_or_email}).";
32            $this->log()->notice($message);
33            $auth_request_repo->addAuthRequest($ip_address, 'BLOCKED', $username_or_email);
34            throw new AuthBlockedException($message);
35        }
36
37        $user = $this->resolveUsernameOrEmail($username_or_email);
38
39        // If the password is wrong, authentication fails.
40        $hash = $user?->getPasswordHash();
41        if (!$user || !$password || !$hash || !$this->verifyPassword($password, $hash)) {
42            $message = "Login attempt with invalid credentials from IP: {$ip_address} (user: {$username_or_email}).";
43            $this->log()->notice($message);
44            $auth_request_repo->addAuthRequest($ip_address, 'INVALID_CREDENTIALS', $username_or_email);
45            throw new InvalidCredentialsException($message, $num_remaining_attempts);
46        }
47
48        $this->log()->info("User login successful: {$username_or_email}");
49        $this->log()->info("  Auth: {$user->getPermissions()}");
50        $this->log()->info("  Root: {$user->getRoot()}");
51        $auth_request_repo->addAuthRequest($ip_address, 'AUTHENTICATED', $username_or_email);
52        return $user;
53    }
54
55    public function validateAccessToken(string $access_token): ?User {
56        $ip_address = $this->server()['REMOTE_ADDR'];
57        $auth_request_repo = $this->entityManager()->getRepository(AuthRequest::class);
58
59        // If there are invalid credentials provided too often, we block.
60        $can_validate = $auth_request_repo->canValidateAccessToken($ip_address);
61        if (!$can_validate) {
62            $message = "Access token validation from blocked IP: {$ip_address}.";
63            $this->log()->notice($message);
64            $auth_request_repo->addAuthRequest($ip_address, 'TOKEN_BLOCKED', '');
65            throw new AuthBlockedException($message);
66        }
67
68        $access_token_repo = $this->entityManager()->getRepository(AccessToken::class);
69        $access_token = $access_token_repo->findOneBy(['token' => $access_token]);
70        $user = $access_token ? $access_token->getUser() : null;
71
72        // If the access token is invalid, authentication fails.
73        if (!$access_token || !$user) {
74            $message = "Invalid access token validation from IP: {$ip_address}.";
75            $this->log()->notice($message);
76            $auth_request_repo->addAuthRequest($ip_address, 'INVALID_TOKEN', '');
77            throw new InvalidCredentialsException($message);
78        }
79
80        $now = $this->dateUtils()->getIsoNow();
81        $expires_at = $access_token->getExpiresAt();
82        $is_access_token_expired = (
83            $expires_at !== null
84            && $expires_at->format('Y-m-d H:i:s') < $now
85        );
86
87        // If the access token is expired, authentication fails.
88        if ($is_access_token_expired) {
89            $message = "Expired access token validation from IP: {$ip_address}.";
90            $this->log()->notice($message);
91            $auth_request_repo->addAuthRequest($ip_address, 'EXPIRED_TOKEN', '');
92            throw new InvalidCredentialsException($message);
93        }
94
95        $this->log()->info("Token validation successful: {$access_token->getId()}");
96        $auth_request_repo->addAuthRequest($ip_address, 'TOKEN_VALIDATED', $user->getUsername());
97        return $user;
98    }
99
100    public function resolveUsernameOrEmail(string $username_or_email): ?User {
101        $user_repo = $this->entityManager()->getRepository(User::class);
102        $user = $user_repo->findOneBy(['username' => $username_or_email]);
103        if (!$user) {
104            $user = $user_repo->findOneBy(['email' => $username_or_email]);
105        }
106        if (!$user) {
107            $user = $user_repo->findOneBy(['old_username' => $username_or_email]);
108        }
109        $res = preg_match(
110            '/^([a-zA-Z0-9-_\.]+)@olzimmerberg.ch$/',
111            $username_or_email,
112            $matches
113        );
114        if (!$user && $res) {
115            $user = $user_repo->findOneBy(['username' => $matches[1]]);
116        }
117        if (!$user && $res) {
118            $user = $user_repo->findOneBy(['old_username' => $matches[1]]);
119        }
120        return $user;
121    }
122
123    public function hasPermission(string $query, ?User $user = null): bool {
124        if (!$user) {
125            $user = $this->getCurrentUser();
126        }
127        $user_permission_map = $this->getUserPermissionMap($user);
128        $roles = $this->getAuthenticatedRoles($user) ?? [];
129        $permission_map = [...$user_permission_map];
130        foreach ($roles as $role) {
131            $role_permission_map = $this->getRolePermissionMap($role);
132            $permission_map = [...$role_permission_map, ...$permission_map];
133        }
134        return ($permission_map['all'] ?? false) || ($permission_map[$query] ?? false);
135    }
136
137    public function hasUserPermission(string $query, ?User $user): bool {
138        $permission_map = $this->getUserPermissionMap($user);
139        return ($permission_map['all'] ?? false) || ($permission_map[$query] ?? false);
140    }
141
142    /** @return array<string, bool> */
143    protected function getUserPermissionMap(?User $user): array {
144        if (!$user) {
145            return ['any' => false];
146        }
147        $user_id = $user->getId() ?: 0;
148        $permission_map = $this->cached_permission_map_by_user[$user_id] ?? null;
149        if ($permission_map != null) {
150            return $permission_map;
151        }
152        $permission_list = preg_split('/[ ]+/', $user->getPermissions()) ?: [];
153        $permission_map = ['any' => true];
154        foreach ($permission_list as $permission) {
155            $permission_map[$permission] = true;
156        }
157        $this->cached_permission_map_by_user[$user_id] = $permission_map;
158        return $permission_map;
159    }
160
161    public function hasRolePermission(string $query, ?Role $role): bool {
162        $permission_map = $this->getRolePermissionMap($role);
163        return ($permission_map['all'] ?? false) || ($permission_map[$query] ?? false);
164    }
165
166    /** @return array<string, bool> */
167    protected function getRolePermissionMap(?Role $role): array {
168        if (!$role || !$role->getOnOff()) {
169            return ['any' => false];
170        }
171        $role_id = $role->getId() ?: 0;
172        $permission_map = $this->cached_permission_map_by_role[$role_id] ?? null;
173        if ($permission_map != null) {
174            return $permission_map;
175        }
176        $permission_list = preg_split('/[ ]+/', $role->getPermissions()) ?: [];
177        $permission_map = ['any' => true];
178        foreach ($permission_list as $permission) {
179            $permission_map[$permission] = true;
180        }
181        $this->cached_permission_map_by_role[$role_id] = $permission_map;
182        return $permission_map;
183    }
184
185    public function getCurrentUser(): ?User {
186        $user = $this->getTokenUser();
187        if ($user) {
188            return $user;
189        }
190        return $this->getSessionUser();
191    }
192
193    public function getCurrentAuthUser(): ?User {
194        return $this->getSessionAuthUser();
195    }
196
197    public function getTokenUser(): ?User {
198        $access_token = $this->getParams()['access_token'] ?? null;
199        if (!$access_token) {
200            return null;
201        }
202        try {
203            return $this->validateAccessToken($access_token);
204        } catch (AuthBlockedException $exc) {
205            return null;
206        } catch (InvalidCredentialsException $exc) {
207            return null;
208        } catch (\Exception $exc) {
209            throw $exc;
210        }
211    }
212
213    public function getSessionUser(): ?User {
214        $username = $this->session()->get('user');
215        return $this->getUserByUsername($username);
216    }
217
218    public function setSessionUser(?User $user): void {
219        if ($user === null) {
220            $this->session()->delete('auth');
221            $this->session()->delete('root');
222            $this->session()->delete('user_id');
223            $this->session()->delete('user');
224            $this->session()->delete('user_name');
225            $this->session()->delete('user_permissions');
226            $this->session()->delete('user_root');
227            $this->session()->delete('user_children');
228            return;
229        }
230        // Family
231        $user_repo = $this->entityManager()->getRepository(User::class);
232        $child_users = $user_repo->findBy(['parent_user' => $user->getId()]);
233        $children = [];
234        foreach ($child_users as $child_user) {
235            $children[] = [
236                'id' => $child_user->getId(),
237                'permissions' => $child_user->getPermissionMap(),
238                'name' => $child_user->getFullName(),
239                'username' => $child_user->getUsername(),
240                'root' => $child_user->getRoot() !== '' ? $child_user->getRoot() : './',
241            ];
242        }
243        $children_json = json_encode($children) ?: '[]';
244
245        $root = $user->getRoot() !== '' ? $user->getRoot() : './';
246        $permission_map_json = json_encode($user->getPermissionMap()) ?: '{}';
247        $this->session()->set('auth', $user->getPermissions()); // TODO: Deprecate
248        $this->session()->set('root', $root); // TODO: Deprecate
249        $this->session()->set('user_id', "{$user->getId()}");
250        $this->session()->set('user', $user->getUsername());
251        $this->session()->set('user_name', $user->getFullName());
252        $this->session()->set('user_permissions', $permission_map_json);
253        $this->session()->set('user_root', $root);
254        $this->session()->set('user_children', $children_json);
255    }
256
257    public function getSessionAuthUser(): ?User {
258        $auth_username = $this->session()->get('auth_user');
259        return $this->getUserByUsername($auth_username);
260    }
261
262    public function setSessionAuthUser(?User $user): void {
263        if ($user === null) {
264            $this->session()->delete('auth_user');
265            $this->session()->delete('auth_user_id');
266            return;
267        }
268        $this->session()->set('auth_user', $user->getUsername());
269        $this->session()->set('auth_user_id', "{$user->getId()}");
270    }
271
272    private function getUserByUsername(?string $username): ?User {
273        if (!$username) {
274            return null;
275        }
276        $cached_user = $this->cached_users[$username] ?? null;
277        if ($cached_user) {
278            return $cached_user;
279        }
280        $user_repo = $this->entityManager()->getRepository(User::class);
281        $fetched_user = $user_repo->findOneBy(['username' => $username]);
282        if ($fetched_user) {
283            $this->cached_users[$username] = $fetched_user;
284        }
285        return $fetched_user;
286    }
287
288    /** @return ?array<Role> */
289    public function getAuthenticatedRoles(?User $user = null): ?array {
290        if (!$user) {
291            $user = $this->getCurrentUser();
292        }
293        if (!$user) {
294            return null;
295        }
296        return [...$user->getRoles()];
297    }
298
299    public function isRoleIdAuthenticated(int $role_id): bool {
300        $authenticated_roles = $this->getAuthenticatedRoles() ?? [];
301        foreach ($authenticated_roles as $authenticated_role) {
302            if ($authenticated_role->getId() === $role_id) {
303                return true;
304            }
305        }
306        return false;
307    }
308
309    public function hasRoleEditPermission(?int $role_id = null): bool {
310        $user = $this->getCurrentUser();
311        if ($this->hasPermission('roles', $user)) {
312            return true;
313        }
314        $auth_roles = $this->getAuthenticatedRoles($user);
315        $is_role_id_authenticated = [];
316        foreach (($auth_roles ?? []) as $auth_role) {
317            if (!$auth_role->getOnOff()) {
318                continue;
319            }
320            $is_role_id_authenticated[$auth_role->getId()] = true;
321        }
322        $role_repo = $this->entityManager()->getRepository(Role::class);
323        for ($i = 0; $i < self::MAX_LOOP; $i++) {
324            if ($role_id === null) {
325                return false;
326            }
327            if (($is_role_id_authenticated[$role_id] ?? false) === true) {
328                return true;
329            }
330            $role = $role_repo->findOneBy(['id' => $role_id]);
331            $role_id = $role?->getParentRoleId() ?? null;
332        }
333        return false;
334    }
335
336    public function isUsernameAllowed(string $username): bool {
337        // TODO: Underscore & uppercase is disallowed in frontend, but allowed in backend.
338        return preg_match('/^[a-zA-Z0-9-_\.]+$/', $username) ? true : false;
339    }
340
341    /** @param ?(User|Role) $existing_entity */
342    public function isUsernameUnique(string $username, ?object $existing_entity): bool {
343        $user_repo = $this->entityManager()->getRepository(User::class);
344        $role_repo = $this->entityManager()->getRepository(Role::class);
345
346        $same_username_user = $user_repo->findOneBy(['username' => $username]);
347        $same_old_username_user = $user_repo->findOneBy(['old_username' => $username]);
348        $same_username_role = $role_repo->findOneBy(['username' => $username]);
349        $same_old_username_role = $role_repo->findOneBy(['old_username' => $username]);
350
351        $has_conflict = (
352            ($same_username_user && ($same_username_user !== $existing_entity))
353            || ($same_old_username_user && ($same_old_username_user !== $existing_entity))
354            || ($same_username_role && ($same_username_role !== $existing_entity))
355            || ($same_old_username_role && ($same_old_username_role !== $existing_entity))
356        );
357        return !$has_conflict;
358    }
359
360    public function isPasswordAllowed(string $password): bool {
361        return strlen($password) >= 8;
362    }
363
364    /** @return array<string, string> */
365    public function getUserAvatar(?User $user): array {
366        $env_utils = $this->envUtils();
367        $code_href = $env_utils->getCodeHref();
368        $data_href = $env_utils->getDataHref();
369        if (!$user) {
370            $initials_enc = urlencode('?');
371            return ['1x' => "{$code_href}assets/user_initials_{$initials_enc}.svg"];
372        }
373        if ($user->getAvatarImageId()) {
374            $image_id = $user->getAvatarImageId();
375            return [
376                '2x' => "{$data_href}img/users/{$user->getId()}/thumb/{$image_id}\$256.jpg",
377                '1x' => "{$data_href}img/users/{$user->getId()}/thumb/{$image_id}\$128.jpg",
378            ];
379        }
380        $first_initial = mb_substr($user->getFirstName(), 0, 1);
381        $last_initial = mb_substr($user->getLastName(), 0, 1);
382        $initials_enc = urlencode(strtoupper("{$first_initial}{$last_initial}"));
383        return ['1x' => "{$code_href}assets/user_initials_{$initials_enc}.svg"];
384    }
385
386    public function hashPassword(string $password): string {
387        return password_hash($password, PASSWORD_DEFAULT);
388    }
389
390    public function verifyPassword(string $password, string $hash): bool {
391        return password_verify($password, $hash);
392    }
393}