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
fluent-support / app / Hooks / Handlers / TwoFaHandler.php

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

267 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 * Shared by valid_till, the email copy and the expiry check in verify2FaEmailCode().
14 */
15 const CODE_TTL_SECONDS = 10 * MINUTE_IN_SECONDS;
16
17 public function maybe2FaRedirect($user = null)
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
33 $return = $this->sendAndGet2FaConfirmFormUrl($user, 'both');
34
35 if (!$return) {
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);
40 }
41
42 $getForm = $this->get2faForm($return);
43
44 wp_send_json([
45 'load_2fa' => 'yes',
46 'two_fa_form' => $getForm
47 ]);
48 }
49
50 public function sendAndGet2FaConfirmFormUrl($user, $return = 'url')
51 {
52 try {
53 $twoFaCode = str_pad(random_int(100123, 900987), 6, 0, STR_PAD_LEFT);
54 } catch (\Exception $e) {
55 $twoFaCode = str_pad(wp_rand(100123, 900987), 6, 0, STR_PAD_LEFT);
56 }
57
58 $string = $user->ID . '-' . wp_generate_uuid4() . wp_rand(1, 99999999);
59 $hash = wp_hash_password($string);
60 $hash = sanitize_title($hash, '', 'display');
61 $hash .= $user->ID . '-' . time();
62
63 $data = array(
64 'login_hash' => $hash,
65 'user_id' => $user->ID,
66 'status' => 'issued',
67 'ip_address' => Helper::getIp(),
68 'use_type' => 'email_2_fa',
69 'user_email' => $user->user_email,
70 'two_fa_code_hash' => wp_hash_password($twoFaCode),
71 'valid_till' => gmdate('Y-m-d H:i:s', current_time('timestamp') + self::CODE_TTL_SECONDS),
72 'created_at' => current_time('mysql'),
73 'updated_at' => current_time('mysql'),
74 'used_count' => 0
75 );
76
77 // One outstanding challenge per account.
78 Meta::where('object_type', 'fs_2fa')
79 ->where('object_id', $user->ID)
80 ->delete();
81
82 $savedRecord = Meta::create([
83 'object_type' => 'fs_2fa',
84 'object_id' => $user->ID,
85 'key' => $hash,
86 'value' => maybe_serialize($data),
87 ]);
88
89 if (!$savedRecord || !$savedRecord->exists) {
90 return false;
91 }
92 $data['twoFaCode'] = $twoFaCode;
93 $this->send2FaEmail($data, $user, '');
94
95 return [
96 'redirect_to' => add_query_arg([
97 'fs_2fa' => 'email',
98 'login_hash' => $hash,
99 'action' => 'fs_2fa_email'
100 ], wp_login_url()),
101 'login_hash' => $hash,
102 ];
103 }
104
105 public function verify2FaEmailCode($data)
106 {
107 $redirectUrl = Helper::getPortalBaseUrl();
108
109 $code = $data['login_passcode'];
110 $hash = $data['login_hash'];
111
112 if (!$code || !$hash) {
113 wp_send_json([
114 'message' => __('Please provide a valid login code', 'fluent-support')
115 ], 423);
116 }
117
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 }
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
135 if (!$logHash) {
136 wp_send_json([
137 'message' => __('Your provided code or url is not valid', 'fluent-support')
138 ], 423);
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
157 if (!wp_check_password($code, $logHash['two_fa_code_hash'])) {
158
159 $logHash['used_count'] += 1;
160
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([
163 'value' => maybe_serialize($logHash)
164 ]);
165
166 wp_send_json([
167 'message' => __('Invalid verification code', 'fluent-support')
168 ], 423);
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 ]);
175
176 if (!$consumed) {
177 wp_send_json([
178 'message' => __('Your provided code or url is not valid', 'fluent-support')
179 ], 423);
180 }
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
193 wp_clear_auth_cookie();
194 wp_set_current_user($user->ID);
195 wp_set_auth_cookie($user->ID);
196
197 wp_send_json([
198 'redirect' => $redirectUrl
199 ], 200);
200 }
201
202 private function send2FaEmail($data, $user, $autoLoginUrl = false)
203 {
204 $emailTo = $user->user_email;
205 // translators: %1s is the site name
206 $emailSubject = sprintf(__('Your Login code for %1s', 'fluent-support'), get_bloginfo('name'));
207
208 $pStart = '<p style="font-family: Arial, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">';
209
210 // translators: %s is the user's display name
211 $message = $pStart . sprintf(__('Hello %s,', 'fluent-support'), $user->display_name) . '</p>' .
212 // translators: %s is the site name
213 $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>' .
214 // translators: %s is the two-factor authentication code
215 $pStart . '<b>' . sprintf(__('Verification Code: %s', 'fluent-support'), $data['twoFaCode']) . '</b></p>' .
216 '<br />' .
217 $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>';
218
219 $message = apply_filters('fluent_support/signup_verification_email_body', $message, $data['twoFaCode'], $data);
220
221 $data = [
222 'body' => $message,
223 'pre_header' => __('Activate your account', 'fluent-support'),
224 'show_footer' => false
225 ];
226
227 $message = Helper::loadView('notification', $data);
228 $headers = array('Content-Type: text/html; charset=UTF-8');
229
230 \wp_mail($emailTo, $emailSubject, $message, $headers);
231 }
232
233 public function get2faForm($data = [])
234 {
235 ob_start();
236 ?>
237 <form
238 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);"
239 class="fs_2fa" id="fs_2fa_form">
240 <input type="hidden" name="login_hash" value="<?php echo esc_attr($data['login_hash']); ?>"/>
241 <div style="margin-bottom: 10px;">
242 <?php esc_html_e('Please check your email inbox and enter the two-factor verification code below:', 'fluent-support'); ?>
243 </div>
244 <div style="margin-bottom: 10px;">
245 <label for="login_passcode"><?php esc_html_e('Verification Code', 'fluent-support'); ?></label>
246 <div>
247 <input
248 style="font-size: 14px; padding: 8px; border: 1px solid #ccc; border-radius: 3px; width: 100%; box-sizing: border-box;"
249 placeholder="<?php esc_html_e('Login Code', 'fluent-support'); ?>" type="text" name="login_passcode"
250 id="login_passcode" class="input" size="20"/>
251 </div>
252 </div>
253 <div>
254 <button
255 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;"
256 id="fs_2fa_confirm" type="submit">
257 <?php esc_html_e('Verify and Login', 'fluent-support'); ?>
258 </button>
259 </div>
260 </form>
261 <?php
262
263 return ob_get_clean();
264 }
265
266 }
267