Lines 90.62% 87 / 96
Methods 62.50% 5 / 8
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 3 / 3 100.00% 1 / 1 1
 requestReset 87.87% 29 / 33 0.00% 0 / 1 10.18
 reset 87.09% 27 / 31 0.00% 0 / 1 15.48
 json 100.00% 1 / 1 100.00% 1 / 1 1
 sendResetLinkEmail 100.00% 25 / 25 100.00% 1 / 1 1
 genericIssuanceResponse 100.00% 1 / 1 100.00% 1 / 1 1
 tooManyRequests 0.00% 0 / 1 0.00% 0 / 1 2
 clientIp 100.00% 1 / 1 100.00% 1 / 1 1
47final class UnauthorizedUserController
48{
49    private const int ISSUE_LIMIT_PER_IP = 5;
50    private const int ISSUE_LIMIT_INTERVAL = 900;
51    private const int ISSUE_LIMIT_PER_LOGIN = 3;
52    private const int ISSUE_LIMIT_PER_LOGIN_INTERVAL = 3600;
53    private const int VERIFY_LIMIT_PER_IP = 10;
54    private const int VERIFY_LIMIT_INTERVAL = 900;
55    private const int RESET_TOKEN_LIFETIME_SECONDS = 3600;
56    private const int MIN_PASSWORD_LENGTH = 8;
57
58    private readonly Configuration $configuration;
59    private readonly PasswordResetTokenService $tokenService;
60    private readonly RateLimiter $rateLimiter;
61
62    /**
63     * @param ?Closure(Configuration): CurrentUser $currentUserFactory
64     * @param ?Closure(Configuration): Mail $mailFactory
65     */
66    public function __construct(
67        private readonly ?Closure $currentUserFactory = null,
68        private readonly ?Closure $mailFactory = null,
69        ?PasswordResetTokenService $tokenService = null,
70        ?RateLimiter $rateLimiter = null,
71        ?Configuration $configuration = null,
72    ) {
73        $this->configuration = $configuration ?? Configuration::getConfigurationInstance();
74        $this->tokenService = $tokenService ?? new PasswordResetTokenService();
75        $this->rateLimiter = $rateLimiter ?? new RateLimiter();
76    }
77
78    /**
79     * Request a password reset email. Always returns a generic success response
80     * regardless of whether the username/email exists, to prevent enumeration.
81     *
82     * @throws Exception
83     */
84    #[Route(path: 'user/password/update', name: 'api.private.user.password', methods: ['PUT'])]
85    public function requestReset(Request $request): JsonResponse
86    {
87        if (!$this->rateLimiter->check(
88            'pwreset:issue:ip:' . $this->clientIp($request),
89            self::ISSUE_LIMIT_PER_IP,
90            self::ISSUE_LIMIT_INTERVAL,
91        )) {
92            return $this->tooManyRequests();
93        }
94
95        $data = json_decode($request->getContent());
96        if (!is_object($data)) {
97            return $this->genericIssuanceResponse();
98        }
99
100        $username = trim((string) Filter::filterVar($data->username ?? '', FILTER_SANITIZE_SPECIAL_CHARS));
101        $email = trim((string) Filter::filterEmail($data->email ?? ''));
102
103        if ($username === '' || $email === '') {
104            return $this->genericIssuanceResponse();
105        }
106
107        if (!$this->rateLimiter->check(
108            'pwreset:issue:user:' . hash('sha256', $username),
109            self::ISSUE_LIMIT_PER_LOGIN,
110            self::ISSUE_LIMIT_PER_LOGIN_INTERVAL,
111        )) {
112            return $this->genericIssuanceResponse();
113        }
114
115        $user = ($this->currentUserFactory ?? CurrentUser::getCurrentUser(...))($this->configuration);
116        $loginExists = $user->getUserByLogin($username, false);
117
118        if (!$loginExists) {
119            return $this->genericIssuanceResponse();
120        }
121
122        if (!hash_equals((string) $user->getUserData('email'), $email)) {
123            return $this->genericIssuanceResponse();
124        }
125
126        $passwordKey = $user->getEncryptedPassword();
127        if ($passwordKey === '') {
128            return $this->genericIssuanceResponse();
129        }
130
131        $token = $this->tokenService->issue($user->getUserId(), $passwordKey, self::RESET_TOKEN_LIFETIME_SECONDS);
132
133        try {
134            $this->sendResetLinkEmail($email, $username, $token);
135        } catch (Exception|TransportExceptionInterface $exception) {
136            // Swallow delivery errors so we do not leak account existence via timing or error.
137            error_log('phpMyFAQ password reset email failed: ' . $exception->getMessage());
138        }
139
140        return $this->genericIssuanceResponse();
141    }
142
143    /**
144     * Consume a signed reset link and update the password.
145     *
146     * @throws Exception
147     */
148    #[Route(path: 'user/password/reset', name: 'api.private.user.password.reset', methods: ['POST'])]
149    public function reset(Request $request): JsonResponse
150    {
151        if (!$this->rateLimiter->check(
152            'pwreset:verify:ip:' . $this->clientIp($request),
153            self::VERIFY_LIMIT_PER_IP,
154            self::VERIFY_LIMIT_INTERVAL,
155        )) {
156            return $this->tooManyRequests();
157        }
158
159        $data = json_decode($request->getContent());
160        if (!is_object($data)) {
161            return $this->json(['error' => Translation::get('resetpwd_err_invalid')], Response::HTTP_BAD_REQUEST);
162        }
163
164        $userId = (int) Filter::filterVar($data->u ?? null, FILTER_VALIDATE_INT);
165        $expires = (int) Filter::filterVar($data->exp ?? null, FILTER_VALIDATE_INT);
166        $signature = (string) Filter::filterVar($data->sig ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
167        $newPassword = is_string($data->password ?? null) ? $data->password : '';
168        $repeatPassword = is_string($data->password_repeat ?? null) ? $data->password_repeat : '';
169
170        if ($userId <= 0 || $expires <= 0 || $signature === '') {
171            return $this->json(['error' => Translation::get('resetpwd_err_invalid')], Response::HTTP_BAD_REQUEST);
172        }
173
174        if (strlen($newPassword) < self::MIN_PASSWORD_LENGTH || strlen($repeatPassword) < self::MIN_PASSWORD_LENGTH) {
175            return $this->json(['error' => Translation::get('msgPasswordTooShort')], Response::HTTP_BAD_REQUEST);
176        }
177
178        if (!hash_equals($newPassword, $repeatPassword)) {
179            return $this->json(['error' => Translation::get('ad_passwd_fail')], Response::HTTP_BAD_REQUEST);
180        }
181
182        $user = ($this->currentUserFactory ?? CurrentUser::getCurrentUser(...))($this->configuration);
183        if (!$user->getUserById($userId, true)) {
184            return $this->json(['error' => Translation::get('resetpwd_err_invalid')], Response::HTTP_BAD_REQUEST);
185        }
186
187        $passwordKey = $user->getEncryptedPassword();
188        if ($passwordKey === '') {
189            return $this->json(['error' => Translation::get('resetpwd_err_invalid')], Response::HTTP_BAD_REQUEST);
190        }
191
192        if (!$this->tokenService->verify($userId, $expires, $signature, $passwordKey)) {
193            return $this->json(['error' => Translation::get('resetpwd_err_invalid')], Response::HTTP_BAD_REQUEST);
194        }
195
196        if (!$user->changePassword($newPassword)) {
197            return $this->json(['error' => Translation::get('ad_passwd_fail')], Response::HTTP_BAD_REQUEST);
198        }
199
200        return $this->json(['success' => Translation::get('resetpwd_success')], Response::HTTP_OK);
201    }
202
203    /**
204     * Returns a JsonResponse that uses json_encode().
205     *
206     * @param string[] $headers
207     */
208    public function json(mixed $data, int $status = 200, array $headers = []): JsonResponse
209    {
210        return new JsonResponse($data, $status, $headers);
211    }
212
213    /**
214     * @param array{userId: int, expires: int, signature: string} $token
215     * @throws Exception|TransportExceptionInterface
216     */
217    private function sendResetLinkEmail(string $email, string $username, #[\SensitiveParameter] array $token): void
218    {
219        $baseUrl = rtrim((string) $this->configuration->getDefaultUrl(), characters: '/');
220        $link = sprintf(
221            '%s/user/reset-password?u=%d&exp=%d&sig=%s',
222            $baseUrl,
223            $token['userId'],
224            $token['expires'],
225            rawurlencode($token['signature']),
226        );
227
228        $message =
229            Translation::getString('lostpwd_text_1')
230            . "\r\n\r\n"
231            . Translation::getString('resetpwd_text_link')
232            . "\r\n"
233            . $link
234            . "\r\n\r\n"
235            . Translation::getString('resetpwd_text_expiry')
236            . "\r\n\r\nUsername: "
237            . $username;
238
239        $mail = ($this->mailFactory
240        ?? static fn(Configuration $configuration): Mail => new Mail($configuration))($this->configuration);
241        $mail->addTo($email);
242        $mail->subject = Utils::resolveMarkers('[%sitename%] Password reset request', $this->configuration);
243        $mail->message = $message;
244        $mail->send();
245        unset($mail);
246    }
247
248    private function genericIssuanceResponse(): JsonResponse
249    {
250        return $this->json(['success' => Translation::get('lostpwd_mail_okay')], Response::HTTP_OK);
251    }
252
253    private function tooManyRequests(): JsonResponse
254    {
255        return $this->json(['error' => 'Too many requests. Please retry later.'], Response::HTTP_TOO_MANY_REQUESTS);
256    }
257
258    private function clientIp(Request $request): string
259    {
260        return $request->getClientIp() ?? 'anonymous';
261    }
262}