Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.10% covered (warning)
79.10%
280 / 354
50.00% covered (danger)
50.00%
11 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Password_Detection
79.10% covered (warning)
79.10%
280 / 354
50.00% covered (danger)
50.00%
11 / 22
156.14
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
64.29% covered (warning)
64.29%
36 / 56
0.00% covered (danger)
0.00%
0 / 1
32.76
 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
91.84% covered (success)
91.84%
90 / 98
0.00% covered (danger)
0.00%
0 / 1
7.03
 user_requires_protection
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 is_multisite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 user_can_publish_on_another_site
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 check_auth_code
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
8
 acquire_attempt_lock
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 release_attempt_lock
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_attempt_lock_table
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 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     * 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 is enabled on this site 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}