PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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 1.5.5 All 68 releases
fluent-support / app / Hooks / Handlers / TwoFaHandler.php

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

273 lines 11.1 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 // 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)
157 $createdAt = $logHash['created_at'] ?? '';
158 if (($createdAt && strtotime($createdAt . ' UTC') < current_time('timestamp') - self::CODE_TTL_SECONDS) || ($logHash['used_count'] ?? 0) > 5 || ($logHash['status'] ?? '') != 'issued') {
159 wp_send_json([
160 'message' => __('Sorry, your login code has been expired. Please try to login again', 'fluent-support')
161 ], 423);
162 }
163
164 if (!wp_check_password($code, $logHash['two_fa_code_hash'])) {
165
166 $logHash['used_count'] += 1;
167
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([
171 'value' => maybe_serialize($logHash)
172 ]);
173
174 wp_send_json([
175 'message' => __('Invalid verification code', 'fluent-support')
176 ], 423);
177 }
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.
180 $logHash['status'] = 'used';
181 $consumed = Meta::where('key', $hash)->where('value', $logHashMeta->value)->update([
182 'value' => maybe_serialize($logHash)
183 ]);
184
185 if (!$consumed) {
186 wp_send_json([
187 'message' => __('Your provided code or url is not valid', 'fluent-support')
188 ], 423);
189 }
190
191 $user = get_user_by('email', $logHash['user_email']);
192
193 if (!$user) {
194 wp_send_json([
195 'message' => __('Your provided code or url is not valid', 'fluent-support')
196 ], 423);
197 }
198
199 wp_clear_auth_cookie();
200 wp_set_current_user($user->ID);
201 wp_set_auth_cookie($user->ID);
202
203 wp_send_json([
204 'redirect' => $redirectUrl
205 ], 200);
206 }
207
208 private function send2FaEmail($data, $user, $autoLoginUrl = false)
209 {
210 $emailTo = $user->user_email;
211 // translators: %1s is the site name
212 $emailSubject = sprintf(__('Your Login code for %1s', 'fluent-support'), get_bloginfo('name'));
213
214 $pStart = '<p style="font-family: Arial, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">';
215
216 // translators: %s is the user's display name
217 $message = $pStart . sprintf(__('Hello %s,', 'fluent-support'), $user->display_name) . '</p>' .
218 // translators: %s is the site name
219 $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>' .
220 // translators: %s is the two-factor authentication code
221 $pStart . '<b>' . sprintf(__('Verification Code: %s', 'fluent-support'), $data['twoFaCode']) . '</b></p>' .
222 '<br />' .
223 $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>';
224
225 $message = apply_filters('fluent_support/signup_verification_email_body', $message, $data['twoFaCode'], $data);
226
227 $data = [
228 'body' => $message,
229 'pre_header' => __('Activate your account', 'fluent-support'),
230 'show_footer' => false
231 ];
232
233 $message = Helper::loadView('notification', $data);
234 $headers = array('Content-Type: text/html; charset=UTF-8');
235
236 \wp_mail($emailTo, $emailSubject, $message, $headers);
237 }
238
239 public function get2faForm($data = [])
240 {
241 ob_start();
242 ?>
243 <form
244 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);"
245 class="fs_2fa" id="fs_2fa_form">
246 <input type="hidden" name="login_hash" value="<?php echo esc_attr($data['login_hash']); ?>"/>
247 <div style="margin-bottom: 10px;">
248 <?php esc_html_e('Please check your email inbox and enter the two-factor verification code below:', 'fluent-support'); ?>
249 </div>
250 <div style="margin-bottom: 10px;">
251 <label for="login_passcode"><?php esc_html_e('Verification Code', 'fluent-support'); ?></label>
252 <div>
253 <input
254 style="font-size: 14px; padding: 8px; border: 1px solid #ccc; border-radius: 3px; width: 100%; box-sizing: border-box;"
255 placeholder="<?php esc_html_e('Login Code', 'fluent-support'); ?>" type="text" name="login_passcode"
256 id="login_passcode" class="input" size="20"/>
257 </div>
258 </div>
259 <div>
260 <button
261 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;"
262 id="fs_2fa_confirm" type="submit">
263 <?php esc_html_e('Verify and Login', 'fluent-support'); ?>
264 </button>
265 </div>
266 </form>
267 <?php
268
269 return ob_get_clean();
270 }
271
272 }
273