diff --git a/assets/js/wu-password-reset.js b/assets/js/wu-password-reset.js new file mode 100644 index 00000000..94de86e9 --- /dev/null +++ b/assets/js/wu-password-reset.js @@ -0,0 +1,121 @@ +/* global jQuery, wp, wu_password_reset */ +/** + * Password strength meter for the password reset form. + * + * @since 2.3.0 + */ +(function($) { + 'use strict'; + + var i18n = typeof wu_password_reset !== 'undefined' ? wu_password_reset : { + enter_password: 'Enter a password', + short: 'Very weak', + weak: 'Weak', + medium: 'Medium', + strong: 'Strong', + mismatch: 'Passwords do not match' + }; + + var isPasswordValid = false; + var minStrength = typeof wu_password_reset !== 'undefined' ? parseInt(wu_password_reset.min_strength, 10) : 3; + + /** + * Check password strength and update the UI. + */ + function checkPasswordStrength($pass1, $pass2, $result, $submit) { + var pass1 = $pass1.val(); + var pass2 = $pass2.val(); + + // Reset classes + $result.attr('class', 'wu-py-2 wu-px-4 wu-block wu-text-sm wu-border-solid wu-border wu-mt-2'); + + if (!pass1) { + $result.addClass('wu-bg-gray-100 wu-border-gray-200').html(i18n.enter_password); + isPasswordValid = false; + $submit.prop('disabled', true); + return; + } + + // Get disallowed list from WordPress + var disallowedList = ''; + if (typeof wp !== 'undefined' && typeof wp.passwordStrength !== 'undefined') { + disallowedList = typeof wp.passwordStrength.userInputDisallowedList === 'undefined' + ? wp.passwordStrength.userInputBlacklist() + : wp.passwordStrength.userInputDisallowedList(); + } + + var strength = wp.passwordStrength.meter(pass1, disallowedList, pass2); + + isPasswordValid = false; + + switch (strength) { + case 0: + case 1: + $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.short); + break; + case 2: + $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.weak); + break; + case 3: + $result.addClass('wu-bg-yellow-200 wu-border-yellow-300').html(i18n.medium); + if (minStrength <= 3) { + isPasswordValid = true; + } + break; + case 4: + $result.addClass('wu-bg-green-200 wu-border-green-300').html(i18n.strong); + isPasswordValid = true; + break; + case 5: + $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.mismatch); + break; + default: + $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.short); + } + + $submit.prop('disabled', !isPasswordValid); + } + + /** + * Initialize the password strength meter. + */ + $(document).ready(function() { + var $pass1 = $('#field-pass1'); + var $pass2 = $('#field-pass2'); + var $submit = $('#wp-submit'); + + if (!$pass1.length) { + return; + } + + // Create strength meter element if it doesn't exist + var $result = $('#pass-strength-result'); + if (!$result.length) { + $result = $('
' + i18n.enter_password + '
'); + $pass1.after($result); + } + + // Bind events + $pass1.on('keyup input', function() { + checkPasswordStrength($pass1, $pass2, $result, $submit); + }); + $pass2.on('keyup input', function() { + checkPasswordStrength($pass1, $pass2, $result, $submit); + }); + + // Disable submit initially + $submit.prop('disabled', true); + + // Prevent form submission if password is too weak + $pass1.closest('form').on('submit', function(e) { + if (!isPasswordValid) { + e.preventDefault(); + return false; + } + }); + + // Initial check + checkPasswordStrength($pass1, $pass2, $result, $submit); + }); + +})(jQuery); diff --git a/inc/checkout/class-checkout-pages.php b/inc/checkout/class-checkout-pages.php index 3c902aa6..709b54b6 100644 --- a/inc/checkout/class-checkout-pages.php +++ b/inc/checkout/class-checkout-pages.php @@ -62,6 +62,8 @@ public function init(): void { add_action('lost_password', [$this, 'maybe_handle_password_reset_errors']); + add_action('validate_password_reset', [$this, 'validate_password_strength'], 5, 2); + add_action('validate_password_reset', [$this, 'maybe_handle_password_reset_errors']); /** @@ -204,6 +206,9 @@ public function get_error_message($error_code, $username = '') { 'password_reset_mismatch' => __('Error: The passwords do not match.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'invalidkey' => __('Error: Your password reset link appears to be invalid. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'expiredkey' => __('Error: Your password reset link has expired. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + 'invalid_key' => __('Error: Your password reset link appears to be invalid. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + 'expired_key' => __('Error: Your password reset link has expired. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + 'password_too_weak' => __('Error: Please choose a stronger password. The password must be at least medium strength.', 'ultimate-multisite'), ]; /** @@ -247,6 +252,95 @@ public function maybe_handle_password_reset_errors($errors): void { } } + /** + * Validate password strength during password reset. + * + * Enforces a minimum password strength of "medium" (strength level 3). + * + * @since 2.3.0 + * + * @param \WP_Error $errors The error object. + * @param \WP_User $user The user object. + * @return void + */ + public function validate_password_strength($errors, $user): void { + + $password = wu_request('pass1', ''); + + if (empty($password)) { + return; + } + + // Use WordPress password strength meter. + // Strength levels: 0-1 = very weak, 2 = weak, 3 = medium, 4 = strong. + // We require at least medium (3). + $strength = $this->calculate_password_strength($password); + + if ($strength < 3) { + $errors->add('password_too_weak', __('Error: Please choose a stronger password. The password must be at least medium strength.', 'ultimate-multisite')); + } + } + + /** + * Calculate password strength using similar logic to WordPress. + * + * @since 2.3.0 + * + * @param string $password The password to check. + * @return int Strength level: 0-1 = very weak, 2 = weak, 3 = medium, 4 = strong. + */ + protected function calculate_password_strength(string $password): int { + + $strength = 0; + $length = strlen($password); + + // Minimum length check + if ($length < 6) { + return 0; + } + + // Length scoring + if ($length >= 8) { + ++$strength; + } + + if ($length >= 12) { + ++$strength; + } + + // Character variety scoring + if (preg_match('/[a-z]/', $password)) { + ++$strength; + } + + if (preg_match('/[A-Z]/', $password)) { + ++$strength; + } + + if (preg_match('/[0-9]/', $password)) { + ++$strength; + } + + if (preg_match('/[^a-zA-Z0-9]/', $password)) { + ++$strength; + } + + // Map to WordPress strength levels (0-4) + if ($strength <= 1) { + return 1; // Very weak + } + + if ($strength <= 2) { + return 2; // Weak + } + + if ($strength <= 4) { + return 3; // Medium + } + + return 4; // Strong + } + /** * Maybe redirects users to the confirm screen. * diff --git a/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index bc3a3574..dedbeb71 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -301,6 +301,33 @@ public function fields() { public function register_scripts(): void { wp_enqueue_style('wu-admin'); + + // Enqueue password strength meter for reset password page. + if ($this->is_reset_password_page()) { + wp_enqueue_script('password-strength-meter'); + + wp_enqueue_script( + 'wu-password-reset', + wu_get_asset('wu-password-reset.js', 'js'), + ['jquery', 'password-strength-meter'], + wu_get_version(), + true + ); + + wp_localize_script( + 'wu-password-reset', + 'wu_password_reset', + [ + 'enter_password' => __('Enter a password', 'ultimate-multisite'), + 'short' => __('Very weak', 'ultimate-multisite'), + 'weak' => __('Weak', 'ultimate-multisite'), + 'medium' => __('Medium', 'ultimate-multisite'), + 'strong' => __('Strong', 'ultimate-multisite'), + 'mismatch' => __('Passwords do not match', 'ultimate-multisite'), + 'min_strength' => 3, // Minimum strength level required (3 = medium). + ] + ); + } } /** @@ -605,7 +632,36 @@ public function output($atts, $content = null): void { $user = check_password_reset_key($rp_key, $rp_login); - if (isset($_POST['pass1']) && isset($_POST['rp_key']) && ! hash_equals(wp_unslash($_POST['rp_key']), wp_unslash($_POST['rp_key']))) { // phpcs:ignore WordPress.Security.NonceVerification + // If the reset key is invalid or expired, redirect with appropriate error. + if (is_wp_error($user)) { + $error_code = $user->get_error_code(); + $redirect_to = add_query_arg( + [ + 'action' => 'lostpassword', + 'error' => $error_code, + ], + remove_query_arg(['action', 'key', 'login']) + ); + + // Clear the invalid cookie. + setcookie( + $rp_cookie, + ' ', + [ + 'expires' => time() - YEAR_IN_SECONDS, + 'path' => '/', + 'domain' => (string) COOKIE_DOMAIN, + 'secure' => is_ssl(), + 'httponly' => true, + ] + ); + + wp_safe_redirect($redirect_to); + + exit; + } + + if (isset($_POST['pass1']) && isset($_POST['rp_key']) && ! hash_equals($rp_key, wp_unslash($_POST['rp_key']))) { // phpcs:ignore WordPress.Security.NonceVerification $user = false; } } else { @@ -615,17 +671,19 @@ public function output($atts, $content = null): void { $redirect_to = add_query_arg('password-reset', 'success', remove_query_arg(['action', 'error'])); $fields = [ - 'pass1' => [ + 'pass1' => [ 'type' => 'password', 'title' => __('New password'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'placeholder' => '', 'value' => '', + 'meter' => true, 'html_attr' => [ 'size' => 24, 'autocapitalize' => 'off', + 'autocomplete' => 'new-password', ], ], - 'pass2' => [ + 'pass2' => [ 'type' => 'password', 'title' => __('Confirm new password'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'placeholder' => '', @@ -633,30 +691,26 @@ public function output($atts, $content = null): void { 'html_attr' => [ 'size' => 24, 'autocapitalize' => 'off', + 'autocomplete' => 'new-password', ], ], - 'lost-password-instructions' => [ - 'type' => 'note', - 'desc' => wp_get_password_hint(), - 'tooltip' => '', - ], - 'action' => [ + 'action' => [ 'type' => 'hidden', 'value' => 'resetpass', ], - 'rp_key' => [ + 'rp_key' => [ 'type' => 'hidden', 'value' => $rp_key, ], - 'user_login' => [ + 'user_login' => [ 'type' => 'hidden', 'value' => $rp_login, ], - 'redirect_to' => [ + 'redirect_to' => [ 'type' => 'hidden', 'value' => $redirect_to, ], - 'wp-submit' => [ + 'wp-submit' => [ 'type' => 'submit', 'title' => __('Save Password'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'value' => __('Save Password'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain diff --git a/views/admin-pages/fields/field-password.php b/views/admin-pages/fields/field-password.php new file mode 100644 index 00000000..40d9f964 --- /dev/null +++ b/views/admin-pages/fields/field-password.php @@ -0,0 +1,64 @@ + +
  • print_wrapper_html_attributes(); ?>> + +
    + + $field, + ] + ); + + ?> + + print_html_attributes(); ?>> + + meter)) : ?> + + + + + + + + $field, + ] + ); + + ?> + +
    + +