Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.98% covered (success)
92.98%
225 / 242
75.00% covered (warning)
75.00%
15 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthApiController
92.95% covered (success)
92.95%
224 / 241
75.00% covered (warning)
75.00%
15 / 20
88.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 login
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 logFailedAuth
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 processAuthenticatedUser
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
5
 mfaVerify
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 validateAndResolveMfaUser
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 completeMfaAuthentication
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 forgotPassword
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 resetPassword
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 buildPasswordResetResponse
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 logout
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 generateMfaTicket
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 validateMfaTicket
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 resolveBaseAppKey
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 resolveMfaSigningKey
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 extractProxyIp
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 resolveClientIp
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 formatHttpProtocol
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 determineFailureReason
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parsePayload
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
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\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\User\Application\DTO\LoginRequestDto;
12use App\Modules\User\Application\Service\PasswordResetServiceInterface;
13use App\Modules\User\Application\Service\UserMfaDeviceServiceInterface;
14use App\Modules\User\Domain\Model\AuthAttemptLog;
15use App\Modules\User\Domain\Model\AuthFailureReason;
16use App\Modules\User\Domain\Model\User;
17use App\Modules\User\Domain\Repository\UserRepositoryInterface;
18use App\Shared\Infrastructure\Http\ApiResponseTrait;
19use App\Shared\Infrastructure\Validation\RequestValidationService;
20use Psr\Http\Message\ResponseFactoryInterface;
21use Psr\Http\Message\ResponseInterface;
22use Psr\Http\Message\ServerRequestInterface;
23
24/**
25 * Authentication REST API Controller.
26 *
27 * Exposes /api/v1/auth/login, /api/v1/auth/logout, /api/v1/auth/mfa-verify,
28 * /api/v1/auth/forgot-password, and /api/v1/auth/reset-password JSON endpoints.
29 *
30 * @package App\Modules\User\Presentation\Api
31 */
32final readonly class AuthApiController
33{
34    use ApiResponseTrait;
35
36    private const string IP_LOCALHOST = '127.0.0.1';
37    private const array DEFAULT_TRUSTED_PROXIES = [self::IP_LOCALHOST, '::1'];
38
39    private RequestValidationService $validationService;
40
41    /**
42     * AuthApiController constructor.
43     *
44     * @param ResponseFactoryInterface $responseFactory PSR-7 Response factory.
45     * @param UserRepositoryInterface $userRepository User domain repository.
46     * @param mixed $validationServiceOrLegacy Optional validation service.
47     * @param RequestValidationService|null $validationService Optional central validation service.
48     * @param UserMfaDeviceServiceInterface|null $mfaService Optional MFA service.
49     * @param PasswordResetServiceInterface|null $passwordResetService Optional password reset service.
50     */
51    public function __construct(
52        private ResponseFactoryInterface $responseFactory,
53        private UserRepositoryInterface $userRepository,
54        mixed $validationServiceOrLegacy = null,
55        ?RequestValidationService $validationService = null,
56        private ?UserMfaDeviceServiceInterface $mfaService = null,
57        private ?PasswordResetServiceInterface $passwordResetService = null
58    ) {
59        $this->validationService = $validationServiceOrLegacy instanceof RequestValidationService
60            ? $validationServiceOrLegacy
61            : ($validationService ?? new RequestValidationService());
62    }
63
64    /**
65     * Handles API login request.
66     *
67     * @param ServerRequestInterface $request PSR-7 Server request.
68     * @return ResponseInterface PSR-7 JSON response.
69     */
70    public function login(ServerRequestInterface $request): ResponseInterface
71    {
72        $valResult = $this->validationService->validateRequest($request, LoginRequestDto::class);
73        if (!$valResult->isValid) {
74            $msg = $valResult->getFirstError() ?? 'Login input cannot be empty';
75            return $this->jsonError($this->responseFactory, $msg, 400);
76        }
77
78        /** @var LoginRequestDto $dto */
79        $dto = $valResult->dto;
80        $user = $dto->login !== '' ? $this->userRepository->findByUsernameOrEmail($dto->login) : null;
81        $reason = $this->determineFailureReason($dto->password, $user);
82
83        if ($reason !== null) {
84            $this->logFailedAuth($request, $dto->login, $user, $reason);
85            return $this->jsonError($this->responseFactory, 'Invalid login credentials', 401);
86        }
87
88        return $this->processAuthenticatedUser($request, $user, $dto->login);
89    }
90
91    private function logFailedAuth(
92        ServerRequestInterface $request,
93        string $loginInput,
94        ?User $user,
95        AuthFailureReason $reason
96    ): void {
97        $userId = ($reason === AuthFailureReason::INVALID_PASSWORD && $user !== null) ? $user->getId() : null;
98        $userAgent = (string) ($request->getHeaderLine('User-Agent') ?: 'API-Client');
99        $this->userRepository->logAuthAttempt(new AuthAttemptLog(
100            userId:          $userId,
101            status:          false,
102            failureReason:   $reason->value,
103            ipAddress:       $this->resolveClientIp($request),
104            userAgent:       substr(strip_tags($userAgent), 0, 512),
105            httpProtocol:    $this->formatHttpProtocol($request->getServerParams()),
106            requestId:       $request->getHeaderLine('X-Request-ID') ?: null,
107            loginIdentifier: $loginInput
108        ));
109    }
110
111    private function processAuthenticatedUser(
112        ServerRequestInterface $request,
113        User $user,
114        string $loginInput
115    ): ResponseInterface {
116        $userId = (int) $user->getId();
117        $username = $user->getUsername();
118
119        if ($this->mfaService !== null && $this->mfaService->hasActiveMfa($userId)) {
120            $ticket = $this->generateMfaTicket($userId);
121            return $this->buildJsonResponse($this->responseFactory, [
122                'status'       => true,
123                'success'      => true,
124                'mfa_required' => true,
125                'mfa_ticket'   => $ticket,
126                'message'      => 'Two-factor authentication (MFA) required.',
127                'data'         => [
128                    'user_id'  => $userId,
129                    'username' => $username,
130                ],
131            ], 200);
132        }
133
134        $userAgent = (string) ($request->getHeaderLine('User-Agent') ?: 'API-Client');
135        $authLogId = $this->userRepository->logAuthAttempt(new AuthAttemptLog(
136            userId:          $userId,
137            status:          true,
138            failureReason:   AuthFailureReason::SUCCESS->value,
139            ipAddress:       $this->resolveClientIp($request),
140            userAgent:       substr(strip_tags($userAgent), 0, 512),
141            httpProtocol:    $this->formatHttpProtocol($request->getServerParams()),
142            requestId:       $request->getHeaderLine('X-Request-ID') ?: null,
143            loginIdentifier: $loginInput
144        ));
145
146        return $this->buildJsonResponse($this->responseFactory, [
147            'status'  => true,
148            'success' => true,
149            'message' => 'Authentication successful',
150            'data'    => [
151                'user_id'      => $userId,
152                'username'     => $username,
153                'email'        => $user->getEmail(),
154                'is_superuser' => $user->isSuperuser(),
155                'locale'       => $user->getLocale(),
156                'auth_log_id'  => $authLogId,
157            ],
158        ], 200);
159    }
160
161    /**
162     * Verifies second factor (TOTP or recovery code) using temporary MFA ticket.
163     *
164     * @param ServerRequestInterface $request PSR-7 Server request.
165     * @return ResponseInterface PSR-7 JSON response.
166     */
167    public function mfaVerify(ServerRequestInterface $request): ResponseInterface
168    {
169        $body = $this->parsePayload($request);
170        $ticket = (string) ($body['mfa_ticket'] ?? '');
171        $code = (string) ($body['code'] ?? '');
172
173        if ($ticket === '' || $code === '') {
174            return $this->jsonError($this->responseFactory, 'Missing MFA ticket or verification code.', 400);
175        }
176
177        $userOrError = $this->validateAndResolveMfaUser($ticket, $code);
178        if ($userOrError instanceof ResponseInterface) {
179            return $userOrError;
180        }
181
182        return $this->completeMfaAuthentication($request, $userOrError);
183    }
184
185    private function validateAndResolveMfaUser(string $ticket, string $code): User|ResponseInterface
186    {
187        $userId = $this->validateMfaTicket($ticket);
188        if ($userId === null) {
189            return $this->jsonError(
190                $this->responseFactory,
191                'MFA login session expired. Please log in again.',
192                401
193            );
194        }
195
196        if ($this->mfaService === null || !$this->mfaService->verifyUserMfa($userId, $code)) {
197            return $this->jsonError($this->responseFactory, 'Invalid MFA verification code.', 401);
198        }
199
200        $user = $this->userRepository->findById($userId);
201        return ($user !== null && $user->isActive())
202            ? $user
203            : $this->jsonError($this->responseFactory, 'User account is inactive.', 401);
204    }
205
206    private function completeMfaAuthentication(ServerRequestInterface $request, User $user): ResponseInterface
207    {
208        $userId = (int) $user->getId();
209        $userAgent = (string) ($request->getHeaderLine('User-Agent') ?: 'API-Client');
210        $authLogId = $this->userRepository->logAuthAttempt(new AuthAttemptLog(
211            userId:          $userId,
212            status:          true,
213            failureReason:   AuthFailureReason::SUCCESS->value,
214            ipAddress:       $this->resolveClientIp($request),
215            userAgent:       substr(strip_tags($userAgent), 0, 512),
216            httpProtocol:    $this->formatHttpProtocol($request->getServerParams()),
217            requestId:       $request->getHeaderLine('X-Request-ID') ?: null,
218            loginIdentifier: $user->getUsername()
219        ));
220
221        return $this->buildJsonResponse($this->responseFactory, [
222            'status'  => true,
223            'success' => true,
224            'message' => 'MFA authentication completed successfully.',
225            'data'    => [
226                'user_id'      => $userId,
227                'username'     => $user->getUsername(),
228                'email'        => $user->getEmail(),
229                'is_superuser' => $user->isSuperuser(),
230                'locale'       => $user->getLocale(),
231                'auth_log_id'  => $authLogId,
232            ],
233        ], 200);
234    }
235
236    /**
237     * Handles password reset request submission via API.
238     *
239     * @param ServerRequestInterface $request PSR-7 server request.
240     * @return ResponseInterface PSR-7 JSON response.
241     */
242    public function forgotPassword(ServerRequestInterface $request): ResponseInterface
243    {
244        $body = $this->parsePayload($request);
245        $loginOrEmail = (string) ($body['login'] ?? $body['email'] ?? '');
246        $ip = $this->resolveClientIp($request);
247        $ua = (string) ($request->getHeaderLine('User-Agent') ?: 'API-Client');
248
249        $scheme = (string) ($request->getUri()->getScheme() ?: 'https');
250        $host = (string) ($request->getUri()->getHost() ?: 'app-admin.ammonly.com');
251        $baseUrl = $scheme . '://' . $host;
252
253        if ($this->passwordResetService === null) {
254            return $this->jsonError($this->responseFactory, 'Password reset service is unavailable.', 500);
255        }
256
257        $queryParams = $request->getQueryParams();
258        $acceptHeader = strtolower($request->getHeaderLine('Accept-Language'));
259        $locale = (string) ($body['locale'] ?? $queryParams['locale'] ?? '');
260        if ($locale === '') {
261            $locale = str_starts_with($acceptHeader, 'en') ? 'en' : 'pl';
262        }
263
264        $result = $this->passwordResetService->requestPasswordReset(
265            $loginOrEmail,
266            $ip,
267            $ua,
268            $baseUrl,
269            $locale
270        );
271        return $this->buildJsonResponse($this->responseFactory, [
272            'status'  => true,
273            'success' => true,
274            'message' => $result['message'],
275        ]);
276    }
277
278    /**
279     * Handles password reset confirmation via API.
280     *
281     * @param ServerRequestInterface $request PSR-7 server request.
282     * @return ResponseInterface PSR-7 JSON response.
283     */
284    public function resetPassword(ServerRequestInterface $request): ResponseInterface
285    {
286        $body = $this->parsePayload($request);
287        $token = (string) ($body['token'] ?? '');
288        $newPassword = (string) ($body['new_password'] ?? $body['password'] ?? '');
289
290        if ($token === '' || $newPassword === '') {
291            return $this->jsonError($this->responseFactory, 'Missing required parameters.', 400);
292        }
293
294        if ($this->passwordResetService === null) {
295            return $this->jsonError($this->responseFactory, 'Password reset service is unavailable.', 500);
296        }
297
298        $ip = $this->resolveClientIp($request);
299        $ua = (string) ($request->getHeaderLine('User-Agent') ?: 'API-Client');
300        $result = $this->passwordResetService->completePasswordReset($token, $newPassword, $ip, $ua);
301
302        return $this->buildPasswordResetResponse($result);
303    }
304
305    /**
306     * @param array<string, mixed> $result
307     */
308    private function buildPasswordResetResponse(array $result): ResponseInterface
309    {
310        if (!($result['success'] ?? false)) {
311            return $this->jsonError($this->responseFactory, (string) $result['message'], 400);
312        }
313
314        return $this->buildJsonResponse($this->responseFactory, [
315            'status'  => true,
316            'success' => true,
317            'message' => $result['message'],
318        ]);
319    }
320
321    /**
322     * Handles API logout request.
323     *
324     * @return ResponseInterface PSR-7 JSON response.
325     */
326    public function logout(): ResponseInterface
327    {
328        return $this->buildJsonResponse($this->responseFactory, [
329            'status'  => true,
330            'success' => true,
331            'message' => 'Logout successful',
332            'data'    => null,
333        ], 200);
334    }
335
336    /**
337     * Generates signed, tamper-proof temporary ticket for pending MFA stage.
338     */
339    private function generateMfaTicket(int $userId): string
340    {
341        $payload = base64_encode((string) json_encode([
342            'uid' => $userId,
343            'exp' => time() + 300,
344            'rnd' => bin2hex(random_bytes(8)),
345        ]));
346        $key = $this->resolveMfaSigningKey();
347        $sig = hash_hmac('sha256', $payload, $key);
348
349        return $payload . '.' . $sig;
350    }
351
352    /**
353     * Validates MFA ticket signature and expiry, returning user ID or null.
354     */
355    private function validateMfaTicket(string $ticket): ?int
356    {
357        $parts = explode('.', $ticket);
358        if (count($parts) !== 2) {
359            return null;
360        }
361
362        [$payload, $sig] = $parts;
363        $key = $this->resolveMfaSigningKey();
364        $expectedSig = hash_hmac('sha256', $payload, $key);
365        if (!hash_equals($expectedSig, $sig)) {
366            return null;
367        }
368
369        $data = json_decode((string) base64_decode($payload), true);
370        $isValid = is_array($data) && isset($data['uid'], $data['exp']) && time() <= (int) $data['exp'];
371
372        return $isValid ? (int) $data['uid'] : null;
373    }
374
375    /**
376     * Resolves application fallback secret key from env or storage file.
377     */
378    private function resolveBaseAppKey(): ?string
379    {
380        $appKey = (string) ($_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '');
381        if (trim($appKey) !== '') {
382            return trim($appKey);
383        }
384
385        $keyFile = dirname(__DIR__, 5) . '/storage/app.key';
386        if (file_exists($keyFile)) {
387            $content = trim((string) file_get_contents($keyFile));
388            if ($content !== '') {
389                return $content;
390            }
391        }
392
393        return null;
394    }
395
396    /**
397     * Resolves dynamic MFA ticket HMAC signing secret from environment or persistent key.
398     */
399    private function resolveMfaSigningKey(): string
400    {
401        $envKey = trim((string) ($_ENV['MFA_SIGNING_KEY'] ?? getenv('MFA_SIGNING_KEY') ?: ''));
402        if ($envKey !== '') {
403            return $envKey;
404        }
405
406        $baseKey = $this->resolveBaseAppKey();
407        $seed = $baseKey !== null ? $baseKey . '_mfa_ticket' : 'ammonly_mfa_ticket_signing_secret_fallback';
408
409        return hash('sha256', $seed);
410    }
411
412    /**
413     * Extracts verified client IP from proxy headers.
414     *
415     * @param ServerRequestInterface $request
416     * @param array<string, mixed>  $serverParams
417     */
418    private function extractProxyIp(ServerRequestInterface $request, array $serverParams): ?string
419    {
420        $realIp = trim($request->getHeaderLine('X-Real-IP'));
421        if ($realIp === '' && !empty($serverParams['HTTP_X_REAL_IP'])) {
422            $realIp = trim((string) $serverParams['HTTP_X_REAL_IP']);
423        }
424        if (filter_var($realIp, FILTER_VALIDATE_IP) !== false) {
425            return $realIp;
426        }
427
428        $forwarded = $request->getHeaderLine('X-Forwarded-For');
429        if ($forwarded === '' && !empty($serverParams['HTTP_X_FORWARDED_FOR'])) {
430            $forwarded = (string) $serverParams['HTTP_X_FORWARDED_FOR'];
431        }
432        if ($forwarded !== '') {
433            $parts = array_filter(array_map('trim', explode(',', $forwarded)));
434            $lastClientIp = end($parts) ?: '';
435            if (filter_var($lastClientIp, FILTER_VALIDATE_IP) !== false) {
436                return $lastClientIp;
437            }
438        }
439
440        return null;
441    }
442
443    /**
444     * Resolves client IP address considering verified trusted proxy headers.
445     */
446    private function resolveClientIp(ServerRequestInterface $request): string
447    {
448        $serverParams = $request->getServerParams();
449        $remoteAddr = (string) ($serverParams['REMOTE_ADDR'] ?? self::IP_LOCALHOST);
450
451        $trustedEnv = (string) ($_ENV['TRUSTED_PROXIES'] ?? getenv('TRUSTED_PROXIES') ?: '');
452        $trustedProxies = self::DEFAULT_TRUSTED_PROXIES;
453        if ($trustedEnv !== '') {
454            $extra = array_filter(array_map('trim', explode(',', $trustedEnv)));
455            $trustedProxies = array_values(array_unique(array_merge($trustedProxies, $extra)));
456        }
457
458        if (in_array($remoteAddr, $trustedProxies, true)) {
459            $proxyIp = $this->extractProxyIp($request, $serverParams);
460            if ($proxyIp !== null) {
461                return $proxyIp;
462            }
463        }
464
465        return filter_var($remoteAddr, FILTER_VALIDATE_IP) !== false ? $remoteAddr : self::IP_LOCALHOST;
466    }
467
468    /**
469     * Formats HTTP protocol version string.
470     */
471    private function formatHttpProtocol(array $serverParams): string
472    {
473        $rawProtocol = (string) ($serverParams['SERVER_PROTOCOL'] ?? 'HTTP/1.1');
474        if (str_contains($rawProtocol, '3')) {
475            return 'HTTP/3';
476        }
477        if (str_contains($rawProtocol, '2')) {
478            return 'HTTP/2';
479        }
480
481        return $rawProtocol;
482    }
483
484    /**
485     * Determines authentication failure reason.
486     */
487    private function determineFailureReason(string $password, ?User $user): ?AuthFailureReason
488    {
489        if ($user === null) {
490            return AuthFailureReason::INVALID_LOGIN;
491        }
492        if (!$user->verifyPassword($password)) {
493            return AuthFailureReason::INVALID_PASSWORD;
494        }
495
496        return null;
497    }
498
499    /**
500     * Extracts parsed array payload from PSR-7 request body supporting JSON and form data.
501     *
502     * @param ServerRequestInterface $request PSR-7 server request.
503     * @return array<string, mixed> Request parameters array.
504     */
505    private function parsePayload(ServerRequestInterface $request): array
506    {
507        $body = $request->getParsedBody();
508        if (is_array($body) && !empty($body)) {
509            return $body;
510        }
511
512        $raw = (string) $request->getBody();
513        if ($raw === '') {
514            return [];
515        }
516
517        $decoded = json_decode($raw, true);
518        return is_array($decoded) ? $decoded : [];
519    }
520}