Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.10% covered (warning)
88.10%
111 / 126
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthController
88.00% covered (warning)
88.00%
110 / 125
64.29% covered (warning)
64.29%
9 / 14
40.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loginForm
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 loginSubmit
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
5.58
 mfaVerifyForm
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 mfaVerifySubmit
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
4.09
 forgotPasswordForm
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 forgotPasswordSubmit
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
5.76
 resetPasswordForm
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 resetPasswordSubmit
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
3.04
 logout
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 htmlResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 redirect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyUserLoginSession
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 resolveSessionMfaTicket
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\User\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\User\Presentation\Api\AuthApiController;
12use Nyholm\Psr7\ServerRequest;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16use Twig\Environment as TwigEnvironment;
17use Yiisoft\Session\SessionInterface;
18use Yiisoft\User\CurrentUser;
19
20/**
21 * Authentication Controller for Guest Zone.
22 *
23 * Handles login form rendering, MFA verification flow, password reset workflow,
24 * API-driven credential verification, and user logout.
25 *
26 * @package App\Modules\User\Presentation\Web
27 */
28final readonly class AuthController
29{
30    private const string CONTENT_TYPE_HTML = 'text/html; charset=utf-8';
31    private const string URL_LOGIN = '/login';
32
33    /**
34     * AuthController constructor.
35     *
36     * @param ResponseFactoryInterface $responseFactory PSR-7 Response factory.
37     * @param TwigEnvironment $twig Twig template environment.
38     * @param AuthApiController $authApiController Auth API controller instance.
39     * @param CurrentUser $currentUser Yii3 CurrentUser service instance.
40     * @param SessionInterface|null $session Yii3 Session instance.
41     */
42    public function __construct(
43        private ResponseFactoryInterface $responseFactory,
44        private TwigEnvironment $twig,
45        private AuthApiController $authApiController,
46        private CurrentUser $currentUser,
47        private ?SessionInterface $session = null
48    ) {
49    }
50
51    /**
52     * Renders login form for guest users.
53     *
54     * @param string|null $successMessage Optional success notification.
55     * @return ResponseInterface PSR-7 HTML response containing login form.
56     */
57    public function loginForm(?string $successMessage = null): ResponseInterface
58    {
59        $html = $this->twig->render('auth/login.twig', [
60            'error'   => null,
61            'success' => $successMessage,
62        ]);
63
64        return $this->htmlResponse($html);
65    }
66
67    /**
68     * Handles submitted login credentials via AuthApiController.
69     *
70     * @param ServerRequestInterface $request PSR-7 Server request.
71     * @return ResponseInterface Redirect response on success or HTML response with error.
72     */
73    public function loginSubmit(ServerRequestInterface $request): ResponseInterface
74    {
75        $apiResponse = $this->authApiController->login($request);
76        $statusCode = $apiResponse->getStatusCode();
77        $payload = (array) json_decode((string) $apiResponse->getBody(), true);
78
79        if ($statusCode !== 200 || !($payload['status'] ?? false)) {
80            $html = $this->twig->render('auth/login.twig', [
81                'error' => $payload['message'] ?? 'Invalid login or password.',
82            ]);
83            return $this->htmlResponse($html, 400);
84        }
85
86        // Handle MFA flow redirect
87        if ($payload['mfa_required'] ?? false) {
88            $mfaTicket = (string) ($payload['mfa_ticket'] ?? '');
89            if ($this->session !== null) {
90                $this->session->set('mfa_ticket', $mfaTicket);
91            }
92            return $this->redirect('/mfa/verify');
93        }
94
95        return $this->applyUserLoginSession((array) ($payload['data'] ?? []));
96    }
97
98    /**
99     * Renders MFA verification form.
100     *
101     * @param ServerRequestInterface $request PSR-7 request.
102     * @return ResponseInterface HTML response.
103     */
104    public function mfaVerifyForm(ServerRequestInterface $request): ResponseInterface
105    {
106        $ticket = $this->resolveSessionMfaTicket($request);
107        if ($ticket === '') {
108            return $this->redirect(self::URL_LOGIN);
109        }
110
111        $html = $this->twig->render('auth/mfa_verify.twig', [
112            'error'      => null,
113            'mfa_ticket' => $ticket,
114        ]);
115
116        return $this->htmlResponse($html);
117    }
118
119    /**
120     * Handles submitted MFA second factor verification code.
121     *
122     * @param ServerRequestInterface $request PSR-7 server request.
123     * @return ResponseInterface Redirect or HTML error response.
124     */
125    public function mfaVerifySubmit(ServerRequestInterface $request): ResponseInterface
126    {
127        $parsedBody = (array) $request->getParsedBody();
128        $ticket = (string) ($parsedBody['mfa_ticket'] ?? $this->resolveSessionMfaTicket($request));
129        $code = (string) ($parsedBody['code'] ?? '');
130
131        $verifyRequest = (new ServerRequest('POST', '/api/v1/auth/mfa-verify'))
132            ->withParsedBody(['mfa_ticket' => $ticket, 'code' => $code])
133            ->withHeader('User-Agent', $request->getHeaderLine('User-Agent'));
134
135        $apiResponse = $this->authApiController->mfaVerify($verifyRequest);
136        $payload = (array) json_decode((string) $apiResponse->getBody(), true);
137
138        if ($apiResponse->getStatusCode() !== 200 || !($payload['status'] ?? false)) {
139            $html = $this->twig->render('auth/mfa_verify.twig', [
140                'error'      => $payload['message'] ?? 'Invalid MFA verification code.',
141                'mfa_ticket' => $ticket,
142            ]);
143            return $this->htmlResponse($html, 400);
144        }
145
146        if ($this->session !== null) {
147            $this->session->remove('mfa_ticket');
148        }
149
150        return $this->applyUserLoginSession((array) ($payload['data'] ?? []));
151    }
152
153    /**
154     * Renders forgot password request form.
155     */
156    public function forgotPasswordForm(): ResponseInterface
157    {
158        $html = $this->twig->render('auth/forgot_password.twig', [
159            'error'   => null,
160            'success' => null,
161        ]);
162
163        return $this->htmlResponse($html);
164    }
165
166    /**
167     * Handles forgot password request form submission.
168     */
169    public function forgotPasswordSubmit(ServerRequestInterface $request): ResponseInterface
170    {
171        $sessionLocale = ($this->session !== null && $this->session->isActive())
172            ? (string) $this->session->get('app_locale', '')
173            : '';
174        $effectiveRequest = $request;
175        if ($sessionLocale !== '') {
176            $parsedBody = (array) $request->getParsedBody();
177            if (!isset($parsedBody['locale'])) {
178                $parsedBody['locale'] = $sessionLocale;
179                $effectiveRequest = $request->withParsedBody($parsedBody);
180            }
181        }
182
183        $apiResponse = $this->authApiController->forgotPassword($effectiveRequest);
184        $payload = (array) json_decode((string) $apiResponse->getBody(), true);
185
186        $html = $this->twig->render('auth/forgot_password.twig', [
187            'error'   => null,
188            'success' => $payload['message'] ?? 'Password reset instructions have been sent.',
189        ]);
190
191        return $this->htmlResponse($html);
192    }
193
194    /**
195     * Renders reset password form for user with valid link token.
196     */
197    public function resetPasswordForm(ServerRequestInterface $request): ResponseInterface
198    {
199        $params = $request->getQueryParams();
200        $token = (string) ($params['token'] ?? '');
201
202        if ($token === '') {
203            return $this->redirect(self::URL_LOGIN);
204        }
205
206        $html = $this->twig->render('auth/reset_password.twig', [
207            'error' => null,
208            'token' => $token,
209        ]);
210
211        return $this->htmlResponse($html);
212    }
213
214    /**
215     * Handles reset password form submission.
216     */
217    public function resetPasswordSubmit(ServerRequestInterface $request): ResponseInterface
218    {
219        $apiResponse = $this->authApiController->resetPassword($request);
220        $payload = (array) json_decode((string) $apiResponse->getBody(), true);
221        $params = (array) $request->getParsedBody();
222        $token = (string) ($params['token'] ?? '');
223
224        if ($apiResponse->getStatusCode() !== 200 || !($payload['status'] ?? false)) {
225            $html = $this->twig->render('auth/reset_password.twig', [
226                'error' => $payload['message'] ?? 'Failed to reset password.',
227                'token' => $token,
228            ]);
229            return $this->htmlResponse($html, 400);
230        }
231
232        $successMsg = (string) ($payload['message'] ?? 'Password has been changed. You can now log in.');
233        return $this->loginForm($successMsg);
234    }
235
236    /**
237     * Logs out current user and redirects to login page.
238     *
239     * @return ResponseInterface Redirect response to /login.
240     */
241    public function logout(): ResponseInterface
242    {
243        if (session_status() === PHP_SESSION_ACTIVE) {
244            // @codeCoverageIgnoreStart
245            session_unset();
246            session_destroy();
247            // @codeCoverageIgnoreEnd
248        }
249
250        $this->currentUser->logout();
251
252        return $this->redirect(self::URL_LOGIN);
253    }
254
255    private function htmlResponse(string $html, int $status = 200): ResponseInterface
256    {
257        $response = $this->responseFactory->createResponse($status);
258        $response->getBody()->write($html);
259        return $response->withHeader('Content-Type', self::CONTENT_TYPE_HTML);
260    }
261
262    private function redirect(string $location): ResponseInterface
263    {
264        return $this->responseFactory->createResponse(302)->withHeader('Location', $location);
265    }
266
267    /**
268     * Applies authenticated user attributes to session and CurrentUser identity.
269     */
270    private function applyUserLoginSession(array $userData): ResponseInterface
271    {
272        $userId = (int) ($userData['user_id'] ?? 0);
273        $authLogId = (int) ($userData['auth_log_id'] ?? 0);
274        $userLocale = (string) ($userData['locale'] ?? 'en');
275
276        $userSessionData = [
277            'id'             => $userId,
278            'username'       => (string) ($userData['username'] ?? ''),
279            'email'          => (string) ($userData['email'] ?? ''),
280            'is_superuser'   => (bool) ($userData['is_superuser'] ?? false),
281            'status'         => (string) ($userData['status'] ?? 'active'),
282            'special_access' => (int) ($userData['special_access'] ?? 1),
283            'is_active'      => (bool) ($userData['is_active'] ?? true),
284            'profile_id'     => isset($userData['profile_id']) ? (int) $userData['profile_id'] : null,
285            'is_client_user' => (bool) ($userData['is_client_user'] ?? true),
286        ];
287
288        if ($this->session !== null) {
289            $this->session->regenerateId();
290            $this->session->set('user_id', $userId);
291            $this->session->set('user', $userSessionData);
292            $this->session->set('app_locale', $userLocale);
293            $this->session->set('last_activity', time());
294            if ($authLogId > 0) {
295                $this->session->set('auth_log_id', $authLogId);
296            }
297        // @codeCoverageIgnoreStart
298        } elseif (session_status() === PHP_SESSION_ACTIVE) {
299            $_SESSION['user_id'] = $userId;
300            $_SESSION['user'] = $userSessionData;
301            $_SESSION['app_locale'] = $userLocale;
302            if ($authLogId > 0) {
303                $_SESSION['auth_log_id'] = $authLogId;
304            }
305        }
306        // @codeCoverageIgnoreEnd
307
308        $this->currentUser->login(new Identity($userId, $userLocale));
309
310        $response = $this->responseFactory->createResponse(302);
311        return $response->withHeader('Location', '/dashboard');
312    }
313
314    /**
315     * Resolves pending MFA ticket from active session or query string.
316     */
317    private function resolveSessionMfaTicket(ServerRequestInterface $request): string
318    {
319        if ($this->session !== null) {
320            $t = $this->session->get('mfa_ticket');
321            if (is_string($t) && $t !== '') {
322                return $t;
323            }
324        }
325
326        $params = $request->getQueryParams();
327        return (string) ($params['mfa_ticket'] ?? $params['ticket'] ?? '');
328    }
329}