Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.60% covered (warning)
89.60%
181 / 202
30.77% covered (danger)
30.77%
4 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordResetService
89.55% covered (warning)
89.55%
180 / 201
30.77% covered (danger)
30.77%
4 / 13
53.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 requestPasswordReset
91.49% covered (success)
91.49%
43 / 47
0.00% covered (danger)
0.00%
0 / 1
7.03
 validateToken
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 checkTokenRecordValidity
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 completePasswordReset
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 executePasswordReset
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 sendPasswordChangedNotification
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 validatePasswordStrength
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 checkPasswordContextRestrictions
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 checkTrivialPasswords
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 insertResetToken
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 findTokenRecord
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 updateUserPasswordAndInvalidate
78.57% covered (warning)
78.57%
22 / 28
0.00% covered (danger)
0.00%
0 / 1
9.80
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\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Password\CompromisedPasswordValidatorInterface;
12use App\Core\Security\Password\HibpCompromisedPasswordValidator;
13use App\Core\Time\NtpTimeSyncService;
14use App\Core\Time\NtpTimeSyncServiceInterface;
15use App\Modules\Mail\Application\Service\SystemMailSenderService;
16use App\Modules\Mail\Application\Service\SystemMailSenderServiceInterface;
17use App\Modules\User\Domain\Repository\UserRepositoryInterface;
18use App\Modules\User\Infrastructure\Repository\SqlUserRepository;
19use DateInterval;
20use DateTimeImmutable;
21use DateTimeInterface;
22use PDO;
23use Throwable;
24
25/**
26 * Enterprise Password Reset Service compliant with OWASP ASVS 5.0 and NIST SP 800-63B.
27 *
28 * Implements anti-user-enumeration, SHA-256 token hashing, NTP-adjusted 15-minute expiration,
29 * active session termination upon reset, and localized transactional email dispatch.
30 *
31 * @package App\Modules\User\Application\Service
32 */
33final readonly class PasswordResetService implements PasswordResetServiceInterface
34{
35    /** @var int Strict token lifetime in minutes (OWASP ASVS standard). */
36    public const TOKEN_LIFETIME_MINUTES = 15;
37
38    /** @var int Minimum password character length according to NIST SP 800-63B. */
39    public const MIN_PASSWORD_LENGTH = 12;
40
41    /** @var string Generic anti-enumeration success message. */
42    public const GENERIC_SUCCESS_MESSAGE = 'If the provided username or email exists, password reset ' .
43        'instructions have been sent to the associated email address.';
44
45    private const string DATETIME_FORMAT_WITH_MICROS = 'Y-m-d H:i:s.u';
46
47    private NtpTimeSyncServiceInterface $timeService;
48    private SystemMailSenderServiceInterface $mailSender;
49    private string $tableResets;
50    private string $tableUsers;
51    private string $tableSessions;
52    private CompromisedPasswordValidatorInterface $compromisedValidator;
53
54    /**
55     * PasswordResetService constructor.
56     *
57     * @param PDO                                        $pdo                  Database PDO instance.
58     * @param UserRepositoryInterface                    $userRepository        User repository.
59     * @param NtpTimeSyncServiceInterface|null           $timeService          Optional NTP time sync service.
60     * @param SystemMailSenderServiceInterface|null      $mailSender           Optional system mail sender.
61     * @param string                                     $tablePrefix          Database table prefix (default 'a_').
62     * @param CompromisedPasswordValidatorInterface|null $compromisedValidator Optional compromised password validator.
63     */
64    public function __construct(
65        private PDO $pdo,
66        private UserRepositoryInterface $userRepository,
67        ?NtpTimeSyncServiceInterface $timeService = null,
68        ?SystemMailSenderServiceInterface $mailSender = null,
69        private string $tablePrefix = 'a_',
70        ?CompromisedPasswordValidatorInterface $compromisedValidator = null
71    ) {
72        $this->timeService = $timeService ?? new NtpTimeSyncService($pdo);
73        $this->mailSender = $mailSender ?? new SystemMailSenderService($pdo);
74        $this->compromisedValidator = $compromisedValidator ?? new HibpCompromisedPasswordValidator();
75        $this->tableResets = $this->tablePrefix . 'mod_user_password_resets_records';
76        $this->tableUsers = $this->tablePrefix . 'mod_users_records';
77        $this->tableSessions = $this->tablePrefix . 'mod_user_sessions';
78    }
79
80    /** {@inheritdoc} */
81    public function requestPasswordReset(
82        string $loginOrEmail,
83        string $ipAddress,
84        string $userAgent,
85        string $appBaseUrl,
86        string $requestLocale = 'pl'
87    ): array {
88        $cleanInput = trim($loginOrEmail);
89        $genericMessage = self::GENERIC_SUCCESS_MESSAGE;
90
91        if ($cleanInput === '') {
92            return ['success' => true, 'message' => $genericMessage];
93        }
94
95        $user = $this->userRepository->findByUsernameOrEmail($cleanInput);
96        $userType = 'administrator';
97        if ($user === null) {
98            try {
99                $clientRepo = new SqlUserRepository($this->pdo, 'c_');
100                $clientUser = $clientRepo->findByUsernameOrEmail($cleanInput);
101                if ($clientUser !== null) {
102                    $user = $clientUser;
103                    $userType = 'user';
104                }
105            } catch (Throwable) {
106                // Table might not exist
107            }
108        }
109
110        if ($user === null || !$user->isActive()) {
111            return ['success' => true, 'message' => $genericMessage];
112        }
113
114        $rawToken = bin2hex(random_bytes(32)); // 256 bits entropy
115        $tokenHash = hash('sha256', $rawToken);
116
117        $now = $this->timeService->getAdjustedDateTime();
118        $expiresAt = $now->add(new DateInterval('PT' . self::TOKEN_LIFETIME_MINUTES . 'M'));
119
120        $this->insertResetToken(
121            userId: (int) $user->getId(),
122            tokenHash: $tokenHash,
123            expiresAt: $expiresAt,
124            ipAddress: $ipAddress,
125            userAgent: substr(strip_tags($userAgent), 0, 512),
126            createdAt: $now,
127            userType: $userType
128        );
129
130        $recipientEmail = $user->getEmail();
131        $recipientName = $user->getUsername();
132        $mailLocale = $user->getLocale();
133        $resetUrl = rtrim($appBaseUrl, '/') . '/reset-password?token=' . urlencode($rawToken);
134
135        $this->mailSender->sendTemplateEmail(
136            recipientEmail: $recipientEmail,
137            recipientName: $recipientName,
138            templateCode: 'PASSWORD_RESET',
139            variables: [
140                'app_name'               => 'Ammonly',
141                'user_name'              => $recipientName,
142                'reset_url'              => $resetUrl,
143                'token_lifetime_minutes' => (string) self::TOKEN_LIFETIME_MINUTES,
144                'expires_at'             => $expiresAt->format('Y-m-d H:i:s'),
145                'ip_address'             => $ipAddress,
146            ],
147            locale: $mailLocale
148        );
149
150        return ['success' => true, 'message' => $genericMessage];
151    }
152
153    /** {@inheritdoc} */
154    public function validateToken(string $rawToken): array
155    {
156        $clean = trim($rawToken);
157        if ($clean === '' || strlen($clean) < 32) {
158            return ['valid' => false, 'error' => 'Invalid or missing password reset token.'];
159        }
160
161        $record = $this->findTokenRecord(hash('sha256', $clean));
162        $error = $this->checkTokenRecordValidity($record);
163        if ($error !== null) {
164            return ['valid' => false, 'error' => $error];
165        }
166
167        return [
168            'valid'     => true,
169            'user_id'   => (int) $record['user_id'],
170            'user_type' => (string) ($record['user_type'] ?? 'administrator'),
171            'email'     => (string) ($record['email'] ?? ''),
172            'username'  => (string) ($record['username'] ?? ''),
173        ];
174    }
175
176    /**
177     * @param array<string, mixed>|null $record
178     */
179    private function checkTokenRecordValidity(?array $record): ?string
180    {
181        if ($record === null) {
182            return 'Password reset token not found or already invalidated.';
183        }
184        if ($record['used_at'] !== null) {
185            return 'This password reset token has already been consumed.';
186        }
187
188        $expiresAt = new DateTimeImmutable((string) $record['expires_at']);
189        return $this->timeService->isTokenExpired($expiresAt)
190            ? 'Password reset token has expired (15-minute validity window).'
191            : null;
192    }
193
194    /** {@inheritdoc} */
195    public function completePasswordReset(
196        string $rawToken,
197        string $newPassword,
198        string $ipAddress,
199        string $userAgent
200    ): array {
201        $validation = $this->validateToken($rawToken);
202        if (!$validation['valid']) {
203            return ['success' => false, 'message' => $validation['error'] ?? 'Invalid token.'];
204        }
205
206        $userType = (string) ($validation['user_type'] ?? 'administrator');
207        $repo = ($userType === 'user')
208            ? new SqlUserRepository($this->pdo, 'c_')
209            : $this->userRepository;
210
211        $user = $repo->findById((int) $validation['user_id']);
212        if ($user === null) {
213            return ['success' => false, 'message' => 'Associated user account not found.'];
214        }
215
216        return $this->executePasswordReset($user, $rawToken, $newPassword, $ipAddress, $userType);
217    }
218
219    private function executePasswordReset(
220        \App\Modules\User\Domain\Model\User $user,
221        string $rawToken,
222        string $newPassword,
223        string $ipAddress,
224        string $userType = 'administrator'
225    ): array {
226        $strengthError = $this->validatePasswordStrength($newPassword, $user->getUsername(), $user->getEmail());
227        if ($strengthError !== null) {
228            return ['success' => false, 'message' => $strengthError];
229        }
230
231        $userId = (int) $user->getId();
232        $tokenHash = hash('sha256', trim($rawToken));
233        $now = $this->timeService->getAdjustedDateTime();
234
235        $this->updateUserPasswordAndInvalidate(
236            userId: $userId,
237            newPassword: $newPassword,
238            tokenHash: $tokenHash,
239            consumedAt: $now,
240            userType: $userType
241        );
242
243        $this->sendPasswordChangedNotification($user, $ipAddress, $now);
244
245        $successMsg = 'Your password has been successfully updated. Please log in with your new password.';
246
247        return ['success' => true, 'message' => $successMsg];
248    }
249
250    private function sendPasswordChangedNotification(
251        \App\Modules\User\Domain\Model\User $user,
252        string $ipAddress,
253        DateTimeInterface $now
254    ): void {
255        $this->mailSender->sendTemplateEmail(
256            recipientEmail: $user->getEmail(),
257            recipientName: $user->getUsername(),
258            templateCode: 'PASSWORD_CHANGED',
259            variables: [
260                'app_name'   => 'Ammonly',
261                'user_name'  => $user->getUsername(),
262                'ip_address' => $ipAddress,
263                'changed_at' => $now->format('Y-m-d H:i:s'),
264            ],
265            locale: $user->getLocale()
266        );
267    }
268
269    /**
270     * Validates new password strength in accordance with NIST SP 800-63B.
271     *
272     * @param string $password Proposed password.
273     * @param string $username User login name.
274     * @param string $email User email.
275     * @return string|null Error message or null if compliant.
276     */
277    private function validatePasswordStrength(string $password, string $username, string $email): ?string
278    {
279        if (mb_strlen($password) < self::MIN_PASSWORD_LENGTH) {
280            return sprintf('Password must be at least %d characters long.', self::MIN_PASSWORD_LENGTH);
281        }
282
283        $contextError = $this->checkPasswordContextRestrictions($password, $username, $email);
284        if ($contextError !== null) {
285            return $contextError;
286        }
287
288        return $this->checkTrivialPasswords($password);
289    }
290
291    private function checkPasswordContextRestrictions(string $password, string $username, string $email): ?string
292    {
293        $lowerPwd = mb_strtolower($password);
294        $lowerUser = mb_strtolower($username);
295        $emailPrefix = mb_strtolower(explode('@', $email)[0] ?? '');
296
297        if ($lowerUser !== '' && str_contains($lowerPwd, $lowerUser)) {
298            return 'Password cannot contain your username.';
299        }
300
301        if ($emailPrefix !== '' && mb_strlen($emailPrefix) >= 3 && str_contains($lowerPwd, $emailPrefix)) {
302            return 'Password cannot contain parts of your email address.';
303        }
304
305        return null;
306    }
307
308    private function checkTrivialPasswords(string $password): ?string
309    {
310        $lowerPwd = mb_strtolower($password);
311        $trivial = ['password1234', 'haslo1234567', 'admin1234567', 'welcome12345'];
312        foreach ($trivial as $bad) {
313            if (str_contains($lowerPwd, $bad)) {
314                return 'Password is too predictable or easily guessable.';
315            }
316        }
317
318        if ($this->compromisedValidator->isCompromised($password)) {
319            return 'The chosen password has appeared in a data breach. Please choose a different password.';
320        }
321
322        return null;
323    }
324
325    /**
326     * Persists new hashed reset token record into database.
327     */
328    private function insertResetToken(
329        int $userId,
330        string $tokenHash,
331        DateTimeInterface $expiresAt,
332        string $ipAddress,
333        string $userAgent,
334        DateTimeInterface $createdAt,
335        string $userType = 'administrator'
336    ): void {
337        $sql = "INSERT INTO `{$this->tableResets}" .
338            '(`user_type`, `user_id`, `token_hash`, `expires_at`, `ip_address`, `user_agent`, ' .
339            '`special_access`, `created_at`, `updated_at`, `created_by`, `owner`) ' .
340            'VALUES (:user_type, :user_id, :token_hash, :expires_at, :ip_address, :user_agent, ' .
341            '0, :created_at, :updated_at, :created_by, :owner)';
342
343        $stmt = $this->pdo->prepare($sql);
344        $stmt->execute([
345            ':user_type'   => $userType,
346            ':user_id'     => $userId,
347            ':token_hash'  => $tokenHash,
348            ':expires_at'  => $expiresAt->format(self::DATETIME_FORMAT_WITH_MICROS),
349            ':ip_address'  => $ipAddress,
350            ':user_agent'  => $userAgent,
351            ':created_at'  => $createdAt->format(self::DATETIME_FORMAT_WITH_MICROS),
352            ':updated_at'  => $createdAt->format(self::DATETIME_FORMAT_WITH_MICROS),
353            ':created_by'  => $userId,
354            ':owner'       => $userId,
355        ]);
356    }
357
358    /**
359     * Looks up token record joined with user details.
360     *
361     * @param string $tokenHash SHA-256 hash.
362     * @return array<string, mixed>|null Record row or null.
363     */
364    private function findTokenRecord(string $tokenHash): ?array
365    {
366        $sql = 'SELECT `id`, `user_type`, `user_id`, `token_hash`, `expires_at`, `used_at` ' .
367            "FROM `{$this->tableResets}" .
368            'WHERE `token_hash` = :hash LIMIT 1';
369
370        $stmt = $this->pdo->prepare($sql);
371        $stmt->execute([':hash' => $tokenHash]);
372        $row = $stmt->fetch(PDO::FETCH_ASSOC);
373
374        if (!is_array($row)) {
375            return null;
376        }
377
378        $userType = (string) ($row['user_type'] ?? 'administrator');
379        $userId = (int) $row['user_id'];
380        $userTable = ($userType === 'user') ? 'c_mod_users_records' : $this->tableUsers;
381
382        try {
383            $userStmt = $this->pdo->prepare(
384                "SELECT `username`, `email` FROM `{$userTable}` WHERE `id` = :id LIMIT 1"
385            );
386            $userStmt->execute([':id' => $userId]);
387            $userRow = $userStmt->fetch(PDO::FETCH_ASSOC);
388            if (is_array($userRow)) {
389                $row['username'] = $userRow['username'];
390                $row['email'] = $userRow['email'];
391            }
392        } catch (Throwable) {
393            // fallback
394        }
395
396        return $row;
397    }
398
399    /**
400     * Atomically persists new password hash, consumes reset token, and invalidates active sessions.
401     */
402    private function updateUserPasswordAndInvalidate(
403        int $userId,
404        string $newPassword,
405        string $tokenHash,
406        DateTimeInterface $consumedAt,
407        string $userType = 'administrator'
408    ): void {
409        $argonSupported = defined('PASSWORD_ARGON2ID');
410        $algo = $argonSupported ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
411        $hashed = password_hash($newPassword, $algo);
412
413        $targetUserTable = ($userType === 'user') ? 'c_mod_users_records' : $this->tableUsers;
414
415        $ownsTransaction = !$this->pdo->inTransaction();
416        if ($ownsTransaction) {
417            $this->pdo->beginTransaction();
418        }
419        try {
420            $updUser = $this->pdo->prepare(
421                "UPDATE `{$targetUserTable}` SET `password_hash` = :hash WHERE `id` = :id"
422            );
423            $updUser->execute([':hash' => $hashed, ':id' => $userId]);
424
425            $updToken = $this->pdo->prepare(
426                "UPDATE `{$this->tableResets}` SET `used_at` = :consumed WHERE `token_hash` = :th"
427            );
428            $updToken->execute([
429                ':consumed' => $consumedAt->format(self::DATETIME_FORMAT_WITH_MICROS),
430                ':th'       => $tokenHash,
431            ]);
432
433            // OWASP ASVS: Revoke all active sessions for this user across all browsers/devices
434            $delSessions = $this->pdo->prepare(
435                "DELETE FROM `{$this->tableSessions}` WHERE `user_id` = :uid AND `user_type` = :utype"
436            );
437            $delSessions->execute([':uid' => $userId, ':utype' => $userType]);
438
439            if ($ownsTransaction && $this->pdo->inTransaction()) {
440                $this->pdo->commit();
441            }
442        } catch (Throwable $e) {
443            if ($ownsTransaction && $this->pdo->inTransaction()) {
444                $this->pdo->rollBack();
445            }
446            throw $e;
447        }
448    }
449}