Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.67% covered (warning)
66.67%
164 / 246
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Password_Detection
66.67% covered (warning)
66.67%
164 / 246
50.00% covered (danger)
50.00%
8 / 16
204.37
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 login_form_password_detection
55.56% covered (warning)
55.56%
25 / 45
0.00% covered (danger)
0.00%
0 / 1
38.47
 redirect_and_exit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 exit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_user
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 render_page
73.58% covered (warning)
73.58%
39 / 53
0.00% covered (danger)
0.00%
0 / 1
19.15
 extract_and_clear_transient_data
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 render_content
65.43% covered (warning)
65.43%
53 / 81
0.00% covered (danger)
0.00%
0 / 1
9.02
 user_requires_protection
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 generate_and_store_transient_data
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
3.63
 redirect_to_login
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_redirect_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle_auth_form_submission
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 set_transient_success
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_transient_error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_styles
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Class used to define Password Detection.
4 *
5 * @package automattic/jetpack-account-protection
6 */
7
8namespace Automattic\Jetpack\Account_Protection;
9
10use Automattic\Jetpack\Assets\Logo as Jetpack_Logo;
11
12/**
13 * Class Password_Detection
14 */
15class 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 ) {
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><?php esc_html_e( 'We\'ve noticed that your current password may have been compromised in a public leak. To keep your account safe, we\'ve added an extra layer of security.', 'jetpack-account-protection' ); ?></p>
328                        <p>
329                            <?php
330                                printf(
331                                    /* translators: %s: Masked email address */
332                                    esc_html__( 'We\'ve sent a code to %s. Please check your inbox and enter the code below to verify it\'s really you.', 'jetpack-account-protection' ),
333                                    esc_html( $this->email_service->mask_email_address( $user->user_email ) )
334                                );
335                            ?>
336                        </p>
337                        <div class="actions">
338                            <form method="post">
339                                <?php wp_nonce_field( 'verify_action', '_wpnonce_verify' ); ?>
340                                <input
341                                    type="text"
342                                    name="user_input"
343                                    class="action-input"
344                                    placeholder="<?php esc_attr_e( 'Enter verification code', 'jetpack-account-protection' ); ?>"
345                                    required
346                                    pattern="\d{6}"
347                                    minlength="6"
348                                    maxlength="6"
349                                    inputmode="numeric"
350                                    oninput="this.value = this.value.replace(/\D/g, '');"
351                                />
352                                <button class="action action-verify" type="submit" name="verify"><?php esc_html_e( 'Verify', 'jetpack-account-protection' ); ?></button>
353                            </form>
354                        </div>
355                        <?php if ( in_array( $error_data['code'], array( 'email_request_limit_exceeded', 'email_send_error' ), true ) ) : ?>
356                            <p class="account-recovery">
357                                <?php
358                                    printf(
359                                        /* translators: %s: Jetpack support link */
360                                        esc_html__( 'If you did not receive your authentication code, please try again later or %s now.', 'jetpack-account-protection' ),
361                                        '<a class="risks-link" href="' . esc_url( wp_lostpassword_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'reset your password', 'jetpack-account-protection' ) . '</a>'
362                                    );
363                                ?>
364                            </p>
365                        <?php else : ?>
366                            <p class="email-status">
367                                <span><?php esc_html_e( "Didn't get the code?", 'jetpack-account-protection' ); ?> </span>
368                                <a class="resend-email-link" href="<?php echo esc_url( $this->get_redirect_url( $token ) . '&resend_email=1&_wpnonce=' . wp_create_nonce( 'resend_email_nonce' ) ); ?>">
369                                    <?php esc_html_e( 'Resend email', 'jetpack-account-protection' ); ?>
370                                </a>
371                            </p>
372                        <?php endif; ?>
373                    <?php endif; ?>
374                </div>
375                <?php wp_footer(); ?>
376            </body>
377        </html>
378        <?php
379        $this->exit();
380    }
381
382    /**
383     * Check if the user requires password protection.
384     *
385     * @param \WP_User $user     The user object.
386     * @param string   $password The password.
387     *
388     * @return bool
389     */
390    private function user_requires_protection( \WP_User $user, string $password ): bool {
391        if ( ! user_can( $user, 'publish_posts' ) && ! user_can( $user, 'edit_published_posts' ) ) {
392            return false;
393        }
394
395        /**
396         * Filter which determines whether or not password detection should be applied for the provided user.
397         *
398         * @since 0.1.0
399         *
400         * @param bool     $requires_protection Whether or not password detection should be applied.
401         * @param \WP_User $user                The user object to apply the filter against.
402         */
403
404        $user_requires_protection = apply_filters( 'jetpack_account_protection_user_requires_protection', true, $user );
405
406        if ( ! $user_requires_protection ) {
407            return false;
408        }
409
410        return wp_check_password( $password, $user->user_pass, $user->ID );
411    }
412
413    /**
414     * Generate and store a consolidated transient for the user.
415     *
416     * @param int    $user_id The user ID.
417     * @param string $auth_code The auth code.
418     *
419     * @return string The generated token associated with the new transient data.
420     */
421    private function generate_and_store_transient_data( int $user_id, string $auth_code ): string {
422        $token = wp_generate_password( 32, false, false );
423
424        $data = array(
425            'user_id'   => $user_id,
426            'auth_code' => $auth_code,
427            'requests'  => 1,
428        );
429
430        $set_token_transient = set_transient( Config::PREFIX . "_{$token}", $data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
431        $set_user_transient  = set_transient( Config::PREFIX . "_last_valid_token_{$user_id}", $token, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
432        if ( ! $set_token_transient || ! $set_user_transient ) {
433            $this->set_transient_error(
434                $user_id,
435                array(
436                    'code'    => 'transient_error',
437                    'message' => __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ),
438                )
439            );
440        }
441
442        return $token;
443    }
444
445    /**
446     * Redirect to the login page.
447     *
448     * @return never
449     */
450    private function redirect_to_login() {
451        $this->redirect_and_exit( wp_login_url() );
452    }
453
454    /**
455     * Get redirect URL.
456     *
457     * @param string $token The token.
458     *
459     * @return string The redirect URL.
460     */
461    private function get_redirect_url( string $token ): string {
462        return home_url( '/wp-login.php?action=password-detection&token=' . $token );
463    }
464
465    /**
466     * Handle auth form submission.
467     *
468     * @param \WP_User $user The current user.
469     * @param string   $token        The token.
470     * @param string   $auth_code    The expected auth code.
471     * @param string   $user_input   The user input.
472     *
473     * @return void
474     */
475    private function handle_auth_form_submission( \WP_User $user, string $token, string $auth_code, string $user_input ): void {
476        if ( $auth_code && $auth_code === $user_input ) {
477            $this->set_transient_success(
478                $user->ID,
479                array(
480                    'code'    => 'auth_code_success',
481                    'message' => __( 'Authentication code verified successfully.', 'jetpack-account-protection' ),
482                )
483            );
484
485            delete_transient( Config::PREFIX . "_{$token}" );
486            delete_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
487            wp_set_auth_cookie( $user->ID, true );
488            wp_set_current_user( $user->ID );
489        } else {
490            $this->set_transient_error(
491                $user->ID,
492                array(
493                    'code'    => 'auth_code_error',
494                    'message' => __( 'Authentication code verification failed. Please try again.', 'jetpack-account-protection' ),
495                )
496            );
497        }
498    }
499
500    /**
501     * Set a transient success message.
502     *
503     * @param int   $user_id    The user ID.
504     * @param array $success    An array of the success code and message.
505     * @param int   $expiration The expiration time in seconds.
506     *
507     * @return void
508     */
509    public function set_transient_success( int $user_id, array $success, int $expiration = 60 ): void {
510        set_transient( Config::PREFIX . "_success_{$user_id}", $success, $expiration );
511    }
512
513    /**
514     * Set a transient error message.
515     *
516     * @param int   $user_id    The user ID.
517     * @param array $error      An array of the error code and message.
518     * @param int   $expiration The expiration time in seconds.
519     *
520     * @return void
521     */
522    public function set_transient_error( int $user_id, array $error, int $expiration = 60 ): void {
523        set_transient( Config::PREFIX . "_error_{$user_id}", $error, $expiration );
524    }
525
526    /**
527     * Enqueue the password detection page styles.
528     *
529     * @return void
530     */
531    public function enqueue_styles(): void {
532        global $pagenow;
533        if ( ! isset( $pagenow ) || $pagenow !== 'wp-login.php' ) {
534            return;
535        }
536        // No nonce verification necessary - reading only
537        // phpcs:ignore WordPress.Security.NonceVerification
538        if ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) {
539            wp_enqueue_style(
540                'password-detection-styles',
541                plugin_dir_url( __FILE__ ) . 'css/password-detection.css',
542                array(),
543                Account_Protection::PACKAGE_VERSION
544            );
545        }
546    }
547}