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 +178 -75 1.10.52.4.0 View file →
@@ -4,9 +4,9 @@
4 4
5 5 use FluentSupport\App\Models\Meta;
6 6 use FluentSupport\App\Services\Helper;
7 7 use FluentSupport\Framework\Support\Arr;
8 -use FluentSupport\Framework\Request\Request;
8 +use FluentSupport\Framework\Http\Request\Request;
9 9 use FluentSupport\App\Hooks\Handlers\AuthHandler;
10 10 use FluentSupport\App\Hooks\Handlers\ReCaptchaHandler;
11 11 use FluentSupport\App\Hooks\Handlers\TwoFaHandler;
12 12 use FluentSupport\App\Hooks\Handlers\EmailVerificationHandler;
@@ -22,15 +22,15 @@
22 22 */
23 23 public function signup(Request $request)
24 24 {
25 25
26 - if(Helper::getAuthProvider() != 'fluent_support') {
26 + if(Helper::getAuthProvider() !== 'fluent_support') {
27 27 return $this->sendError([
28 28 'message' => __('You are not allowed to signup using this form', 'fluent-support')
29 29 ]);
30 30 }
31 31
32 - if (!wp_verify_nonce($request->get('_fsupport_signup_nonce'), 'fluent_support_signup_nonce')) {
32 + if (!wp_verify_nonce($request->getSafe('_fsupport_signup_nonce', 'sanitize_text_field'), 'fluent_support_signup_nonce')) {
33 33 return $this->sendError([
34 34 'message' => __('Security verification failed. Please try again', 'fluent-support')
35 35 ]);
36 36 }
@@ -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,23 +82,51 @@
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 - $logHash = Meta::where('object_type', 'fs_login_hashes',)
88 + $logHashMeta = Meta::where('object_type', 'fs_login_hashes',)
83 89 ->where('key', $verificationHash)
84 90 ->first();
85 - $logHash = Helper::safeUnserialize($logHash->value);
86 91
92 + if (!$logHashMeta) {
93 + wp_send_json([
94 + 'message' => __('Please provide a valid verification code that was sent to your email address', 'fluent-support')
95 + ], 422);
96 + }
97 +
98 + $logHash = Helper::safeUnserialize($logHashMeta->value);
99 +
87 100 if (!$logHash) {
88 101 wp_send_json([
89 - 'message' => __('Please provide a valid verification code that sent to your email address', 'fluent-support')
102 + 'message' => __('Please provide a valid verification code that was sent to your email address', 'fluent-support')
90 103 ], 422);
91 104 }
92 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 +
93 121 // check if it got expired or not
94 - if ($logHash['used_count'] > 5 || strtotime($logHash['valid_till']) < current_time('timestamp')) {
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)
127 + $validTill = $logHash['valid_till'] ?? '';
128 + if (($logHash['used_count'] ?? 0) > 5 || ($validTill && strtotime($validTill . ' UTC') < current_time('timestamp'))) {
95 129 wp_send_json([
96 130 'message' => __('Your verification code has been expired. Please try again', 'fluent-support')
97 131 ], 422);
98 132 }
@@ -104,18 +138,34 @@
104 138 'value' => maybe_serialize($logHash)
105 139 ]);
106 140
107 141 wp_send_json([
108 - 'message' => __('Please provide a valid verification code that sent to your email address', 'fluent-support')
142 + 'message' => __('Please provide a valid verification code that was sent to your email address', 'fluent-support')
109 143 ], 422);
110 144 }
111 145
112 - $logHash['used_count'] += 1;
113 - $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 + ]);
114 157
115 - Meta::where('key', $logHash['login_hash'])->update([
116 - 'value' => maybe_serialize($logHash)
117 - ]);
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'];
118 168 }
119 169
120 170 /*
121 171 * Action After validate user signup validation success
@@ -168,15 +218,15 @@
168 218 * @return \WP_REST_Response
169 219 */
170 220 public function handleLogin(Request $request)
171 221 {
172 - if(Helper::getAuthProvider() != 'fluent_support') {
222 + if(Helper::getAuthProvider() !== 'fluent_support') {
173 223 return $this->sendError([
174 224 'message' => __('You are not allowed to login using this form', 'fluent-support')
175 225 ]);
176 226 }
177 227
178 - if (!wp_verify_nonce($request->get('_support_login_nonce'), 'fsupport_login_nonce')) {
228 + if (!wp_verify_nonce($request->getSafe('_support_login_nonce', 'sanitize_text_field'), 'fsupport_login_nonce')) {
179 229 return $this->response([
180 230 'message' => __('Security verification failed', 'fluent-support')
181 231 ], 403);
182 232 }
@@ -182,11 +232,11 @@
182 232 }
183 233
184 234 $data = $request->all();
185 235
186 - $checkRecaptchaAvailability = $this->isRecaptchaApplicable('login_form');
236 + $checkRecaptchaAvailability = ReCaptchaHandler::isRecaptchaApplicable('login_form');
187 237 if ($checkRecaptchaAvailability) {
188 - $validateCaptcha = ReCaptchaHandler::validateRecaptcha($data['g-recaptcha-response']);
238 + $validateCaptcha = ReCaptchaHandler::validateRecaptcha($data['g-recaptcha-response'] ?? '', null, null, 'login');
189 239
190 240 if (!$validateCaptcha) {
191 241 return $this->response([
192 242 'message' => __('Your recaptcha is not verified', 'fluent-support')
@@ -199,12 +249,10 @@
199 249 'message' => __('Email and Password is required', 'fluent-support')
200 250 ], 403);
201 251 }
202 252 $redirectUrl = Helper::getPortalBaseUrl();
203 - if ($redirect = $request->get('redirect_to')) {
204 - if (filter_var($redirect, FILTER_VALIDATE_URL)) {
205 - $redirectUrl = sanitize_url($redirect);
206 - }
253 + if ($redirect = $request->getSafe('redirect_to', 'sanitize_text_field')) {
254 + $redirectUrl = wp_validate_redirect($redirect, $redirectUrl);
207 255 }
208 256
209 257 if (get_current_user_id()) { // user already registered
210 258 return $this->sendSuccess([
@@ -212,9 +260,9 @@
212 260 ]);
213 261 }
214 262
215 263 $email = sanitize_user($data['log']);
216 - $password = trim($data['pwd']);
264 + $password = trim($data['pwd'] ?? '');
217 265
218 266 if (is_email($email)) {
219 267 $user = get_user_by('email', $email);
220 268 } else {
@@ -220,12 +268,32 @@
220 268 } else {
221 269 $user = get_user_by('login', $email);
222 270 }
223 271
272 + // Rate limiting: per-IP bucket (5 attempts) + per-account bucket (20 attempts)
273 + $ip = Helper::getIp();
274 + $ipKey = $user
275 + ? 'fs_login_ip_' . wp_hash($user->ID . '|' . $ip)
276 + : 'fs_login_ip_' . wp_hash(strtolower($email) . '|' . $ip);
277 + $accountKey = $user
278 + ? 'fs_login_act_' . wp_hash($user->ID)
279 + : 'fs_login_act_' . wp_hash(strtolower($email));
280 +
281 + $ipAttempts = get_transient($ipKey);
282 + $accountAttempts = get_transient($accountKey);
283 +
284 + if (($ipAttempts !== false && $ipAttempts >= 5) || ($accountAttempts !== false && $accountAttempts >= 20)) {
285 + return $this->sendError([
286 + 'message' => __('Too many login attempts. Please try again after 15 minutes.', 'fluent-support')
287 + ], 429);
288 + }
289 +
224 290 if (!$user) {
225 - $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'));
226 292
227 293 do_action('wp_login_failed', $email, $user);
294 + $this->incrementLoginAttempts($ipKey);
295 + $this->incrementLoginAttempts($accountKey);
228 296
229 297 return $this->response([
230 298 'message' => __('Email or Password is not valid. Please try again', 'fluent-support')
231 299 ], 403);
@@ -232,9 +300,18 @@
232 300
233 301 }
234 302
235 303 $twoFactorEnabled = Helper::getBusinessSettings('enable_two_fa');
236 - if ('yes' == $twoFactorEnabled) {
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 +
237 314 (new TwoFaHandler)->maybe2FaRedirect($user);
238 315 }
239 316
240 317 if (apply_filters('fluent_support_use_native_login', true)) {
@@ -239,13 +316,20 @@
239 316
240 317 if (apply_filters('fluent_support_use_native_login', true)) {
241 318 $user = wp_signon();
242 319 if (is_wp_error($user)) {
320 + $this->incrementLoginAttempts($ipKey);
321 + $this->incrementLoginAttempts($accountKey);
243 322 return $this->response([
244 323 'message' => $user->get_error_message()
245 324 ], 403);
246 325 }
247 326
327 + // Clear rate limits for the authenticated user
328 + $authIpKey = 'fs_login_ip_' . wp_hash($user->ID . '|' . $ip);
329 + $authAccountKey = 'fs_login_act_' . wp_hash($user->ID);
330 + delete_transient($authIpKey);
331 + delete_transient($authAccountKey);
248 332 return $this->sendSuccess([
249 333 'redirect' => $redirectUrl
250 334 ]);
251 335 }
@@ -250,8 +334,10 @@
250 334 ]);
251 335 }
252 336
253 337 if (wp_check_password($password, $user->user_pass, $user->ID)) {
338 + delete_transient($ipKey);
339 + delete_transient($accountKey);
254 340 $this->login($user->ID);
255 341 return $this->sendSuccess([
256 342 'redirect' => $redirectUrl
257 343 ]);
@@ -256,29 +342,24 @@
256 342 'redirect' => $redirectUrl
257 343 ]);
258 344 }
259 345
346 + $this->incrementLoginAttempts($ipKey);
347 + $this->incrementLoginAttempts($accountKey);
348 +
260 349 return $this->response([
261 - 'message' => __('<strong>Error</strong>: Invalid username, email address or incorrect password.', 'fluent-support')
350 + 'message' => __('Invalid username, email address or incorrect password.', 'fluent-support')
262 351 ], 403);
263 352 }
264 353
265 - public function isRecaptchaApplicable($formName)
354 + private function incrementLoginAttempts($rateLimitKey)
266 355 {
267 - $reCaptchaSettingsData = Meta::where('object_type', '_fs_recaptcha_settings')->first();
268 - if(!isset($reCaptchaSettingsData->value)){
269 - return false;
356 + $attempts = get_transient($rateLimitKey);
357 + if ($attempts === false) {
358 + set_transient($rateLimitKey, 1, 15 * MINUTE_IN_SECONDS);
359 + } else {
360 + set_transient($rateLimitKey, $attempts + 1, 15 * MINUTE_IN_SECONDS);
270 361 }
271 - $reCaptchaData = Helper::safeUnserialize($reCaptchaSettingsData->value, []);
272 - if(!isset($reCaptchaData['is_enabled']) || !isset($reCaptchaData['formContainingReCaptcha'])){
273 - return false;
274 - }
275 - $isEnabled = filter_var($reCaptchaData['is_enabled'], FILTER_VALIDATE_BOOLEAN);
276 - if (!$isEnabled) {
277 - return false;
278 - }
279 - $formContainingReCaptcha = $reCaptchaData['formContainingReCaptcha'];
280 - return 'yes' === $formContainingReCaptcha[$formName];
281 362 }
282 363
283 364 private function nativeLoginHandler($user, $info, $redirectUrl = '')
284 365 {
@@ -380,9 +461,9 @@
380 461
381 462 public function resetPassword(Request $request)
382 463 {
383 464
384 - if(Helper::getAuthProvider() != 'fluent_support') {
465 + if(Helper::getAuthProvider() !== 'fluent_support') {
385 466 return $this->sendError([
386 467 'message' => __('You are not allowed to reset password using this form', 'fluent-support')
387 468 ]);
388 469 }
@@ -388,15 +469,15 @@
388 469 }
389 470
390 471 $errors = new \WP_Error();
391 472
392 - if (!wp_verify_nonce($request->get('_fsupport_reset_pass_nonce'), 'fluent_support_reset_pass_nonce')) {
473 + if (!wp_verify_nonce($request->getSafe('_fsupport_reset_pass_nonce', 'sanitize_text_field'), 'fluent_support_reset_pass_nonce')) {
393 474 return $this->sendError([
394 475 'message' => __('Security verification failed. Please try again', 'fluent-support')
395 476 ]);
396 477 }
397 478
398 - $usernameOrEmail = trim(wp_unslash($request->get('user_login')));
479 + $usernameOrEmail = trim(wp_unslash($request->getSafe('user_login', 'sanitize_text_field')));
399 480
400 481 if (!$usernameOrEmail) {
401 482 return $this->sendError([
402 483 'message' => 'Username or email is required'
@@ -402,8 +483,18 @@
402 483 'message' => 'Username or email is required'
403 484 ]);
404 485 }
405 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 +
406 497 $user_data = get_user_by('email', $usernameOrEmail);
407 498
408 499 if (!$user_data) {
409 500 $user_data = get_user_by('login', $usernameOrEmail);
@@ -409,11 +500,9 @@
409 500 $user_data = get_user_by('login', $usernameOrEmail);
410 501 }
411 502
412 503 if (!$user_data) {
413 - return $this->sendError([
414 - 'message' => __('Invalid username or email', 'fluent-support')
415 - ]);
504 + return $this->sendResetPassResponse();
416 505 }
417 506
418 507 $user_data = apply_filters('lostpassword_user_data', $user_data, $errors);
419 508
@@ -421,24 +510,17 @@
421 510
422 511 $errors = apply_filters('lostpassword_errors', $errors, $user_data);
423 512
424 513 if ($errors->has_errors()) {
425 - return $this->sendError([
426 - 'message' => $errors->get_error_message()
427 - ]);
514 + return $this->sendResetPassResponse();
428 515 }
429 516
430 517 if (!$user_data) {
431 - return $this->sendError([
432 - 'message' => __('<strong>Error</strong>: There is no account with that username or email address.', 'fluent-support')
433 - ]);
518 + return $this->sendResetPassResponse();
434 519 }
435 520
436 521 if (is_multisite() && !is_user_member_of_blog($user_data->ID, get_current_blog_id())) {
437 -
438 - return $this->sendError([
439 - 'message' => __('<strong>Error</strong>: Invalid username or email', 'fluent-support')
440 - ]);
522 + return $this->sendResetPassResponse();
441 523 }
442 524
443 525 // Redefining user_login ensures we return the right case in the email.
444 526 $user_login = $user_data->user_login;
@@ -446,21 +528,13 @@
446 528 do_action('retrieve_password', $user_login);
447 529
448 530 $allow = apply_filters('allow_password_reset', true, $user_data->ID);
449 531
450 - if (!$allow) {
451 - return $this->sendError([
452 - 'message' => __('Password reset is not allowed for this user', 'fluent-support')
453 - ]);
532 + if (!$allow || is_wp_error($allow)) {
533 + return $this->sendResetPassResponse();
454 534 }
455 535
456 - if (is_wp_error($allow)) {
457 - return $this->sendError([
458 - 'message' => $allow->get_error_message()
459 - ]);
460 - }
461 536
462 -
463 537 /*
464 538 * Filter reset password link text
465 539 *
466 540 * @since v1.5.7
@@ -468,8 +542,21 @@
468 542 */
469 543 // translators: %s is the site name
470 544 $linkText = apply_filters("fluent_support/reset_password_link", sprintf(__('Reset your password for %s', 'fluent-support'), get_bloginfo('name')));
471 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 +
472 559 $resetUrl = add_query_arg([
473 560 'action' => 'rp',
474 561 'key' => get_password_reset_key($user_data),
475 562 'login' => rawurlencode($user_data->user_login)
@@ -507,10 +594,24 @@
507 594 $headers = array('Content-Type: text/html; charset=UTF-8');
508 595
509 596 wp_mail($user_data->user_email, $mailSubject, $message, $headers);
510 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 + {
511 612 return $this->sendSuccess([
512 - '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')
513 614 ]);
514 615 }
515 616
516 617 /**
@@ -553,9 +654,9 @@
553 654 */
554 655 $userName = apply_filters('fluent_support/signup_username', Arr::get($formData, 'username'));
555 656
556 657 if (empty($formData['password'])) {
557 - $password = wp_generate_password(8);
658 + $password = wp_generate_password(16, true, true);
558 659 } else {
559 660 $password = $formData['password'];
560 661 }
561 662
@@ -593,16 +694,18 @@
593 694 * @param $formData
594 695 */
595 696 public function maybeUpdateUser($userId, $formData)
596 697 {
597 - $name = trim(Arr::get($formData, 'first_name') . ' ' . Arr::get($formData, 'last_name'));
698 + $firstName = Arr::get($formData, 'first_name', '');
699 + $lastName = Arr::get($formData, 'last_name', '');
700 + $name = trim($firstName . ' ' . $lastName);
598 701
599 702 $data = array_filter([
600 703 'ID' => $userId,
601 704 'user_nicename' => $name,
602 705 'display_name' => $name,
603 - 'first_name' => Arr::get($formData, 'first_name'),
604 - 'last_name' => Arr::get($formData, 'last_name'),
706 + 'first_name' => $firstName,
707 + 'last_name' => $lastName,
605 708 ]);
606 709
607 710 if ($name) {
608 711 /*