PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-account-protection / src / class-password-detection.php

class-password-detection.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at jetpack_vendor/automattic/jetpack-account-protection/src/class-password-detection.php

784 lines 27.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class used to define Password Detection.
4 *
5 * @package automattic/jetpack-account-protection
6 */
7
8 namespace Automattic\Jetpack\Account_Protection;
9
10 use Automattic\Jetpack\Assets\Logo as Jetpack_Logo;
11
12 /**
13 * Class Password_Detection
14 */
15 class Password_Detection {
16 /**
17 * Email service dependency.
18 *
19 * @var Email_Service
20 */
21 private $email_service;
22
23 /**
24 * Validation service dependency.
25 *
26 * @var Validation_Service
27 */
28 private $validation_service;
29
30 /**
31 * Values of the attempt locks this request holds, keyed by user ID.
32 *
33 * @var string[]
34 */
35 private $attempt_locks = array();
36
37 /**
38 * Password_Detection constructor.
39 *
40 * @param ?Email_Service $email_service Email service instance.
41 * @param ?Validation_Service $validation_service Validation service instance.
42 */
43 public function __construct( ?Email_Service $email_service = null, ?Validation_Service $validation_service = null ) {
44 $this->email_service = $email_service ?? new Email_Service();
45 $this->validation_service = $validation_service ?? new Validation_Service();
46 }
47
48 /**
49 * Check if the password is safe after login.
50 *
51 * @param \WP_User|\WP_Error|null $user The user or error object, or null.
52 * @param string|null $password The password.
53 *
54 * @return \WP_User|\WP_Error|null The user object, error object, or null.
55 */
56 public function login_form_password_detection( $user, ?string $password = null ) {
57 // First check if the user object and password are valid. Third-party plugins might pass
58 // incompatible types to authentication hooks, so we need this extra check.
59 if ( is_wp_error( $user ) || ! ( $user instanceof \WP_User ) || $password === null ) {
60 return $user;
61 }
62
63 if ( ! $this->user_requires_protection( $user, $password ) ) {
64 return $user;
65 }
66
67 // Skip if we're validating a Brute force protection recovery token
68 if ( get_transient( 'jetpack_protect_recovery_key_validated_' . $user->ID ) ) {
69 return $user;
70 }
71
72 if ( ! $this->validation_service->is_leaked_password( $password ) ) {
73 return $user;
74 }
75
76 $auth_code = $this->email_service->generate_auth_code();
77 $existing_transient_token = get_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
78 $existing_transient = $existing_transient_token ? get_transient( Config::PREFIX . "_{$existing_transient_token}" ) : null;
79
80 if ( $existing_transient && isset( $existing_transient['requests'] ) &&
81 $existing_transient['requests'] >= Config::PASSWORD_DETECTION_EMAIL_REQUEST_LIMIT ) {
82
83 // Resend limit reached, prevent sending new email
84 $this->set_transient_error(
85 $user->ID,
86 array(
87 'code' => 'email_request_limit_exceeded',
88 'message' => __( 'Email request limit exceeded. Please try again later.', 'jetpack-account-protection' ),
89 )
90 );
91
92 $this->redirect_and_exit( $this->get_redirect_url( $existing_transient_token ) );
93
94 }
95
96 // The same limit applies per user across the network.
97 if ( $this->email_service->user_email_limit_reached( $user->ID ) ) {
98 $this->set_transient_error(
99 $user->ID,
100 array(
101 'code' => 'email_request_limit_exceeded',
102 'message' => __( 'Email request limit exceeded. Please try again later.', 'jetpack-account-protection' ),
103 )
104 );
105
106 $this->redirect_and_exit( $this->get_redirect_url( $existing_transient_token ? $existing_transient_token : $this->generate_and_store_transient_data( $user->ID, $auth_code ) ) );
107 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
108 return $user;
109 }
110
111 $email_sent = $this->email_service->api_send_auth_email( $user->ID, $auth_code );
112
113 if ( is_wp_error( $email_sent ) ) {
114 $this->set_transient_error(
115 $user->ID,
116 array(
117 'code' => $email_sent->get_error_code(),
118 'message' => $email_sent->get_error_message(),
119 )
120 );
121 } else {
122 $this->email_service->count_user_email( $user->ID );
123 }
124
125 $new_transient_token = null;
126
127 // Update or create a transient token
128 if ( $existing_transient ) {
129 if ( ! is_wp_error( $email_sent ) ) {
130 $existing_transient['auth_code'] = $auth_code;
131 $existing_transient['requests'] = ( $existing_transient['requests'] ?? 0 ) + 1;
132
133 if ( ! set_transient( Config::PREFIX . "_{$existing_transient_token}", $existing_transient, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) {
134 $this->set_transient_error(
135 $user->ID,
136 array(
137 'code' => 'transient_error',
138 'message' => __( 'Failed to update authentication token. Please try again.', 'jetpack-account-protection' ),
139 )
140 );
141 }
142 }
143 } else {
144 $new_transient_token = $this->generate_and_store_transient_data( $user->ID, $auth_code );
145 }
146
147 $this->redirect_and_exit( $this->get_redirect_url( $new_transient_token ? $new_transient_token : $existing_transient_token ) );
148 }
149
150 /**
151 * Redirect and exit.
152 *
153 * @param string $redirect_location The redirect location.
154 *
155 * @return never
156 */
157 protected function redirect_and_exit( string $redirect_location ) {
158 wp_safe_redirect( $redirect_location );
159 $this->exit();
160 }
161
162 /**
163 * Exit decoupling.
164 *
165 * @return never
166 */
167 protected function exit() {
168 exit;
169 }
170
171 /**
172 * Load user by ID. Dependency decoupling.
173 *
174 * @param int $user_id The user ID.
175 *
176 * @return \WP_User|null The user object.
177 */
178 protected function load_user( int $user_id ) {
179 return get_user_by( 'ID', $user_id );
180 }
181
182 /**
183 * Render password detection page.
184 */
185 public function render_page() {
186 if ( is_user_logged_in() ) {
187 $this->redirect_and_exit( get_dashboard_url( get_current_user_id() ) );
188 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
189 return;
190 }
191
192 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : null;
193 $transient_data = get_transient( Config::PREFIX . "_{$token}" );
194 if ( ! $transient_data ) {
195 $this->redirect_to_login();
196 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
197 return;
198 }
199
200 $user_id = $transient_data['user_id'] ?? null;
201 $user = $user_id ? $this->load_user( (int) $user_id ) : null;
202 if ( ! $user instanceof \WP_User ) {
203 $this->redirect_to_login();
204 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
205 return;
206 }
207
208 // Handle resend email request
209 if ( isset( $_GET['resend_email'] ) && $_GET['resend_email'] === '1' ) {
210 if ( isset( $_GET['_wpnonce'] )
211 && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'resend_email_nonce' )
212 ) {
213 $email_resent = $this->email_service->resend_auth_email( $user->ID, $transient_data, $token );
214 if ( is_wp_error( $email_resent ) ) {
215 $this->set_transient_error(
216 $user->ID,
217 array(
218 'code' => $email_resent->get_error_code(),
219 'message' => $email_resent->get_error_message(),
220 )
221 );
222 } else {
223 $this->set_transient_success(
224 $user->ID,
225 array(
226 'code' => 'email_resend_success',
227 'message' => __( 'Authentication email resent successfully.', 'jetpack-account-protection' ),
228 )
229 );
230 }
231
232 $this->redirect_and_exit( $this->get_redirect_url( $token ) );
233 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
234 return;
235 } else {
236 $this->set_transient_error(
237 $user->ID,
238 array(
239 'code' => 'email_resend_nonce_error',
240 'message' => __( 'Resend nonce verification failed. Please try again.', 'jetpack-account-protection' ),
241 )
242 );
243 }
244 }
245
246 // Handle verify form submission
247 if ( isset( $_POST['verify'] ) ) {
248 if ( ! empty( $_POST['_wpnonce_verify'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_verify'] ) ), 'verify_action' ) ) {
249 $user_input = isset( $_POST['user_input'] ) ? sanitize_text_field( wp_unslash( $_POST['user_input'] ) ) : null;
250
251 $this->handle_auth_form_submission( $user, $token, $transient_data, $user_input );
252 } else {
253 $this->set_transient_error(
254 $user->ID,
255 array(
256 'code' => 'verify_nonce_error',
257 'message' => __( 'Verify nonce verification failed. Please try again.', 'jetpack-account-protection' ),
258 )
259 );
260 }
261 }
262
263 $this->render_content( $user, $token );
264 }
265
266 /**
267 * Extract transient data safely and delete the transient.
268 *
269 * @param string $transient_key The transient key.
270 * @return array An array containing 'message' and 'code'.
271 */
272 public function extract_and_clear_transient_data( string $transient_key ): array {
273 $data = get_transient( $transient_key );
274 delete_transient( $transient_key );
275
276 return array(
277 'message' => $data['message'] ?? null,
278 'code' => $data['code'] ?? null,
279 );
280 }
281
282 /**
283 * Render content for password detection page.
284 *
285 * @param \WP_User $user The user.
286 * @param string $token The token.
287 *
288 * @return void
289 */
290 public function render_content( \WP_User $user, string $token ): void {
291 $error_transient_key = Config::PREFIX . "_error_{$user->ID}";
292 $success_transient_key = Config::PREFIX . "_success_{$user->ID}";
293
294 $error_data = $this->extract_and_clear_transient_data( $error_transient_key );
295 $success_data = $this->extract_and_clear_transient_data( $success_transient_key );
296
297 $body_classes = 'password-detection-wrapper';
298 if ( 'auth_code_success' === $success_data['code'] ) {
299 $body_classes .= ' interim-login-success';
300 }
301
302 ?>
303 <!DOCTYPE html>
304 <html>
305 <head>
306 <meta charset="UTF-8">
307 <meta name="viewport" content="width=device-width, initial-scale=1.0">
308 <title><?php esc_html_e( 'Jetpack - Secure Your Account', 'jetpack-account-protection' ); ?></title>
309 <?php wp_head(); ?>
310 </head>
311 <body class="<?php echo esc_attr( $body_classes ); ?>">
312 <div class="password-detection-content">
313 <?php
314 $jetpack_logo = new Jetpack_Logo();
315 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
316 echo $jetpack_logo->get_jp_emblem( true );
317 ?>
318 <p class="password-detection-title"><?php echo $success_data['code'] === 'auth_code_success' ? esc_html__( 'Take action to stay secure', 'jetpack-account-protection' ) : esc_html__( 'Verify your identity', 'jetpack-account-protection' ); ?></p>
319 <?php if ( $error_data['message'] ) : ?>
320 <div class="error notice">
321 <p class="notice-message"><?php echo esc_html( $error_data['message'] ); ?></p>
322 </div>
323 <?php endif; ?>
324 <?php if ( $success_data['message'] ) : ?>
325 <div class="success notice">
326 <p class="notice-message"><?php echo esc_html( $success_data['message'] ); ?></p>
327 </div>
328 <?php endif; ?>
329 <?php if ( $success_data['code'] === 'auth_code_success' ) : ?>
330 <p><?php esc_html_e( "You're all set! You can now access your account.", 'jetpack-account-protection' ); ?></p>
331 <p><?php esc_html_e( 'Please keep in mind that your current password was found in a public leak, which means your account might be at risk. It is highly recommended that you update your password.', 'jetpack-account-protection' ); ?></p>
332 <div class="actions">
333 <a href="<?php echo esc_url( get_dashboard_url( $user->ID, 'profile.php#password' ) ); ?>" class="action action-update-password">
334 <?php esc_html_e( 'Create a new password', 'jetpack-account-protection' ); ?>
335 </a>
336 <a href="<?php echo esc_url( get_dashboard_url( $user->ID ) ); ?>" class="action action-proceed">
337 <?php esc_html_e( 'Proceed without updating', 'jetpack-account-protection' ); ?>
338 </a>
339 </div>
340
341 <p>
342 <?php
343 printf(
344 /* translators: %s: Risks of using weak passwords link */
345 esc_html__( 'Learn more about the %s and how to protect your account.', 'jetpack-account-protection' ),
346 '<a class="risks-link" href="' . esc_url( Config::SUPPORT_LINK . '#risks-of-using-a-weak-password' ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'risks of using weak passwords', 'jetpack-account-protection' ) . '</a>'
347 );
348 ?>
349 </p>
350 <?php else : ?>
351 <p>
352 <?php
353 printf(
354 /* translators: %s: Jetpack Account Protection link */
355 esc_html__( '%s has flagged that your password may appear in a known data breach.', 'jetpack-account-protection' ),
356 '<a class="how-it-works-link" href="' . esc_url( Config::SUPPORT_LINK . '#how-account-protection-works' ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Jetpack Account Protection', 'jetpack-account-protection' ) . '</a>'
357 );
358 ?>
359 </p>
360 <p><?php esc_html_e( 'This security feature was automatically activated with a recent Jetpack update to help keep your account safe.', 'jetpack-account-protection' ); ?></p>
361 <p>
362 <?php
363 printf(
364 /* translators: %s: Masked email address */
365 esc_html__( 'As an extra layer of security, we\'ve sent a verification code to your WordPress profile email address (%s).', 'jetpack-account-protection' ),
366 esc_html( $this->email_service->mask_email_address( $user->user_email ) )
367 );
368 ?>
369 </p>
370 <p>
371 <?php esc_html_e( 'Please check your inbox and enter the code below to complete your login:', 'jetpack-account-protection' ); ?>
372 </p>
373 <div class="actions">
374 <form method="post">
375 <?php wp_nonce_field( 'verify_action', '_wpnonce_verify' ); ?>
376 <input
377 type="text"
378 name="user_input"
379 class="action-input"
380 placeholder="<?php esc_attr_e( 'Enter verification code', 'jetpack-account-protection' ); ?>"
381 required
382 pattern="\d{6}"
383 minlength="6"
384 maxlength="6"
385 inputmode="numeric"
386 oninput="this.value = this.value.replace(/\D/g, '');"
387 />
388 <button class="action action-verify" type="submit" name="verify"><?php esc_html_e( 'Verify', 'jetpack-account-protection' ); ?></button>
389 </form>
390 </div>
391 <?php if ( in_array( $error_data['code'], array( 'email_request_limit_exceeded', 'email_send_error', 'auth_code_user_attempt_limit_exceeded' ), true ) ) : ?>
392 <p class="account-recovery">
393 <?php
394 printf(
395 /* translators: %s: Jetpack support link */
396 esc_html__( 'If you did not receive your authentication code or are experiencing difficulties using it, try again later or %s now.', 'jetpack-account-protection' ),
397 '<a class="risks-link" href="' . esc_url( wp_lostpassword_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'reset your password', 'jetpack-account-protection' ) . '</a>'
398 );
399 ?>
400 </p>
401 <?php else : ?>
402 <p class="email-status">
403 <?php
404 printf(
405 /* translators: %s: Resend email link */
406 esc_html__( "Didn't get the code? Check your spam folder or %s.", 'jetpack-account-protection' ),
407 '<a class="resend-email-link" href="' . esc_url( $this->get_redirect_url( $token ) . '&resend_email=1&_wpnonce=' . wp_create_nonce( 'resend_email_nonce' ) ) . '">' . esc_html__( 'resend the email', 'jetpack-account-protection' ) . '</a>'
408 );
409 ?>
410 </p>
411 <p class="email-status">
412 <?php
413 printf(
414 /* translators: %s: Contact Jetpack Support link */
415 esc_html__( 'No longer have access to this email address or need additional help? %s.', 'jetpack-account-protection' ),
416 '<a class="contact-support-link" href="' . esc_url( 'https://jetpack.com/contact-support/?rel=support' ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Contact Jetpack Support', 'jetpack-account-protection' ) . '</a>'
417 );
418 ?>
419 </p>
420 <?php endif; ?>
421
422 <?php endif; ?>
423 </div>
424 <?php wp_footer(); ?>
425 </body>
426 </html>
427 <?php
428 $this->exit();
429 }
430
431 /**
432 * Check if the user requires password protection.
433 *
434 * @param \WP_User $user The user object.
435 * @param string $password The password.
436 *
437 * @return bool
438 */
439 private function user_requires_protection( \WP_User $user, string $password ): bool {
440 $can_publish = user_can( $user, 'publish_posts' ) || user_can( $user, 'edit_published_posts' );
441 $password_is_correct = null;
442
443 // On multisite, a publishing role on any of the user's sites counts. Looked up only for a correct password.
444 if ( ! $can_publish && $this->is_multisite() ) {
445 $password_is_correct = wp_check_password( $password, $user->user_pass, $user->ID );
446 $can_publish = $password_is_correct && $this->user_can_publish_on_another_site( $user );
447 }
448
449 if ( ! $can_publish ) {
450 return false;
451 }
452
453 /**
454 * Filter which determines whether or not password detection should be applied for the provided user.
455 *
456 * @since 0.1.0
457 *
458 * @param bool $requires_protection Whether or not password detection should be applied.
459 * @param \WP_User $user The user object to apply the filter against.
460 */
461
462 $user_requires_protection = apply_filters( 'jetpack_account_protection_user_requires_protection', true, $user );
463
464 if ( ! $user_requires_protection ) {
465 return false;
466 }
467
468 return $password_is_correct ?? wp_check_password( $password, $user->user_pass, $user->ID );
469 }
470
471 /**
472 * Whether this is a multisite network. Dependency decoupling.
473 *
474 * @return bool
475 */
476 protected function is_multisite(): bool {
477 return is_multisite();
478 }
479
480 /**
481 * Whether the user can publish on any other site of the network they belong to.
482 *
483 * @param \WP_User $user The user object.
484 *
485 * @return bool
486 */
487 protected function user_can_publish_on_another_site( \WP_User $user ): bool {
488 $current_site_id = get_current_blog_id();
489
490 foreach ( get_blogs_of_user( $user->ID ) as $site ) {
491 if ( (int) $site->userblog_id === $current_site_id ) {
492 continue;
493 }
494
495 switch_to_blog( $site->userblog_id );
496 // Pass the ID, not the object: the object's capabilities are bound to the site it was loaded on.
497 $can_publish = user_can( $user->ID, 'publish_posts' ) || user_can( $user->ID, 'edit_published_posts' );
498 restore_current_blog();
499
500 if ( $can_publish ) {
501 return true;
502 }
503 }
504
505 return false;
506 }
507
508 /**
509 * Generate and store a consolidated transient for the user.
510 *
511 * @param int $user_id The user ID.
512 * @param string $auth_code The auth code.
513 *
514 * @return string The generated token associated with the new transient data.
515 */
516 private function generate_and_store_transient_data( int $user_id, string $auth_code ): string {
517 $token = wp_generate_password( 32, false, false );
518
519 $data = array(
520 'user_id' => $user_id,
521 'auth_code' => $auth_code,
522 'requests' => 1,
523 );
524
525 $set_token_transient = set_transient( Config::PREFIX . "_{$token}", $data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
526 $set_user_transient = set_transient( Config::PREFIX . "_last_valid_token_{$user_id}", $token, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
527 if ( ! $set_token_transient || ! $set_user_transient ) {
528 $this->set_transient_error(
529 $user_id,
530 array(
531 'code' => 'transient_error',
532 'message' => __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ),
533 )
534 );
535 }
536
537 return $token;
538 }
539
540 /**
541 * Redirect to the login page.
542 *
543 * @return never
544 */
545 private function redirect_to_login() {
546 $this->redirect_and_exit( wp_login_url() );
547 }
548
549 /**
550 * Get redirect URL.
551 *
552 * @param string $token The token.
553 *
554 * @return string The redirect URL.
555 */
556 private function get_redirect_url( string $token ): string {
557 return home_url( '/wp-login.php?action=password-detection&token=' . $token );
558 }
559
560 /**
561 * Handle auth form submission.
562 *
563 * @param \WP_User $user The current user.
564 * @param string $token The token.
565 * @param array $transient_data The stored data for the token.
566 * @param string|null $user_input The user input.
567 *
568 * @return void
569 */
570 private function handle_auth_form_submission( \WP_User $user, string $token, array $transient_data, ?string $user_input ): void {
571 // One submission per user is checked at a time, so every wrong try is counted before the next is read.
572 if ( ! $this->acquire_attempt_lock( $user->ID ) ) {
573 $this->set_transient_error(
574 $user->ID,
575 array(
576 'code' => 'auth_code_error',
577 'message' => __( 'Authentication code verification failed. Please try again.', 'jetpack-account-protection' ),
578 )
579 );
580 return;
581 }
582
583 try {
584 $this->check_auth_code( $user, $token, $transient_data, $user_input );
585 } finally {
586 $this->release_attempt_lock( $user->ID );
587 }
588 }
589
590 /**
591 * Check a submitted code against the stored one and count it when it is wrong.
592 *
593 * @param \WP_User $user The current user.
594 * @param string $token The token.
595 * @param array $transient_data The stored data for the token.
596 * @param string|null $user_input The user input.
597 *
598 * @return void
599 */
600 private function check_auth_code( \WP_User $user, string $token, array $transient_data, ?string $user_input ): void {
601 $auth_code = $transient_data['auth_code'] ?? null;
602
603 // Wrong tries are counted per code, so a newly sent code starts from zero, and per user across the network.
604 $code_attempts_key = Config::PREFIX . "_failed_attempts_{$token}_{$auth_code}";
605 $user_attempts_key = Config::PREFIX . "_failed_attempts_user_{$user->ID}";
606 $code_attempts = (int) get_transient( $code_attempts_key );
607 $user_attempts = (int) get_site_transient( $user_attempts_key );
608 $can_try = $code_attempts < Config::PASSWORD_DETECTION_FAILED_ATTEMPT_LIMIT
609 && $user_attempts < Config::PASSWORD_DETECTION_USER_FAILED_ATTEMPT_LIMIT;
610
611 if ( $can_try && $auth_code && $auth_code === $user_input ) {
612 $this->set_transient_success(
613 $user->ID,
614 array(
615 'code' => 'auth_code_success',
616 'message' => __( 'Authentication code verified successfully.', 'jetpack-account-protection' ),
617 )
618 );
619
620 delete_transient( Config::PREFIX . "_{$token}" );
621 delete_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
622 delete_transient( $code_attempts_key );
623 delete_site_transient( $user_attempts_key );
624 delete_site_transient( Email_Service::get_user_email_count_key( $user->ID ) );
625 wp_set_auth_cookie( $user->ID, true );
626 wp_set_current_user( $user->ID );
627 return;
628 }
629
630 if ( $can_try ) {
631 set_transient( $code_attempts_key, ++$code_attempts, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
632 set_site_transient( $user_attempts_key, ++$user_attempts, Config::PASSWORD_DETECTION_USER_FAILED_ATTEMPT_EXPIRATION );
633 }
634
635 if ( $user_attempts >= Config::PASSWORD_DETECTION_USER_FAILED_ATTEMPT_LIMIT ) {
636 $this->set_transient_error(
637 $user->ID,
638 array(
639 'code' => 'auth_code_user_attempt_limit_exceeded',
640 'message' => __( 'Too many incorrect verification codes. Please try again later.', 'jetpack-account-protection' ),
641 )
642 );
643 return;
644 }
645
646 if ( $code_attempts >= Config::PASSWORD_DETECTION_FAILED_ATTEMPT_LIMIT ) {
647 $this->set_transient_error(
648 $user->ID,
649 array(
650 'code' => 'auth_code_attempt_limit_exceeded',
651 'message' => __( 'Too many incorrect verification codes. Please request a new code.', 'jetpack-account-protection' ),
652 )
653 );
654 return;
655 }
656
657 $this->set_transient_error(
658 $user->ID,
659 array(
660 'code' => 'auth_code_error',
661 'message' => __( 'Authentication code verification failed. Please try again.', 'jetpack-account-protection' ),
662 )
663 );
664 }
665
666 /**
667 * Take the lock for checking a user's submitted code. Dependency decoupling.
668 *
669 * @param int $user_id The user ID.
670 *
671 * @return bool Whether the lock was taken.
672 */
673 protected function acquire_attempt_lock( int $user_id ): bool {
674 global $wpdb;
675
676 $table = $this->get_attempt_lock_table();
677 $name = Config::PREFIX . "_attempt_lock_{$user_id}";
678 $now = time();
679 // The time it was taken, then digits that tell this request's lock from any other.
680 $value = sprintf( '%d.%09d', $now, wp_rand( 0, 999999999 ) );
681
682 // INSERT IGNORE adds the row only when there is none, as WP_Upgrader::create_lock() does.
683 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The Options API cannot add a row only when it is missing; the table name is not user input.
684 $taken = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$table} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", $name, $value ) );
685
686 if ( ! $taken ) {
687 // Take over a lock that a request left behind without releasing it.
688 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- See above.
689 $taken = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET option_value = %s WHERE option_name = %s AND option_value + 0 < %d", $value, $name, $now - Config::PASSWORD_DETECTION_ATTEMPT_LOCK_EXPIRATION ) );
690 }
691
692 if ( $taken ) {
693 $this->attempt_locks[ $user_id ] = $value;
694 }
695
696 return (bool) $taken;
697 }
698
699 /**
700 * Release the lock for checking a user's submitted code. Dependency decoupling.
701 *
702 * @param int $user_id The user ID.
703 *
704 * @return void
705 */
706 protected function release_attempt_lock( int $user_id ): void {
707 global $wpdb;
708
709 if ( ! isset( $this->attempt_locks[ $user_id ] ) ) {
710 return;
711 }
712
713 // Matching the value leaves the row alone when another request has since taken the lock over.
714 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- The lock row is not written through the Options API.
715 $wpdb->delete(
716 $this->get_attempt_lock_table(),
717 array(
718 'option_name' => Config::PREFIX . "_attempt_lock_{$user_id}",
719 'option_value' => $this->attempt_locks[ $user_id ],
720 )
721 );
722 unset( $this->attempt_locks[ $user_id ] );
723 }
724
725 /**
726 * Get the table holding the lock, which on multisite is the main site's so the network shares it.
727 *
728 * @return string
729 */
730 private function get_attempt_lock_table(): string {
731 global $wpdb;
732
733 return is_multisite() ? $wpdb->get_blog_prefix( get_main_site_id() ) . 'options' : $wpdb->options;
734 }
735
736 /**
737 * Set a transient success message.
738 *
739 * @param int $user_id The user ID.
740 * @param array $success An array of the success code and message.
741 * @param int $expiration The expiration time in seconds.
742 *
743 * @return void
744 */
745 public function set_transient_success( int $user_id, array $success, int $expiration = 60 ): void {
746 set_transient( Config::PREFIX . "_success_{$user_id}", $success, $expiration );
747 }
748
749 /**
750 * Set a transient error message.
751 *
752 * @param int $user_id The user ID.
753 * @param array $error An array of the error code and message.
754 * @param int $expiration The expiration time in seconds.
755 *
756 * @return void
757 */
758 public function set_transient_error( int $user_id, array $error, int $expiration = 60 ): void {
759 set_transient( Config::PREFIX . "_error_{$user_id}", $error, $expiration );
760 }
761
762 /**
763 * Enqueue the password detection page styles.
764 *
765 * @return void
766 */
767 public function enqueue_styles(): void {
768 global $pagenow;
769 if ( ! isset( $pagenow ) || $pagenow !== 'wp-login.php' ) {
770 return;
771 }
772 // No nonce verification necessary - reading only
773 // phpcs:ignore WordPress.Security.NonceVerification
774 if ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) {
775 wp_enqueue_style(
776 'password-detection-styles',
777 plugin_dir_url( __FILE__ ) . 'css/password-detection.css',
778 array(),
779 Account_Protection::PACKAGE_VERSION
780 );
781 }
782 }
783 }
784