Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.79% covered (warning)
69.79%
134 / 192
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
UsersController
69.79% covered (warning)
69.79%
134 / 192
41.67% covered (danger)
41.67%
5 / 12
87.34
0.00% covered (danger)
0.00%
0 / 1
 beforeFilter
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 login
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getRedirectResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 handleCookieConsent
28.57% covered (danger)
28.57%
6 / 21
0.00% covered (danger)
0.00%
0 / 1
14.11
 logout
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 register
78.57% covered (warning)
78.57%
22 / 28
0.00% covered (danger)
0.00%
0 / 1
5.25
 sendConfirmationEmailMessage
10.53% covered (danger)
10.53%
2 / 19
0.00% covered (danger)
0.00%
0 / 1
9.45
 edit
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 confirmEmail
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
3.02
 forgotPassword
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
5.03
 sendPasswordResetEmail
11.76% covered (danger)
11.76%
2 / 17
0.00% covered (danger)
0.00%
0 / 1
9.18
 resetPassword
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
5
1<?php
2declare(strict_types=1);
3
4namespace App\Controller;
5
6use App\Model\Entity\User;
7use App\Model\Entity\UserAccountConfirmation;
8use App\Utility\SettingsManager;
9use Authentication\IdentityInterface;
10use Cake\Event\EventInterface;
11use Cake\Http\Cookie\Cookie;
12use Cake\Http\Response;
13use Cake\Log\Log;
14use Cake\Log\LogTrait;
15use Cake\Queue\QueueManager;
16use Cake\Routing\Router;
17use Cake\Utility\Text;
18use Exception;
19
20/**
21 * Users Controller
22 *
23 * Handles user-related operations such as registration, login, logout, and account management.
24 *
25 * @property \App\Model\Table\UsersTable $Users
26 */
27class UsersController extends AppController
28{
29    use LogTrait;
30
31    /**
32     * Configures actions that can be accessed without authentication.
33     *
34     * @param \Cake\Event\EventInterface $event The event object.
35     * @return void
36     */
37    public function beforeFilter(EventInterface $event): void
38    {
39        parent::beforeFilter($event);
40
41        $this->Authentication->allowUnauthenticated(
42            [
43                'login',
44                'logout',
45                'register',
46                'confirmEmail',
47                'forgotPassword',
48                'resetPassword',
49            ],
50        );
51    }
52
53    /**
54     * Handles user login functionality.
55     *
56     * Authenticates the user and redirects them based on their role and previous page.
57     *
58     * @return \Cake\Http\Response|null Redirects on successful login, or null on failure.
59     */
60    public function login(): ?Response
61    {
62        $result = $this->Authentication->getResult();
63        if ($result != null && $result->isValid()) {
64            $identity = $this->Authentication->getIdentity();
65            $this->handleCookieConsent($identity);
66            $user = $this->Users->get($identity->getIdentifier());
67
68            return $this->getRedirectResponse($user);
69        }
70
71        if ($this->request->is('post')) {
72            $this->Flash->error(__('Invalid username or password'));
73        }
74
75        return null;
76    }
77
78    /**
79     * Determines and returns the appropriate redirect response based on user role.
80     *
81     * This method handles post-login redirection by checking if the user is an admin.
82     * Admin users are redirected to the admin articles section, while regular users
83     * are redirected to either their intended destination or the homepage.
84     *
85     * @param \App\Model\Entity\User $user The authenticated user entity
86     * @return \Cake\Http\Response The redirect response object
87     */
88    private function getRedirectResponse(User $user): Response
89    {
90        // Send admin users to admin area
91        if ($user->is_admin) {
92            return $this->redirect('/admin/articles');
93        }
94        // send everyone else to non admin
95        $target = $this->Authentication->getLoginRedirect() ?? '/';
96
97        return $this->redirect($target);
98    }
99
100    /**
101     * Handles cookie consent management on login for users.
102     *
103     * This method manages the cookie consent process by:
104     * - Checking for existing consent cookies
105     * - Validating consent against the current user
106     * - Updating consent records when user IDs don't match
107     * - Setting or expiring consent cookies as needed
108     * - Making consent data available to the view
109     *
110     * @param \Authentication\IdentityInterface $user The authenticated user entity
111     * @return void
112     * @throws \Cake\Database\Exception\DatabaseException When database operations fail
113     * @throws \Cake\Http\Exception\InvalidCsrfTokenException When CSRF validation fails
114     * @throws \RuntimeException When cookie operations fail
115     */
116    private function handleCookieConsent(IdentityInterface $user): void
117    {
118        $sessionId = $this->request->getSession()->id();
119        $consentTable = $this->fetchTable('CookieConsents');
120        $consentCookie = $this->request->getCookie('consent_cookie');
121        $consentData = null;
122
123        if ($consentCookie) {
124            $consentCookie = json_decode($consentCookie, true);
125            if ($consentCookie['user_id'] != $user->getIdentifier()) {
126                $consent = $consentTable->getLatestConsent($sessionId, $user->getIdentifier());
127                if ($consent) {
128                    $consent['user_id'] = $user->getIdentifier();
129                    $consent = $consentTable->newEntity($consent);
130                    if ($consentCookie['user_id'] == null) {
131                        $consentTable->save($consent);
132                    }
133                    $cookie = $consentTable->createConsentCookie($consent);
134                    $this->response = $this->response->withCookie($cookie);
135                    $consentCookie = $this->request->getCookie('consent_cookie');
136                    // Set the cookie data to the view
137                    $consentData = json_decode($consentCookie, true);
138                    $this->set('consentData', $consentData);
139                } else {
140                    $this->response = $this->response->withExpiredCookie(new Cookie('consent_cookie'));
141                    $this->set('consentData', null);
142                }
143            }
144        } else {
145            $this->set('consentData', null);
146        }
147    }
148
149    /**
150     * Logs out the current user.
151     *
152     * @return \Cake\Http\Response|null Redirects to the login page.
153     */
154    public function logout(): ?Response
155    {
156        $this->Authentication->logout();
157
158        return $this->redirect(['_name' => 'login', 'prefix' => false]);
159    }
160
161    /**
162     * Handles user registration process.
163     *
164     * Creates a new user account and sends a confirmation email.
165     *
166     * @return \Cake\Http\Response|null Redirects on successful registration, or null on failure.
167     */
168    public function register(): ?Response
169    {
170        if (!SettingsManager::read('Users.registrationEnabled', false)) {
171            return $this->redirect($this->referer());
172        }
173
174        $user = $this->Users->newEmptyEntity();
175        if ($this->request->is('post')) {
176            $data = $this->request->getData();
177            // Set username to be the same as email
178            $data['username'] = $data['email'];
179
180            $user = $this->Users->patchEntity($user, $data);
181            // Be super certain is_admin is false for new registrations
182            $user->is_admin = false;
183            $user->setAccess('is_admin', false);
184            $user->active = true;
185            $user->setAccess('active', true);
186
187            if ($this->Users->save($user)) {
188                $confirmationsTable = $this->fetchTable('UserAccountConfirmations');
189                $confirmation = $confirmationsTable->newEntity([
190                    'user_id' => $user->id,
191                    'confirmation_code' => Text::uuid(),
192                ]);
193
194                if ($confirmationsTable->save($confirmation)) {
195                    $this->Flash->success(__('Registration successful. Please check your email for confirmation.'));
196                    $this->sendConfirmationEmailMessage($user, $confirmation);
197                } else {
198                    $this->Flash->error(
199                        __('Registration successful, but there was an issue creating the confirmation link.'),
200                    );
201                }
202
203                return $this->redirect(['action' => 'login']);
204            } else {
205                $this->Flash->error(__('Registration failed. Please, try again.'));
206
207                return $this->response->withStatus(403);
208            }
209        }
210        $this->set(compact('user'));
211
212        return null;
213    }
214
215    /**
216     * Sends a confirmation email to the user.
217     *
218     * @param \App\Model\Entity\User $user The user entity.
219     * @param \App\Model\Entity\UserAccountConfirmation $confirmation The confirmation entity.
220     * @return void
221     */
222    private function sendConfirmationEmailMessage(User $user, UserAccountConfirmation $confirmation): void
223    {
224        if (env('CAKE_ENV') === 'test') {
225            return;
226        }
227
228        try {
229            $data = [
230                'template_identifier' => 'confirm_email',
231                'from' => SettingsManager::read('Email.reply_email', 'noreply@example.com'),
232                'to' => $user->email,
233                'viewVars' => [
234                    'username' => $user->username,
235                    'confirmation_code' => $confirmation->confirmation_code,
236                    'confirm_email_link' => Router::url([
237                        'controller' => 'Users',
238                        'action' => 'confirmEmail',
239                        $confirmation->confirmation_code,
240                    ], true),
241                ],
242            ];
243
244            QueueManager::push('App\Job\SendEmailJob', $data);
245        } catch (Exception $e) {
246            Log::error(__('Failed to send confirmation email message: {0}', $e->getMessage()));
247        }
248    }
249
250    /**
251     * Allows a user to edit their own account information.
252     *
253     * @param string|null $id The ID of the user to be edited.
254     * @return \Cake\Http\Response|null Redirects after editing, or null on GET requests.
255     */
256    public function edit(?string $id = null): ?Response
257    {
258        $currentUserId = $this->Authentication->getIdentity()->getIdentifier();
259
260        if ($id !== $currentUserId) {
261            $this->log('Unauthorized access attempt to edit another user\'s account', 'warning', [
262                'group_name' => 'unauthorized_user_edit_attempt',
263                'user_id' => $currentUserId,
264                'attempted_user_id' => $id,
265                'url' => $this->request->getRequestTarget(),
266                'ip' => $this->request->clientIp(),
267                'scope' => ['user'],
268            ]);
269            $this->Flash->error(__('We were unable to find that account.'));
270
271            return $this->redirect(['_name' => 'account', $currentUserId]);
272        }
273
274        $user = $this->Users->get($this->Authentication->getIdentity()->getIdentifier(), contain: []);
275
276        if ($this->request->is(['patch', 'post', 'put'])) {
277            $user->setAccess('is_admin', false);
278            $user->setAccess('active', false);
279            $data = $this->request->getData();
280            $user = $this->Users->patchEntity($user, $data);
281            if ($this->Users->save($user)) {
282                $this->Flash->success(__('Your account has been updated.'));
283            } else {
284                $this->Flash->error(__('Your account could not be updated.'));
285            }
286        }
287        $this->set(compact('user'));
288
289        return null;
290    }
291
292    /**
293     * Confirms a user's email address using a confirmation code.
294     *
295     * @param string $confirmationCode The confirmation code to validate.
296     * @return \Cake\Http\Response|null Redirects after confirmation attempt.
297     */
298    public function confirmEmail(string $confirmationCode): ?Response
299    {
300        $confirmationsTable = $this->fetchTable('UserAccountConfirmations');
301        $confirmation = $confirmationsTable->find()
302            ->where(['confirmation_code' => $confirmationCode])
303            ->first();
304
305        if ($confirmation) {
306            $user = $this->Users->get($confirmation->user_id);
307            $user->setAccess('active', true);
308            $user->active = true;
309
310            if ($this->Users->save($user)) {
311                $confirmationsTable->delete($confirmation);
312                $this->Flash->success(__('Your account has been confirmed. You can now log in.'));
313
314                return $this->redirect(['action' => 'login']);
315            } else {
316                $this->Flash->error(__('There was an issue confirming your account. Please try again.'));
317
318                return $this->redirect(['action' => 'register']);
319            }
320        } else {
321            $this->Flash->error(__('Invalid confirmation code.'));
322
323            return $this->redirect(['action' => 'register']);
324        }
325    }
326
327    /**
328     * Handles the forgot password functionality.
329     *
330     * Allows users to request a password reset link via email.
331     *
332     * @return \Cake\Http\Response|null Redirects on successful request, or null on failure.
333     */
334    public function forgotPassword(): ?Response
335    {
336        if (!SettingsManager::read('Users.registrationEnabled', false)) {
337            return $this->redirect($this->referer());
338        }
339
340        if ($this->request->is('post')) {
341            $email = $this->request->getData('email');
342            $user = $this->Users->findByEmail($email)->first();
343
344            if ($user) {
345                $confirmationsTable = $this->fetchTable('UserAccountConfirmations');
346                $confirmation = $confirmationsTable->newEntity([
347                    'user_id' => $user->id,
348                    'confirmation_code' => Text::uuid(),
349                ]);
350
351                if ($confirmationsTable->save($confirmation)) {
352                    $this->sendPasswordResetEmail($user, $confirmation);
353                    $this->Flash->success(__(
354                        'If your email is registered, you will receive a link to reset your password.',
355                    ));
356
357                    return $this->redirect(['action' => 'login']);
358                }
359            }
360            $this->Flash->success(__('If your email is registered, you will receive a link to reset your password.'));
361        }
362
363        return null;
364    }
365
366    /**
367     * Sends a password reset email to the user.
368     *
369     * @param \App\Model\Entity\User $user The user entity.
370     * @param \App\Model\Entity\UserAccountConfirmation $confirmation The confirmation entity.
371     * @return void
372     */
373    private function sendPasswordResetEmail(User $user, UserAccountConfirmation $confirmation): void
374    {
375        if (env('CAKE_ENV') === 'test') {
376            return;
377        }
378
379        try {
380            $data = [
381                'template_identifier' => 'reset_password',
382                'from' => SettingsManager::read('Email.reply_email', 'noreply@example.com'),
383                'to' => $user->email,
384                'viewVars' => [
385                    'username' => $user->username,
386                    'reset_password_link' => Router::url([
387                        '_name' => 'reset-password',
388                        $confirmation->confirmation_code,
389                    ], true),
390                ],
391            ];
392
393            QueueManager::push('App\Job\SendEmailJob', $data);
394        } catch (Exception $e) {
395            Log::error(__('Failed to send password reset email: {0}', $e->getMessage()));
396        }
397    }
398
399    /**
400     * Handles the password reset functionality.
401     *
402     * Allows users to reset their password using a valid confirmation code.
403     *
404     * @param string $confirmationCode The confirmation code from the password reset link.
405     * @return \Cake\Http\Response|null Redirects after successful password reset, or null on failure.
406     */
407    public function resetPassword(string $confirmationCode): ?Response
408    {
409        if (!SettingsManager::read('Users.registrationEnabled', false)) {
410            return $this->redirect($this->referer());
411        }
412
413        $confirmationsTable = $this->fetchTable('UserAccountConfirmations');
414        $confirmation = $confirmationsTable->find()
415            ->where(['confirmation_code' => $confirmationCode])
416            ->first();
417
418        if (!$confirmation) {
419            $this->Flash->error(__('Invalid or expired password reset link.'));
420
421            return $this->redirect(['action' => 'login']);
422        }
423
424        $user = $this->Users->get($confirmation->user_id);
425
426        if ($this->request->is(['patch', 'post', 'put'])) {
427            $user->setAccess('is_admin', false);
428            $user->setAccess('active', false);
429            $user = $this->Users->patchEntity($user, $this->request->getData(), [
430                'validate' => 'resetPassword',
431            ]);
432
433            if ($this->Users->save($user)) {
434                $confirmationsTable->delete($confirmation);
435                $this->Flash->success(__('Your password has been reset. Please log in with your new password.'));
436
437                return $this->redirect(['action' => 'login']);
438            } else {
439                $this->Flash->error(__('There was an issue resetting your password. Please try again.'));
440            }
441        }
442
443        $this->set(compact('user', 'confirmationCode'));
444
445        return null;
446    }
447}