Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
LoginHandler
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
5 / 5
15
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 rehashPasswordIfNeeded
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 determineFailureReason
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 logFailure
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
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\Application\Command;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\User\Domain\Model\AuthFailureReason;
12use App\Modules\User\Domain\Model\User;
13use App\Modules\User\Domain\Repository\UserRepositoryInterface;
14
15/**
16 * User Login Command Handler.
17 *
18 * Validates user credentials, records audit attempt logs in a_logs_user_auth_records,
19 * and returns User entity upon success.
20 *
21 * @package App\Modules\User\Application\Command
22 */
23final readonly class LoginHandler
24{
25    /**
26     * LoginHandler constructor.
27     *
28     * @param UserRepositoryInterface $userRepository User repository contract instance.
29     */
30    public function __construct(
31        private UserRepositoryInterface $userRepository
32    ) {
33    }
34
35    /**
36     * Handles authentication command, records audit log, and returns User entity if credentials match.
37     *
38     * @param LoginCommand $command Login command containing credentials and client metadata.
39     * @return User|null Mapped User entity on success, null on authentication failure.
40     */
41    public function handle(LoginCommand $command): ?User
42    {
43        if ($this->userRepository->countRecentFailedAttempts($command->ipAddress, 900) >= 5) {
44            $this->logFailure(null, AuthFailureReason::BLOCKED_IP, $command);
45            return null;
46        }
47
48        if ($this->userRepository->countRecentFailedAttemptsByLogin($command->login, 900) >= 5) {
49            $this->logFailure(null, AuthFailureReason::BLOCKED_ACCOUNT, $command);
50            return null;
51        }
52
53        $user = $this->userRepository->findByUsernameOrEmail($command->login);
54        $reason = $this->determineFailureReason($user, $command->password);
55
56        if ($reason !== null) {
57            $userId = ($user !== null && $user->isActive()) ? $user->getId() : null;
58            $this->logFailure($userId, $reason, $command);
59            return null;
60        }
61
62        $this->rehashPasswordIfNeeded($user, $command->password);
63
64        $this->userRepository->logAuthAttempt(new \App\Modules\User\Domain\Model\AuthAttemptLog(
65            userId:          $user->getId(),
66            status:          true,
67            failureReason:   AuthFailureReason::SUCCESS->value,
68            ipAddress:       $command->ipAddress,
69            userAgent:       $command->userAgent,
70            httpProtocol:    $command->httpProtocol,
71            requestId:       $command->requestId,
72            loginIdentifier: $command->login
73        ));
74
75        return $user;
76    }
77
78    private const string DUMMY_HASH =
79        '$argon2id$v=19$m=65536,t=3,p=1$YWJjZGVmZ2hpamtsbW5vcA$6rY3h0Qk7jS1Vf0uP5Yk7g';
80
81    /**
82     * Transparently upgrades password hash to Argon2id if algorithm parameters have evolved.
83     */
84    private function rehashPasswordIfNeeded(User $user, string $plainPassword): void
85    {
86        $targetAlgo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
87        if (password_needs_rehash($user->getPasswordHash(), $targetAlgo)) {
88            $newHash = password_hash($plainPassword, $targetAlgo);
89            $this->userRepository->updatePassword($user->getId(), $newHash);
90        }
91    }
92
93    /**
94     * Determines failure reason based on user presence, active status, and password verification.
95     */
96    private function determineFailureReason(?User $user, string $password): ?AuthFailureReason
97    {
98        if ($user === null || !$user->isActive()) {
99            password_verify($password, self::DUMMY_HASH);
100            return AuthFailureReason::INVALID_LOGIN;
101        }
102
103        if (!password_verify($password, $user->getPasswordHash())) {
104            return AuthFailureReason::INVALID_PASSWORD;
105        }
106
107        return null;
108    }
109
110    /**
111     * Logs authentication failure attempt.
112     */
113    private function logFailure(?int $userId, AuthFailureReason $reason, LoginCommand $command): void
114    {
115        $this->userRepository->logAuthAttempt(new \App\Modules\User\Domain\Model\AuthAttemptLog(
116            userId:          $userId,
117            status:          false,
118            failureReason:   $reason->value,
119            ipAddress:       $command->ipAddress,
120            userAgent:       $command->userAgent,
121            httpProtocol:    $command->httpProtocol,
122            requestId:       $command->requestId,
123            loginIdentifier: $command->login
124        ));
125    }
126}