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/Hooks/Handlers/TwoFaHandler.php +99 -35 1.10.0 → 2.4.5 View file →
@@ -8,14 +8,36 @@
8 8
9 9
10 10 class TwoFaHandler
11 11 {
12 + /**
13 + * Shared by valid_till, the email copy and the expiry check in verify2FaEmailCode().
14 + */
15 + const CODE_TTL_SECONDS = 10 * MINUTE_IN_SECONDS;
16 +
12 17 public function maybe2FaRedirect($user = null)
13 18 {
19 + // The account bucket is the main throttle. The IP bucket is looser because
20 + // offices and NATs share IPs.
21 + $ipKey = 'fs_2fa_send_ip_' . wp_hash(Helper::getIp());
22 + $accountKey = 'fs_2fa_send_act_' . wp_hash($user->ID);
23 +
24 + $ipExceeded = Helper::hitRateLimit($ipKey, 20);
25 + $accountExceeded = Helper::hitRateLimit($accountKey, 10);
26 +
27 + if ($ipExceeded || $accountExceeded) {
28 + wp_send_json([
29 + 'message' => __('Too many verification code requests. Please try again after 15 minutes.', 'fluent-support')
30 + ], 429);
31 + }
32 +
14 33 $return = $this->sendAndGet2FaConfirmFormUrl($user, 'both');
15 34
16 35 if (!$return) {
17 - return false;
36 + // Fail closed so the caller can't fall through to a password-only login.
37 + wp_send_json([
38 + 'message' => __('Unable to send verification code. Please try again later.', 'fluent-support')
39 + ], 500);
18 40 }
19 41
20 42 $getForm = $this->get2faForm($return);
21 43
@@ -41,34 +63,31 @@
41 63 $data = array(
42 64 'login_hash' => $hash,
43 65 'user_id' => $user->ID,
44 66 'status' => 'issued',
45 - 'ip_address' => $_SERVER['HTTP_USER_AGENT'],
67 + 'ip_address' => Helper::getIp(),
46 68 'use_type' => 'email_2_fa',
47 69 'user_email' => $user->user_email,
48 70 'two_fa_code_hash' => wp_hash_password($twoFaCode),
49 - 'valid_till' => date('Y-m-d H:i:s', current_time('timestamp') + 10 * 30),
71 + 'valid_till' => gmdate('Y-m-d H:i:s', current_time('timestamp') + self::CODE_TTL_SECONDS),
50 72 'created_at' => current_time('mysql'),
51 73 'updated_at' => current_time('mysql'),
52 74 'used_count' => 0
53 75 );
54 76
55 - $existingRecord = Meta::where('key', $hash)->first();
77 + // One outstanding challenge per account.
78 + Meta::where('object_type', 'fs_2fa')
79 + ->where('object_id', $user->ID)
80 + ->delete();
56 81
57 - if ($existingRecord) {
58 - $saveSettingsData = Meta::where('key', $hash)->update([
59 - 'value' => maybe_serialize($data)
60 - ]);
61 - } else {
62 - $saveSettingsData = Meta::updateOrInsert([
63 - 'object_type' => 'fs_2fa',
64 - 'key' => $hash,
65 - ], [
66 - 'value' => maybe_serialize($data)
67 - ]);
68 - }
82 + $savedRecord = Meta::create([
83 + 'object_type' => 'fs_2fa',
84 + 'object_id' => $user->ID,
85 + 'key' => $hash,
86 + 'value' => maybe_serialize($data),
87 + ]);
69 88
70 - if (!$saveSettingsData) {
89 + if (!$savedRecord || !$savedRecord->exists) {
71 90 return false;
72 91 }
73 92 $data['twoFaCode'] = $twoFaCode;
74 93 $this->send2FaEmail($data, $user, '');
@@ -95,46 +114,87 @@
95 114 'message' => __('Please provide a valid login code', 'fluent-support')
96 115 ], 423);
97 116 }
98 117
99 - $logHash = Meta::where('key', $hash)->first();
100 - $logHash = Helper::safeUnserialize($logHash->value, []);
118 + $ipKey = 'fs_2fa_verify_ip_' . wp_hash(Helper::getIp());
119 + if (Helper::hitRateLimit($ipKey, 20)) {
120 + wp_send_json([
121 + 'message' => __('Too many verification attempts. Please try again after 15 minutes.', 'fluent-support')
122 + ], 429);
123 + }
101 124
125 + $logHashMeta = Meta::where('object_type', 'fs_2fa')->where('key', $hash)->first();
126 +
127 + if (!$logHashMeta) {
128 + wp_send_json([
129 + 'message' => __('Your provided code or url is not valid', 'fluent-support')
130 + ], 423);
131 + }
132 +
133 + $logHash = Helper::safeUnserialize($logHashMeta->value, []);
134 +
102 135 if (!$logHash) {
103 136 wp_send_json([
104 137 'message' => __('Your provided code or url is not valid', 'fluent-support')
105 138 ], 423);
106 139 }
140 +
141 + $accountKey = 'fs_2fa_verify_act_' . wp_hash($logHash['user_id'] ?? '');
142 + if (Helper::hitRateLimit($accountKey, 10)) {
143 + wp_send_json([
144 + 'message' => __('Too many verification attempts. Please try again after 15 minutes.', 'fluent-support')
145 + ], 429);
146 + }
147 +
148 + // created_at and current_time('timestamp') share WP's site-offset clock. Parse it as
149 + // UTC so a plugin changing PHP's default timezone can't shift the expiry.
150 + $createdAt = $logHash['created_at'] ?? '';
151 + if (($createdAt && strtotime($createdAt . ' UTC') < current_time('timestamp') - self::CODE_TTL_SECONDS) || ($logHash['used_count'] ?? 0) > 5 || ($logHash['status'] ?? '') != 'issued') {
152 + wp_send_json([
153 + 'message' => __('Sorry, your login code has been expired. Please try to login again', 'fluent-support')
154 + ], 423);
155 + }
156 +
107 157 if (!wp_check_password($code, $logHash['two_fa_code_hash'])) {
108 158
109 159 $logHash['used_count'] += 1;
110 160
111 - Meta::where('key', $hash)->update([
161 + // Compare-and-set on the old value so concurrent guesses can't race past the attempt limit.
162 + Meta::where('object_type', 'fs_2fa')->where('key', $hash)->where('value', $logHashMeta->value)->update([
112 163 'value' => maybe_serialize($logHash)
113 164 ]);
114 165
115 - return false;
166 + wp_send_json([
167 + 'message' => __('Invalid verification code', 'fluent-support')
168 + ], 423);
116 169 }
170 + // Compare-and-set consume, so only one concurrent request can log in with this code.
171 + $logHash['status'] = 'used';
172 + $consumed = Meta::where('object_type', 'fs_2fa')->where('key', $hash)->where('value', $logHashMeta->value)->update([
173 + 'value' => maybe_serialize($logHash)
174 + ]);
117 175
118 - if (strtotime($logHash['created_at']) < current_time('timestamp') - 600 || $logHash['used_count'] > 5 || $logHash['status'] != 'issued') {
176 + if (!$consumed) {
119 177 wp_send_json([
120 - 'message' => __('Sorry, your login code has been expired. Please try to login again', 'fluent-support')
178 + 'message' => __('Your provided code or url is not valid', 'fluent-support')
121 179 ], 423);
122 180 }
123 - $user = get_user_by('email', $logHash['user_email']);
124 181
182 + // Log in the account the code was issued to, and only while it still owns the
183 + // email the code went to. An email lookup alone would follow the address to
184 + // whichever account holds it now.
185 + $user = get_user_by('ID', (int) ($logHash['user_id'] ?? 0));
186 +
187 + if (!$user || strcasecmp((string) $user->user_email, (string) ($logHash['user_email'] ?? '')) !== 0) {
188 + wp_send_json([
189 + 'message' => __('Your provided code or url is not valid', 'fluent-support')
190 + ], 423);
191 + }
192 +
125 193 wp_clear_auth_cookie();
126 194 wp_set_current_user($user->ID);
127 195 wp_set_auth_cookie($user->ID);
128 196
129 - if (is_user_logged_in()) {
130 - $logHash['status'] = 'used';
131 -
132 - Meta::where('key', $hash)->update([
133 - 'value' => maybe_serialize($logHash)
134 - ]);
135 - }
136 -
137 197 wp_send_json([
138 198 'redirect' => $redirectUrl
139 199 ], 200);
140 200 }
@@ -141,23 +201,27 @@
141 201
142 202 private function send2FaEmail($data, $user, $autoLoginUrl = false)
143 203 {
144 204 $emailTo = $user->user_email;
205 + // translators: %1s is the site name
145 206 $emailSubject = sprintf(__('Your Login code for %1s', 'fluent-support'), get_bloginfo('name'));
146 207
147 208 $pStart = '<p style="font-family: Arial, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">';
148 209
210 + // translators: %s is the user's display name
149 211 $message = $pStart . sprintf(__('Hello %s,', 'fluent-support'), $user->display_name) . '</p>' .
212 + // translators: %s is the site name
150 213 $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')) . '</p>' .
151 - $pStart . '<b>' . sprintf(__('Verification Code: %s', 'fluent-security'), $data['twoFaCode']) . '</b></p>' .
214 + // translators: %s is the two-factor authentication code
215 + $pStart . '<b>' . sprintf(__('Verification Code: %s', 'fluent-support'), $data['twoFaCode']) . '</b></p>' .
152 216 '<br />' .
153 - $pStart . __('This code is valid for 10 minutes and is meant to ensure the security of your account. If you did not initiate this request, please ignore this email.', 'fluent-security') . '</p>';
217 + $pStart . __('This code is valid for 10 minutes and is meant to ensure the security of your account. If you did not initiate this request, please ignore this email.', 'fluent-support') . '</p>';
154 218
155 219 $message = apply_filters('fluent_support/signup_verification_email_body', $message, $data['twoFaCode'], $data);
156 220
157 221 $data = [
158 222 'body' => $message,
159 - 'pre_header' => __('Activate your account', 'fluent-security'),
223 + 'pre_header' => __('Activate your account', 'fluent-support'),
160 224 'show_footer' => false
161 225 ];
162 226
163 227 $message = Helper::loadView('notification', $data);