Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.69% covered (success)
97.69%
127 / 130
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthService
97.69% covered (success)
97.69%
127 / 130
81.82% covered (warning)
81.82%
9 / 11
44
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
 register
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 login
94.29% covered (success)
94.29%
33 / 35
0.00% covered (danger)
0.00%
0 / 1
9.02
 generateTokens
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 refreshToken
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 logout
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 logoutAllDevices
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentUser
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 validateUsername
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 validateEmail
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 validatePassword
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Auth\Service;
6
7use App\Domain\Auth\Data\TokenData;
8use App\Domain\Auth\Data\UserAuthData;
9use App\Domain\Auth\Repository\AuthRepository;
10use App\Domain\Exception\AuthenticationException;
11use App\Domain\Exception\BadRequestException;
12use App\Domain\Exception\ConflictException;
13use App\Domain\Exception\ValidationException;
14use App\Support\Row;
15use DateTimeImmutable;
16use RuntimeException;
17
18use function sprintf;
19use function strlen;
20
21final class AuthService
22{
23    // Password requirements
24    private const int MIN_PASSWORD_LENGTH = 8;
25    private const int MAX_PASSWORD_LENGTH = 72;
26    private const int MAX_LOGIN_ATTEMPTS = 5;
27    private const int LOCK_DURATION_MINUTES = 15;
28
29    public function __construct(
30        private readonly AuthRepository $repository,
31        private readonly PasswordService $passwordService,
32        private readonly JwtService $jwtService,
33    ) {}
34
35    public function register(
36        string $username,
37        string $email,
38        string $password,
39        ?int $investorId = null,
40        string $role = 'investor',
41    ): UserAuthData {
42        // Validate inputs
43        $this->validateUsername($username);
44        $this->validateEmail($email);
45        $this->validatePassword($password);
46
47        // Check for duplicates
48        if ($this->repository->usernameExists($username)) {
49            throw new ConflictException('Username already exists');
50        }
51
52        if ($this->repository->emailExists($email)) {
53            throw new ConflictException('Email already exists');
54        }
55
56        // Hash password
57        $passwordHash = $this->passwordService->hashPassword($password);
58
59        // Create user
60        $userId = $this->repository->createUser(
61            $investorId,
62            $username,
63            $email,
64            $passwordHash,
65            $role,
66        );
67
68        // Return user data
69        $user = $this->repository->findUserById($userId);
70
71        if ($user === null) {
72            throw new RuntimeException('Failed to create user');
73        }
74
75        return $user;
76    }
77
78    /**
79     * @param string $username
80     * @param string $password
81     * @param ?string $ipAddress
82     * @param ?string $userAgent
83     * @return array{user: UserAuthData, tokens: TokenData}
84     */
85    public function login(
86        string $username,
87        string $password,
88        ?string $ipAddress = null,
89        ?string $userAgent = null,
90    ): array {
91        // Find user by username
92        $user = $this->repository->findUserByUsername($username);
93
94        if ($user === null) {
95            throw new AuthenticationException('Invalid credentials');
96        }
97
98        // Check if an account is locked
99        if ($this->repository->isAccountLocked($user->userId)) {
100            throw new AuthenticationException('Account is temporarily locked due to too many failed login attempts');
101        }
102
103        // Verify password
104        $hash = $this->repository->getPasswordHash($user->userId);
105
106        if ($hash === null) {
107            throw new AuthenticationException('Invalid credentials');
108        }
109        if (!$this->passwordService->verifyPassword($password, $hash)) {
110            // Increment failed attempts
111            $this->repository->incrementFailedLoginAttempts($user->userId);
112
113            // Get updated user to check failed attempts
114            $user = $this->repository->findUserById($user->userId);
115
116            if ($user !== null && $user->failedLoginAttempts >= self::MAX_LOGIN_ATTEMPTS) {
117                $lockedUntil = new DateTimeImmutable('+' . self::LOCK_DURATION_MINUTES . ' minutes');
118                $this->repository->lockAccount($user->userId, $lockedUntil);
119
120                throw new AuthenticationException('Account is temporarily locked due to too many failed login attempts');
121            }
122
123            throw new AuthenticationException('Invalid credentials');
124        }
125
126        // Check if an account is active
127        if (!$user->isActive) {
128            throw new AuthenticationException('Account is not active.');
129        }
130
131        // Generate tokens
132        $tokens = $this->jwtService->generateTokenPair($user->userId, $user->role);
133
134        // Store refresh token in database
135        $expiresAt = new DateTimeImmutable('+30 days');
136        $this->repository->storeRefreshToken(
137            $user->userId,
138            $tokens->refreshToken,
139            $expiresAt,
140            $ipAddress,
141            $userAgent,
142        );
143
144        // Update last login and reset failed attempts
145        $this->repository->updateLastLogin($user->userId);
146
147        // Get updated user data (with reset failed attempts)
148        $user = $this->repository->findUserById($user->userId);
149
150        if ($user === null) {
151            throw new RuntimeException('Failed to retrieve user after login');
152        }
153        return [
154            'user' => $user,
155            'tokens' => $tokens,
156        ];
157    }
158
159    /**
160     * Generate tokens for a user (used for impersonation).
161     *
162     * @param array{user_id: int, username: string, email: string, role: string} $userData
163     * @param string|null $ipAddress
164     * @param string|null $userAgent
165     *
166     * @return array{accessToken: string, refreshToken: string, expiresIn: int, tokenType: string}
167     */
168    public function generateTokens(
169        array $userData,
170        ?string $ipAddress = null,
171        ?string $userAgent = null,
172    ): array {
173        $userId = (int)$userData['user_id'];
174        $role = $userData['role'];
175
176        // Generate tokens
177        $tokens = $this->jwtService->generateTokenPair($userId, $role);
178
179        // Store refresh token in database
180        $expiresAt = new DateTimeImmutable('+30 days');
181        $this->repository->storeRefreshToken(
182            $userId,
183            $tokens->refreshToken,
184            $expiresAt,
185            $ipAddress,
186            $userAgent,
187        );
188
189        return [
190            'accessToken' => $tokens->accessToken,
191            'refreshToken' => $tokens->refreshToken,
192            'expiresIn' => $tokens->expiresIn,
193            'tokenType' => 'Bearer',
194        ];
195    }
196
197    public function refreshToken(string $refreshToken): TokenData
198    {
199        // Validate refresh token format
200        $payload = $this->jwtService->validateToken($refreshToken);
201
202        if ($payload === null || $payload['type'] !== 'refresh') {
203            throw new AuthenticationException('Invalid refresh token');
204        }
205
206        // Check if refresh token exists in database and is not expired
207        $session = $this->repository->findSessionByRefreshToken($refreshToken);
208
209        if ($session === null) {
210            throw new AuthenticationException('Refresh token not found or expired');
211        }
212
213        // Get user
214        $user = $this->repository->findUserById(Row::int($session, 'userId'));
215
216        if ($user === null || !$user->isActive) {
217            throw new AuthenticationException('User not found or inactive');
218        }
219
220        // Generate new token pair
221        $tokens = $this->jwtService->generateTokenPair($user->userId, $user->role);
222
223        // Update refresh token in database
224        $this->repository->revokeRefreshToken($refreshToken);
225
226        $expiresAt = new DateTimeImmutable('+30 days');
227        $this->repository->storeRefreshToken(
228            $user->userId,
229            $tokens->refreshToken,
230            $expiresAt,
231            Row::nullableString($session, 'ipAddress'),
232            Row::nullableString($session, 'userAgent'),
233        );
234
235        return $tokens;
236    }
237
238    public function logout(string $refreshToken): void
239    {
240        if (!$this->repository->revokeRefreshToken($refreshToken)) {
241            throw new AuthenticationException('Invalid refresh token');
242        }
243    }
244
245    public function logoutAllDevices(int $userId): int
246    {
247        return $this->repository->revokeAllUserTokens($userId);
248    }
249
250    public function getCurrentUser(string $accessToken): UserAuthData
251    {
252        $payload = $this->jwtService->validateToken($accessToken);
253
254        if ($payload === null || $payload['type'] !== 'access') {
255            throw new AuthenticationException('Invalid access token');
256        }
257
258        $user = $this->repository->findUserById(Row::int($payload, 'userId'));
259
260        if ($user === null || !$user->isActive) {
261            throw new AuthenticationException('User not found or inactive');
262        }
263
264        return $user;
265    }
266
267    private function validateUsername(string $username): void
268    {
269        if (empty(trim($username))) {
270            throw new BadRequestException('Username cannot be empty');
271        }
272
273        if (strlen($username) < 3) {
274            throw new ValidationException('Username must be at least 3 characters');
275        }
276
277        if (strlen($username) > 50) {
278            throw new ValidationException('Username cannot exceed 50 characters');
279        }
280
281        if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
282            throw new ValidationException('Username can only contain letters, numbers, and underscores');
283        }
284    }
285
286    private function validateEmail(string $email): void
287    {
288        if (empty(trim($email))) {
289            throw new BadRequestException('Email cannot be empty');
290        }
291
292        // Check length BEFORE validation (so we can give proper error message)
293
294        if (strlen($email) > 255) {
295            throw new ValidationException('Email cannot exceed 255 characters');
296        }
297        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
298            throw new ValidationException('Invalid email format');
299        }
300    }
301
302    private function validatePassword(string $password): void
303    {
304        if (strlen($password) < self::MIN_PASSWORD_LENGTH) {
305            throw new ValidationException(
306                sprintf('Password must be at least %d characters', self::MIN_PASSWORD_LENGTH),
307            );
308        }
309
310        if (strlen($password) > self::MAX_PASSWORD_LENGTH) {
311            throw new ValidationException(
312                sprintf('Password cannot exceed %d characters', self::MAX_PASSWORD_LENGTH),
313            );
314        }
315
316        if (!preg_match('/[A-Z]/', $password)) {
317            throw new ValidationException('Password must contain at least one uppercase letter');
318        }
319
320        if (!preg_match('/[a-z]/', $password)) {
321            throw new ValidationException('Password must contain at least one lowercase letter');
322        }
323
324        if (!preg_match('/[0-9]/', $password)) {
325            throw new ValidationException('Password must contain at least one number');
326        }
327    }
328}