Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 179 |
|
0.00% |
0 / 23 |
CRAP | |
0.00% |
0 / 1 |
AuthUtils | |
0.00% |
0 / 179 |
|
0.00% |
0 / 23 |
5700 | |
0.00% |
0 / 1 |
authenticate | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
42 | |||
validateAccessToken | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
56 | |||
resolveUsernameOrEmail | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
56 | |||
hasPermission | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
hasUserPermission | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
getUserPermissionMap | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
42 | |||
hasRolePermission | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
getRolePermissionMap | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
42 | |||
getCurrentUser | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getCurrentAuthUser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTokenUser | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
getSessionUser | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getSessionAuthUser | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getUserByUsername | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
getAuthenticatedRoles | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
isRoleIdAuthenticated | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
hasRoleEditPermission | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
42 | |||
isUsernameAllowed | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
isPasswordAllowed | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUserAvatar | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
hashPassword | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
verifyPassword | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
fromEnv | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace Olz\Utils; |
4 | |
5 | use Olz\Entity\AccessToken; |
6 | use Olz\Entity\AuthRequest; |
7 | use Olz\Entity\Roles\Role; |
8 | use Olz\Entity\Users\User; |
9 | use Olz\Exceptions\AuthBlockedException; |
10 | use Olz\Exceptions\InvalidCredentialsException; |
11 | |
12 | class 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 | } |