Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.74% covered (success)
94.74%
18 / 19
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordResetTokenService
94.74% covered (success)
94.74%
18 / 19
66.67% covered (warning)
66.67%
2 / 3
11.02
0.00% covered (danger)
0.00%
0 / 1
 issue
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 verify
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 sign
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Issues and verifies signed password reset tokens.
5 *
6 * The token is an HMAC-SHA256 over "{userId}|{expires}" keyed by the user's
7 * current encrypted password. Because the key changes the moment the password
8 * is updated, every previously issued token is implicitly invalidated. This
9 * gives single-use semantics without any additional state.
10 *
11 * This Source Code Form is subject to the terms of the Mozilla Public License,
12 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
13 * obtain one at https://mozilla.org/MPL/2.0/.
14 *
15 * @package   phpMyFAQ
16 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
17 * @copyright 2026 phpMyFAQ Team
18 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
19 * @link      https://www.phpmyfaq.de
20 * @since     2026-05-10
21 */
22
23declare(strict_types=1);
24
25namespace phpMyFAQ\User;
26
27final class PasswordResetTokenService
28{
29    public const int DEFAULT_LIFETIME_SECONDS = 3600;
30
31    public const int MAX_LIFETIME_SECONDS = 86_400;
32
33    /**
34     * @return array{userId: int, expires: int, signature: string}
35     */
36    public function issue(int $userId, string $passwordKey, ?int $lifetimeSeconds = null): array
37    {
38        $lifetime = $lifetimeSeconds ?? self::DEFAULT_LIFETIME_SECONDS;
39        if ($lifetime < 60 || $lifetime > self::MAX_LIFETIME_SECONDS) {
40            $lifetime = self::DEFAULT_LIFETIME_SECONDS;
41        }
42
43        $expires = time() + $lifetime;
44
45        return [
46            'userId' => $userId,
47            'expires' => $expires,
48            'signature' => $this->sign($userId, $expires, $passwordKey),
49        ];
50    }
51
52    public function verify(int $userId, int $expires, string $signature, string $passwordKey): bool
53    {
54        if ($userId <= 0 || $expires <= 0 || $signature === '' || $passwordKey === '') {
55            return false;
56        }
57
58        $now = time();
59        if ($expires < $now) {
60            return false;
61        }
62
63        if ($expires > ($now + self::MAX_LIFETIME_SECONDS)) {
64            return false;
65        }
66
67        $expected = $this->sign($userId, $expires, $passwordKey);
68
69        return hash_equals($expected, $signature);
70    }
71
72    private function sign(int $userId, int $expires, string $passwordKey): string
73    {
74        return hash_hmac('sha256', $userId . '|' . $expires, $passwordKey);
75    }
76}