Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.77% covered (success)
97.77%
175 / 179
91.30% covered (success)
91.30%
21 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthUtils
97.77% covered (success)
97.77%
175 / 179
91.30% covered (success)
91.30%
21 / 23
75
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
6
 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
 getSessionAuthUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 isUsernameAllowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 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
 fromEnv
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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) {
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 getSessionAuthUser(): ?User {
219        $auth_username = $this->session()->get('auth_user');
220        return $this->getUserByUsername($auth_username);
221    }
222
223    private function getUserByUsername(?string $username): ?User {
224        if (!$username) {
225            return null;
226        }
227        $cached_user = $this->cached_users[$username] ?? null;
228        if ($cached_user) {
229            return $cached_user;
230        }
231        $user_repo = $this->entityManager()->getRepository(User::class);
232        $fetched_user = $user_repo->findOneBy(['username' => $username]);
233        if ($fetched_user) {
234            $this->cached_users[$username] = $fetched_user;
235        }
236        return $fetched_user;
237    }
238
239    /** @return ?array<Role> */
240    public function getAuthenticatedRoles(?User $user = null): ?array {
241        if (!$user) {
242            $user = $this->getCurrentUser();
243        }
244        if (!$user) {
245            return null;
246        }
247        return [...$user->getRoles()];
248    }
249
250    public function isRoleIdAuthenticated(int $role_id): bool {
251        $authenticated_roles = $this->getAuthenticatedRoles() ?? [];
252        foreach ($authenticated_roles as $authenticated_role) {
253            if ($authenticated_role->getId() === $role_id) {
254                return true;
255            }
256        }
257        return false;
258    }
259
260    public function hasRoleEditPermission(?int $role_id = null): bool {
261        $user = $this->getCurrentUser();
262        if ($this->hasPermission('roles', $user)) {
263            return true;
264        }
265        $auth_roles = $this->getAuthenticatedRoles($user);
266        $is_role_id_authenticated = [];
267        foreach (($auth_roles ?? []) as $auth_role) {
268            $is_role_id_authenticated[$auth_role->getId()] = true;
269        }
270        $role_repo = $this->entityManager()->getRepository(Role::class);
271        for ($i = 0; $i < self::MAX_LOOP; $i++) {
272            if ($role_id === null) {
273                return false;
274            }
275            if (($is_role_id_authenticated[$role_id] ?? false) === true) {
276                return true;
277            }
278            $role = $role_repo->findOneBy(['id' => $role_id]);
279            $role_id = $role?->getParentRoleId() ?? null;
280        }
281        return false;
282    }
283
284    public function isUsernameAllowed(string $username): bool {
285        return preg_match('/^[a-zA-Z0-9-_\.]+$/', $username) ? true : false;
286    }
287
288    public function isPasswordAllowed(string $password): bool {
289        return strlen($password) >= 8;
290    }
291
292    /** @return array<string, string> */
293    public function getUserAvatar(?User $user): array {
294        $env_utils = $this->envUtils();
295        $code_href = $env_utils->getCodeHref();
296        $data_href = $env_utils->getDataHref();
297        if (!$user) {
298            $initials_enc = urlencode('?');
299            return ['1x' => "{$code_href}assets/user_initials_{$initials_enc}.svg"];
300        }
301        if ($user->getAvatarImageId()) {
302            $image_id = $user->getAvatarImageId();
303            return [
304                '2x' => "{$data_href}img/users/{$user->getId()}/thumb/{$image_id}\$256.jpg",
305                '1x' => "{$data_href}img/users/{$user->getId()}/thumb/{$image_id}\$128.jpg",
306            ];
307        }
308        $first_initial = mb_substr($user->getFirstName(), 0, 1);
309        $last_initial = mb_substr($user->getLastName(), 0, 1);
310        $initials_enc = urlencode(strtoupper("{$first_initial}{$last_initial}"));
311        return ['1x' => "{$code_href}assets/user_initials_{$initials_enc}.svg"];
312    }
313
314    public function hashPassword(string $password): string {
315        return password_hash($password, PASSWORD_DEFAULT);
316    }
317
318    public function verifyPassword(string $password, string $hash): bool {
319        return password_verify($password, $hash);
320    }
321
322    public static function fromEnv(): self {
323        return new self();
324    }
325}