PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.3.2
Fluent Support – Helpdesk & Customer Support Ticket System v2.3.2
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
fluent-support / app / Hooks / Handlers / TwoFaHandler.php

TwoFaHandler.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.3.2, at app/Hooks/Handlers/TwoFaHandler.php

268 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Hooks\Handlers;
4
5 use FluentSupport\App\Models\Meta;
6 use FluentSupport\App\Services\Helper;
7 use FluentSupport\Framework\Support\Arr;
8
9
10 class TwoFaHandler
11 {
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.
16 */
17 const CODE_TTL_SECONDS = 10 * MINUTE_IN_SECONDS;
18
19 public function maybe2FaRedirect($user = null)
20 {
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.
23 $ipKey = 'fs_2fa_send_ip_' . wp_hash(Helper::getIp());
24 $accountKey = 'fs_2fa_send_act_' . wp_hash($user->ID);
25
26 $ipExceeded = Helper::hitRateLimit($ipKey, 20);
27 $accountExceeded = Helper::hitRateLimit($accountKey, 10);
28
29 if ($ipExceeded || $accountExceeded) {
30 wp_send_json([
31 'message' => __('Too many verification code requests. Please try again after 15 minutes.', 'fluent-support')
32 ], 429);
33 }
34
35 $return = $this->sendAndGet2FaConfirmFormUrl($user, 'both');
36
37 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.
40 wp_send_json([
41 'message' => __('Unable to send verification code. Please try again later.', 'fluent-support')
42 ], 500);
43 }
44
45 $getForm = $this->get2faForm($return);
46
47 wp_send_json([
48 'load_2fa' => 'yes',
49 'two_fa_form' => $getForm
50 ]);
51 }
52
53 public function sendAndGet2FaConfirmFormUrl($user, $return = 'url')
54 {
55 try {
56 $twoFaCode = str_pad(random_int(100123, 900987), 6, 0, STR_PAD_LEFT);
57 } catch (\Exception $e) {
58 $twoFaCode = str_pad(wp_rand(100123, 900987), 6, 0, STR_PAD_LEFT);
59 }
60
61 $string = $user->ID . '-' . wp_generate_uuid4() . wp_rand(1, 99999999);
62 $hash = wp_hash_password($string);
63 $hash = sanitize_title($hash, '', 'display');
64 $hash .= $user->ID . '-' . time();
65
66 $data = array(
67 'login_hash' => $hash,
68 'user_id' => $user->ID,
69 'status' => 'issued',
70 'ip_address' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
71 'use_type' => 'email_2_fa',
72 'user_email' => $user->user_email,
73 'two_fa_code_hash' => wp_hash_password($twoFaCode),
74 'valid_till' => gmdate('Y-m-d H:i:s', current_time('timestamp') + self::CODE_TTL_SECONDS),
75 'created_at' => current_time('mysql'),
76 'updated_at' => current_time('mysql'),
77 'used_count' => 0
78 );
79
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.
82 Meta::where('object_type', 'fs_2fa')
83 ->where('object_id', $user->ID)
84 ->delete();
85
86 $savedRecord = Meta::create([
87 'object_type' => 'fs_2fa',
88 'object_id' => $user->ID,
89 'key' => $hash,
90 'value' => maybe_serialize($data),
91 ]);
92
93 if (!$savedRecord || !$savedRecord->exists) {
94 return false;
95 }
96 $data['twoFaCode'] = $twoFaCode;
97 $this->send2FaEmail($data, $user, '');
98
99 return [
100 'redirect_to' => add_query_arg([
101 'fs_2fa' => 'email',
102 'login_hash' => $hash,
103 'action' => 'fs_2fa_email'
104 ], wp_login_url()),
105 'login_hash' => $hash,
106 ];
107 }
108
109 public function verify2FaEmailCode($data)
110 {
111 $redirectUrl = Helper::getPortalBaseUrl();
112
113 $code = $data['login_passcode'];
114 $hash = $data['login_hash'];
115
116 if (!$code || !$hash) {
117 wp_send_json([
118 'message' => __('Please provide a valid login code', 'fluent-support')
119 ], 423);
120 }
121
122 $ipKey = 'fs_2fa_verify_ip_' . wp_hash(Helper::getIp());
123 if (Helper::hitRateLimit($ipKey, 20)) {
124 wp_send_json([
125 'message' => __('Too many verification attempts. Please try again after 15 minutes.', 'fluent-support')
126 ], 429);
127 }
128
129 $logHashMeta = Meta::where('key', $hash)->first();
130
131 if (!$logHashMeta) {
132 wp_send_json([
133 'message' => __('Your provided code or url is not valid', 'fluent-support')
134 ], 423);
135 }
136
137 $logHash = Helper::safeUnserialize($logHashMeta->value, []);
138
139 if (!$logHash) {
140 wp_send_json([
141 'message' => __('Your provided code or url is not valid', 'fluent-support')
142 ], 423);
143 }
144
145 $accountKey = 'fs_2fa_verify_act_' . wp_hash($logHash['user_id'] ?? '');
146 if (Helper::hitRateLimit($accountKey, 10)) {
147 wp_send_json([
148 'message' => __('Too many verification attempts. Please try again after 15 minutes.', 'fluent-support')
149 ], 429);
150 }
151
152 $createdAt = $logHash['created_at'] ?? '';
153 if (($createdAt && strtotime($createdAt) < current_time('timestamp') - self::CODE_TTL_SECONDS) || ($logHash['used_count'] ?? 0) > 5 || ($logHash['status'] ?? '') != 'issued') {
154 wp_send_json([
155 'message' => __('Sorry, your login code has been expired. Please try to login again', 'fluent-support')
156 ], 423);
157 }
158
159 if (!wp_check_password($code, $logHash['two_fa_code_hash'])) {
160
161 $logHash['used_count'] += 1;
162
163 // Atomic conditional update: only applies if the row hasn't changed since we read it,
164 // preventing concurrent requests from racing past the attempt limit.
165 Meta::where('key', $hash)->where('value', $logHashMeta->value)->update([
166 'value' => maybe_serialize($logHash)
167 ]);
168
169 wp_send_json([
170 'message' => __('Invalid verification code', 'fluent-support')
171 ], 423);
172 }
173 // Consume the code atomically before logging in: only one concurrent request
174 // can win this update, so only one can ever log in with this code.
175 $logHash['status'] = 'used';
176 $consumed = Meta::where('key', $hash)->where('value', $logHashMeta->value)->update([
177 'value' => maybe_serialize($logHash)
178 ]);
179
180 if (!$consumed) {
181 wp_send_json([
182 'message' => __('Your provided code or url is not valid', 'fluent-support')
183 ], 423);
184 }
185
186 $user = get_user_by('email', $logHash['user_email']);
187
188 if (!$user) {
189 wp_send_json([
190 'message' => __('Your provided code or url is not valid', 'fluent-support')
191 ], 423);
192 }
193
194 wp_clear_auth_cookie();
195 wp_set_current_user($user->ID);
196 wp_set_auth_cookie($user->ID);
197
198 wp_send_json([
199 'redirect' => $redirectUrl
200 ], 200);
201 }
202
203 private function send2FaEmail($data, $user, $autoLoginUrl = false)
204 {
205 $emailTo = $user->user_email;
206 // translators: %1s is the site name
207 $emailSubject = sprintf(__('Your Login code for %1s', 'fluent-support'), get_bloginfo('name'));
208
209 $pStart = '<p style="font-family: Arial, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">';
210
211 // translators: %s is the user's display name
212 $message = $pStart . sprintf(__('Hello %s,', 'fluent-support'), $user->display_name) . '</p>' .
213 // translators: %s is the site name
214 $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>' .
215 // translators: %s is the two-factor authentication code
216 $pStart . '<b>' . sprintf(__('Verification Code: %s', 'fluent-support'), $data['twoFaCode']) . '</b></p>' .
217 '<br />' .
218 $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>';
219
220 $message = apply_filters('fluent_support/signup_verification_email_body', $message, $data['twoFaCode'], $data);
221
222 $data = [
223 'body' => $message,
224 'pre_header' => __('Activate your account', 'fluent-support'),
225 'show_footer' => false
226 ];
227
228 $message = Helper::loadView('notification', $data);
229 $headers = array('Content-Type: text/html; charset=UTF-8');
230
231 \wp_mail($emailTo, $emailSubject, $message, $headers);
232 }
233
234 public function get2faForm($data = [])
235 {
236 ob_start();
237 ?>
238 <form
239 style="margin-top: 20px; padding: 20px; font-weight: 400; overflow: hidden; background: #f6f6f6; border: 1px solid #ccc; box-shadow: 0 0 10px rgba(0,0,0,.15);"
240 class="fs_2fa" id="fs_2fa_form">
241 <input type="hidden" name="login_hash" value="<?php echo esc_attr($data['login_hash']); ?>"/>
242 <div style="margin-bottom: 10px;">
243 <?php esc_html_e('Please check your email inbox and enter the two-factor verification code below:', 'fluent-support'); ?>
244 </div>
245 <div style="margin-bottom: 10px;">
246 <label for="login_passcode"><?php esc_html_e('Verification Code', 'fluent-support'); ?></label>
247 <div>
248 <input
249 style="font-size: 14px; padding: 8px; border: 1px solid #ccc; border-radius: 3px; width: 100%; box-sizing: border-box;"
250 placeholder="<?php esc_html_e('Login Code', 'fluent-support'); ?>" type="text" name="login_passcode"
251 id="login_passcode" class="input" size="20"/>
252 </div>
253 </div>
254 <div>
255 <button
256 style="display: inline-block; cursor: pointer; border: 0; background: #2271b1; color: #fff; text-decoration: none; text-shadow: none; min-height: 32px; padding: 8px 24px; font-size: 14px; border-radius: 3px;"
257 id="fs_2fa_confirm" type="submit">
258 <?php esc_html_e('Verify and Login', 'fluent-support'); ?>
259 </button>
260 </div>
261 </form>
262 <?php
263
264 return ob_get_clean();
265 }
266
267 }
268