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