Lines
90.62%
87 / 96
Methods
62.50%
5 / 8
Classes
0.00%
0 / 1
| 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 | ||
| 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 | } |