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 +92 -32 1.10.2 → 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' => 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,45 +114,86 @@
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 -
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 196
137 197 wp_send_json([
138 198 'redirect' => $redirectUrl
139 199 ], 200);