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 +18 -24 2.4.0 → 2.4.5 View file →
@@ -9,18 +9,16 @@
9 9
10 10 class TwoFaHandler
11 11 {
12 12 /**
13 - * How long an issued 2FA code stays valid, in seconds. Kept as a single
14 - * constant so the "valid_till" value, the email copy, and the expiry
15 - * check in verify2FaEmailCode() can never drift apart again.
13 + * Shared by valid_till, the email copy and the expiry check in verify2FaEmailCode().
16 14 */
17 15 const CODE_TTL_SECONDS = 10 * MINUTE_IN_SECONDS;
18 16
19 17 public function maybe2FaRedirect($user = null)
20 18 {
21 - // IP bucket is a generous volumetric backstop (shared office/NAT IPs can have
22 - // many unrelated accounts logging in); the account bucket is the primary throttle.
19 + // The account bucket is the main throttle. The IP bucket is looser because
20 + // offices and NATs share IPs.
23 21 $ipKey = 'fs_2fa_send_ip_' . wp_hash(Helper::getIp());
24 22 $accountKey = 'fs_2fa_send_act_' . wp_hash($user->ID);
25 23
26 24 $ipExceeded = Helper::hitRateLimit($ipKey, 20);
@@ -34,10 +32,9 @@
34 32
35 33 $return = $this->sendAndGet2FaConfirmFormUrl($user, 'both');
36 34
37 35 if (!$return) {
38 - // Fail closed: if the OTP couldn't be issued/persisted, do not let the
39 - // caller fall through to a normal (non-2FA) login with the already-verified password.
36 + // Fail closed so the caller can't fall through to a password-only login.
40 37 wp_send_json([
41 38 'message' => __('Unable to send verification code. Please try again later.', 'fluent-support')
42 39 ], 500);
43 40 }
@@ -66,9 +63,9 @@
66 63 $data = array(
67 64 'login_hash' => $hash,
68 65 'user_id' => $user->ID,
69 66 'status' => 'issued',
70 - 'ip_address' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
67 + 'ip_address' => Helper::getIp(),
71 68 'use_type' => 'email_2_fa',
72 69 'user_email' => $user->user_email,
73 70 'two_fa_code_hash' => wp_hash_password($twoFaCode),
74 71 'valid_till' => gmdate('Y-m-d H:i:s', current_time('timestamp') + self::CODE_TTL_SECONDS),
@@ -76,10 +73,9 @@
76 73 'updated_at' => current_time('mysql'),
77 74 'used_count' => 0
78 75 );
79 76
80 - // Only one outstanding 2FA challenge per account: invalidate any previous
81 - // issued codes before minting a new one, instead of letting them pile up.
77 + // One outstanding challenge per account.
82 78 Meta::where('object_type', 'fs_2fa')
83 79 ->where('object_id', $user->ID)
84 80 ->delete();
85 81
@@ -125,9 +121,9 @@
125 121 'message' => __('Too many verification attempts. Please try again after 15 minutes.', 'fluent-support')
126 122 ], 429);
127 123 }
128 124
129 - $logHashMeta = Meta::where('key', $hash)->first();
125 + $logHashMeta = Meta::where('object_type', 'fs_2fa')->where('key', $hash)->first();
130 126
131 127 if (!$logHashMeta) {
132 128 wp_send_json([
133 129 'message' => __('Your provided code or url is not valid', 'fluent-support')
@@ -148,13 +144,10 @@
148 144 'message' => __('Too many verification attempts. Please try again after 15 minutes.', 'fluent-support')
149 145 ], 429);
150 146 }
151 147
152 - // created_at is a current_time('mysql') string (gmdate() on a current_time('timestamp')
153 - // basis, always UTC-equivalent since WP resets the runtime timezone to UTC on every
154 - // request), so it must be parsed back as UTC here - otherwise strtotime() would silently
155 - // reinterpret it under whatever timezone a plugin/theme may have switched to via
156 - // date_default_timezone_set() without restoring it, causing false expiry (or non-expiry)
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.
157 150 $createdAt = $logHash['created_at'] ?? '';
158 151 if (($createdAt && strtotime($createdAt . ' UTC') < current_time('timestamp') - self::CODE_TTL_SECONDS) || ($logHash['used_count'] ?? 0) > 5 || ($logHash['status'] ?? '') != 'issued') {
159 152 wp_send_json([
160 153 'message' => __('Sorry, your login code has been expired. Please try to login again', 'fluent-support')
@@ -164,11 +157,10 @@
164 157 if (!wp_check_password($code, $logHash['two_fa_code_hash'])) {
165 158
166 159 $logHash['used_count'] += 1;
167 160
168 - // Atomic conditional update: only applies if the row hasn't changed since we read it,
169 - // preventing concurrent requests from racing past the attempt limit.
170 - Meta::where('key', $hash)->where('value', $logHashMeta->value)->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([
171 163 'value' => maybe_serialize($logHash)
172 164 ]);
173 165
174 166 wp_send_json([
@@ -174,12 +166,11 @@
174 166 wp_send_json([
175 167 'message' => __('Invalid verification code', 'fluent-support')
176 168 ], 423);
177 169 }
178 - // Consume the code atomically before logging in: only one concurrent request
179 - // can win this update, so only one can ever log in with this code.
170 + // Compare-and-set consume, so only one concurrent request can log in with this code.
180 171 $logHash['status'] = 'used';
181 - $consumed = Meta::where('key', $hash)->where('value', $logHashMeta->value)->update([
172 + $consumed = Meta::where('object_type', 'fs_2fa')->where('key', $hash)->where('value', $logHashMeta->value)->update([
182 173 'value' => maybe_serialize($logHash)
183 174 ]);
184 175
185 176 if (!$consumed) {
@@ -187,11 +178,14 @@
187 178 'message' => __('Your provided code or url is not valid', 'fluent-support')
188 179 ], 423);
189 180 }
190 181
191 - $user = get_user_by('email', $logHash['user_email']);
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));
192 186
193 - if (!$user) {
187 + if (!$user || strcasecmp((string) $user->user_email, (string) ($logHash['user_email'] ?? '')) !== 0) {
194 188 wp_send_json([
195 189 'message' => __('Your provided code or url is not valid', 'fluent-support')
196 190 ], 423);
197 191 }