PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
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-beta, at jetpack_vendor/automattic/jetpack-account-protection/src/class-password-detection.php

573 lines 19.5 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 * Password_Detection constructor.
32 *
33 * @param ?Email_Service $email_service Email service instance.
34 * @param ?Validation_Service $validation_service Validation service instance.
35 */
36 public function __construct( ?Email_Service $email_service = null, ?Validation_Service $validation_service = null ) {
37 $this->email_service = $email_service ?? new Email_Service();
38 $this->validation_service = $validation_service ?? new Validation_Service();
39 }
40
41 /**
42 * Check if the password is safe after login.
43 *
44 * @param \WP_User|\WP_Error|null $user The user or error object, or null.
45 * @param string|null $password The password.
46 *
47 * @return \WP_User|\WP_Error|null The user object, error object, or null.
48 */
49 public function login_form_password_detection( $user, ?string $password = null ) {
50 // First check if the user object and password are valid. Third-party plugins might pass
51 // incompatible types to authentication hooks, so we need this extra check.
52 if ( is_wp_error( $user ) || ! ( $user instanceof \WP_User ) || $password === null ) {
53 return $user;
54 }
55
56 if ( ! $this->user_requires_protection( $user, $password ) ) {
57 return $user;
58 }
59
60 // Skip if we're validating a Brute force protection recovery token
61 if ( get_transient( 'jetpack_protect_recovery_key_validated_' . $user->ID ) ) {
62 return $user;
63 }
64
65 if ( ! $this->validation_service->is_leaked_password( $password ) ) {
66 return $user;
67 }
68
69 $auth_code = $this->email_service->generate_auth_code();
70 $existing_transient_token = get_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
71 $existing_transient = $existing_transient_token ? get_transient( Config::PREFIX . "_{$existing_transient_token}" ) : null;
72
73 if ( $existing_transient && isset( $existing_transient['requests'] ) &&
74 $existing_transient['requests'] >= Config::PASSWORD_DETECTION_EMAIL_REQUEST_LIMIT ) {
75
76 // Resend limit reached, prevent sending new email
77 $this->set_transient_error(
78 $user->ID,
79 array(
80 'code' => 'email_request_limit_exceeded',
81 'message' => __( 'Email request limit exceeded. Please try again later.', 'jetpack-account-protection' ),
82 )
83 );
84
85 $this->redirect_and_exit( $this->get_redirect_url( $existing_transient_token ) );
86
87 }
88
89 $email_sent = $this->email_service->api_send_auth_email( $user->ID, $auth_code );
90
91 if ( is_wp_error( $email_sent ) ) {
92 $this->set_transient_error(
93 $user->ID,
94 array(
95 'code' => $email_sent->get_error_code(),
96 'message' => $email_sent->get_error_message(),
97 )
98 );
99 }
100
101 $new_transient_token = null;
102
103 // Update or create a transient token
104 if ( $existing_transient ) {
105 if ( ! is_wp_error( $email_sent ) ) {
106 $existing_transient['auth_code'] = $auth_code;
107 $existing_transient['requests'] = ( $existing_transient['requests'] ?? 0 ) + 1;
108
109 if ( ! set_transient( Config::PREFIX . "_{$existing_transient_token}", $existing_transient, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) {
110 $this->set_transient_error(
111 $user->ID,
112 array(
113 'code' => 'transient_error',
114 'message' => __( 'Failed to update authentication token. Please try again.', 'jetpack-account-protection' ),
115 )
116 );
117 }
118 }
119 } else {
120 $new_transient_token = $this->generate_and_store_transient_data( $user->ID, $auth_code );
121 }
122
123 $this->redirect_and_exit( $this->get_redirect_url( $new_transient_token ? $new_transient_token : $existing_transient_token ) );
124 }
125
126 /**
127 * Redirect and exit.
128 *
129 * @param string $redirect_location The redirect location.
130 *
131 * @return never
132 */
133 protected function redirect_and_exit( string $redirect_location ) {
134 wp_safe_redirect( $redirect_location );
135 $this->exit();
136 }
137
138 /**
139 * Exit decoupling.
140 *
141 * @return never
142 */
143 protected function exit() {
144 exit;
145 }
146
147 /**
148 * Load user by ID. Dependency decoupling.
149 *
150 * @param int $user_id The user ID.
151 *
152 * @return \WP_User|null The user object.
153 */
154 protected function load_user( int $user_id ) {
155 return get_user_by( 'ID', $user_id );
156 }
157
158 /**
159 * Render password detection page.
160 */
161 public function render_page() {
162 if ( is_user_logged_in() ) {
163 $this->redirect_and_exit( admin_url() );
164 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
165 return;
166 }
167
168 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : null;
169 $transient_data = get_transient( Config::PREFIX . "_{$token}" );
170 if ( ! $transient_data ) {
171 $this->redirect_to_login();
172 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
173 return;
174 }
175
176 $user_id = $transient_data['user_id'] ?? null;
177 $user = $user_id ? $this->load_user( (int) $user_id ) : null;
178 if ( ! $user instanceof \WP_User ) {
179 $this->redirect_to_login();
180 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
181 return;
182 }
183
184 // Handle resend email request
185 if ( isset( $_GET['resend_email'] ) && $_GET['resend_email'] === '1' ) {
186 if ( isset( $_GET['_wpnonce'] )
187 && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'resend_email_nonce' )
188 ) {
189 $email_resent = $this->email_service->resend_auth_email( $user->ID, $transient_data, $token );
190 if ( is_wp_error( $email_resent ) ) {
191 $this->set_transient_error(
192 $user->ID,
193 array(
194 'code' => $email_resent->get_error_code(),
195 'message' => $email_resent->get_error_message(),
196 )
197 );
198 } else {
199 $this->set_transient_success(
200 $user->ID,
201 array(
202 'code' => 'email_resend_success',
203 'message' => __( 'Authentication email resent successfully.', 'jetpack-account-protection' ),
204 )
205 );
206 }
207
208 $this->redirect_and_exit( $this->get_redirect_url( $token ) );
209 // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
210 return;
211 } else {
212 $this->set_transient_error(
213 $user->ID,
214 array(
215 'code' => 'email_resend_nonce_error',
216 'message' => __( 'Resend nonce verification failed. Please try again.', 'jetpack-account-protection' ),
217 )
218 );
219 }
220 }
221
222 // Handle verify form submission
223 if ( isset( $_POST['verify'] ) ) {
224 if ( ! empty( $_POST['_wpnonce_verify'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_verify'] ) ), 'verify_action' ) ) {
225 $user_input = isset( $_POST['user_input'] ) ? sanitize_text_field( wp_unslash( $_POST['user_input'] ) ) : null;
226
227 $this->handle_auth_form_submission( $user, $token, $transient_data['auth_code'] ?? null, $user_input );
228 } else {
229 $this->set_transient_error(
230 $user->ID,
231 array(
232 'code' => 'verify_nonce_error',
233 'message' => __( 'Verify nonce verification failed. Please try again.', 'jetpack-account-protection' ),
234 )
235 );
236 }
237 }
238
239 $this->render_content( $user, $token );
240 }
241
242 /**
243 * Extract transient data safely and delete the transient.
244 *
245 * @param string $transient_key The transient key.
246 * @return array An array containing 'message' and 'code'.
247 */
248 public function extract_and_clear_transient_data( string $transient_key ): array {
249 $data = get_transient( $transient_key );
250 delete_transient( $transient_key );
251
252 return array(
253 'message' => $data['message'] ?? null,
254 'code' => $data['code'] ?? null,
255 );
256 }
257
258 /**
259 * Render content for password detection page.
260 *
261 * @param \WP_User $user The user.
262 * @param string $token The token.
263 *
264 * @return void
265 */
266 public function render_content( \WP_User $user, string $token ): void {
267 $error_transient_key = Config::PREFIX . "_error_{$user->ID}";
268 $success_transient_key = Config::PREFIX . "_success_{$user->ID}";
269
270 $error_data = $this->extract_and_clear_transient_data( $error_transient_key );
271 $success_data = $this->extract_and_clear_transient_data( $success_transient_key );
272
273 $body_classes = 'password-detection-wrapper';
274 if ( 'auth_code_success' === $success_data['code'] ) {
275 $body_classes .= ' interim-login-success';
276 }
277
278 ?>
279 <!DOCTYPE html>
280 <html>
281 <head>
282 <meta charset="UTF-8">
283 <meta name="viewport" content="width=device-width, initial-scale=1.0">
284 <title><?php esc_html_e( 'Jetpack - Secure Your Account', 'jetpack-account-protection' ); ?></title>
285 <?php wp_head(); ?>
286 </head>
287 <body class="<?php echo esc_attr( $body_classes ); ?>">
288 <div class="password-detection-content">
289 <?php
290 $jetpack_logo = new Jetpack_Logo();
291 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
292 echo $jetpack_logo->get_jp_emblem( true );
293 ?>
294 <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>
295 <?php if ( $error_data['message'] ) : ?>
296 <div class="error notice">
297 <p class="notice-message"><?php echo esc_html( $error_data['message'] ); ?></p>
298 </div>
299 <?php endif; ?>
300 <?php if ( $success_data['message'] ) : ?>
301 <div class="success notice">
302 <p class="notice-message"><?php echo esc_html( $success_data['message'] ); ?></p>
303 </div>
304 <?php endif; ?>
305 <?php if ( $success_data['code'] === 'auth_code_success' ) : ?>
306 <p><?php esc_html_e( "You're all set! You can now access your account.", 'jetpack-account-protection' ); ?></p>
307 <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>
308 <div class="actions">
309 <a href="<?php echo esc_url( admin_url( 'profile.php#password' ) ); ?>" class="action action-update-password">
310 <?php esc_html_e( 'Create a new password', 'jetpack-account-protection' ); ?>
311 </a>
312 <a href="<?php echo esc_url( admin_url() ); ?>" class="action action-proceed">
313 <?php esc_html_e( 'Proceed without updating', 'jetpack-account-protection' ); ?>
314 </a>
315 </div>
316
317 <p>
318 <?php
319 printf(
320 /* translators: %s: Risks of using weak passwords link */
321 esc_html__( 'Learn more about the %s and how to protect your account.', 'jetpack-account-protection' ),
322 '<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>'
323 );
324 ?>
325 </p>
326 <?php else : ?>
327 <p>
328 <?php
329 printf(
330 /* translators: %s: Jetpack Account Protection link */
331 esc_html__( '%s has flagged that your password may appear in a known data breach.', 'jetpack-account-protection' ),
332 '<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>'
333 );
334 ?>
335 </p>
336 <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>
337 <p>
338 <?php
339 printf(
340 /* translators: %s: Masked email address */
341 esc_html__( 'As an extra layer of security, we\'ve sent a verification code to your WordPress profile email address (%s).', 'jetpack-account-protection' ),
342 esc_html( $this->email_service->mask_email_address( $user->user_email ) )
343 );
344 ?>
345 </p>
346 <p>
347 <?php esc_html_e( 'Please check your inbox and enter the code below to complete your login:', 'jetpack-account-protection' ); ?>
348 </p>
349 <div class="actions">
350 <form method="post">
351 <?php wp_nonce_field( 'verify_action', '_wpnonce_verify' ); ?>
352 <input
353 type="text"
354 name="user_input"
355 class="action-input"
356 placeholder="<?php esc_attr_e( 'Enter verification code', 'jetpack-account-protection' ); ?>"
357 required
358 pattern="\d{6}"
359 minlength="6"
360 maxlength="6"
361 inputmode="numeric"
362 oninput="this.value = this.value.replace(/\D/g, '');"
363 />
364 <button class="action action-verify" type="submit" name="verify"><?php esc_html_e( 'Verify', 'jetpack-account-protection' ); ?></button>
365 </form>
366 </div>
367 <?php if ( in_array( $error_data['code'], array( 'email_request_limit_exceeded', 'email_send_error' ), true ) ) : ?>
368 <p class="account-recovery">
369 <?php
370 printf(
371 /* translators: %s: Jetpack support link */
372 esc_html__( 'If you did not receive your authentication code, please try again later or %s now.', 'jetpack-account-protection' ),
373 '<a class="risks-link" href="' . esc_url( wp_lostpassword_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'reset your password', 'jetpack-account-protection' ) . '</a>'
374 );
375 ?>
376 </p>
377 <?php else : ?>
378 <p class="email-status">
379 <?php
380 printf(
381 /* translators: %s: Resend email link */
382 esc_html__( "Didn't get the code? Check your spam folder or %s.", 'jetpack-account-protection' ),
383 '<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>'
384 );
385 ?>
386 </p>
387 <p class="email-status">
388 <?php
389 printf(
390 /* translators: %s: Contact Jetpack Support link */
391 esc_html__( 'No longer have access to this email address or need additional help? %s.', 'jetpack-account-protection' ),
392 '<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>'
393 );
394 ?>
395 </p>
396 <?php endif; ?>
397
398 <?php endif; ?>
399 </div>
400 <?php wp_footer(); ?>
401 </body>
402 </html>
403 <?php
404 $this->exit();
405 }
406
407 /**
408 * Check if the user requires password protection.
409 *
410 * @param \WP_User $user The user object.
411 * @param string $password The password.
412 *
413 * @return bool
414 */
415 private function user_requires_protection( \WP_User $user, string $password ): bool {
416 if ( ! user_can( $user, 'publish_posts' ) && ! user_can( $user, 'edit_published_posts' ) ) {
417 return false;
418 }
419
420 /**
421 * Filter which determines whether or not password detection should be applied for the provided user.
422 *
423 * @since 0.1.0
424 *
425 * @param bool $requires_protection Whether or not password detection should be applied.
426 * @param \WP_User $user The user object to apply the filter against.
427 */
428
429 $user_requires_protection = apply_filters( 'jetpack_account_protection_user_requires_protection', true, $user );
430
431 if ( ! $user_requires_protection ) {
432 return false;
433 }
434
435 return wp_check_password( $password, $user->user_pass, $user->ID );
436 }
437
438 /**
439 * Generate and store a consolidated transient for the user.
440 *
441 * @param int $user_id The user ID.
442 * @param string $auth_code The auth code.
443 *
444 * @return string The generated token associated with the new transient data.
445 */
446 private function generate_and_store_transient_data( int $user_id, string $auth_code ): string {
447 $token = wp_generate_password( 32, false, false );
448
449 $data = array(
450 'user_id' => $user_id,
451 'auth_code' => $auth_code,
452 'requests' => 1,
453 );
454
455 $set_token_transient = set_transient( Config::PREFIX . "_{$token}", $data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
456 $set_user_transient = set_transient( Config::PREFIX . "_last_valid_token_{$user_id}", $token, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
457 if ( ! $set_token_transient || ! $set_user_transient ) {
458 $this->set_transient_error(
459 $user_id,
460 array(
461 'code' => 'transient_error',
462 'message' => __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ),
463 )
464 );
465 }
466
467 return $token;
468 }
469
470 /**
471 * Redirect to the login page.
472 *
473 * @return never
474 */
475 private function redirect_to_login() {
476 $this->redirect_and_exit( wp_login_url() );
477 }
478
479 /**
480 * Get redirect URL.
481 *
482 * @param string $token The token.
483 *
484 * @return string The redirect URL.
485 */
486 private function get_redirect_url( string $token ): string {
487 return home_url( '/wp-login.php?action=password-detection&token=' . $token );
488 }
489
490 /**
491 * Handle auth form submission.
492 *
493 * @param \WP_User $user The current user.
494 * @param string $token The token.
495 * @param string $auth_code The expected auth code.
496 * @param string $user_input The user input.
497 *
498 * @return void
499 */
500 private function handle_auth_form_submission( \WP_User $user, string $token, string $auth_code, string $user_input ): void {
501 if ( $auth_code && $auth_code === $user_input ) {
502 $this->set_transient_success(
503 $user->ID,
504 array(
505 'code' => 'auth_code_success',
506 'message' => __( 'Authentication code verified successfully.', 'jetpack-account-protection' ),
507 )
508 );
509
510 delete_transient( Config::PREFIX . "_{$token}" );
511 delete_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
512 wp_set_auth_cookie( $user->ID, true );
513 wp_set_current_user( $user->ID );
514 } else {
515 $this->set_transient_error(
516 $user->ID,
517 array(
518 'code' => 'auth_code_error',
519 'message' => __( 'Authentication code verification failed. Please try again.', 'jetpack-account-protection' ),
520 )
521 );
522 }
523 }
524
525 /**
526 * Set a transient success message.
527 *
528 * @param int $user_id The user ID.
529 * @param array $success An array of the success code and message.
530 * @param int $expiration The expiration time in seconds.
531 *
532 * @return void
533 */
534 public function set_transient_success( int $user_id, array $success, int $expiration = 60 ): void {
535 set_transient( Config::PREFIX . "_success_{$user_id}", $success, $expiration );
536 }
537
538 /**
539 * Set a transient error message.
540 *
541 * @param int $user_id The user ID.
542 * @param array $error An array of the error code and message.
543 * @param int $expiration The expiration time in seconds.
544 *
545 * @return void
546 */
547 public function set_transient_error( int $user_id, array $error, int $expiration = 60 ): void {
548 set_transient( Config::PREFIX . "_error_{$user_id}", $error, $expiration );
549 }
550
551 /**
552 * Enqueue the password detection page styles.
553 *
554 * @return void
555 */
556 public function enqueue_styles(): void {
557 global $pagenow;
558 if ( ! isset( $pagenow ) || $pagenow !== 'wp-login.php' ) {
559 return;
560 }
561 // No nonce verification necessary - reading only
562 // phpcs:ignore WordPress.Security.NonceVerification
563 if ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) {
564 wp_enqueue_style(
565 'password-detection-styles',
566 plugin_dir_url( __FILE__ ) . 'css/password-detection.css',
567 array(),
568 Account_Protection::PACKAGE_VERSION
569 );
570 }
571 }
572 }
573