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 +77 -98 2.3.1 → 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,9 +108,8 @@
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 - // the code must still be unused and must not have been consumed by a prior request
101 112 if (($logHash['status'] ?? '') !== 'issued') {
102 113 wp_send_json([
103 114 'message' => __('Your verification code has already been used. Please try again', 'fluent-support')
104 115 ], 422);
@@ -103,10 +114,9 @@
103 114 'message' => __('Your verification code has already been used. Please try again', 'fluent-support')
104 115 ], 422);
105 116 }
106 117
107 - // records created before the email-binding fix (or any other legacy/malformed record)
108 - // have no bound email; treat them as invalid rather than proceeding with a null email
118 + // Old or malformed records have no bound email.
109 119 if (empty($logHash['email'])) {
110 120 wp_send_json([
111 121 'message' => __('Your verification code has expired. Please request a new one', 'fluent-support')
112 122 ], 422);
@@ -111,11 +121,12 @@
111 121 'message' => __('Your verification code has expired. Please request a new one', 'fluent-support')
112 122 ], 422);
113 123 }
114 124
115 - // check if it got expired or not
125 + // valid_till is written with gmdate(). Parse it as UTC explicitly, since a plugin
126 + // may have changed the PHP default timezone.
116 127 $validTill = $logHash['valid_till'] ?? '';
117 - 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'))) {
118 129 wp_send_json([
119 130 'message' => __('Your verification code has been expired. Please try again', 'fluent-support')
120 131 ], 422);
121 132 }
@@ -131,10 +142,10 @@
131 142 'message' => __('Please provide a valid verification code that was sent to your email address', 'fluent-support')
132 143 ], 422);
133 144 }
134 145
135 - // atomically consume the code: only succeeds if the record is still in the exact
136 - // state we just read, closing the race where two requests both pass the checks above
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.
137 148 $consumed = Meta::where('key', $logHash['login_hash'])
138 149 ->where('object_type', 'fs_login_hashes')
139 150 ->where('value', $logHashMeta->value)
140 151 ->update([
@@ -149,11 +160,9 @@
149 160 'message' => __('Your verification code has already been used. Please try again', 'fluent-support')
150 161 ], 422);
151 162 }
152 163
153 - // the email is now server-verified for this code; ignore whatever the client
154 - // submitted and use the address the code was actually issued to, so the signup
155 - // can never be completed against a different (e.g. victim's) email address
164 + // Use the address the code was issued to, not the submitted one.
156 165 $formData['email'] = $logHash['email'];
157 166 }
158 167
159 168 /*
@@ -165,13 +174,13 @@
165 174 do_action('fluent_support/after_signup_validation', $formData);
166 175
167 176 $userId = $this->createUser($formData);
168 177
169 - if (is_wp_error($userId)) {
178 + if (!$userId) {
170 179 return $this->response(
171 180 apply_filters(
172 181 'fluent_support/signup_create_user_error',
173 - ['error' => $userId->get_error_message()]
182 + ['error' => __('Could not create your account. The username or email address may already be registered.', 'fluent-support')]
174 183 ), 423);
175 184 }
176 185
177 186 /*
@@ -177,9 +186,8 @@
177 186 /*
178 187 * Action After creating WP user from ticket sign up form
179 188 *
180 189 * @since v1.0.0
181 - * @param array $formData
182 190 */
183 191 do_action('fluent_support/after_creating_user');
184 192
185 193 $this->maybeUpdateUser($userId, $formData);
@@ -197,13 +205,18 @@
197 205 'message' => __('Successfully registered to the site.', 'fluent-support'),
198 206 'redirect' => Arr::get($formData, '__redirect_to', Helper::getPortalBaseUrl())
199 207 ]);
200 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 +
201 215 return $this->response($response);
202 216 }
203 217
204 218 /**
205 - * handleLogin method will perform login functionality and redirect
206 219 * @param Request $request
207 220 * @return \WP_REST_Response
208 221 */
209 222 public function handleLogin(Request $request)
@@ -221,11 +234,11 @@
221 234 }
222 235
223 236 $data = $request->all();
224 237
225 - $checkRecaptchaAvailability = $this->isRecaptchaApplicable('login_form');
238 + $checkRecaptchaAvailability = ReCaptchaHandler::isRecaptchaApplicable('login_form');
226 239 if ($checkRecaptchaAvailability) {
227 - $validateCaptcha = ReCaptchaHandler::validateRecaptcha($data['g-recaptcha-response']);
240 + $validateCaptcha = ReCaptchaHandler::validateRecaptcha($data['g-recaptcha-response'] ?? '', null, null, 'login');
228 241
229 242 if (!$validateCaptcha) {
230 243 return $this->response([
231 244 'message' => __('Your recaptcha is not verified', 'fluent-support')
@@ -242,9 +255,9 @@
242 255 if ($redirect = $request->getSafe('redirect_to', 'sanitize_text_field')) {
243 256 $redirectUrl = wp_validate_redirect($redirect, $redirectUrl);
244 257 }
245 258
246 - if (get_current_user_id()) { // user already registered
259 + if (get_current_user_id()) { // already logged in
247 260 return $this->sendSuccess([
248 261 'redirect' => $redirectUrl
249 262 ]);
250 263 }
@@ -257,9 +270,9 @@
257 270 } else {
258 271 $user = get_user_by('login', $email);
259 272 }
260 273
261 - // Rate limiting: per-IP bucket (5 attempts) + per-account bucket (20 attempts)
274 + // 5 attempts per account+IP, 20 per account.
262 275 $ip = Helper::getIp();
263 276 $ipKey = $user
264 277 ? 'fs_login_ip_' . wp_hash($user->ID . '|' . $ip)
265 278 : 'fs_login_ip_' . wp_hash(strtolower($email) . '|' . $ip);
@@ -312,9 +325,8 @@
312 325 'message' => $user->get_error_message()
313 326 ], 403);
314 327 }
315 328
316 - // Clear rate limits for the authenticated user
317 329 $authIpKey = 'fs_login_ip_' . wp_hash($user->ID . '|' . $ip);
318 330 $authAccountKey = 'fs_login_act_' . wp_hash($user->ID);
319 331 delete_transient($authIpKey);
320 332 delete_transient($authAccountKey);
@@ -349,26 +361,8 @@
349 361 set_transient($rateLimitKey, $attempts + 1, 15 * MINUTE_IN_SECONDS);
350 362 }
351 363 }
352 364
353 - public function isRecaptchaApplicable($formName)
354 - {
355 - $reCaptchaSettingsData = Meta::where('object_type', '_fs_recaptcha_settings')->first();
356 - if(!isset($reCaptchaSettingsData->value)){
357 - return false;
358 - }
359 - $reCaptchaData = Helper::safeUnserialize($reCaptchaSettingsData->value, []);
360 - if(!isset($reCaptchaData['is_enabled']) || !isset($reCaptchaData['formContainingReCaptcha'])){
361 - return false;
362 - }
363 - $isEnabled = filter_var($reCaptchaData['is_enabled'], FILTER_VALIDATE_BOOLEAN);
364 - if (!$isEnabled) {
365 - return false;
366 - }
367 - $formContainingReCaptcha = $reCaptchaData['formContainingReCaptcha'];
368 - return 'yes' === $formContainingReCaptcha[$formName];
369 - }
370 -
371 365 private function nativeLoginHandler($user, $info, $redirectUrl = '')
372 366 {
373 367 if (!$redirectUrl) {
374 368 $redirectUrl = Helper::getPortalBaseUrl();
@@ -393,9 +387,9 @@
393 387 }
394 388
395 389 $user_signon = wp_signon($info, $secure_cookie);
396 390
397 - // 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.
398 392 if (!is_wp_error($user_signon) && empty($_COOKIE[LOGGED_IN_COOKIE])) {
399 393 if (headers_sent()) {
400 394 return $this->response([
401 395 // translators: %1$s is the URL to WordPress cookies documentation, %2$s is the URL to WordPress support forums
@@ -434,9 +428,8 @@
434 428 ]);
435 429 }
436 430
437 431 /**
438 - * getRules method will prepare the rules for the input field
439 432 * @param array $fields
440 433 * @return mixed
441 434 */
442 435 protected function getRules($fields = [])
@@ -486,16 +479,14 @@
486 479 $usernameOrEmail = trim(wp_unslash($request->getSafe('user_login', 'sanitize_text_field')));
487 480
488 481 if (!$usernameOrEmail) {
489 482 return $this->sendError([
490 - 'message' => 'Username or email is required'
483 + 'message' => __('Username or email is required.', 'fluent-support')
491 484 ]);
492 485 }
493 486
494 - // IP bucket is a generous volumetric backstop (shared office/NAT IPs can have many
495 - // unrelated users). It runs before the account lookup so that probes for accounts
496 - // that don't exist are throttled too. Keyed on the IP only, so a 429 here reveals
497 - // nothing about whether any given account exists.
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.
498 489 if (Helper::hitRateLimit('fs_reset_pass_ip_' . wp_hash(Helper::getIp()), 20)) {
499 490 return $this->sendError([
500 491 'message' => __('Too many password reset requests. Please try again after 15 minutes.', 'fluent-support')
501 492 ], 429);
@@ -507,11 +498,9 @@
507 498 $user_data = get_user_by('login', $usernameOrEmail);
508 499 }
509 500
510 501 if (!$user_data) {
511 - return $this->sendError([
512 - 'message' => __('Invalid username or email', 'fluent-support')
513 - ]);
502 + return $this->sendResetPassResponse();
514 503 }
515 504
516 505 $user_data = apply_filters('lostpassword_user_data', $user_data, $errors);
517 506
@@ -519,24 +508,17 @@
519 508
520 509 $errors = apply_filters('lostpassword_errors', $errors, $user_data);
521 510
522 511 if ($errors->has_errors()) {
523 - return $this->sendError([
524 - 'message' => $errors->get_error_message()
525 - ]);
512 + return $this->sendResetPassResponse();
526 513 }
527 514
528 515 if (!$user_data) {
529 - return $this->sendError([
530 - 'message' => __('There is no account with that username or email address.', 'fluent-support')
531 - ]);
516 + return $this->sendResetPassResponse();
532 517 }
533 518
534 519 if (is_multisite() && !is_user_member_of_blog($user_data->ID, get_current_blog_id())) {
535 -
536 - return $this->sendError([
537 - 'message' => __('Invalid username or email', 'fluent-support')
538 - ]);
520 + return $this->sendResetPassResponse();
539 521 }
540 522
541 523 // Redefining user_login ensures we return the right case in the email.
542 524 $user_login = $user_data->user_login;
@@ -544,21 +526,13 @@
544 526 do_action('retrieve_password', $user_login);
545 527
546 528 $allow = apply_filters('allow_password_reset', true, $user_data->ID);
547 529
548 - if (!$allow) {
549 - return $this->sendError([
550 - 'message' => __('Password reset is not allowed for this user', 'fluent-support')
551 - ]);
530 + if (!$allow || is_wp_error($allow)) {
531 + return $this->sendResetPassResponse();
552 532 }
553 533
554 - if (is_wp_error($allow)) {
555 - return $this->sendError([
556 - 'message' => $allow->get_error_message()
557 - ]);
558 - }
559 534
560 -
561 535 /*
562 536 * Filter reset password link text
563 537 *
564 538 * @since v1.5.7
@@ -566,19 +540,14 @@
566 540 */
567 541 // translators: %s is the site name
568 542 $linkText = apply_filters("fluent_support/reset_password_link", sprintf(__('Reset your password for %s', 'fluent-support'), get_bloginfo('name')));
569 543
570 - // Issuance cooldown. get_password_reset_key() rotates the stored key, invalidating
571 - // any link already sitting in the account owner's inbox, so an unthrottled caller
572 - // could deny password recovery indefinitely. Suppressing the duplicate issuance is
573 - // safe: reset mail only ever goes to the account owner, so whoever triggered the
574 - // first send has already put a working link in that inbox.
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.
575 546 $cooldownKey = 'fs_reset_pass_sent_' . wp_hash($user_data->ID);
576 547
577 548 if (get_transient($cooldownKey)) {
578 - return $this->sendError([
579 - 'message' => __('A password reset link was already sent to this account recently. Please check your email, including the spam folder, or try again in a few minutes.', 'fluent-support')
580 - ], 429);
549 + return $this->sendResetPassResponse();
581 550 }
582 551
583 552 set_transient($cooldownKey, 1, 5 * MINUTE_IN_SECONDS);
584 553
@@ -620,15 +589,24 @@
620 589 $headers = array('Content-Type: text/html; charset=UTF-8');
621 590
622 591 wp_mail($user_data->user_email, $mailSubject, $message, $headers);
623 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 + {
624 603 return $this->sendSuccess([
625 - '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')
626 605 ]);
627 606 }
628 607
629 608 /**
630 - * getMessages message will return the validation message regarding sign up or sign in
631 609 * @param array $rules
632 610 * @return mixed
633 611 */
634 612 protected function getMessages($rules = [])
@@ -643,11 +621,10 @@
643 621 return apply_filters('fluent_support/signup_validation_messages', [], $rules);
644 622 }
645 623
646 624 /**
647 - * createUser method will create new user
648 625 * @param array $formData
649 - * @return mixed
626 + * @return int|false
650 627 */
651 628 public function createUser($formData = [])
652 629 {
653 630 /*
@@ -700,14 +677,19 @@
700 677
701 678 }
702 679
703 680 /**
704 - * maybeUpdateUser method will update user information if exists
705 - * @param $userId
706 - * @param $formData
681 + * Sets the name fields from first/last name, if given.
682 + *
683 + * @param int $userId
684 + * @param array $formData
707 685 */
708 686 public function maybeUpdateUser($userId, $formData)
709 687 {
688 + if (!$userId) {
689 + return;
690 + }
691 +
710 692 $firstName = Arr::get($formData, 'first_name', '');
711 693 $lastName = Arr::get($formData, 'last_name', '');
712 694 $name = trim($firstName . ' ' . $lastName);
713 695
@@ -747,9 +729,9 @@
747 729 }
748 730 }
749 731
750 732 public function addUserMetaData($userId, $formData) {
751 - $customFieldsKey = apply_filters('fluent_support/custom_registration_form_fields_key', Helper::getBusinessSettings('custom_registration_form_field'));
733 + $customFieldsKey = Helper::getCustomRegistrationFieldKeys();
752 734
753 735 if (empty($customFieldsKey)) {
754 736 return;
755 737 }
@@ -754,18 +736,16 @@
754 736 return;
755 737 }
756 738
757 739 foreach ($customFieldsKey as $key) {
758 - if (isset($formData[$key])) {
759 - $fieldValue = $formData[$key];
760 - 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]));
761 742 }
762 743 }
763 744 }
764 745
765 746 /**
766 - * assignRole method will assign role to a given user id
767 - * @param $userId
747 + * @param int $userId
768 748 */
769 749 protected function assignRole($userId)
770 750 {
771 751 $user = new \WP_User($userId);
@@ -773,9 +753,9 @@
773 753 /*
774 754 * Action before assigning role to registered user
775 755 *
776 756 * @since v1.0.0
777 - * @param array $data
757 + * @param \WP_User $user
778 758 */
779 759 do_action('fluent_support/before_assigning_role', $user);
780 760 /*
781 761 * Filter user assignable role after signup
@@ -789,9 +769,9 @@
789 769 /*
790 770 * Action after assigning role to registered user
791 771 *
792 772 * @since v1.0.0
793 - * @param array $data
773 + * @param \WP_User $user
794 774 */
795 775 do_action('fluent_support/after_assigning_role', $user);
796 776 }
797 777
@@ -796,10 +776,9 @@
796 776 }
797 777
798 778
799 779 /**
800 - * login method will clear existing cookies and set new cookie for a given user id
801 - * @param $userId
780 + * @param int $userId
802 781 */
803 782 protected function login($userId)
804 783 {
805 784 /*