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 +84 -32 2.2.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' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_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' => gmdate('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,10 +114,17 @@
95 114 'message' => __('Please provide a valid login code', 'fluent-support')
96 115 ], 423);
97 116 }
98 117
99 - $logHashMeta = Meta::where('key', $hash)->first();
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 + }
100 124
125 + $logHashMeta = Meta::where('object_type', 'fs_2fa')->where('key', $hash)->first();
126 +
101 127 if (!$logHashMeta) {
102 128 wp_send_json([
103 129 'message' => __('Your provided code or url is not valid', 'fluent-support')
104 130 ], 423);
@@ -110,38 +136,64 @@
110 136 wp_send_json([
111 137 'message' => __('Your provided code or url is not valid', 'fluent-support')
112 138 ], 423);
113 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 +
114 157 if (!wp_check_password($code, $logHash['two_fa_code_hash'])) {
115 158
116 159 $logHash['used_count'] += 1;
117 160
118 - 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([
119 163 'value' => maybe_serialize($logHash)
120 164 ]);
121 165
122 - return false;
166 + wp_send_json([
167 + 'message' => __('Invalid verification code', 'fluent-support')
168 + ], 423);
123 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 + ]);
124 175
125 - $createdAt = $logHash['created_at'] ?? '';
126 - if (($createdAt && strtotime($createdAt) < current_time('timestamp') - 600) || ($logHash['used_count'] ?? 0) > 5 || ($logHash['status'] ?? '') != 'issued') {
176 + if (!$consumed) {
127 177 wp_send_json([
128 - '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')
129 179 ], 423);
130 180 }
131 - $user = get_user_by('email', $logHash['user_email']);
132 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 +
133 193 wp_clear_auth_cookie();
134 194 wp_set_current_user($user->ID);
135 195 wp_set_auth_cookie($user->ID);
136 -
137 - if (is_user_logged_in()) {
138 - $logHash['status'] = 'used';
139 -
140 - Meta::where('key', $hash)->update([
141 - 'value' => maybe_serialize($logHash)
142 - ]);
143 - }
144 196
145 197 wp_send_json([
146 198 'redirect' => $redirectUrl
147 199 ], 200);