ID); $ipExceeded = Helper::hitRateLimit($ipKey, 20); $accountExceeded = Helper::hitRateLimit($accountKey, 10); if ($ipExceeded || $accountExceeded) { wp_send_json([ 'message' => __('Too many verification code requests. Please try again after 15 minutes.', 'fluent-support') ], 429); } $return = $this->sendAndGet2FaConfirmFormUrl($user, 'both'); if (!$return) { // Fail closed: if the OTP couldn't be issued/persisted, do not let the // caller fall through to a normal (non-2FA) login with the already-verified password. wp_send_json([ 'message' => __('Unable to send verification code. Please try again later.', 'fluent-support') ], 500); } $getForm = $this->get2faForm($return); wp_send_json([ 'load_2fa' => 'yes', 'two_fa_form' => $getForm ]); } public function sendAndGet2FaConfirmFormUrl($user, $return = 'url') { try { $twoFaCode = str_pad(random_int(100123, 900987), 6, 0, STR_PAD_LEFT); } catch (\Exception $e) { $twoFaCode = str_pad(wp_rand(100123, 900987), 6, 0, STR_PAD_LEFT); } $string = $user->ID . '-' . wp_generate_uuid4() . wp_rand(1, 99999999); $hash = wp_hash_password($string); $hash = sanitize_title($hash, '', 'display'); $hash .= $user->ID . '-' . time(); $data = array( 'login_hash' => $hash, 'user_id' => $user->ID, 'status' => 'issued', 'ip_address' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '', 'use_type' => 'email_2_fa', 'user_email' => $user->user_email, 'two_fa_code_hash' => wp_hash_password($twoFaCode), 'valid_till' => gmdate('Y-m-d H:i:s', current_time('timestamp') + self::CODE_TTL_SECONDS), 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql'), 'used_count' => 0 ); // Only one outstanding 2FA challenge per account: invalidate any previous // issued codes before minting a new one, instead of letting them pile up. Meta::where('object_type', 'fs_2fa') ->where('object_id', $user->ID) ->delete(); $savedRecord = Meta::create([ 'object_type' => 'fs_2fa', 'object_id' => $user->ID, 'key' => $hash, 'value' => maybe_serialize($data), ]); if (!$savedRecord || !$savedRecord->exists) { return false; } $data['twoFaCode'] = $twoFaCode; $this->send2FaEmail($data, $user, ''); return [ 'redirect_to' => add_query_arg([ 'fs_2fa' => 'email', 'login_hash' => $hash, 'action' => 'fs_2fa_email' ], wp_login_url()), 'login_hash' => $hash, ]; } public function verify2FaEmailCode($data) { $redirectUrl = Helper::getPortalBaseUrl(); $code = $data['login_passcode']; $hash = $data['login_hash']; if (!$code || !$hash) { wp_send_json([ 'message' => __('Please provide a valid login code', 'fluent-support') ], 423); } $ipKey = 'fs_2fa_verify_ip_' . wp_hash(Helper::getIp()); if (Helper::hitRateLimit($ipKey, 20)) { wp_send_json([ 'message' => __('Too many verification attempts. Please try again after 15 minutes.', 'fluent-support') ], 429); } $logHashMeta = Meta::where('key', $hash)->first(); if (!$logHashMeta) { wp_send_json([ 'message' => __('Your provided code or url is not valid', 'fluent-support') ], 423); } $logHash = Helper::safeUnserialize($logHashMeta->value, []); if (!$logHash) { wp_send_json([ 'message' => __('Your provided code or url is not valid', 'fluent-support') ], 423); } $accountKey = 'fs_2fa_verify_act_' . wp_hash($logHash['user_id'] ?? ''); if (Helper::hitRateLimit($accountKey, 10)) { wp_send_json([ 'message' => __('Too many verification attempts. Please try again after 15 minutes.', 'fluent-support') ], 429); } // created_at is a current_time('mysql') string (gmdate() on a current_time('timestamp') // basis, always UTC-equivalent since WP resets the runtime timezone to UTC on every // request), so it must be parsed back as UTC here - otherwise strtotime() would silently // reinterpret it under whatever timezone a plugin/theme may have switched to via // date_default_timezone_set() without restoring it, causing false expiry (or non-expiry) $createdAt = $logHash['created_at'] ?? ''; if (($createdAt && strtotime($createdAt . ' UTC') < current_time('timestamp') - self::CODE_TTL_SECONDS) || ($logHash['used_count'] ?? 0) > 5 || ($logHash['status'] ?? '') != 'issued') { wp_send_json([ 'message' => __('Sorry, your login code has been expired. Please try to login again', 'fluent-support') ], 423); } if (!wp_check_password($code, $logHash['two_fa_code_hash'])) { $logHash['used_count'] += 1; // Atomic conditional update: only applies if the row hasn't changed since we read it, // preventing concurrent requests from racing past the attempt limit. Meta::where('key', $hash)->where('value', $logHashMeta->value)->update([ 'value' => maybe_serialize($logHash) ]); wp_send_json([ 'message' => __('Invalid verification code', 'fluent-support') ], 423); } // Consume the code atomically before logging in: only one concurrent request // can win this update, so only one can ever log in with this code. $logHash['status'] = 'used'; $consumed = Meta::where('key', $hash)->where('value', $logHashMeta->value)->update([ 'value' => maybe_serialize($logHash) ]); if (!$consumed) { wp_send_json([ 'message' => __('Your provided code or url is not valid', 'fluent-support') ], 423); } $user = get_user_by('email', $logHash['user_email']); if (!$user) { wp_send_json([ 'message' => __('Your provided code or url is not valid', 'fluent-support') ], 423); } wp_clear_auth_cookie(); wp_set_current_user($user->ID); wp_set_auth_cookie($user->ID); wp_send_json([ 'redirect' => $redirectUrl ], 200); } private function send2FaEmail($data, $user, $autoLoginUrl = false) { $emailTo = $user->user_email; // translators: %1s is the site name $emailSubject = sprintf(__('Your Login code for %1s', 'fluent-support'), get_bloginfo('name')); $pStart = '
'; // translators: %s is the user's display name $message = $pStart . sprintf(__('Hello %s,', 'fluent-support'), $user->display_name) . '
' . // translators: %s is the site name $pStart . sprintf(__('Someone requested to login to %s and here is the Login code that you can use in the login form', 'fluent-support'), get_bloginfo('name')) . '' . // translators: %s is the two-factor authentication code $pStart . '' . sprintf(__('Verification Code: %s', 'fluent-support'), $data['twoFaCode']) . '' . '