Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.62% |
87 / 96 |
|
62.50% |
5 / 8 |
CRAP | |
0.00% |
0 / 1 |
| UnauthorizedUserController | |
90.62% |
87 / 96 |
|
62.50% |
5 / 8 |
31.79 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| requestReset | |
87.88% |
29 / 33 |
|
0.00% |
0 / 1 |
10.18 | |||
| reset | |
87.10% |
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 | |||
| 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 | |
| 27 | declare(strict_types=1); |
| 28 | |
| 29 | namespace phpMyFAQ\Controller\Frontend\Api; |
| 30 | |
| 31 | use Closure; |
| 32 | use phpMyFAQ\Configuration; |
| 33 | use phpMyFAQ\Core\Exception; |
| 34 | use phpMyFAQ\Filter; |
| 35 | use phpMyFAQ\Http\RateLimiter; |
| 36 | use phpMyFAQ\Mail; |
| 37 | use phpMyFAQ\Translation; |
| 38 | use phpMyFAQ\User\CurrentUser; |
| 39 | use phpMyFAQ\User\PasswordResetTokenService; |
| 40 | use phpMyFAQ\Utils; |
| 41 | use Symfony\Component\HttpFoundation\JsonResponse; |
| 42 | use Symfony\Component\HttpFoundation\Request; |
| 43 | use Symfony\Component\HttpFoundation\Response; |
| 44 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; |
| 45 | use Symfony\Component\Routing\Attribute\Route; |
| 46 | |
| 47 | final 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 | } |