Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.62% covered (success)
90.62%
87 / 96
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
UnauthorizedUserController
90.62% covered (success)
90.62%
87 / 96
62.50% covered (warning)
62.50%
5 / 8
31.79
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
1
 requestReset
87.88% covered (success)
87.88%
29 / 33
0.00% covered (danger)
0.00%
0 / 1
10.18
 reset
87.10% covered (success)
87.10%
27 / 31
0.00% covered (danger)
0.00%
0 / 1
15.48
 json
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendResetLinkEmail
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 genericIssuanceResponse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tooManyRequests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 clientIp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Public, unauthenticated user endpoints (password reset).
5 *
6 * Exposes two endpoints:
7 *
8 *  - PUT  /api/user/password/update  -> request a reset link by email.
9 *  - POST /api/user/password/reset   -> consume a signed reset link and set a new password.
10 *
11 * The issuance endpoint always returns the same generic response to defeat
12 * username/email enumeration. Both endpoints are rate-limited per client IP
13 * on top of the global API rate limiter.
14 *
15 * This Source Code Form is subject to the terms of the Mozilla Public License,
16 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
17 * obtain one at https://mozilla.org/MPL/2.0/.
18 *
19 * @package   phpMyFAQ
20 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
21 * @copyright 2024-2026 phpMyFAQ Team
22 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
23 * @link      https://www.phpmyfaq.de
24 * @since     2024-07-08
25 */
26
27declare(strict_types=1);
28
29namespace phpMyFAQ\Controller\Frontend\Api;
30
31use Closure;
32use phpMyFAQ\Configuration;
33use phpMyFAQ\Core\Exception;
34use phpMyFAQ\Filter;
35use phpMyFAQ\Http\RateLimiter;
36use phpMyFAQ\Mail;
37use phpMyFAQ\Translation;
38use phpMyFAQ\User\CurrentUser;
39use phpMyFAQ\User\PasswordResetTokenService;
40use phpMyFAQ\Utils;
41use Symfony\Component\HttpFoundation\JsonResponse;
42use Symfony\Component\HttpFoundation\Request;
43use Symfony\Component\HttpFoundation\Response;
44use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
45use Symfony\Component\Routing\Attribute\Route;
46
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}