Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.12% covered (success)
94.12%
16 / 17
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordService
94.12% covered (success)
94.12%
16 / 17
80.00% covered (warning)
80.00%
4 / 5
10.02
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateStrength
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 hashPassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verifyPassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 needsRehash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Auth\Service;
6
7use App\Domain\Exception\ValidationException;
8
9use function preg_match;
10use function strlen;
11
12final class PasswordService
13{
14    private const int MIN_LENGTH = 8;
15
16    /** Production bcrypt work factor. */
17    private const int DEFAULT_COST = 12;
18
19    /** Reduced work factor used only by the automated test suite. */
20    private const int TEST_COST = 4;
21
22    private readonly int $cost;
23
24    /**
25     * @param int|null $cost Explicit bcrypt cost. Leave null for the default:
26     *   production strength (12) everywhere, except under the PHPUnit suite —
27     *   which sets PHPUNIT_TEST_SUITE in phpunit.xml, and nothing else ever
28     *   does, so a deployed server can never hit this branch — where we drop to
29     *   cost 4 so the hundreds of register/login/hash operations across the
30     *   suite stop dominating the run. Pass a value to override (e.g. the unit
31     *   test that asserts the production cost).
32     */
33    public function __construct(?int $cost = null)
34    {
35        $this->cost = $cost ?? (getenv('PHPUNIT_TEST_SUITE') !== false
36            ? self::TEST_COST
37            : self::DEFAULT_COST);
38    }
39
40    /**
41     * Validate password strength against the policy ResetPasswordAction
42     * has enforced since 2026-04: ≥8 chars, at least one upper, one lower,
43     * one digit. Centralized here so ChangePassword (FSC-105) and any
44     * future password-set flow apply the same rules.
45     *
46     * @param string $password
47     * @throws ValidationException when the password fails any rule.
48     */
49    public function validateStrength(string $password): void
50    {
51        if (strlen($password) < self::MIN_LENGTH) {
52            throw new ValidationException(sprintf(
53                'Password must be at least %d characters',
54                self::MIN_LENGTH,
55            ));
56        }
57        if (!preg_match('/[A-Z]/', $password)) {
58            throw new ValidationException('Password must contain at least one uppercase letter');
59        }
60        if (!preg_match('/[a-z]/', $password)) {
61            throw new ValidationException('Password must contain at least one lowercase letter');
62        }
63        if (!preg_match('/[0-9]/', $password)) {
64            throw new ValidationException('Password must contain at least one number');
65        }
66    }
67
68    /**
69     * Hash a password using bcrypt.
70     *
71     * @param string $password
72     */
73    public function hashPassword(string $password): string
74    {
75        return password_hash($password, PASSWORD_BCRYPT, ['cost' => $this->cost]);
76    }
77
78    /**
79     * Verify a password against a hash.
80     *
81     * @param string $password
82     * @param string $hash
83     */
84    public function verifyPassword(string $password, string $hash): bool
85    {
86        return password_verify($password, $hash);
87    }
88
89    /**
90     * Check if password needs rehashing.
91     *
92     * @param string $hash
93     */
94    public function needsRehash(string $hash): bool
95    {
96        return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => $this->cost]);
97    }
98}