PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.0
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
← All changes | app/Http/Controllers/AuthController.php +110 -57 2.3.02.4.0 View file →
@@ -56,11 +56,17 @@
56 56 * @param array $formData
57 57 */
58 58 do_action('fluent_support/before_signup_validation', $formData);
59 59
60 - $checkRecaptchaAvailability = $this->isRecaptchaApplicable('signup_form');
61 - if ($checkRecaptchaAvailability && !$formData['_email_verification_hash']) {
62 - $validateCaptcha = ReCaptchaHandler::validateRecaptcha($formData['g-recaptcha-response']);
60 + // the verification code is only submitted on the second step, after the customer has
61 + // already passed the captcha and received the email; that step validates the hash
62 + // against an issued record below, so the captcha can be skipped there. The first
63 + // submit (which sends the verification email) always has to pass the captcha.
64 + $isVerificationStep = !empty($formData['_email_verification_token']);
65 +
66 + $checkRecaptchaAvailability = ReCaptchaHandler::isRecaptchaApplicable('signup_form');
67 + if ($checkRecaptchaAvailability && !$isVerificationStep) {
68 + $validateCaptcha = ReCaptchaHandler::validateRecaptcha($formData['g-recaptcha-response'] ?? '', null, null, 'signup');
63 69 if (!$validateCaptcha) {
64 70 return $this->response([
65 71 'message' => __('Your recaptcha is not verified', 'fluent-support')
66 72 ], 422);
@@ -68,9 +74,9 @@
68 74 }
69 75
70 76 $this->validate($formData, $rules, $messages);
71 77
72 - if (empty($formData['_email_verification_token'])) {
78 + if (!$isVerificationStep) {
73 79 $tokenHtml = EmailVerificationHandler::sendSignupEmailVerificationHtml($formData);
74 80
75 81 return $this->response([
76 82 'verification_html' => $tokenHtml
@@ -76,9 +82,9 @@
76 82 'verification_html' => $tokenHtml
77 83 ]);
78 84 } else {
79 85 $token = $formData['_email_verification_token'];
80 - $verificationHash = $formData['_email_verification_hash'];
86 + $verificationHash = $formData['_email_verification_hash'] ?? '';
81 87
82 88 $logHashMeta = Meta::where('object_type', 'fs_login_hashes',)
83 89 ->where('key', $verificationHash)
84 90 ->first();
@@ -96,11 +102,31 @@
96 102 'message' => __('Please provide a valid verification code that was sent to your email address', 'fluent-support')
97 103 ], 422);
98 104 }
99 105
106 + // the code must still be unused and must not have been consumed by a prior request
107 + if (($logHash['status'] ?? '') !== 'issued') {
108 + wp_send_json([
109 + 'message' => __('Your verification code has already been used. Please try again', 'fluent-support')
110 + ], 422);
111 + }
112 +
113 + // records created before the email-binding fix (or any other legacy/malformed record)
114 + // have no bound email; treat them as invalid rather than proceeding with a null email
115 + if (empty($logHash['email'])) {
116 + wp_send_json([
117 + 'message' => __('Your verification code has expired. Please request a new one', 'fluent-support')
118 + ], 422);
119 + }
120 +
100 121 // check if it got expired or not
122 + // valid_till is written via gmdate() on a current_time('timestamp') basis (always
123 + // UTC-equivalent, since WP resets the runtime timezone to UTC on every request), so
124 + // it must be parsed back as UTC here - otherwise strtotime() would silently reinterpret
125 + // it under whatever timezone a plugin/theme may have switched to via
126 + // date_default_timezone_set() without restoring it, causing false expiry (or non-expiry)
101 127 $validTill = $logHash['valid_till'] ?? '';
102 - if (($logHash['used_count'] ?? 0) > 5 || ($validTill && strtotime($validTill) < current_time('timestamp'))) {
128 + if (($logHash['used_count'] ?? 0) > 5 || ($validTill && strtotime($validTill . ' UTC') < current_time('timestamp'))) {
103 129 wp_send_json([
104 130 'message' => __('Your verification code has been expired. Please try again', 'fluent-support')
105 131 ], 422);
106 132 }
@@ -116,14 +142,30 @@
116 142 'message' => __('Please provide a valid verification code that was sent to your email address', 'fluent-support')
117 143 ], 422);
118 144 }
119 145
120 - $logHash['used_count'] += 1;
121 - $logHash['status'] = 'used';
146 + // atomically consume the code: only succeeds if the record is still in the exact
147 + // state we just read, closing the race where two requests both pass the checks above
148 + $consumed = Meta::where('key', $logHash['login_hash'])
149 + ->where('object_type', 'fs_login_hashes')
150 + ->where('value', $logHashMeta->value)
151 + ->update([
152 + 'value' => maybe_serialize(array_merge($logHash, [
153 + 'used_count' => ($logHash['used_count'] ?? 0) + 1,
154 + 'status' => 'used',
155 + ]))
156 + ]);
122 157
123 - Meta::where('key', $logHash['login_hash'])->update([
124 - 'value' => maybe_serialize($logHash)
125 - ]);
158 + if (!$consumed) {
159 + wp_send_json([
160 + 'message' => __('Your verification code has already been used. Please try again', 'fluent-support')
161 + ], 422);
162 + }
163 +
164 + // the email is now server-verified for this code; ignore whatever the client
165 + // submitted and use the address the code was actually issued to, so the signup
166 + // can never be completed against a different (e.g. victim's) email address
167 + $formData['email'] = $logHash['email'];
126 168 }
127 169
128 170 /*
129 171 * Action After validate user signup validation success
@@ -190,11 +232,11 @@
190 232 }
191 233
192 234 $data = $request->all();
193 235
194 - $checkRecaptchaAvailability = $this->isRecaptchaApplicable('login_form');
236 + $checkRecaptchaAvailability = ReCaptchaHandler::isRecaptchaApplicable('login_form');
195 237 if ($checkRecaptchaAvailability) {
196 - $validateCaptcha = ReCaptchaHandler::validateRecaptcha($data['g-recaptcha-response']);
238 + $validateCaptcha = ReCaptchaHandler::validateRecaptcha($data['g-recaptcha-response'] ?? '', null, null, 'login');
197 239
198 240 if (!$validateCaptcha) {
199 241 return $this->response([
200 242 'message' => __('Your recaptcha is not verified', 'fluent-support')
@@ -245,9 +287,9 @@
245 287 ], 429);
246 288 }
247 289
248 290 if (!$user) {
249 - $user = new \WP_Error('authentication_failed', __('<strong>Error</strong>: Invalid username, email address or incorrect password.', 'fluent-support'));
291 + $user = new \WP_Error('authentication_failed', __('Invalid username, email address or incorrect password.', 'fluent-support'));
250 292
251 293 do_action('wp_login_failed', $email, $user);
252 294 $this->incrementLoginAttempts($ipKey);
253 295 $this->incrementLoginAttempts($accountKey);
@@ -259,8 +301,17 @@
259 301 }
260 302
261 303 $twoFactorEnabled = Helper::getBusinessSettings('enable_two_fa');
262 304 if ('yes' === $twoFactorEnabled) {
305 + if (!wp_check_password($password, $user->user_pass, $user->ID)) {
306 + $this->incrementLoginAttempts($ipKey);
307 + $this->incrementLoginAttempts($accountKey);
308 +
309 + return $this->response([
310 + 'message' => __('Invalid username, email address or incorrect password.', 'fluent-support')
311 + ], 403);
312 + }
313 +
263 314 (new TwoFaHandler)->maybe2FaRedirect($user);
264 315 }
265 316
266 317 if (apply_filters('fluent_support_use_native_login', true)) {
@@ -295,9 +346,9 @@
295 346 $this->incrementLoginAttempts($ipKey);
296 347 $this->incrementLoginAttempts($accountKey);
297 348
298 349 return $this->response([
299 - 'message' => __('<strong>Error</strong>: Invalid username, email address or incorrect password.', 'fluent-support')
350 + 'message' => __('Invalid username, email address or incorrect password.', 'fluent-support')
300 351 ], 403);
301 352 }
302 353
303 354 private function incrementLoginAttempts($rateLimitKey)
@@ -309,26 +360,8 @@
309 360 set_transient($rateLimitKey, $attempts + 1, 15 * MINUTE_IN_SECONDS);
310 361 }
311 362 }
312 363
313 - public function isRecaptchaApplicable($formName)
314 - {
315 - $reCaptchaSettingsData = Meta::where('object_type', '_fs_recaptcha_settings')->first();
316 - if(!isset($reCaptchaSettingsData->value)){
317 - return false;
318 - }
319 - $reCaptchaData = Helper::safeUnserialize($reCaptchaSettingsData->value, []);
320 - if(!isset($reCaptchaData['is_enabled']) || !isset($reCaptchaData['formContainingReCaptcha'])){
321 - return false;
322 - }
323 - $isEnabled = filter_var($reCaptchaData['is_enabled'], FILTER_VALIDATE_BOOLEAN);
324 - if (!$isEnabled) {
325 - return false;
326 - }
327 - $formContainingReCaptcha = $reCaptchaData['formContainingReCaptcha'];
328 - return 'yes' === $formContainingReCaptcha[$formName];
329 - }
330 -
331 364 private function nativeLoginHandler($user, $info, $redirectUrl = '')
332 365 {
333 366 if (!$redirectUrl) {
334 367 $redirectUrl = Helper::getPortalBaseUrl();
@@ -450,8 +483,18 @@
450 483 'message' => 'Username or email is required'
451 484 ]);
452 485 }
453 486
487 + // IP bucket is a generous volumetric backstop (shared office/NAT IPs can have many
488 + // unrelated users). It runs before the account lookup so that probes for accounts
489 + // that don't exist are throttled too. Keyed on the IP only, so a 429 here reveals
490 + // nothing about whether any given account exists.
491 + if (Helper::hitRateLimit('fs_reset_pass_ip_' . wp_hash(Helper::getIp()), 20)) {
492 + return $this->sendError([
493 + 'message' => __('Too many password reset requests. Please try again after 15 minutes.', 'fluent-support')
494 + ], 429);
495 + }
496 +
454 497 $user_data = get_user_by('email', $usernameOrEmail);
455 498
456 499 if (!$user_data) {
457 500 $user_data = get_user_by('login', $usernameOrEmail);
@@ -457,11 +500,9 @@
457 500 $user_data = get_user_by('login', $usernameOrEmail);
458 501 }
459 502
460 503 if (!$user_data) {
461 - return $this->sendError([
462 - 'message' => __('Invalid username or email', 'fluent-support')
463 - ]);
504 + return $this->sendResetPassResponse();
464 505 }
465 506
466 507 $user_data = apply_filters('lostpassword_user_data', $user_data, $errors);
467 508
@@ -469,24 +510,17 @@
469 510
470 511 $errors = apply_filters('lostpassword_errors', $errors, $user_data);
471 512
472 513 if ($errors->has_errors()) {
473 - return $this->sendError([
474 - 'message' => $errors->get_error_message()
475 - ]);
514 + return $this->sendResetPassResponse();
476 515 }
477 516
478 517 if (!$user_data) {
479 - return $this->sendError([
480 - 'message' => __('<strong>Error</strong>: There is no account with that username or email address.', 'fluent-support')
481 - ]);
518 + return $this->sendResetPassResponse();
482 519 }
483 520
484 521 if (is_multisite() && !is_user_member_of_blog($user_data->ID, get_current_blog_id())) {
485 -
486 - return $this->sendError([
487 - 'message' => __('<strong>Error</strong>: Invalid username or email', 'fluent-support')
488 - ]);
522 + return $this->sendResetPassResponse();
489 523 }
490 524
491 525 // Redefining user_login ensures we return the right case in the email.
492 526 $user_login = $user_data->user_login;
@@ -494,21 +528,13 @@
494 528 do_action('retrieve_password', $user_login);
495 529
496 530 $allow = apply_filters('allow_password_reset', true, $user_data->ID);
497 531
498 - if (!$allow) {
499 - return $this->sendError([
500 - 'message' => __('Password reset is not allowed for this user', 'fluent-support')
501 - ]);
532 + if (!$allow || is_wp_error($allow)) {
533 + return $this->sendResetPassResponse();
502 534 }
503 535
504 - if (is_wp_error($allow)) {
505 - return $this->sendError([
506 - 'message' => $allow->get_error_message()
507 - ]);
508 - }
509 536
510 -
511 537 /*
512 538 * Filter reset password link text
513 539 *
514 540 * @since v1.5.7
@@ -516,8 +542,21 @@
516 542 */
517 543 // translators: %s is the site name
518 544 $linkText = apply_filters("fluent_support/reset_password_link", sprintf(__('Reset your password for %s', 'fluent-support'), get_bloginfo('name')));
519 545
546 + // Issuance cooldown. get_password_reset_key() rotates the stored key, invalidating
547 + // any link already sitting in the account owner's inbox, so an unthrottled caller
548 + // could deny password recovery indefinitely. Suppressing the duplicate issuance is
549 + // safe: reset mail only ever goes to the account owner, so whoever triggered the
550 + // first send has already put a working link in that inbox.
551 + $cooldownKey = 'fs_reset_pass_sent_' . wp_hash($user_data->ID);
552 +
553 + if (get_transient($cooldownKey)) {
554 + return $this->sendResetPassResponse();
555 + }
556 +
557 + set_transient($cooldownKey, 1, 5 * MINUTE_IN_SECONDS);
558 +
520 559 $resetUrl = add_query_arg([
521 560 'action' => 'rp',
522 561 'key' => get_password_reset_key($user_data),
523 562 'login' => rawurlencode($user_data->user_login)
@@ -555,10 +594,24 @@
555 594 $headers = array('Content-Type: text/html; charset=UTF-8');
556 595
557 596 wp_mail($user_data->user_email, $mailSubject, $message, $headers);
558 597
598 + return $this->sendResetPassResponse();
599 + }
600 +
601 + /**
602 + * Single response for every password reset outcome.
603 + *
604 + * Whether the account exists, is disallowed, is not a member of this site or is
605 + * inside the resend cooldown, the caller sees the same thing — otherwise the form
606 + * confirms which usernames and email addresses are real.
607 + *
608 + * @return mixed
609 + */
610 + protected function sendResetPassResponse()
611 + {
559 612 return $this->sendSuccess([
560 - 'message' => __('Please check your email for the reset link', 'fluent-support')
613 + 'message' => __('If an account matches that username or email, a password reset link has been sent. Please check your inbox, including the spam folder.', 'fluent-support')
561 614 ]);
562 615 }
563 616
564 617 /**