Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.57% covered (warning)
88.57%
124 / 140
5.88% covered (danger)
5.88%
1 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthRepository
88.57% covered (warning)
88.57%
124 / 140
5.88% covered (danger)
5.88%
1 / 17
40.16
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
 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
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Auth\Repository;
6
7use App\Domain\Auth\Data\UserAuthData;
8use DateTimeImmutable;
9use PDO;
10use RuntimeException;
11
12final class AuthRepository
13{
14    public function __construct(
15        private readonly PDO $pdo,
16    ) {}
17
18    public function findUserByEmail(string $email): ?UserAuthData
19    {
20        $stmt = $this->pdo->prepare(
21            'SELECT user_id as "userId",
22                    investor_id as "investorId",
23                    username,
24                    email,
25                    password_hash as "passwordHash",
26                    role,
27                    is_active as "isActive",
28                    last_login as "lastLogin",
29                    failed_login_attempts as "failedLoginAttempts",
30                    locked_until as "lockedUntil"
31             FROM users
32             WHERE email = :email',
33        );
34
35        if ($stmt === false) {
36            throw new RuntimeException('Failed to prepare statement');
37        }
38        $stmt->execute(['email' => $email]);
39        $row = $stmt->fetch();
40
41        return $row !== false ? UserAuthData::fromRow($row) : null;
42    }
43
44    public function findUserByUsername(string $username): ?UserAuthData
45    {
46        $stmt = $this->pdo->prepare(
47            'SELECT user_id as "userId",
48                    investor_id as "investorId",
49                    username,
50                    email,
51                    password_hash as "passwordHash",
52                    role,
53                    is_active as "isActive",
54                    last_login as "lastLogin",
55                    failed_login_attempts as "failedLoginAttempts",
56                    locked_until as "lockedUntil"
57             FROM users
58             WHERE username = :username',
59        );
60
61        if ($stmt === false) {
62            throw new RuntimeException('Failed to prepare statement');
63        }
64        $stmt->execute(['username' => $username]);
65        $row = $stmt->fetch();
66
67        return $row !== false ? UserAuthData::fromRow($row) : null;
68    }
69
70    public function findUserById(int $userId): ?UserAuthData
71    {
72        $stmt = $this->pdo->prepare(
73            'SELECT user_id as "userId",
74                    investor_id as "investorId",
75                    username,
76                    email,
77                    password_hash as "passwordHash",
78                    role,
79                    is_active as "isActive",
80                    last_login as "lastLogin",
81                    failed_login_attempts as "failedLoginAttempts",
82                    locked_until as "lockedUntil"
83             FROM users
84             WHERE user_id = :user_id',
85        );
86
87        if ($stmt === false) {
88            throw new RuntimeException('Failed to prepare statement');
89        }
90        $stmt->execute(['user_id' => $userId]);
91        $row = $stmt->fetch();
92
93        return $row !== false ? UserAuthData::fromRow($row) : null;
94    }
95
96    public function getPasswordHash(int $userId): ?string
97    {
98        $stmt = $this->pdo->prepare(
99            'SELECT password_hash FROM users WHERE user_id = :user_id',
100        );
101
102        if ($stmt === false) {
103            throw new RuntimeException('Failed to prepare statement');
104        }
105        $stmt->execute(['user_id' => $userId]);
106        $result = $stmt->fetchColumn();
107
108        return $result !== false ? (string)$result : null;
109    }
110
111    public function createUser(
112        ?int $investorId,
113        string $username,
114        string $email,
115        string $passwordHash,
116        string $role = 'investor',
117    ): int {
118        $stmt = $this->pdo->prepare(
119            'INSERT INTO users (investor_id, username, email, password_hash, role, is_active, created_at, updated_at)
120             VALUES (:investor_id, :username, :email, :password_hash, :role, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
121             RETURNING user_id',
122        );
123
124        if ($stmt === false) {
125            throw new RuntimeException('Failed to prepare statement');
126        }
127        $stmt->execute([
128            'investor_id' => $investorId,
129            'username' => $username,
130            'email' => $email,
131            'password_hash' => $passwordHash,
132            'role' => $role,
133        ]);
134
135        return (int)$stmt->fetchColumn();
136    }
137
138    public function emailExists(string $email): bool
139    {
140        $stmt = $this->pdo->prepare(
141            'SELECT COUNT(*) FROM users WHERE email = :email',
142        );
143
144        if ($stmt === false) {
145            throw new RuntimeException('Failed to prepare statement');
146        }
147        $stmt->execute(['email' => $email]);
148
149        return (int)$stmt->fetchColumn() > 0;
150    }
151
152    public function usernameExists(string $username): bool
153    {
154        $stmt = $this->pdo->prepare(
155            'SELECT COUNT(*) FROM users WHERE username = :username',
156        );
157
158        if ($stmt === false) {
159            throw new RuntimeException('Failed to prepare statement');
160        }
161        $stmt->execute(['username' => $username]);
162
163        return (int)$stmt->fetchColumn() > 0;
164    }
165
166    public function storeRefreshToken(
167        int $userId,
168        string $refreshToken,
169        DateTimeImmutable $expiresAt,
170        ?string $ipAddress = null,
171        ?string $userAgent = null,
172    ): int {
173        $stmt = $this->pdo->prepare(
174            'INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent, created_at)
175             VALUES (:user_id, :refresh_token, :expires_at, :ip_address, :user_agent, CURRENT_TIMESTAMP)
176             RETURNING session_id',
177        );
178
179        if ($stmt === false) {
180            throw new RuntimeException('Failed to prepare statement');
181        }
182        $stmt->execute([
183            'user_id' => $userId,
184            'refresh_token' => $refreshToken,
185            'expires_at' => $expiresAt->format('Y-m-d H:i:sP'),
186            'ip_address' => $ipAddress,
187            'user_agent' => $userAgent,
188        ]);
189
190        return (int)$stmt->fetchColumn();
191    }
192
193    /**
194     * @param string $refreshToken
195     * @return array<string, mixed>|null
196     */
197    public function findSessionByRefreshToken(string $refreshToken): ?array
198    {
199        $stmt = $this->pdo->prepare(
200            'SELECT session_id as "sessionId",
201                    user_id as "userId",
202                    refresh_token as "refreshToken",
203                    expires_at as "expiresAt",
204                    ip_address as "ipAddress",
205                    user_agent as "userAgent",
206                    created_at as "createdAt"
207             FROM user_sessions
208             WHERE refresh_token = :refresh_token
209             AND expires_at > CURRENT_TIMESTAMP',
210        );
211        if ($stmt === false) {
212            throw new RuntimeException('Failed to prepare statement');
213        }
214
215        $stmt->execute(['refresh_token' => $refreshToken]);
216        $row = $stmt->fetch();
217
218        return $row !== false ? $row : null;
219    }
220
221    public function revokeRefreshToken(string $refreshToken): bool
222    {
223        $stmt = $this->pdo->prepare(
224            'DELETE FROM user_sessions WHERE refresh_token = :refresh_token',
225        );
226
227        if ($stmt === false) {
228            throw new RuntimeException('Failed to prepare statement');
229        }
230        $stmt->execute(['refresh_token' => $refreshToken]);
231
232        return $stmt->rowCount() > 0;
233    }
234
235    public function revokeAllUserTokens(int $userId): int
236    {
237        $stmt = $this->pdo->prepare(
238            'DELETE FROM user_sessions WHERE user_id = :user_id',
239        );
240
241        if ($stmt === false) {
242            throw new RuntimeException('Failed to prepare statement');
243        }
244        $stmt->execute(['user_id' => $userId]);
245
246        return $stmt->rowCount();
247    }
248
249    public function cleanupExpiredTokens(): int
250    {
251        $stmt = $this->pdo->prepare(
252            'DELETE FROM user_sessions WHERE expires_at < CURRENT_TIMESTAMP',
253        );
254
255        if ($stmt === false) {
256            throw new RuntimeException('Failed to prepare statement');
257        }
258        $stmt->execute();
259
260        return $stmt->rowCount();
261    }
262
263    public function updateLastLogin(int $userId): void
264    {
265        $stmt = $this->pdo->prepare(
266            'UPDATE users
267             SET last_login = CURRENT_TIMESTAMP,
268                 failed_login_attempts = 0,
269                 locked_until = NULL,
270                 updated_at = CURRENT_TIMESTAMP
271             WHERE user_id = :user_id',
272        );
273
274        if ($stmt === false) {
275            throw new RuntimeException('Failed to prepare statement');
276        }
277        $stmt->execute(['user_id' => $userId]);
278    }
279
280    public function incrementFailedLoginAttempts(int $userId): void
281    {
282        $stmt = $this->pdo->prepare(
283            'UPDATE users
284             SET failed_login_attempts = failed_login_attempts + 1,
285                 updated_at = CURRENT_TIMESTAMP
286             WHERE user_id = :user_id',
287        );
288
289        if ($stmt === false) {
290            throw new RuntimeException('Failed to prepare statement');
291        }
292        $stmt->execute(['user_id' => $userId]);
293    }
294
295    public function lockAccount(int $userId, DateTimeImmutable $lockedUntil): void
296    {
297        $stmt = $this->pdo->prepare(
298            'UPDATE users
299             SET locked_until = :locked_until,
300                 updated_at = CURRENT_TIMESTAMP
301             WHERE user_id = :user_id',
302        );
303
304        if ($stmt === false) {
305            throw new RuntimeException('Failed to prepare statement');
306        }
307        $stmt->execute([
308            'user_id' => $userId,
309            'locked_until' => $lockedUntil->format('Y-m-d H:i:sP'),
310        ]);
311    }
312
313    public function isAccountLocked(int $userId): bool
314    {
315        $stmt = $this->pdo->prepare(
316            'SELECT locked_until FROM users
317             WHERE user_id = :user_id
318             AND locked_until > CURRENT_TIMESTAMP',
319        );
320
321        if ($stmt === false) {
322            throw new RuntimeException('Failed to prepare statement');
323        }
324        $stmt->execute(['user_id' => $userId]);
325
326        return $stmt->fetchColumn() !== false;
327    }
328}