<?php
/**
* Class used to define Password Detection.
*
* @package automattic/jetpack-account-protection
*/
namespace Automattic\Jetpack\Account_Protection;
use Automattic\Jetpack\Assets\Logo as Jetpack_Logo;
/**
* Class Password_Detection
*/
class Password_Detection {
/**
* Email service dependency.
*
* @var Email_Service
*/
private $email_service;
/**
* Validation service dependency.
*
* @var Validation_Service
*/
private $validation_service;
/**
* Password_Detection constructor.
*
* @param ?Email_Service $email_service Email service instance.
* @param ?Validation_Service $validation_service Validation service instance.
*/
public function __construct( ?Email_Service $email_service = null, ?Validation_Service $validation_service = null ) {
$this->email_service = $email_service ?? new Email_Service();
$this->validation_service = $validation_service ?? new Validation_Service();
}
/**
* Check if the password is safe after login.
*
* @param \WP_User|\WP_Error|null $user The user or error object, or null.
* @param string|null $password The password.
*
* @return \WP_User|\WP_Error|null The user object, error object, or null.
*/
public function login_form_password_detection( $user, ?string $password ) {
// First check if the user object and password are valid. Third-party plugins might pass
// incompatible types to authentication hooks, so we need this extra check.
if ( is_wp_error( $user ) || ! ( $user instanceof \WP_User ) || $password === null ) {
return $user;
}
if ( ! $this->user_requires_protection( $user, $password ) ) {
return $user;
}
// Skip if we're validating a Brute force protection recovery token
if ( get_transient( 'jetpack_protect_recovery_key_validated_' . $user->ID ) ) {
return $user;
}
if ( ! $this->validation_service->is_leaked_password( $password ) ) {
return $user;
}
$auth_code = $this->email_service->generate_auth_code();
$existing_transient_token = get_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
$existing_transient = $existing_transient_token ? get_transient( Config::PREFIX . "_{$existing_transient_token}" ) : null;
if ( $existing_transient && isset( $existing_transient['requests'] ) &&
$existing_transient['requests'] >= Config::PASSWORD_DETECTION_EMAIL_REQUEST_LIMIT ) {
// Resend limit reached, prevent sending new email
$this->set_transient_error(
$user->ID,
array(
'code' => 'email_request_limit_exceeded',
'message' => __( 'Email request limit exceeded. Please try again later.', 'jetpack-account-protection' ),
)
);
$this->redirect_and_exit( $this->get_redirect_url( $existing_transient_token ) );
}
$email_sent = $this->email_service->api_send_auth_email( $user->ID, $auth_code );
if ( is_wp_error( $email_sent ) ) {
$this->set_transient_error(
$user->ID,
array(
'code' => $email_sent->get_error_code(),
'message' => $email_sent->get_error_message(),
)
);
}
$new_transient_token = null;
// Update or create a transient token
if ( $existing_transient ) {
if ( ! is_wp_error( $email_sent ) ) {
$existing_transient['auth_code'] = $auth_code;
$existing_transient['requests'] = ( $existing_transient['requests'] ?? 0 ) + 1;
if ( ! set_transient( Config::PREFIX . "_{$existing_transient_token}", $existing_transient, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) {
$this->set_transient_error(
$user->ID,
array(
'code' => 'transient_error',
'message' => __( 'Failed to update authentication token. Please try again.', 'jetpack-account-protection' ),
)
);
}
}
} else {
$new_transient_token = $this->generate_and_store_transient_data( $user->ID, $auth_code );
}
$this->redirect_and_exit( $this->get_redirect_url( $new_transient_token ? $new_transient_token : $existing_transient_token ) );
}
/**
* Redirect and exit.
*
* @param string $redirect_location The redirect location.
*
* @return never
*/
protected function redirect_and_exit( string $redirect_location ) {
wp_safe_redirect( $redirect_location );
$this->exit();
}
/**
* Exit decoupling.
*
* @return never
*/
protected function exit() {
exit;
}
/**
* Load user by ID. Dependency decoupling.
*
* @param int $user_id The user ID.
*
* @return \WP_User|null The user object.
*/
protected function load_user( int $user_id ) {
return get_user_by( 'ID', $user_id );
}
/**
* Render password detection page.
*/
public function render_page() {
if ( is_user_logged_in() ) {
$this->redirect_and_exit( admin_url() );
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
return;
}
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : null;
$transient_data = get_transient( Config::PREFIX . "_{$token}" );
if ( ! $transient_data ) {
$this->redirect_to_login();
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
return;
}
$user_id = $transient_data['user_id'] ?? null;
$user = $user_id ? $this->load_user( (int) $user_id ) : null;
if ( ! $user instanceof \WP_User ) {
$this->redirect_to_login();
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
return;
}
// Handle resend email request
if ( isset( $_GET['resend_email'] ) && $_GET['resend_email'] === '1' ) {
if ( isset( $_GET['_wpnonce'] )
&& wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'resend_email_nonce' )
) {
$email_resent = $this->email_service->resend_auth_email( $user->ID, $transient_data, $token );
if ( is_wp_error( $email_resent ) ) {
$this->set_transient_error(
$user->ID,
array(
'code' => $email_resent->get_error_code(),
'message' => $email_resent->get_error_message(),
)
);
} else {
$this->set_transient_success(
$user->ID,
array(
'code' => 'email_resend_success',
'message' => __( 'Authentication email resent successfully.', 'jetpack-account-protection' ),
)
);
}
$this->redirect_and_exit( $this->get_redirect_url( $token ) );
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
return;
} else {
$this->set_transient_error(
$user->ID,
array(
'code' => 'email_resend_nonce_error',
'message' => __( 'Resend nonce verification failed. Please try again.', 'jetpack-account-protection' ),
)
);
}
}
// Handle verify form submission
if ( isset( $_POST['verify'] ) ) {
if ( ! empty( $_POST['_wpnonce_verify'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_verify'] ) ), 'verify_action' ) ) {
$user_input = isset( $_POST['user_input'] ) ? sanitize_text_field( wp_unslash( $_POST['user_input'] ) ) : null;
$this->handle_auth_form_submission( $user, $token, $transient_data['auth_code'] ?? null, $user_input );
} else {
$this->set_transient_error(
$user->ID,
array(
'code' => 'verify_nonce_error',
'message' => __( 'Verify nonce verification failed. Please try again.', 'jetpack-account-protection' ),
)
);
}
}
$this->render_content( $user, $token );
}
/**
* Extract transient data safely and delete the transient.
*
* @param string $transient_key The transient key.
* @return array An array containing 'message' and 'code'.
*/
public function extract_and_clear_transient_data( string $transient_key ): array {
$data = get_transient( $transient_key );
delete_transient( $transient_key );
return array(
'message' => $data['message'] ?? null,
'code' => $data['code'] ?? null,
);
}
/**
* Render content for password detection page.
*
* @param \WP_User $user The user.
* @param string $token The token.
*
* @return void
*/
public function render_content( \WP_User $user, string $token ): void {
$error_transient_key = Config::PREFIX . "_error_{$user->ID}";
$success_transient_key = Config::PREFIX . "_success_{$user->ID}";
$error_data = $this->extract_and_clear_transient_data( $error_transient_key );
$success_data = $this->extract_and_clear_transient_data( $success_transient_key );
$body_classes = 'password-detection-wrapper';
if ( 'auth_code_success' === $success_data['code'] ) {
$body_classes .= ' interim-login-success';
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php esc_html_e( 'Jetpack - Secure Your Account', 'jetpack-account-protection' ); ?></title>
<?php wp_head(); ?>
</head>
<body class="<?php echo esc_attr( $body_classes ); ?>">
<div class="password-detection-content">
<?php
$jetpack_logo = new Jetpack_Logo();
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $jetpack_logo->get_jp_emblem( true );
?>
<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>
<?php if ( $error_data['message'] ) : ?>
<div class="error notice">
<p class="notice-message"><?php echo esc_html( $error_data['message'] ); ?></p>
</div>
<?php endif; ?>
<?php if ( $success_data['message'] ) : ?>
<div class="success notice">
<p class="notice-message"><?php echo esc_html( $success_data['message'] ); ?></p>
</div>
<?php endif; ?>
<?php if ( $success_data['code'] === 'auth_code_success' ) : ?>
<p><?php esc_html_e( "You're all set! You can now access your account.", 'jetpack-account-protection' ); ?></p>
<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>
<div class="actions">
<a href="<?php echo esc_url( admin_url( 'profile.php#password' ) ); ?>" class="action action-update-password">
<?php esc_html_e( 'Create a new password', 'jetpack-account-protection' ); ?>
</a>
<a href="<?php echo esc_url( admin_url() ); ?>" class="action action-proceed">
<?php esc_html_e( 'Proceed without updating', 'jetpack-account-protection' ); ?>
</a>
</div>
<p>
<?php
printf(
/* translators: %s: Risks of using weak passwords link */
esc_html__( 'Learn more about the %s and how to protect your account.', 'jetpack-account-protection' ),
'<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>'
);
?>
</p>
<?php else : ?>
<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>
<p>
<?php
printf(
/* translators: %s: Masked email address */
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' ),
esc_html( $this->email_service->mask_email_address( $user->user_email ) )
);
?>
</p>
<div class="actions">
<form method="post">
<?php wp_nonce_field( 'verify_action', '_wpnonce_verify' ); ?>
<input
type="text"
name="user_input"
class="action-input"
placeholder="<?php esc_attr_e( 'Enter verification code', 'jetpack-account-protection' ); ?>"
required
pattern="\d{6}"
minlength="6"
maxlength="6"
inputmode="numeric"
oninput="this.value = this.value.replace(/\D/g, '');"
/>
<button class="action action-verify" type="submit" name="verify"><?php esc_html_e( 'Verify', 'jetpack-account-protection' ); ?></button>
</form>
</div>
<?php if ( in_array( $error_data['code'], array( 'email_request_limit_exceeded', 'email_send_error' ), true ) ) : ?>
<p class="account-recovery">
<?php
printf(
/* translators: %s: Jetpack support link */
esc_html__( 'If you did not receive your authentication code, please try again later or %s now.', 'jetpack-account-protection' ),
'<a class="risks-link" href="' . esc_url( wp_lostpassword_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'reset your password', 'jetpack-account-protection' ) . '</a>'
);
?>
</p>
<?php else : ?>
<p class="email-status">
<span><?php esc_html_e( "Didn't get the code?", 'jetpack-account-protection' ); ?> </span>
<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' ) ); ?>">
<?php esc_html_e( 'Resend email', 'jetpack-account-protection' ); ?>
</a>
</p>
<?php endif; ?>
<?php endif; ?>
</div>
<?php wp_footer(); ?>
</body>
</html>
<?php
$this->exit();
}
/**
* Check if the user requires password protection.
*
* @param \WP_User $user The user object.
* @param string $password The password.
*
* @return bool
*/
private function user_requires_protection( \WP_User $user, string $password ): bool {
if ( ! user_can( $user, 'publish_posts' ) && ! user_can( $user, 'edit_published_posts' ) ) {
return false;
}
/**
* Filter which determines whether or not password detection should be applied for the provided user.
*
* @since 0.1.0
*
* @param bool $requires_protection Whether or not password detection should be applied.
* @param \WP_User $user The user object to apply the filter against.
*/
$user_requires_protection = apply_filters( 'jetpack_account_protection_user_requires_protection', true, $user );
if ( ! $user_requires_protection ) {
return false;
}
return wp_check_password( $password, $user->user_pass, $user->ID );
}
/**
* Generate and store a consolidated transient for the user.
*
* @param int $user_id The user ID.
* @param string $auth_code The auth code.
*
* @return string The generated token associated with the new transient data.
*/
private function generate_and_store_transient_data( int $user_id, string $auth_code ): string {
$token = wp_generate_password( 32, false, false );
$data = array(
'user_id' => $user_id,
'auth_code' => $auth_code,
'requests' => 1,
);
$set_token_transient = set_transient( Config::PREFIX . "_{$token}", $data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
$set_user_transient = set_transient( Config::PREFIX . "_last_valid_token_{$user_id}", $token, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
if ( ! $set_token_transient || ! $set_user_transient ) {
$this->set_transient_error(
$user_id,
array(
'code' => 'transient_error',
'message' => __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ),
)
);
}
return $token;
}
/**
* Redirect to the login page.
*
* @return never
*/
private function redirect_to_login() {
$this->redirect_and_exit( wp_login_url() );
}
/**
* Get redirect URL.
*
* @param string $token The token.
*
* @return string The redirect URL.
*/
private function get_redirect_url( string $token ): string {
return home_url( '/wp-login.php?action=password-detection&token=' . $token );
}
/**
* Handle auth form submission.
*
* @param \WP_User $user The current user.
* @param string $token The token.
* @param string $auth_code The expected auth code.
* @param string $user_input The user input.
*
* @return void
*/
private function handle_auth_form_submission( \WP_User $user, string $token, string $auth_code, string $user_input ): void {
if ( $auth_code && $auth_code === $user_input ) {
$this->set_transient_success(
$user->ID,
array(
'code' => 'auth_code_success',
'message' => __( 'Authentication code verified successfully.', 'jetpack-account-protection' ),
)
);
delete_transient( Config::PREFIX . "_{$token}" );
delete_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
wp_set_auth_cookie( $user->ID, true );
wp_set_current_user( $user->ID );
} else {
$this->set_transient_error(
$user->ID,
array(
'code' => 'auth_code_error',
'message' => __( 'Authentication code verification failed. Please try again.', 'jetpack-account-protection' ),
)
);
}
}
/**
* Set a transient success message.
*
* @param int $user_id The user ID.
* @param array $success An array of the success code and message.
* @param int $expiration The expiration time in seconds.
*
* @return void
*/
public function set_transient_success( int $user_id, array $success, int $expiration = 60 ): void {
set_transient( Config::PREFIX . "_success_{$user_id}", $success, $expiration );
}
/**
* Set a transient error message.
*
* @param int $user_id The user ID.
* @param array $error An array of the error code and message.
* @param int $expiration The expiration time in seconds.
*
* @return void
*/
public function set_transient_error( int $user_id, array $error, int $expiration = 60 ): void {
set_transient( Config::PREFIX . "_error_{$user_id}", $error, $expiration );
}
/**
* Enqueue the password detection page styles.
*
* @return void
*/
public function enqueue_styles(): void {
global $pagenow;
if ( ! isset( $pagenow ) || $pagenow !== 'wp-login.php' ) {
return;
}
// No nonce verification necessary - reading only
// phpcs:ignore WordPress.Security.NonceVerification
if ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) {
wp_enqueue_style(
'password-detection-styles',
plugin_dir_url( __FILE__ ) . 'css/password-detection.css',
array(),
Account_Protection::PACKAGE_VERSION
);
}
}
}