diff --git a/projects/packages/account-protection/src/assets/check.svg b/projects/packages/account-protection/src/assets/check.svg new file mode 100644 index 0000000000000..d88457419a8b1 --- /dev/null +++ b/projects/packages/account-protection/src/assets/check.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/cross.svg b/projects/packages/account-protection/src/assets/cross.svg new file mode 100644 index 0000000000000..3c33e4931cada --- /dev/null +++ b/projects/packages/account-protection/src/assets/cross.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/info.svg b/projects/packages/account-protection/src/assets/info.svg new file mode 100644 index 0000000000000..67e27b83571a6 --- /dev/null +++ b/projects/packages/account-protection/src/assets/info.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/jetpack-logo.svg b/projects/packages/account-protection/src/assets/jetpack-logo.svg index b91e3c5c216f5..dd6ad79d05a91 100644 --- a/projects/packages/account-protection/src/assets/jetpack-logo.svg +++ b/projects/packages/account-protection/src/assets/jetpack-logo.svg @@ -3,10 +3,12 @@ x="0px" y="0px" height="32px" + viewBox='0 0 118 32' aria-labelledby="jetpack-logo" role="img" > "Jetpack Logo" + + + + + + diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 9e028ee993853..b1eb031854e26 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -44,17 +44,26 @@ class Account_Protection { */ private $password_manager; + /** + * Password_Strength_Meter instance + * + * @var Password_Strength_Meter + */ + private $password_strength_meter; + /** * Account_Protection constructor. * - * @param ?Modules $modules Modules instance. - * @param ?Password_Detection $password_detection Password detection instance. - * @param ?Password_Manager $password_manager Validation service instance. + * @param ?Modules $modules Modules instance. + * @param ?Password_Detection $password_detection Password detection instance. + * @param ?Password_Manager $password_manager Password manager instance. + * @param ?Password_Strength_Meter $password_strength_meter Password strength meter instance. */ - public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null, ?Password_Manager $password_manager = null ) { - $this->modules = $modules ?? new Modules(); - $this->password_detection = $password_detection ?? new Password_Detection(); - $this->password_manager = $password_manager ?? new Password_Manager(); + public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null, ?Password_Manager $password_manager = null, ?Password_Strength_Meter $password_strength_meter = null ) { + $this->modules = $modules ?? new Modules(); + $this->password_detection = $password_detection ?? new Password_Detection(); + $this->password_manager = $password_manager ?? new Password_Manager(); + $this->password_strength_meter = $password_strength_meter ?? new Password_Strength_Meter(); } /** @@ -108,13 +117,20 @@ protected function register_runtime_hooks(): void { add_action( 'wp_enqueue_scripts', array( $this->password_detection, 'enqueue_styles' ) ); // Add password validation - add_action( 'user_profile_update_errors', array( $this->password_manager, 'validate_profile_update' ), 10, 3 ); add_action( 'validate_password_reset', array( $this->password_manager, 'validate_password_reset' ), 10, 2 ); // Update recent passwords list add_action( 'profile_update', array( $this->password_manager, 'on_profile_update' ), 10, 2 ); add_action( 'after_password_reset', array( $this->password_manager, 'on_password_reset' ), 10, 1 ); + + // Enqueue password strength meter scripts + add_action( 'admin_enqueue_scripts', array( $this->password_strength_meter, 'enqueue_jetpack_password_strength_meter_profile_script' ) ); + add_action( 'login_enqueue_scripts', array( $this->password_strength_meter, 'enqueue_jetpack_password_strength_meter_reset_script' ) ); + + // AJAX endpoint for password validation + add_action( 'wp_ajax_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); + add_action( 'wp_ajax_nopriv_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); } /** diff --git a/projects/packages/account-protection/src/class-config.php b/projects/packages/account-protection/src/class-config.php index 97020daac1f90..54ea114ba99eb 100644 --- a/projects/packages/account-protection/src/class-config.php +++ b/projects/packages/account-protection/src/class-config.php @@ -11,10 +11,17 @@ * Class Config */ class Config { + // Password Detection Constants public const PASSWORD_DETECTION_TRANSIENT_PREFIX = 'password_detection'; public const PASSWORD_DETECTION_ERROR_CODE = 'password_detection_validation_error'; public const PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION = 600; // 10 minutes public const PASSWORD_DETECTION_MAX_RESEND_ATTEMPTS = 3; - public const VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY = 'jetpack_account_protection_recent_password_hashes'; + // Password Manager Constants + public const PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY = 'jetpack_account_protection_recent_password_hashes'; + public const PASSWORD_MANAGER_RECENT_PASSWORDS_LIMIT = 10; + + // Validation Service Constants + public const VALIDATION_SERVICE_MIN_LENGTH = 6; + public const VALIDATION_SERVICE_MAX_LENGTH = 150; } diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php index 95f8a0c288975..f6e404fc4b5d8 100644 --- a/projects/packages/account-protection/src/class-password-detection.php +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -355,15 +355,19 @@ private function set_transient_error( int $user_id, string $message, int $expira * @return void */ public function enqueue_styles(): void { + global $pagenow; + if ( ! isset( $pagenow ) || $pagenow !== 'wp-login.php' ) { + return; + } // No nonce verification necessary - reading only // phpcs:disable WordPress.Security.NonceVerification - if ( ( isset( $GLOBALS['pagenow'] ) && $GLOBALS['pagenow'] === 'wp-login.php' ) && ( 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 - ); + 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 + ); } } } diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php index 42fa92d615c36..bebb3debf31cf 100644 --- a/projects/packages/account-protection/src/class-password-manager.php +++ b/projects/packages/account-protection/src/class-password-manager.php @@ -47,15 +47,7 @@ public function validate_profile_update( \WP_Error $errors, bool $update, \stdCl return; } - if ( $update ) { - if ( $this->validation_service->is_current_password( $user->ID, $user->user_pass ) ) { - $errors->add( 'password_error', __( 'Error: The password was used recently.', 'jetpack-account-protection' ) ); - return; - } - } - - $context = $update ? 'update' : 'create-user'; - $error = $this->validation_service->return_first_validation_error( $user, $user->user_pass, $context ); + $error = $this->validation_service->get_first_validation_error( $user->user_pass, true, $user ); if ( ! empty( $error ) ) { $errors->add( 'password_error', $error ); @@ -89,12 +81,7 @@ public function validate_password_reset( \WP_Error $errors, $user ): void { // phpcs:ignore WordPress.Security.NonceVerification $password = sanitize_text_field( wp_unslash( $_POST['pass1'] ) ); - if ( $this->validation_service->is_current_password( $user->ID, $password ) ) { - $errors->add( 'password_error', __( 'Error: The password was used recently.', 'jetpack-account-protection' ) ); - return; - } - - $error = $this->validation_service->return_first_validation_error( $user, $password, 'reset' ); + $error = $this->validation_service->get_first_validation_error( $password ); if ( ! empty( $error ) ) { $errors->add( 'password_error', $error ); return; @@ -136,7 +123,7 @@ public function on_password_reset( $user ): void { * @return void */ public function save_recent_password( int $user_id, string $password_hash ): void { - $recent_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); + $recent_passwords = get_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); if ( ! is_array( $recent_passwords ) ) { $recent_passwords = array(); @@ -148,8 +135,8 @@ public function save_recent_password( int $user_id, string $password_hash ): voi // Add the new hashed password and keep only the last 10 array_unshift( $recent_passwords, $password_hash ); - $recent_passwords = array_slice( $recent_passwords, 0, 10 ); + $recent_passwords = array_slice( $recent_passwords, 0, Config::PASSWORD_MANAGER_RECENT_PASSWORDS_LIMIT ); - update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, $recent_passwords ); + update_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, $recent_passwords ); } } diff --git a/projects/packages/account-protection/src/class-password-strength-meter.php b/projects/packages/account-protection/src/class-password-strength-meter.php new file mode 100644 index 0000000000000..fbeda04429e37 --- /dev/null +++ b/projects/packages/account-protection/src/class-password-strength-meter.php @@ -0,0 +1,142 @@ +validation_service = $validation_service ?? new Validation_Service(); + } + + /** + * AJAX endpoint for password validation. + * + * @return void + */ + public function validate_password_ajax(): void { + if ( ! isset( $_POST['password'] ) ) { + wp_send_json_error( array( 'message' => __( 'No password provided.', 'jetpack-account-protection' ) ) ); + } + + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'validate_password_nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'jetpack-account-protection' ) ) ); + } + + $user_specific = false; + if ( isset( $_POST['user_specific'] ) ) { + $user_specific = filter_var( sanitize_text_field( wp_unslash( $_POST['user_specific'] ) ), FILTER_VALIDATE_BOOLEAN ); + } + + $password = sanitize_text_field( wp_unslash( $_POST['password'] ) ); + $state = $this->validation_service->get_validation_state( $password, $user_specific ); + + wp_send_json_success( array( 'state' => $state ) ); + } + + /** + * Enqueue the password strength meter script on the profile page. + * + * @return void + */ + public function enqueue_jetpack_password_strength_meter_profile_script(): void { + global $pagenow; + + if ( ! isset( $pagenow ) || ! in_array( $pagenow, array( 'profile.php', 'user-new.php', 'user-edit.php' ), true ) ) { + return; + } + + $this->enqueue_script(); + $this->enqueue_styles(); + + // Only profile page should run user specific checks. + $this->localize_jetpack_data( 'profile.php' === $pagenow ); + } + + /** + * Enqueue the password strength meter script on the reset password page. + * + * @return void + */ + public function enqueue_jetpack_password_strength_meter_reset_script(): void { + // No nonce verification necessary as the action includes a robust verification process + // phpcs:disable WordPress.Security.NonceVerification + if ( isset( $_GET['action'] ) && ( 'rp' === $_GET['action'] || 'resetpass' === $_GET['action'] ) ) { + $this->enqueue_script(); + $this->enqueue_styles(); + $this->localize_jetpack_data(); + } + } + + /** + * Localize the Jetpack data for the password strength meter. + * + * @param bool $user_specific Whether or not to run user specific checks. + * + * @return void + */ + public function localize_jetpack_data( bool $user_specific = false ): void { + wp_localize_script( + 'jetpack-password-strength-meter', + 'jetpackData', + array( + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'validate_password_nonce' ), + 'userSpecific' => $user_specific, + 'logo' => plugin_dir_url( __FILE__ ) . 'assets/jetpack-logo.svg', + 'infoIcon' => plugin_dir_url( __FILE__ ) . 'assets/info.svg', + 'checkIcon' => plugin_dir_url( __FILE__ ) . 'assets/check.svg', + 'crossIcon' => plugin_dir_url( __FILE__ ) . 'assets/cross.svg', + 'loadingIcon' => plugin_dir_url( __FILE__ ) . 'assets/loading.svg', + 'validationInitialState' => $this->validation_service->get_validation_initial_state( $user_specific ), + ) + ); + } + + /** + * Enqueue the password strength meter script. + * + * @return void + */ + public function enqueue_script(): void { + wp_enqueue_script( + 'jetpack-password-strength-meter', + plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', + array( 'jquery' ), + Account_Protection::PACKAGE_VERSION, + true + ); + } + + /** + * Enqueue the password strength meter styles. + * + * @return void + */ + public function enqueue_styles(): void { + wp_enqueue_style( + 'strength-meter-styles', + plugin_dir_url( __FILE__ ) . 'css/strength-meter.css', + array(), + Account_Protection::PACKAGE_VERSION + ); + } +} diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index a64b5065c4fff..bd83739e513ef 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -50,58 +50,95 @@ protected function request_suffixes( string $password_prefix ) { } /** - * Return all validation errors. + * Return validation initial state. * - * @param \WP_User|\stdClass $user The user object or a copy. - * @param string $password The password to check. + * @param bool $user_specific Whether or not to include user specific checks. * - * @return array An array of validation errors (if any). + * @return array An array of all validation statuses and messages. */ - public function return_all_validation_errors( $user, string $password ): array { - $errors = array(); + public function get_validation_initial_state( $user_specific ): array { + $base_conditions = array( + 'core' => array( + 'status' => null, + 'message' => __( 'Strong password', 'jetpack-account-protection' ), + 'info' => __( 'Passwords should meet WordPress core security requirements to enhance account protection.', 'jetpack-account-protection' ), + ), + 'contains_backslash' => array( + 'status' => null, + 'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ), + 'info' => null, + ), + 'invalid_length' => array( + 'status' => null, + 'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ), + 'info' => null, + ), + 'weak' => array( + 'status' => null, + 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), + 'info' => __( 'If found in a public breach, this password may already be known to attackers.', 'jetpack-account-protection' ), + ), + ); - if ( $this->contains_backslash( $password ) ) { - $errors[] = __( 'Doesn\'t contain a backslash (\\) character', 'jetpack-account-protection' ); + if ( ! $user_specific ) { + return $base_conditions; } - if ( $this->is_invalid_length( $password ) ) { - $errors[] = __( 'Between 6 and 150 characters', 'jetpack-account-protection' ); - } + $user_specific_conditions = array( + 'matches_user_data' => array( + 'status' => null, + 'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ), + 'info' => __( 'Using a password similar to your username or email makes it easier to guess.', 'jetpack-account-protection' ), + ), + 'recent' => array( + 'status' => null, + 'message' => __( 'Not used recently', 'jetpack-account-protection' ), + 'info' => __( 'Reusing old passwords may increase security risks. A fresh password improves protection.', 'jetpack-account-protection' ), + ), + ); - if ( $this->matches_user_data( $user, $password ) ) { - $errors[] = __( 'Doesn\'t match user data', 'jetpack-account-protection' ); - } + return array_merge( $base_conditions, $user_specific_conditions ); + } - if ( $this->is_recent_password( $user->ID, $password ) ) { - $errors[] = __( 'Not used recently', 'jetpack-account-protection' ); - } + /** + * Return validation state - client-side. + * + * @param string $password The password to check. + * @param bool $user_specific Whether or not to run user specific checks. + * + * @return array An array of the status of each check. + */ + public function get_validation_state( string $password, $user_specific ): array { + $validation_state = $this->get_validation_initial_state( $user_specific ); - if ( $this->is_weak_password( $password ) ) { - $errors[] = __( 'Not a leaked password.', 'jetpack-account-protection' ); + $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password ); + $validation_state['invalid_length']['status'] = $this->is_invalid_length( $password ); + $validation_state['weak']['status'] = $this->is_weak_password( $password ); + + if ( ! $user_specific ) { + return $validation_state; } - return $errors; + // Run checks on existing user data + $user = wp_get_current_user(); + $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); + $validation_state['recent']['status'] = $this->is_recent_password( $user, $password ); + + return $validation_state; } /** - * Return first validation error. + * Return first validation error - server-side. * - * @param \WP_User|\stdClass $user The user object or a copy. - * @param string $password The password to check. - * @param 'create-user'|'update'|'reset' $context The context the validation is run in. + * @param string $password The password to check. + * @param bool $user_specific Whether or not to run user specific checks. + * @param \stdClass|null $user The user data or null. * * @return string The first validation errors (if any). */ - public function return_first_validation_error( $user, string $password, $context ): string { - // Reset form includes this validation in core - if ( 'reset' !== $context ) { - if ( empty( $password ) ) { - return __( 'Error: The password cannot be a space or all spaces.', 'jetpack-account-protection' ); - } - } - - // Update and create-user forms include this validation in core - if ( 'reset' === $context ) { + public function get_first_validation_error( string $password, $user_specific = false, $user = null ): string { + // Update and create-user forms include backlash validation + if ( ! $user_specific ) { if ( $this->contains_backslash( $password ) ) { return __( 'Error: The password cannot contain a backslash (\\) character.', 'jetpack-account-protection' ); } @@ -111,18 +148,24 @@ public function return_first_validation_error( $user, string $password, $context return __( 'Error: The password must be between 6 and 150 characters.', 'jetpack-account-protection' ); } - if ( $this->matches_user_data( $user, $password ) ) { - return __( 'Error: The password matches user data.', 'jetpack-account-protection' ); + if ( $this->is_weak_password( $password ) ) { + return __( 'Error: The password was found in a public leak.', 'jetpack-account-protection' ); } - if ( 'create-user' !== $context ) { - if ( $this->is_recent_password( $user->ID, $password ) ) { - return __( 'Error: The password was used recently.', 'jetpack-account-protection' ); + // Skip user-specific checks during password reset + if ( $user_specific ) { + // Reset form includes empty validation + if ( empty( $password ) ) { + return __( 'Error: The password cannot be a space or all spaces.', 'jetpack-account-protection' ); } - } - if ( $this->is_weak_password( $password ) ) { - return __( 'Error: The password was found in a public leak.', 'jetpack-account-protection' ); + // Run checks on new user data + if ( $this->matches_user_data( $user, $password ) ) { + return __( 'Error: The password matches new user data.', 'jetpack-account-protection' ); + } + if ( $this->is_recent_password( $user, $password ) ) { + return __( 'Error: The password was used recently.', 'jetpack-account-protection' ); + } } return ''; @@ -148,7 +191,7 @@ public function contains_backslash( string $password ): bool { */ public function is_invalid_length( string $password ): bool { $length = strlen( $password ); - return $length < 6 || $length > 150; + return $length < Config::VALIDATION_SERVICE_MIN_LENGTH || $length > Config::VALIDATION_SERVICE_MAX_LENGTH; } /** @@ -253,14 +296,18 @@ public function is_current_password( int $user_id, string $password ): bool { /** * Check if the password has been used recently by the user. * - * @param int $user_id The user ID. - * @param string $password The password to check. + * @param \WP_User|\stdClass $user The user data. + * @param string $password The password to check. * - * @return bool True if the password hash was recently used, false otherwise. + * @return bool True if the password was recently used, false otherwise. */ - public function is_recent_password( int $user_id, string $password ): bool { - $recent_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); + public function is_recent_password( $user, string $password ): bool { + $user_data = $user instanceof \WP_User ? $user : get_userdata( $user->ID ); + if ( $this->is_current_password( $user_data->ID, $password ) ) { + return true; + } + $recent_passwords = get_user_meta( $user->ID, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) { return false; } diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css new file mode 100644 index 0000000000000..30034fe371d53 --- /dev/null +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -0,0 +1,101 @@ +.validation-checklist { + display: flex; + flex-direction: column; + gap: 8px; + margin: 16px 0; +} + +.validation-item { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 0; + + .validation-icon { + height: 24px; + } + + .validation-message { + margin-top: 0; + } +} + +.info-popover { + position: relative; + display: inline-block; + height: 20px; +} + +.info-icon { + height: 20px; + cursor: pointer; +} + +.popover { + display: none; + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 6px 10px; + border-radius: 4px; + white-space: normal; + width: 150px; + font-size: 12px; + box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); + z-index: 10; + text-align: center; +} + +.popover-arrow { + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid #333; +} + +#your-profile .strength-meter, +#createuser .strength-meter { + margin: 0 1px; +} + +.strength-meter { + display: flex; + justify-content: space-between; + align-items: center; + height: 30px; + padding: 0 16px; + margin-bottom: 16px; + border-radius: 0 0 4px 4px; + background-color: #C3C4C7; +} + +.strength-meter .strength { + display: flex; + align-items: center; + font-size: 12px; + font-weight: 500; + color: black; + margin: 0; +} + +.branding { + display: flex; + align-items: center; + gap: 4px; +} + +.branding .powered-by { + font-size: 12px; + color: black; + margin: 0; +} + +.branding img { + height: 18px; +} diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js new file mode 100644 index 0000000000000..a31f1d638a555 --- /dev/null +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -0,0 +1,331 @@ +/* global jQuery, jetpackData */ + +jQuery( document ).ready( function ( $ ) { + const UIComponents = { + core: { + passwordInputWrapper: $( '.user-pass1-wrap' ), + passwordInput: $( '#pass1' ), + passwordStrengthResults: $( '#pass-strength-result' ), + weakPasswordConfirmation: $( '.pw-weak' ), + weakPasswordConfirmationCheckbox: $( '.pw-weak input[type="checkbox"]' ), + submitButtons: $( '#submit, #createusersub, #wp-submit' ), + }, + passwordValidationStatus: $( '
', { id: 'password-validation-status' } ), + validationCheckList: $( '
    ', { class: 'validation-checklist' } ), + strengthMeter: {}, + validationChecklistItems: {}, + }; + + let currentAjaxRequest = null; + + initializeUI(); + bindEvents(); + + /** + * Apply initial UI structure and styling + */ + function initializeUI() { + const { passwordInputWrapper, passwordInput, passwordStrengthResults } = UIComponents.core; + + passwordInputWrapper.css( { + 'margin-bottom': '16px', + } ); + passwordInput.css( { + 'border-color': '#8C8F94', + 'border-radius': '4px 4px 0 0', + } ); + passwordStrengthResults.hide(); + passwordInput.after( UIComponents.passwordValidationStatus ); + UIComponents.passwordValidationStatus.append( UIComponents.validationCheckList ); + + initializeStrengthMeter(); + initializeValidationChecklist(); + } + + /** + * Generate and append the initial strength meter state + */ + function initializeStrengthMeter() { + const strengthMeterWrapper = $( '
    ', { + class: 'strength-meter', + 'aria-live': 'polite', + } ); + + const strengthText = $( '

    ', { + class: 'strength', + text: 'Validating...', + } ); + + const branding = $( '

    ', { class: 'branding' } ).append( + $( '

    ', { class: 'powered-by', text: 'Powered by ' } ), + $( '', { src: jetpackData.logo, alt: 'Jetpack Logo' } ) + ); + + strengthMeterWrapper.append( strengthText, branding ); + UIComponents.validationCheckList.before( strengthMeterWrapper ); + + UIComponents.strengthMeter = { + wrapper: strengthMeterWrapper, + text: strengthText, + branding, + }; + } + + /** + * Generate and append the initial validation checklist state + */ + function initializeValidationChecklist() { + Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { + const listItem = $( '

  • ', { class: 'validation-item', 'data-key': key } ); + + // Hide the core and backslash validation items by default + if ( [ 'core', 'contains_backslash' ].includes( key ) ) { + listItem.hide(); + } + + const validationIcon = $( '', { + src: jetpackData.loadingIcon, + alt: 'Validating...', + class: 'validation-icon', + } ); + + const validationMessage = $( '

    ', { + text: value.message, + class: 'validation-message', + } ); + + let infoIconPopover = null; + if ( value.info ) { + infoIconPopover = createInfoIconPopover( value.info ); + } + + listItem.append( validationIcon, validationMessage, infoIconPopover ); + UIComponents.validationCheckList.append( listItem ); + + UIComponents.validationChecklistItems[ key ] = { + icon: validationIcon, + text: validationMessage, + item: listItem, + }; + } ); + } + + /** + * Bind events to the UI components + */ + function bindEvents() { + const { passwordInput } = UIComponents.core; + + passwordInput.on( 'input', validatePassword ); + passwordInput.on( 'pwupdate', validatePassword ); + } + + /** + * Validate the current password input + */ + function validatePassword() { + const { passwordInput, passwordStrengthResults } = UIComponents.core; + + const password = passwordInput.val(); + + if ( currentAjaxRequest ) { + const oldRequest = currentAjaxRequest; + currentAjaxRequest = null; + oldRequest.abort(); + } + + if ( ! password?.trim() ) { + applyStyling( [], true ); + return; + } + + // Ensure core strength meter is hidden + passwordStrengthResults.hide(); + + renderLoadingState(); + + currentAjaxRequest = $.ajax( { + url: jetpackData.ajaxurl, + type: 'POST', + data: { + action: 'validate_password_ajax', + nonce: jetpackData.nonce, + password: password, + user_specific: jetpackData.userSpecific, + }, + success: handleValidationResponse, + error: handleValidationError, + } ); + } + + /** + * Handles the password validation response. + * @param {object} response - The response object. + */ + function handleValidationResponse( response ) { + currentAjaxRequest = null; + + if ( response.success ) { + const failedValidationConditions = updateValidationChecklist( response.data.state ); + applyStyling( failedValidationConditions ); + } else { + restoreCoreStrengthMeter(); + } + } + + /** + * Handles validation errors. + * @param {object} jqXHR - The jqXHR object. + * @param {any} textStatus - The status of the request. + */ + function handleValidationError( jqXHR, textStatus ) { + if ( textStatus !== 'abort' ) { + restoreCoreStrengthMeter(); + } + } + + /** + * Updates the validation checklist based on the response data. + * + * @param {object} state - The validation state. + * @return {object} - The failed conditions. + */ + function updateValidationChecklist( state ) { + const failedConditions = []; + + // Manually update core strength meter status + const corePasswordStrengthResultsClass = + UIComponents.core.passwordStrengthResults.attr( 'class' ) || ''; + const coreValidationFailed = ! ( + corePasswordStrengthResultsClass.includes( 'strong' ) || + corePasswordStrengthResultsClass.includes( 'good' ) + ); + + Object.entries( state ).forEach( ( [ key, item ] ) => { + const validationFailed = key === 'core' ? coreValidationFailed : item.status; + const checklistItem = UIComponents.validationChecklistItems[ key ]; + + // Display the core and backslash validation items they fail + if ( [ 'core', 'contains_backslash' ].includes( key ) ) { + checklistItem.item.css( 'display', validationFailed ? 'flex' : 'none' ); + } + + if ( checklistItem ) { + checklistItem.icon.attr( + 'src', + validationFailed ? jetpackData.crossIcon : jetpackData.checkIcon + ); + checklistItem.icon.attr( 'alt', validationFailed ? 'Invalid' : 'Valid' ); + checklistItem.text.css( { color: validationFailed ? '#E65054' : '#008710' } ); + } + + if ( validationFailed ) failedConditions.push( key ); + } ); + + return failedConditions; + } + + /** + * + * Apply styling based on validation results + * + * @param {Array} failedValidationConditions - Array containing failed validation conditions keys + * @param {boolean} passwordIsEmpty - Whether the password input is empty + */ + function applyStyling( failedValidationConditions, passwordIsEmpty = false ) { + if ( passwordIsEmpty ) { + renderEmptyInputState(); + return; + } + + const isPasswordStrong = failedValidationConditions.length === 0; + const color = isPasswordStrong ? '#9DD977' : '#FFABAF'; + const strengthText = isPasswordStrong ? 'Strong' : 'Weak'; + + const { + weakPasswordConfirmation, + weakPasswordConfirmationCheckbox, + submitButtons, + passwordInput, + } = UIComponents.core; + const { wrapper, text } = UIComponents.strengthMeter; + + if ( isPasswordStrong || weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { + submitButtons.prop( 'disabled', false ); + } else { + submitButtons.prop( 'disabled', true ); + } + + weakPasswordConfirmation.css( 'display', isPasswordStrong ? 'none' : 'table-row' ); + + text.text( strengthText ); + wrapper.css( 'background-color', color ); + + passwordInput.css( { + 'border-color': color, + 'border-radius': '4px 4px 0px 0px', + } ); + + UIComponents.passwordValidationStatus.show(); + } + + /** + * Render the empty input state + */ + function renderEmptyInputState() { + UIComponents.passwordValidationStatus.hide(); + UIComponents.core.passwordInput.removeAttr( 'style' ); + } + + /** + * Render the loading state + */ + function renderLoadingState() { + const { passwordInput, weakPasswordConfirmation, submitButtons } = UIComponents.core; + submitButtons.prop( 'disabled', true ); + weakPasswordConfirmation.hide(); + + Object.values( UIComponents.validationChecklistItems ).forEach( ( { icon, text } ) => { + icon.attr( 'src', jetpackData.loadingIcon ); + icon.attr( 'alt', 'Validating...' ); + text.css( { color: '#3C434A', transition: 'color 0.2s ease-in-out' } ); + } ); + + UIComponents.strengthMeter.text.text( 'Validating...' ); + UIComponents.strengthMeter.wrapper.css( 'background-color', '#C3C4C7' ); + passwordInput.css( { + 'border-color': '#C3C4C7', + 'border-radius': '4px 4px 0px 0px', + } ); + + UIComponents.passwordValidationStatus.show(); + } + + /** + * Resets UI to core strength meter. + */ + function restoreCoreStrengthMeter() { + renderEmptyInputState(); + UIComponents.core.passwordStrengthResults.show(); + } + + /** + * Creates an info popover element. + * + * @param {string} infoText - The text to display in the popover. + * @return {jQuery} - The info popover element. + */ + function createInfoIconPopover( infoText ) { + const infoIcon = $( '', { src: jetpackData.infoIcon, alt: 'Info', class: 'info-icon' } ); + const popover = $( '

    ', { text: infoText, class: 'popover' } ).append( + $( '
    ', { class: 'popover-arrow' } ) + ); + + infoIcon.hover( + () => popover.fadeIn( 200 ), + () => popover.fadeOut( 200 ) + ); + + return $( '
    ', { class: 'info-popover' } ).append( infoIcon, popover ); + } +} ); diff --git a/projects/packages/account-protection/tests/php/test-password-manager.php b/projects/packages/account-protection/tests/php/test-password-manager.php index 718e2d4c528d1..d1fbfd26e6124 100644 --- a/projects/packages/account-protection/tests/php/test-password-manager.php +++ b/projects/packages/account-protection/tests/php/test-password-manager.php @@ -26,7 +26,7 @@ public function test_validate_profile_update_success() { $validation_service_mock = $this->createMock( Validation_Service::class ); $validation_service_mock->expects( $this->once() ) - ->method( 'return_first_validation_error' ) + ->method( 'get_first_validation_error' ) ->willReturn( '' ); $password_manager_mock = new Password_Manager( $validation_service_mock ); @@ -56,7 +56,7 @@ public function test_validate_password_reset_with_valid_user() { $validation_service_mock = $this->createMock( Validation_Service::class ); $validation_service_mock->expects( $this->once() ) - ->method( 'return_first_validation_error' ) + ->method( 'get_first_validation_error' ) ->willReturn( '' ); $password_manager_mock = new Password_Manager( $validation_service_mock ); @@ -121,13 +121,13 @@ public function test_save_recent_password_stores_last_10_passwords() { 'hash10', ); - update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, $password_hashes ); + update_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, $password_hashes ); $validation_service_mock = $this->createMock( Validation_Service::class ); $password_manager_mock = new Password_Manager( $validation_service_mock ); $password_manager_mock->save_recent_password( $user_id, 'new_hash' ); - $stored_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); + $stored_passwords = get_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); $this->assertCount( 10, $stored_passwords ); $this->assertEquals( 'new_hash', $stored_passwords[0] ); } diff --git a/projects/packages/account-protection/tests/php/test-validation-service.php b/projects/packages/account-protection/tests/php/test-validation-service.php index b7b8696bbb87a..e342b935de09c 100644 --- a/projects/packages/account-protection/tests/php/test-validation-service.php +++ b/projects/packages/account-protection/tests/php/test-validation-service.php @@ -196,23 +196,25 @@ public function test_returns_false_if_password_is_not_current_password() { } public function test_returns_true_if_password_was_recently_used() { - $user_id = 1; - $password_hash = wp_hash_password( 'somepassword' ); + $user = new \WP_User(); + $user->user_pass = wp_hash_password( 'somepassword' ); + $user->ID = 1; - update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $password_hash ) ); + update_user_meta( $user->ID, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $user->user_pass ) ); $validation_service = new Validation_Service( $this->get_connection_manager() ); - $this->assertTrue( $validation_service->is_recent_password( $user_id, 'somepassword' ) ); + $this->assertTrue( $validation_service->is_recent_password( $user, 'somepassword' ) ); } public function test_returns_false_if_password_was_not_recently_used() { - $user_id = 1; - $password_hash = wp_hash_password( 'somepassword' ); + $user = new \WP_User(); + $user->user_pass = wp_hash_password( 'somepassword' ); + $user->ID = 1; - update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $password_hash ) ); + update_user_meta( $user->ID, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $user->user_pass ) ); $validation_service = new Validation_Service( $this->get_connection_manager() ); - $this->assertFalse( $validation_service->is_recent_password( $user_id, 'anotherpassword' ) ); + $this->assertFalse( $validation_service->is_recent_password( $user, 'anotherpassword' ) ); } public function test_returns_true_if_password_matches_user_data() { diff --git a/projects/plugins/jetpack/_inc/client/security/account-protection.jsx b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx index 62cafbe901792..2365fd31dc6c0 100644 --- a/projects/plugins/jetpack/_inc/client/security/account-protection.jsx +++ b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx @@ -25,7 +25,7 @@ const AccountProtectionComponent = class extends Component { module={ this.props.getModule( 'account-protection' ) } support={ { text: __( - 'Jetpack recommends enabling this feature. Please be mindful of the risks', + 'Jetpack recommends enabling this feature. Please be mindful of the risks.', 'jetpack' ), link: '#', // TODO: Update link once doc is avaiable