PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 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 All 69 releases
← All changes | app/Http/Controllers/AuthController.php +129 -85 2.2.0 → 2.4.5 View file →
@@ -14,9 +14,11 @@
14 14
15 15 class AuthController extends Controller
16 16 {
17 17 /**
18 - * signUp method will create new user submitted data from sign up form
18 + * Two steps: the first submit emails a verification code, the second
19 + * verifies it and creates the user.
20 + *
19 21 * @param Request $request
20 22 * @return \WP_REST_Response
21 23 * @throws \FluentSupport\Framework\Validator\ValidationException
22 24 */
@@ -28,8 +30,14 @@
28 30 'message' => __('You are not allowed to signup using this form', 'fluent-support')
29 31 ]);
30 32 }
31 33
34 + if (!Helper::isSignupEnabled()) {
35 + return $this->sendError([
36 + 'message' => __('User registration is not enabled on this site.', 'fluent-support')
37 + ], 403);
38 + }
39 +
32 40 if (!wp_verify_nonce($request->getSafe('_fsupport_signup_nonce', 'sanitize_text_field'), 'fluent_support_signup_nonce')) {
33 41 return $this->sendError([
34 42 'message' => __('Security verification failed. Please try again', 'fluent-support')
35 43 ]);
@@ -56,11 +64,15 @@
56 64 * @param array $formData
57 65 */
58 66 do_action('fluent_support/before_signup_validation', $formData);
59 67
60 - $checkRecaptchaAvailability = $this->isRecaptchaApplicable('signup_form');
61 - if ($checkRecaptchaAvailability && !$formData['_email_verification_hash']) {
62 - $validateCaptcha = ReCaptchaHandler::validateRecaptcha($formData['g-recaptcha-response']);
68 + // The captcha was already passed on the first step, and the second step is
69 + // gated by the issued verification record below.
70 + $isVerificationStep = !empty($formData['_email_verification_token']);
71 +
72 + $checkRecaptchaAvailability = ReCaptchaHandler::isRecaptchaApplicable('signup_form');
73 + if ($checkRecaptchaAvailability && !$isVerificationStep) {
74 + $validateCaptcha = ReCaptchaHandler::validateRecaptcha($formData['g-recaptcha-response'] ?? '', null, null, 'signup');
63 75 if (!$validateCaptcha) {
64 76 return $this->response([
65 77 'message' => __('Your recaptcha is not verified', 'fluent-support')
66 78 ], 422);
@@ -68,9 +80,9 @@
68 80 }
69 81
70 82 $this->validate($formData, $rules, $messages);
71 83
72 - if (empty($formData['_email_verification_token'])) {
84 + if (!$isVerificationStep) {
73 85 $tokenHtml = EmailVerificationHandler::sendSignupEmailVerificationHtml($formData);
74 86
75 87 return $this->response([
76 88 'verification_html' => $tokenHtml
@@ -76,9 +88,9 @@
76 88 'verification_html' => $tokenHtml
77 89 ]);
78 90 } else {
79 91 $token = $formData['_email_verification_token'];
80 - $verificationHash = $formData['_email_verification_hash'];
92 + $verificationHash = $formData['_email_verification_hash'] ?? '';
81 93
82 94 $logHashMeta = Meta::where('object_type', 'fs_login_hashes',)
83 95 ->where('key', $verificationHash)
84 96 ->first();
@@ -96,11 +108,25 @@
96 108 'message' => __('Please provide a valid verification code that was sent to your email address', 'fluent-support')
97 109 ], 422);
98 110 }
99 111
100 - // check if it got expired or not
112 + if (($logHash['status'] ?? '') !== 'issued') {
113 + wp_send_json([
114 + 'message' => __('Your verification code has already been used. Please try again', 'fluent-support')
115 + ], 422);
116 + }
117 +
118 + // Old or malformed records have no bound email.
119 + if (empty($logHash['email'])) {
120 + wp_send_json([
121 + 'message' => __('Your verification code has expired. Please request a new one', 'fluent-support')
122 + ], 422);
123 + }
124 +
125 + // valid_till is written with gmdate(). Parse it as UTC explicitly, since a plugin
126 + // may have changed the PHP default timezone.
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,28 @@
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 + // Consume atomically: the update only matches if the record is unchanged since
147 + // we read it, so two concurrent requests can't both succeed.
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 + // Use the address the code was issued to, not the submitted one.
165 + $formData['email'] = $logHash['email'];
126 166 }
127 167
128 168 /*
129 169 * Action After validate user signup validation success
@@ -134,13 +174,13 @@
134 174 do_action('fluent_support/after_signup_validation', $formData);
135 175
136 176 $userId = $this->createUser($formData);
137 177
138 - if (is_wp_error($userId)) {
178 + if (!$userId) {
139 179 return $this->response(
140 180 apply_filters(
141 181 'fluent_support/signup_create_user_error',
142 - ['error' => $userId->get_error_message()]
182 + ['error' => __('Could not create your account. The username or email address may already be registered.', 'fluent-support')]
143 183 ), 423);
144 184 }
145 185
146 186 /*
@@ -146,9 +186,8 @@
146 186 /*
147 187 * Action After creating WP user from ticket sign up form
148 188 *
149 189 * @since v1.0.0
150 - * @param array $formData
151 190 */
152 191 do_action('fluent_support/after_creating_user');
153 192
154 193 $this->maybeUpdateUser($userId, $formData);
@@ -166,13 +205,18 @@
166 205 'message' => __('Successfully registered to the site.', 'fluent-support'),
167 206 'redirect' => Arr::get($formData, '__redirect_to', Helper::getPortalBaseUrl())
168 207 ]);
169 208
209 + // The browser navigates to this by script; keep it on this site.
210 + if (!empty($response['redirect'])) {
211 + $portalUrl = Helper::getPortalBaseUrl();
212 + $response['redirect'] = wp_validate_redirect(esc_url_raw($response['redirect']), $portalUrl) ?: $portalUrl;
213 + }
214 +
170 215 return $this->response($response);
171 216 }
172 217
173 218 /**
174 - * handleLogin method will perform login functionality and redirect
175 219 * @param Request $request
176 220 * @return \WP_REST_Response
177 221 */
178 222 public function handleLogin(Request $request)
@@ -190,11 +234,11 @@
190 234 }
191 235
192 236 $data = $request->all();
193 237
194 - $checkRecaptchaAvailability = $this->isRecaptchaApplicable('login_form');
238 + $checkRecaptchaAvailability = ReCaptchaHandler::isRecaptchaApplicable('login_form');
195 239 if ($checkRecaptchaAvailability) {
196 - $validateCaptcha = ReCaptchaHandler::validateRecaptcha($data['g-recaptcha-response']);
240 + $validateCaptcha = ReCaptchaHandler::validateRecaptcha($data['g-recaptcha-response'] ?? '', null, null, 'login');
197 241
198 242 if (!$validateCaptcha) {
199 243 return $this->response([
200 244 'message' => __('Your recaptcha is not verified', 'fluent-support')
@@ -211,9 +255,9 @@
211 255 if ($redirect = $request->getSafe('redirect_to', 'sanitize_text_field')) {
212 256 $redirectUrl = wp_validate_redirect($redirect, $redirectUrl);
213 257 }
214 258
215 - if (get_current_user_id()) { // user already registered
259 + if (get_current_user_id()) { // already logged in
216 260 return $this->sendSuccess([
217 261 'redirect' => $redirectUrl
218 262 ]);
219 263 }
@@ -226,9 +270,9 @@
226 270 } else {
227 271 $user = get_user_by('login', $email);
228 272 }
229 273
230 - // Rate limiting: per-IP bucket (5 attempts) + per-account bucket (20 attempts)
274 + // 5 attempts per account+IP, 20 per account.
231 275 $ip = Helper::getIp();
232 276 $ipKey = $user
233 277 ? 'fs_login_ip_' . wp_hash($user->ID . '|' . $ip)
234 278 : 'fs_login_ip_' . wp_hash(strtolower($email) . '|' . $ip);
@@ -245,9 +289,9 @@
245 289 ], 429);
246 290 }
247 291
248 292 if (!$user) {
249 - $user = new \WP_Error('authentication_failed', __('<strong>Error</strong>: Invalid username, email address or incorrect password.', 'fluent-support'));
293 + $user = new \WP_Error('authentication_failed', __('Invalid username, email address or incorrect password.', 'fluent-support'));
250 294
251 295 do_action('wp_login_failed', $email, $user);
252 296 $this->incrementLoginAttempts($ipKey);
253 297 $this->incrementLoginAttempts($accountKey);
@@ -259,8 +303,17 @@
259 303 }
260 304
261 305 $twoFactorEnabled = Helper::getBusinessSettings('enable_two_fa');
262 306 if ('yes' === $twoFactorEnabled) {
307 + if (!wp_check_password($password, $user->user_pass, $user->ID)) {
308 + $this->incrementLoginAttempts($ipKey);
309 + $this->incrementLoginAttempts($accountKey);
310 +
311 + return $this->response([
312 + 'message' => __('Invalid username, email address or incorrect password.', 'fluent-support')
313 + ], 403);
314 + }
315 +
263 316 (new TwoFaHandler)->maybe2FaRedirect($user);
264 317 }
265 318
266 319 if (apply_filters('fluent_support_use_native_login', true)) {
@@ -272,9 +325,8 @@
272 325 'message' => $user->get_error_message()
273 326 ], 403);
274 327 }
275 328
276 - // Clear rate limits for the authenticated user
277 329 $authIpKey = 'fs_login_ip_' . wp_hash($user->ID . '|' . $ip);
278 330 $authAccountKey = 'fs_login_act_' . wp_hash($user->ID);
279 331 delete_transient($authIpKey);
280 332 delete_transient($authAccountKey);
@@ -295,9 +347,9 @@
295 347 $this->incrementLoginAttempts($ipKey);
296 348 $this->incrementLoginAttempts($accountKey);
297 349
298 350 return $this->response([
299 - 'message' => __('<strong>Error</strong>: Invalid username, email address or incorrect password.', 'fluent-support')
351 + 'message' => __('Invalid username, email address or incorrect password.', 'fluent-support')
300 352 ], 403);
301 353 }
302 354
303 355 private function incrementLoginAttempts($rateLimitKey)
@@ -309,26 +361,8 @@
309 361 set_transient($rateLimitKey, $attempts + 1, 15 * MINUTE_IN_SECONDS);
310 362 }
311 363 }
312 364
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 365 private function nativeLoginHandler($user, $info, $redirectUrl = '')
332 366 {
333 367 if (!$redirectUrl) {
334 368 $redirectUrl = Helper::getPortalBaseUrl();
@@ -353,9 +387,9 @@
353 387 }
354 388
355 389 $user_signon = wp_signon($info, $secure_cookie);
356 390
357 - // Note: No sanitization needed here as we're only checking emptiness, not using the cookie value
391 + // Only checked for emptiness, so no sanitizing needed.
358 392 if (!is_wp_error($user_signon) && empty($_COOKIE[LOGGED_IN_COOKIE])) {
359 393 if (headers_sent()) {
360 394 return $this->response([
361 395 // translators: %1$s is the URL to WordPress cookies documentation, %2$s is the URL to WordPress support forums
@@ -394,9 +428,8 @@
394 428 ]);
395 429 }
396 430
397 431 /**
398 - * getRules method will prepare the rules for the input field
399 432 * @param array $fields
400 433 * @return mixed
401 434 */
402 435 protected function getRules($fields = [])
@@ -446,12 +479,20 @@
446 479 $usernameOrEmail = trim(wp_unslash($request->getSafe('user_login', 'sanitize_text_field')));
447 480
448 481 if (!$usernameOrEmail) {
449 482 return $this->sendError([
450 - 'message' => 'Username or email is required'
483 + 'message' => __('Username or email is required.', 'fluent-support')
451 484 ]);
452 485 }
453 486
487 + // Generous because of shared NAT IPs. Runs before the account lookup so probes for
488 + // missing accounts are throttled too, and a 429 reveals nothing about existence.
489 + if (Helper::hitRateLimit('fs_reset_pass_ip_' . wp_hash(Helper::getIp()), 20)) {
490 + return $this->sendError([
491 + 'message' => __('Too many password reset requests. Please try again after 15 minutes.', 'fluent-support')
492 + ], 429);
493 + }
494 +
454 495 $user_data = get_user_by('email', $usernameOrEmail);
455 496
456 497 if (!$user_data) {
457 498 $user_data = get_user_by('login', $usernameOrEmail);
@@ -457,11 +498,9 @@
457 498 $user_data = get_user_by('login', $usernameOrEmail);
458 499 }
459 500
460 501 if (!$user_data) {
461 - return $this->sendError([
462 - 'message' => __('Invalid username or email', 'fluent-support')
463 - ]);
502 + return $this->sendResetPassResponse();
464 503 }
465 504
466 505 $user_data = apply_filters('lostpassword_user_data', $user_data, $errors);
467 506
@@ -469,24 +508,17 @@
469 508
470 509 $errors = apply_filters('lostpassword_errors', $errors, $user_data);
471 510
472 511 if ($errors->has_errors()) {
473 - return $this->sendError([
474 - 'message' => $errors->get_error_message()
475 - ]);
512 + return $this->sendResetPassResponse();
476 513 }
477 514
478 515 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 - ]);
516 + return $this->sendResetPassResponse();
482 517 }
483 518
484 519 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 - ]);
520 + return $this->sendResetPassResponse();
489 521 }
490 522
491 523 // Redefining user_login ensures we return the right case in the email.
492 524 $user_login = $user_data->user_login;
@@ -494,21 +526,13 @@
494 526 do_action('retrieve_password', $user_login);
495 527
496 528 $allow = apply_filters('allow_password_reset', true, $user_data->ID);
497 529
498 - if (!$allow) {
499 - return $this->sendError([
500 - 'message' => __('Password reset is not allowed for this user', 'fluent-support')
501 - ]);
530 + if (!$allow || is_wp_error($allow)) {
531 + return $this->sendResetPassResponse();
502 532 }
503 533
504 - if (is_wp_error($allow)) {
505 - return $this->sendError([
506 - 'message' => $allow->get_error_message()
507 - ]);
508 - }
509 534
510 -
511 535 /*
512 536 * Filter reset password link text
513 537 *
514 538 * @since v1.5.7
@@ -516,8 +540,18 @@
516 540 */
517 541 // translators: %s is the site name
518 542 $linkText = apply_filters("fluent_support/reset_password_link", sprintf(__('Reset your password for %s', 'fluent-support'), get_bloginfo('name')));
519 543
544 + // get_password_reset_key() invalidates the previous link, so repeated requests could
545 + // block recovery. The owner already has a working link from the first send.
546 + $cooldownKey = 'fs_reset_pass_sent_' . wp_hash($user_data->ID);
547 +
548 + if (get_transient($cooldownKey)) {
549 + return $this->sendResetPassResponse();
550 + }
551 +
552 + set_transient($cooldownKey, 1, 5 * MINUTE_IN_SECONDS);
553 +
520 554 $resetUrl = add_query_arg([
521 555 'action' => 'rp',
522 556 'key' => get_password_reset_key($user_data),
523 557 'login' => rawurlencode($user_data->user_login)
@@ -555,15 +589,24 @@
555 589 $headers = array('Content-Type: text/html; charset=UTF-8');
556 590
557 591 wp_mail($user_data->user_email, $mailSubject, $message, $headers);
558 592
593 + return $this->sendResetPassResponse();
594 + }
595 +
596 + /**
597 + * Same response for every outcome, so the form can't confirm which accounts exist.
598 + *
599 + * @return mixed
600 + */
601 + protected function sendResetPassResponse()
602 + {
559 603 return $this->sendSuccess([
560 - 'message' => __('Please check your email for the reset link', 'fluent-support')
604 + '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 605 ]);
562 606 }
563 607
564 608 /**
565 - * getMessages message will return the validation message regarding sign up or sign in
566 609 * @param array $rules
567 610 * @return mixed
568 611 */
569 612 protected function getMessages($rules = [])
@@ -578,11 +621,10 @@
578 621 return apply_filters('fluent_support/signup_validation_messages', [], $rules);
579 622 }
580 623
581 624 /**
582 - * createUser method will create new user
583 625 * @param array $formData
584 - * @return mixed
626 + * @return int|false
585 627 */
586 628 public function createUser($formData = [])
587 629 {
588 630 /*
@@ -635,14 +677,19 @@
635 677
636 678 }
637 679
638 680 /**
639 - * maybeUpdateUser method will update user information if exists
640 - * @param $userId
641 - * @param $formData
681 + * Sets the name fields from first/last name, if given.
682 + *
683 + * @param int $userId
684 + * @param array $formData
642 685 */
643 686 public function maybeUpdateUser($userId, $formData)
644 687 {
688 + if (!$userId) {
689 + return;
690 + }
691 +
645 692 $firstName = Arr::get($formData, 'first_name', '');
646 693 $lastName = Arr::get($formData, 'last_name', '');
647 694 $name = trim($firstName . ' ' . $lastName);
648 695
@@ -682,9 +729,9 @@
682 729 }
683 730 }
684 731
685 732 public function addUserMetaData($userId, $formData) {
686 - $customFieldsKey = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
733 + $customFieldsKey = Helper::getCustomRegistrationFieldKeys();
687 734
688 735 if (empty($customFieldsKey)) {
689 736 return;
690 737 }
@@ -689,18 +736,16 @@
689 736 return;
690 737 }
691 738
692 739 foreach ($customFieldsKey as $key) {
693 - if (isset($formData[$key])) {
694 - $fieldValue = $formData[$key];
695 - update_user_meta($userId, $key, $fieldValue);
740 + if (isset($formData[$key]) && is_scalar($formData[$key])) {
741 + update_user_meta($userId, $key, sanitize_text_field($formData[$key]));
696 742 }
697 743 }
698 744 }
699 745
700 746 /**
701 - * assignRole method will assign role to a given user id
702 - * @param $userId
747 + * @param int $userId
703 748 */
704 749 protected function assignRole($userId)
705 750 {
706 751 $user = new \WP_User($userId);
@@ -708,9 +753,9 @@
708 753 /*
709 754 * Action before assigning role to registered user
710 755 *
711 756 * @since v1.0.0
712 - * @param array $data
757 + * @param \WP_User $user
713 758 */
714 759 do_action('fluent_support/before_assigning_role', $user);
715 760 /*
716 761 * Filter user assignable role after signup
@@ -724,9 +769,9 @@
724 769 /*
725 770 * Action after assigning role to registered user
726 771 *
727 772 * @since v1.0.0
728 - * @param array $data
773 + * @param \WP_User $user
729 774 */
730 775 do_action('fluent_support/after_assigning_role', $user);
731 776 }
732 777
@@ -731,10 +776,9 @@
731 776 }
732 777
733 778
734 779 /**
735 - * login method will clear existing cookies and set new cookie for a given user id
736 - * @param $userId
780 + * @param int $userId
737 781 */
738 782 protected function login($userId)
739 783 {
740 784 /*