Lines 94.73% 18 / 19
Methods 66.66% 2 / 3
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 issue 88.88% 8 / 9 0.00% 0 / 1 3.01
 verify 100.00% 9 / 9 100.00% 1 / 1 7
 sign 100.00% 1 / 1 100.00% 1 / 1 1
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}