Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.85% covered (warning)
81.85%
203 / 248
3.57% covered (danger)
3.57%
1 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthRepository
81.85% covered (warning)
81.85%
203 / 248
3.57% covered (danger)
3.57%
1 / 28
92.02
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
 findUserByEmail
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 findUserByUsername
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 findUserByInvestorId
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 findUserById
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getPasswordHash
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 createUser
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 emailExists
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 usernameExists
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 storeRefreshToken
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 findSessionByRefreshToken
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 revokeRefreshToken
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 revokeAllUserTokens
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 cleanupExpiredTokens
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 updateLastLogin
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 incrementFailedLoginAttempts
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 lockAccount
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 isAccountLocked
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 storePasswordResetToken
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 findValidResetToken
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 markResetTokenUsed
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 invalidateResetTokensForUser
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 updatePassword
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 isEmailTakenByAnotherUser
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 updateUserEmail
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 storeEmailChangeToken
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 findValidEmailChangeToken
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 markEmailChangeTokenUsed
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Auth\Repository;
6
7use App\Domain\Auth\Data\UserAuthData;
8use App\Support\Row;
9use DateTimeImmutable;
10use PDO;
11use RuntimeException;
12
13final class AuthRepository
14{
15    public function __construct(
16        private readonly PDO $pdo,
17    ) {}
18
19    public function findUserByEmail(string $email): ?UserAuthData
20    {
21        $stmt = $this->pdo->prepare(
22            'SELECT user_id as "userId",
23                    investor_id as "investorId",
24                    username,
25                    email,
26                    password_hash as "passwordHash",
27                    role,
28                    is_active as "isActive",
29                    last_login as "lastLogin",
30                    failed_login_attempts as "failedLoginAttempts",
31                    locked_until as "lockedUntil"
32             FROM users
33             WHERE email = :email',
34        );
35
36        if ($stmt === false) {
37            throw new RuntimeException('Failed to prepare statement');
38        }
39        $stmt->execute(['email' => $email]);
40        $row = $stmt->fetch(PDO::FETCH_ASSOC);
41
42        return is_array($row) ? UserAuthData::fromRow($row) : null;
43    }
44
45    public function findUserByUsername(string $username): ?UserAuthData
46    {
47        $stmt = $this->pdo->prepare(
48            'SELECT user_id as "userId",
49                    investor_id as "investorId",
50                    username,
51                    email,
52                    password_hash as "passwordHash",
53                    role,
54                    is_active as "isActive",
55                    last_login as "lastLogin",
56                    failed_login_attempts as "failedLoginAttempts",
57                    locked_until as "lockedUntil"
58             FROM users
59             WHERE username = :username',
60        );
61
62        if ($stmt === false) {
63            throw new RuntimeException('Failed to prepare statement');
64        }
65        $stmt->execute(['username' => $username]);
66        $row = $stmt->fetch(PDO::FETCH_ASSOC);
67
68        return is_array($row) ? UserAuthData::fromRow($row) : null;
69    }
70
71    /**
72     * Resolve the login record for an investor. An investor row and its user
73     * row are 1:1 via users.investor_id; admin-side password tools (FSC-124)
74     * address the customer by investorId, so this bridges to the user.
75     *
76     * @param int $investorId
77     */
78    public function findUserByInvestorId(int $investorId): ?UserAuthData
79    {
80        $stmt = $this->pdo->prepare(
81            'SELECT user_id as "userId",
82                    investor_id as "investorId",
83                    username,
84                    email,
85                    password_hash as "passwordHash",
86                    role,
87                    is_active as "isActive",
88                    last_login as "lastLogin",
89                    failed_login_attempts as "failedLoginAttempts",
90                    locked_until as "lockedUntil"
91             FROM users
92             WHERE investor_id = :investor_id',
93        );
94
95        if ($stmt === false) {
96            throw new RuntimeException('Failed to prepare statement');
97        }
98        $stmt->execute(['investor_id' => $investorId]);
99        $row = $stmt->fetch(PDO::FETCH_ASSOC);
100
101        return is_array($row) ? UserAuthData::fromRow($row) : null;
102    }
103
104    public function findUserById(int $userId): ?UserAuthData
105    {
106        $stmt = $this->pdo->prepare(
107            'SELECT user_id as "userId",
108                    investor_id as "investorId",
109                    username,
110                    email,
111                    password_hash as "passwordHash",
112                    role,
113                    is_active as "isActive",
114                    last_login as "lastLogin",
115                    failed_login_attempts as "failedLoginAttempts",
116                    locked_until as "lockedUntil"
117             FROM users
118             WHERE user_id = :user_id',
119        );
120
121        if ($stmt === false) {
122            throw new RuntimeException('Failed to prepare statement');
123        }
124        $stmt->execute(['user_id' => $userId]);
125        $row = $stmt->fetch(PDO::FETCH_ASSOC);
126
127        return is_array($row) ? UserAuthData::fromRow($row) : null;
128    }
129
130    public function getPasswordHash(int $userId): ?string
131    {
132        $stmt = $this->pdo->prepare(
133            'SELECT password_hash FROM users WHERE user_id = :user_id',
134        );
135
136        if ($stmt === false) {
137            throw new RuntimeException('Failed to prepare statement');
138        }
139        $stmt->execute(['user_id' => $userId]);
140        $result = $stmt->fetchColumn();
141
142        return $result !== false ? (string)$result : null;
143    }
144
145    public function createUser(
146        ?int $investorId,
147        string $username,
148        string $email,
149        string $passwordHash,
150        string $role = 'investor',
151    ): int {
152        $stmt = $this->pdo->prepare(
153            'INSERT INTO users (investor_id, username, email, password_hash, role, is_active, created_at, updated_at)
154             VALUES (:investor_id, :username, :email, :password_hash, :role, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
155             RETURNING user_id',
156        );
157
158        if ($stmt === false) {
159            throw new RuntimeException('Failed to prepare statement');
160        }
161        $stmt->execute([
162            'investor_id' => $investorId,
163            'username' => $username,
164            'email' => $email,
165            'password_hash' => $passwordHash,
166            'role' => $role,
167        ]);
168
169        return (int)$stmt->fetchColumn();
170    }
171
172    public function emailExists(string $email): bool
173    {
174        $stmt = $this->pdo->prepare(
175            'SELECT COUNT(*) FROM users WHERE email = :email',
176        );
177
178        if ($stmt === false) {
179            throw new RuntimeException('Failed to prepare statement');
180        }
181        $stmt->execute(['email' => $email]);
182
183        return (int)$stmt->fetchColumn() > 0;
184    }
185
186    public function usernameExists(string $username): bool
187    {
188        $stmt = $this->pdo->prepare(
189            'SELECT COUNT(*) FROM users WHERE username = :username',
190        );
191
192        if ($stmt === false) {
193            throw new RuntimeException('Failed to prepare statement');
194        }
195        $stmt->execute(['username' => $username]);
196
197        return (int)$stmt->fetchColumn() > 0;
198    }
199
200    public function storeRefreshToken(
201        int $userId,
202        string $refreshToken,
203        DateTimeImmutable $expiresAt,
204        ?string $ipAddress = null,
205        ?string $userAgent = null,
206    ): int {
207        $stmt = $this->pdo->prepare(
208            'INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent, created_at)
209             VALUES (:user_id, :refresh_token, :expires_at, :ip_address, :user_agent, CURRENT_TIMESTAMP)
210             RETURNING session_id',
211        );
212
213        if ($stmt === false) {
214            throw new RuntimeException('Failed to prepare statement');
215        }
216        $stmt->execute([
217            'user_id' => $userId,
218            'refresh_token' => $refreshToken,
219            'expires_at' => $expiresAt->format('Y-m-d H:i:sP'),
220            'ip_address' => $ipAddress,
221            'user_agent' => $userAgent,
222        ]);
223
224        return (int)$stmt->fetchColumn();
225    }
226
227    /**
228     * @param string $refreshToken
229     * @return array<mixed>|null
230     */
231    public function findSessionByRefreshToken(string $refreshToken): ?array
232    {
233        $stmt = $this->pdo->prepare(
234            'SELECT session_id as "sessionId",
235                    user_id as "userId",
236                    refresh_token as "refreshToken",
237                    expires_at as "expiresAt",
238                    ip_address as "ipAddress",
239                    user_agent as "userAgent",
240                    created_at as "createdAt"
241             FROM user_sessions
242             WHERE refresh_token = :refresh_token
243             AND expires_at > CURRENT_TIMESTAMP',
244        );
245        if ($stmt === false) {
246            throw new RuntimeException('Failed to prepare statement');
247        }
248
249        $stmt->execute(['refresh_token' => $refreshToken]);
250        $row = $stmt->fetch(PDO::FETCH_ASSOC);
251
252        return is_array($row) ? $row : null;
253    }
254
255    public function revokeRefreshToken(string $refreshToken): bool
256    {
257        $stmt = $this->pdo->prepare(
258            'DELETE FROM user_sessions WHERE refresh_token = :refresh_token',
259        );
260
261        if ($stmt === false) {
262            throw new RuntimeException('Failed to prepare statement');
263        }
264        $stmt->execute(['refresh_token' => $refreshToken]);
265
266        return $stmt->rowCount() > 0;
267    }
268
269    public function revokeAllUserTokens(int $userId): int
270    {
271        $stmt = $this->pdo->prepare(
272            'DELETE FROM user_sessions WHERE user_id = :user_id',
273        );
274
275        if ($stmt === false) {
276            throw new RuntimeException('Failed to prepare statement');
277        }
278        $stmt->execute(['user_id' => $userId]);
279
280        return $stmt->rowCount();
281    }
282
283    public function cleanupExpiredTokens(): int
284    {
285        $stmt = $this->pdo->prepare(
286            'DELETE FROM user_sessions WHERE expires_at < CURRENT_TIMESTAMP',
287        );
288
289        if ($stmt === false) {
290            throw new RuntimeException('Failed to prepare statement');
291        }
292        $stmt->execute();
293
294        return $stmt->rowCount();
295    }
296
297    public function updateLastLogin(int $userId): void
298    {
299        $stmt = $this->pdo->prepare(
300            'UPDATE users
301             SET last_login = CURRENT_TIMESTAMP,
302                 failed_login_attempts = 0,
303                 locked_until = NULL,
304                 updated_at = CURRENT_TIMESTAMP
305             WHERE user_id = :user_id',
306        );
307
308        if ($stmt === false) {
309            throw new RuntimeException('Failed to prepare statement');
310        }
311        $stmt->execute(['user_id' => $userId]);
312    }
313
314    public function incrementFailedLoginAttempts(int $userId): void
315    {
316        $stmt = $this->pdo->prepare(
317            'UPDATE users
318             SET failed_login_attempts = failed_login_attempts + 1,
319                 updated_at = CURRENT_TIMESTAMP
320             WHERE user_id = :user_id',
321        );
322
323        if ($stmt === false) {
324            throw new RuntimeException('Failed to prepare statement');
325        }
326        $stmt->execute(['user_id' => $userId]);
327    }
328
329    public function lockAccount(int $userId, DateTimeImmutable $lockedUntil): void
330    {
331        $stmt = $this->pdo->prepare(
332            'UPDATE users
333             SET locked_until = :locked_until,
334                 updated_at = CURRENT_TIMESTAMP
335             WHERE user_id = :user_id',
336        );
337
338        if ($stmt === false) {
339            throw new RuntimeException('Failed to prepare statement');
340        }
341        $stmt->execute([
342            'user_id' => $userId,
343            'locked_until' => $lockedUntil->format('Y-m-d H:i:sP'),
344        ]);
345    }
346
347    public function isAccountLocked(int $userId): bool
348    {
349        $stmt = $this->pdo->prepare(
350            'SELECT locked_until FROM users WHERE user_id = :user_id AND locked_until IS NOT NULL',
351        );
352
353        if ($stmt === false) {
354            throw new RuntimeException('Failed to prepare statement');
355        }
356        $stmt->execute(['user_id' => $userId]);
357
358        $lockedUntil = $stmt->fetchColumn();
359
360        if ($lockedUntil === false) {
361            return false;
362        }
363
364        if (new DateTimeImmutable((string)$lockedUntil) <= new DateTimeImmutable()) {
365            // Lock expired â€” reset attempts so user isn't immediately re-locked
366            $reset = $this->pdo->prepare(
367                'UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE user_id = :user_id',
368            );
369
370            if ($reset !== false) {
371                $reset->execute(['user_id' => $userId]);
372            }
373
374            return false;
375        }
376
377        return true;
378    }
379
380    public function storePasswordResetToken(int $userId, string $token, DateTimeImmutable $expiresAt): void
381    {
382        $stmt = $this->pdo->prepare(
383            'INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (:user_id, :token, :expires_at)',
384        );
385
386        if ($stmt === false) {
387            throw new RuntimeException('Failed to prepare statement');
388        }
389        $stmt->execute([
390            'user_id' => $userId,
391            'token' => $token,
392            'expires_at' => $expiresAt->format('Y-m-d H:i:sP'),
393        ]);
394    }
395
396    /**
397     * @param string $token
398     * @return array{userId: int, tokenId: int}|null
399     */
400    public function findValidResetToken(string $token): ?array
401    {
402        $stmt = $this->pdo->prepare(
403            'SELECT token_id AS "tokenId", user_id AS "userId" FROM password_reset_tokens
404             WHERE token = :token AND expires_at > CURRENT_TIMESTAMP AND used_at IS NULL',
405        );
406
407        if ($stmt === false) {
408            throw new RuntimeException('Failed to prepare statement');
409        }
410        $stmt->execute(['token' => $token]);
411        $row = $stmt->fetch(PDO::FETCH_ASSOC);
412
413        if (!is_array($row)) {
414            return null;
415        }
416
417        return [
418            'userId' => Row::int($row, 'userId'),
419            'tokenId' => Row::int($row, 'tokenId'),
420        ];
421    }
422
423    public function markResetTokenUsed(int $tokenId): void
424    {
425        $stmt = $this->pdo->prepare(
426            'UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_id = :token_id',
427        );
428
429        if ($stmt === false) {
430            throw new RuntimeException('Failed to prepare statement');
431        }
432        $stmt->execute(['token_id' => $tokenId]);
433    }
434
435    /**
436     * Burn every outstanding (unused, unexpired) reset token for a user.
437     * Called when an admin sets the password directly (FSC-124) so a stale
438     * link that was already in flight can't later overwrite the new password.
439     *
440     * @param int $userId
441     * @return int number of tokens invalidated
442     */
443    public function invalidateResetTokensForUser(int $userId): int
444    {
445        $stmt = $this->pdo->prepare(
446            'UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP
447             WHERE user_id = :user_id AND used_at IS NULL',
448        );
449
450        if ($stmt === false) {
451            throw new RuntimeException('Failed to prepare statement');
452        }
453        $stmt->execute(['user_id' => $userId]);
454
455        return $stmt->rowCount();
456    }
457
458    public function updatePassword(int $userId, string $passwordHash): void
459    {
460        $stmt = $this->pdo->prepare(
461            'UPDATE users SET password_hash = :hash, failed_login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = :user_id',
462        );
463
464        if ($stmt === false) {
465            throw new RuntimeException('Failed to prepare statement');
466        }
467        $stmt->execute(['hash' => $passwordHash, 'user_id' => $userId]);
468    }
469
470    /**
471     * True if the email belongs to a *different* user's login record. Used by
472     * the self-service email change so a customer can re-confirm their own
473     * address without tripping the uniqueness guard (FSC-86).
474     * @param string $email
475     * @param int $excludeUserId
476     */
477    public function isEmailTakenByAnotherUser(string $email, int $excludeUserId): bool
478    {
479        $stmt = $this->pdo->prepare(
480            'SELECT EXISTS(SELECT 1 FROM users WHERE email = :email AND user_id <> :user_id)',
481        );
482
483        if ($stmt === false) {
484            throw new RuntimeException('Failed to prepare statement');
485        }
486        $stmt->execute(['email' => $email, 'user_id' => $excludeUserId]);
487
488        return (bool)$stmt->fetchColumn();
489    }
490
491    public function updateUserEmail(int $userId, string $newEmail): void
492    {
493        $stmt = $this->pdo->prepare(
494            'UPDATE users SET email = :email, updated_at = CURRENT_TIMESTAMP WHERE user_id = :user_id',
495        );
496
497        if ($stmt === false) {
498            throw new RuntimeException('Failed to prepare statement');
499        }
500        $stmt->execute(['email' => $newEmail, 'user_id' => $userId]);
501    }
502
503    public function storeEmailChangeToken(
504        int $userId,
505        string $newEmail,
506        string $token,
507        DateTimeImmutable $expiresAt,
508    ): void {
509        $stmt = $this->pdo->prepare(
510            'INSERT INTO email_change_tokens (user_id, new_email, token, expires_at)
511             VALUES (:user_id, :new_email, :token, :expires_at)',
512        );
513
514        if ($stmt === false) {
515            throw new RuntimeException('Failed to prepare statement');
516        }
517        $stmt->execute([
518            'user_id' => $userId,
519            'new_email' => $newEmail,
520            'token' => $token,
521            'expires_at' => $expiresAt->format('Y-m-d H:i:sP'),
522        ]);
523    }
524
525    /**
526     * @param string $token
527     * @return array{userId: int, tokenId: int, newEmail: string}|null
528     */
529    public function findValidEmailChangeToken(string $token): ?array
530    {
531        $stmt = $this->pdo->prepare(
532            'SELECT token_id AS "tokenId", user_id AS "userId", new_email AS "newEmail"
533             FROM email_change_tokens
534             WHERE token = :token AND expires_at > CURRENT_TIMESTAMP AND used_at IS NULL',
535        );
536
537        if ($stmt === false) {
538            throw new RuntimeException('Failed to prepare statement');
539        }
540        $stmt->execute(['token' => $token]);
541        $row = $stmt->fetch(PDO::FETCH_ASSOC);
542
543        if (!is_array($row)) {
544            return null;
545        }
546
547        return [
548            'userId' => Row::int($row, 'userId'),
549            'tokenId' => Row::int($row, 'tokenId'),
550            'newEmail' => Row::string($row, 'newEmail'),
551        ];
552    }
553
554    public function markEmailChangeTokenUsed(int $tokenId): void
555    {
556        $stmt = $this->pdo->prepare(
557            'UPDATE email_change_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_id = :token_id',
558        );
559
560        if ($stmt === false) {
561            throw new RuntimeException('Failed to prepare statement');
562        }
563        $stmt->execute(['token_id' => $tokenId]);
564    }
565}