diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php
index 7156849eabac3..9e028ee993853 100644
--- a/projects/packages/account-protection/src/class-account-protection.php
+++ b/projects/packages/account-protection/src/class-account-protection.php
@@ -16,6 +16,13 @@ class Account_Protection {
const PACKAGE_VERSION = '0.1.0-alpha';
const ACCOUNT_PROTECTION_MODULE_NAME = 'account-protection';
+ /**
+ * Flag to track if hooks have been registered.
+ *
+ * @var bool
+ */
+ private static $hooks_registered = false;
+
/**
* Modules instance.
*
@@ -30,15 +37,24 @@ class Account_Protection {
*/
private $password_detection;
+ /**
+ * Password manager instance
+ *
+ * @var Password_Manager
+ */
+ private $password_manager;
+
/**
* Account_Protection constructor.
*
* @param ?Modules $modules Modules instance.
* @param ?Password_Detection $password_detection Password detection instance.
+ * @param ?Password_Manager $password_manager Validation service instance.
*/
- public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null ) {
+ 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();
}
/**
@@ -47,11 +63,17 @@ public function __construct( ?Modules $modules = null, ?Password_Detection $pass
* @return void
*/
public function init(): void {
+ if ( self::$hooks_registered ) {
+ return;
+ }
+
$this->register_hooks();
if ( $this->is_enabled() ) {
$this->register_runtime_hooks();
}
+
+ self::$hooks_registered = true;
}
/**
@@ -83,6 +105,16 @@ protected function register_runtime_hooks(): void {
// Add password detection flow
add_action( 'login_form_password-detection', array( $this->password_detection, 'render_page' ), 10, 2 );
+ 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 );
}
/**
diff --git a/projects/packages/account-protection/src/class-config.php b/projects/packages/account-protection/src/class-config.php
index 99d461441752a..97020daac1f90 100644
--- a/projects/packages/account-protection/src/class-config.php
+++ b/projects/packages/account-protection/src/class-config.php
@@ -11,9 +11,10 @@
* Class Config
*/
class Config {
- public const TRANSIENT_PREFIX = 'password_detection';
- public const ERROR_CODE = 'password_detection_validation_error';
- public const ERROR_MESSAGE = 'Password validation failed.';
- public const EMAIL_SENT_EXPIRATION = 600; // 10 minutes
- public const MAX_RESEND_ATTEMPTS = 3;
+ 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';
}
diff --git a/projects/packages/account-protection/src/class-email-service.php b/projects/packages/account-protection/src/class-email-service.php
index 4ca7e2fecf21e..aa07beff85d40 100644
--- a/projects/packages/account-protection/src/class-email-service.php
+++ b/projects/packages/account-protection/src/class-email-service.php
@@ -95,7 +95,7 @@ protected function send_email_request( int $blog_id, array $body ) {
* @return bool True if the email was resent successfully, false otherwise.
*/
public function resend_auth_email( \WP_User $user, array $transient_data, string $token ): bool {
- if ( $transient_data['resend_attempts'] >= Config::MAX_RESEND_ATTEMPTS ) {
+ if ( $transient_data['resend_attempts'] >= Config::PASSWORD_DETECTION_MAX_RESEND_ATTEMPTS ) {
return false;
}
@@ -108,7 +108,7 @@ public function resend_auth_email( \WP_User $user, array $transient_data, string
++$transient_data['resend_attempts'];
- if ( ! set_transient( Config::TRANSIENT_PREFIX . "_{$token}", $transient_data, Config::EMAIL_SENT_EXPIRATION ) ) {
+ if ( ! set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$token}", $transient_data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) {
return false;
}
diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php
index 613b982f5a0be..95f8a0c288975 100644
--- a/projects/packages/account-protection/src/class-password-detection.php
+++ b/projects/packages/account-protection/src/class-password-detection.php
@@ -50,7 +50,6 @@ public function login_form_password_detection( $user, string $password ) {
}
if ( $this->validation_service->is_weak_password( $password ) ) {
- // TODO: Every time the user logs in we generate a new token based transient. This might not be ideal.
$transient = $this->generate_and_store_transient_data( $user->ID );
$email_sent = $this->email_service->api_send_auth_email( $user, $transient['auth_code'] );
@@ -59,8 +58,8 @@ public function login_form_password_detection( $user, string $password ) {
}
return new \WP_Error(
- Config::ERROR_CODE,
- Config::ERROR_MESSAGE,
+ Config::PASSWORD_DETECTION_ERROR_CODE,
+ __( 'Password validation failed.', 'jetpack-account-protection' ),
array( 'token' => $transient['token'] )
);
}
@@ -126,7 +125,7 @@ public function render_page() {
}
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : null;
- $transient_data = get_transient( Config::TRANSIENT_PREFIX . "_{$token}" );
+ $transient_data = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$token}" );
if ( ! $transient_data ) {
$this->redirect_to_login();
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
@@ -141,8 +140,6 @@ public function render_page() {
return;
}
- add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) );
-
// Handle resend email request
if ( isset( $_GET['resend_email'] ) && $_GET['resend_email'] === '1' ) {
if ( isset( $_GET['_wpnonce'] )
@@ -152,7 +149,7 @@ public function render_page() {
if ( ! $email_resent ) {
$message = __( 'Failed to resend authentication email. Please try again.', 'jetpack-account-protection' );
- if ( $transient_data['resend_attempts'] >= Config::MAX_RESEND_ATTEMPTS ) {
+ if ( $transient_data['resend_attempts'] >= Config::PASSWORD_DETECTION_MAX_RESEND_ATTEMPTS ) {
$message = __( 'Resend limit exceeded. Please try again later.', 'jetpack-account-protection' );
}
@@ -190,7 +187,7 @@ public function render_page() {
* @return void
*/
public function render_content( \WP_User $user, string $token ): void {
- $transient_key = Config::TRANSIENT_PREFIX . "_error_{$user->ID}";
+ $transient_key = Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_error_{$user->ID}";
$error_message = get_transient( $transient_key );
delete_transient( $transient_key );
@@ -286,7 +283,7 @@ private function generate_and_store_transient_data( int $user_id ): array {
'resend_attempts' => 0,
);
- $transient_set = set_transient( Config::TRANSIENT_PREFIX . "_{$token}", $data, Config::EMAIL_SENT_EXPIRATION );
+ $transient_set = set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$token}", $data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
if ( ! $transient_set ) {
$this->set_transient_error( $user_id, __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ) );
}
@@ -330,7 +327,7 @@ private function get_redirect_url( string $token ): string {
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 ) {
// TODO: Ensure all transient are also removed on module and/or plugin deactivation
- delete_transient( Config::TRANSIENT_PREFIX . "_{$token}" );
+ delete_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$token}" );
wp_set_auth_cookie( $user->ID, true );
// TODO: Notify user to update their password/redirect to password update page
$this->redirect_and_exit( admin_url() );
@@ -349,7 +346,7 @@ private function handle_auth_form_submission( \WP_User $user, string $token, str
* @return void
*/
private function set_transient_error( int $user_id, string $message, int $expiration = 60 ): void {
- set_transient( Config::TRANSIENT_PREFIX . "_error_{$user_id}", $message, $expiration );
+ set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_error_{$user_id}", $message, $expiration );
}
/**
@@ -358,11 +355,15 @@ private function set_transient_error( int $user_id, string $message, int $expira
* @return void
*/
public function enqueue_styles(): void {
- wp_enqueue_style(
- 'password-detection-styles',
- plugin_dir_url( __FILE__ ) . 'css/password-detection.css',
- array(),
- Account_Protection::PACKAGE_VERSION
- );
+ // 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
+ );
+ }
}
}
diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php
new file mode 100644
index 0000000000000..42fa92d615c36
--- /dev/null
+++ b/projects/packages/account-protection/src/class-password-manager.php
@@ -0,0 +1,155 @@
+validation_service = $validation_service ?? new Validation_Service();
+ }
+
+ /**
+ * Validate the profile update.
+ *
+ * @param \WP_Error $errors The error object.
+ * @param bool $update Whether the user is being updated.
+ * @param \stdClass $user A copy of the new user object.
+ *
+ * @return void
+ */
+ public function validate_profile_update( \WP_Error $errors, bool $update, \stdClass $user ): void {
+ if ( empty( $user->user_pass ) ) {
+ return;
+ }
+
+ // If bypass is enabled, do not validate the password
+ // phpcs:ignore WordPress.Security.NonceVerification
+ if ( isset( $_POST['pw_weak'] ) && 'on' === $_POST['pw_weak'] ) {
+ 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 );
+
+ if ( ! empty( $error ) ) {
+ $errors->add( 'password_error', $error );
+ return;
+ }
+ }
+
+ /**
+ * Validate the password reset.
+ *
+ * @param \WP_Error $errors The error object.
+ * @param \WP_User|\WP_Error $user The user object.
+ *
+ * @return void
+ */
+ public function validate_password_reset( \WP_Error $errors, $user ): void {
+ if ( is_wp_error( $user ) ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification
+ if ( empty( $_POST['pass1'] ) ) {
+ return;
+ }
+
+ // If bypass is enabled, do not validate the password
+ // phpcs:ignore WordPress.Security.NonceVerification
+ if ( isset( $_POST['pw_weak'] ) && 'on' === $_POST['pw_weak'] ) {
+ return;
+ }
+
+ // 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' );
+ if ( ! empty( $error ) ) {
+ $errors->add( 'password_error', $error );
+ return;
+ }
+ }
+
+ /**
+ * Handle the profile update.
+ *
+ * @param int $user_id The user ID.
+ * @param \WP_User $old_user_data Object containing user data prior to update.
+ *
+ * @return void
+ */
+ public function on_profile_update( int $user_id, \WP_User $old_user_data ): void {
+ // phpcs:ignore WordPress.Security.NonceVerification
+ if ( isset( $_POST['action'] ) && $_POST['action'] === 'update' ) {
+ $this->save_recent_password( $user_id, $old_user_data->user_pass );
+ }
+ }
+
+ /**
+ * Handle the password reset.
+ *
+ * @param \WP_User $user The user.
+ *
+ * @return void
+ */
+ public function on_password_reset( $user ): void {
+ $this->save_recent_password( $user->ID, $user->user_pass );
+ }
+
+ /**
+ * Save the new password hash to the user's recent passwords list.
+ *
+ * @param int $user_id The user ID.
+ * @param string $password_hash The password hash to store.
+ *
+ * @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 );
+
+ if ( ! is_array( $recent_passwords ) ) {
+ $recent_passwords = array();
+ }
+
+ if ( in_array( $password_hash, $recent_passwords, true ) ) {
+ return;
+ }
+
+ // 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 );
+
+ update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, $recent_passwords );
+ }
+}
diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php
index 6430bb0515bc8..a64b5065c4fff 100644
--- a/projects/packages/account-protection/src/class-validation-service.php
+++ b/projects/packages/account-protection/src/class-validation-service.php
@@ -49,6 +49,153 @@ protected function request_suffixes( string $password_prefix ) {
);
}
+ /**
+ * Return all validation errors.
+ *
+ * @param \WP_User|\stdClass $user The user object or a copy.
+ * @param string $password The password to check.
+ *
+ * @return array An array of validation errors (if any).
+ */
+ public function return_all_validation_errors( $user, string $password ): array {
+ $errors = array();
+
+ if ( $this->contains_backslash( $password ) ) {
+ $errors[] = __( 'Doesn\'t contain a backslash (\\) character', 'jetpack-account-protection' );
+ }
+
+ if ( $this->is_invalid_length( $password ) ) {
+ $errors[] = __( 'Between 6 and 150 characters', 'jetpack-account-protection' );
+ }
+
+ if ( $this->matches_user_data( $user, $password ) ) {
+ $errors[] = __( 'Doesn\'t match user data', 'jetpack-account-protection' );
+ }
+
+ if ( $this->is_recent_password( $user->ID, $password ) ) {
+ $errors[] = __( 'Not used recently', 'jetpack-account-protection' );
+ }
+
+ if ( $this->is_weak_password( $password ) ) {
+ $errors[] = __( 'Not a leaked password.', 'jetpack-account-protection' );
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Return first validation error.
+ *
+ * @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.
+ *
+ * @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 ) {
+ if ( $this->contains_backslash( $password ) ) {
+ return __( 'Error: The password cannot contain a backslash (\\) character.', 'jetpack-account-protection' );
+ }
+ }
+
+ if ( $this->is_invalid_length( $password ) ) {
+ 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 ( 'create-user' !== $context ) {
+ if ( $this->is_recent_password( $user->ID, $password ) ) {
+ return __( 'Error: The password was used recently.', 'jetpack-account-protection' );
+ }
+ }
+
+ if ( $this->is_weak_password( $password ) ) {
+ return __( 'Error: The password was found in a public leak.', 'jetpack-account-protection' );
+ }
+
+ return '';
+ }
+
+ /**
+ * Check if the password contains a backslash.
+ *
+ * @param string $password The password to check.
+ *
+ * @return bool True if the password contains a backslash, false otherwise.
+ */
+ public function contains_backslash( string $password ): bool {
+ return strpos( $password, '\\' ) !== false;
+ }
+
+ /**
+ * Check if the password length is within the allowed range.
+ *
+ * @param string $password The password to check.
+ *
+ * @return bool True if the password is between 6 and 150 characters, false otherwise.
+ */
+ public function is_invalid_length( string $password ): bool {
+ $length = strlen( $password );
+ return $length < 6 || $length > 150;
+ }
+
+ /**
+ * Check if the password matches any user data.
+ *
+ * @param \WP_User|\stdClass $user The user.
+ * @param string $password The password to check.
+ *
+ * @return bool True if the password matches any user data, false otherwise.
+ */
+ public function matches_user_data( $user, string $password ): bool {
+ if ( ! $user ) {
+ return false;
+ }
+
+ $email_parts = explode( '@', $user->user_email ); // test@example.com
+ $email_username = $email_parts[0]; // 'test'
+ $email_domain = $email_parts[1]; // 'example.com'
+ $email_provider = explode( '.', $email_domain )[0]; // 'example'
+
+ $user_data = array(
+ $user->user_login ?? '',
+ $user->display_name ?? '',
+ $user->first_name ?? '',
+ $user->last_name ?? '',
+ $user->user_email ?? '',
+ $email_username ?? '',
+ $email_provider ?? '',
+ $user->nickname ?? '',
+ );
+
+ $password_lower = strtolower( $password );
+
+ foreach ( $user_data as $data ) {
+ // Skip if $data is 3 characters or less.
+ if ( strlen( $data ) <= 3 ) {
+ continue;
+ }
+
+ if ( ! empty( $data ) && strpos( $password_lower, strtolower( $data ) ) !== false ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Check if the password is in the list of compromised/common passwords.
*
@@ -85,4 +232,45 @@ public function is_weak_password( string $password ): bool {
return false;
}
+
+ /**
+ * Check if the password is the current password for the user.
+ *
+ * @param int $user_id The user ID.
+ * @param string $password The password to check.
+ *
+ * @return bool True if the password is the current password, false otherwise.
+ */
+ public function is_current_password( int $user_id, string $password ): bool {
+ $user = get_userdata( $user_id );
+ if ( ! $user ) {
+ return false;
+ }
+
+ return wp_check_password( $password, $user->user_pass, $user->ID );
+ }
+
+ /**
+ * 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.
+ *
+ * @return bool True if the password hash 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 );
+
+ if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) {
+ return false;
+ }
+
+ foreach ( $recent_passwords as $old_hashed_password ) {
+ if ( wp_check_password( $password, $old_hashed_password ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
}
diff --git a/projects/packages/account-protection/tests/php/test-account-protection.php b/projects/packages/account-protection/tests/php/test-account-protection.php
index 0bbb8c831d5ce..fbb481b26023b 100644
--- a/projects/packages/account-protection/tests/php/test-account-protection.php
+++ b/projects/packages/account-protection/tests/php/test-account-protection.php
@@ -37,6 +37,11 @@ public function test_init_registers_hooks_and_runtime_hooks_if_module_enabled():
}
public function test_init_registers_hooks_but_not_runtime_hooks_if_module_disabled(): void {
+ $reflection = new \ReflectionClass( Account_Protection::class );
+ $property = $reflection->getProperty( 'hooks_registered' );
+ $property->setAccessible( true );
+ $property->setValue( false );
+
$sut = $this->createPartialMock( Account_Protection::class, array( 'is_enabled', 'register_hooks', 'register_runtime_hooks' ) );
$sut->expects( $this->once() )
->method( 'is_enabled' )
diff --git a/projects/packages/account-protection/tests/php/test-email-service.php b/projects/packages/account-protection/tests/php/test-email-service.php
index a71d02fafa8c4..795f3aceb6582 100644
--- a/projects/packages/account-protection/tests/php/test-email-service.php
+++ b/projects/packages/account-protection/tests/php/test-email-service.php
@@ -6,7 +6,7 @@
use WorDBless\BaseTestCase;
/**
- * Tests for the Account_Protection class.
+ * Tests for the Email_Service class.
*/
class Email_Service_Test extends BaseTestCase {
@@ -66,7 +66,7 @@ public function test_resend_auth_mail_sends_mail_and_remembers_2fa_token_success
$this->assertTrue( $result, 'Resending auth mail should return true as success indicator.' );
// Verify the transient has the expected data
- $new_transient = get_transient( Config::TRANSIENT_PREFIX . "_{$my_token}" );
+ $new_transient = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$my_token}" );
$this->assertSame( 1, $new_transient['resend_attempts'], 'Resend attempts should be 1.' );
$this->assertMatchesRegularExpression( '/^[0-9]{6}$/', $new_transient['auth_code'], 'Auth code should be 6 digits.' );
}
diff --git a/projects/packages/account-protection/tests/php/test-password-detection.php b/projects/packages/account-protection/tests/php/test-password-detection.php
index f8e7aac1d3866..0417a6f6f0b29 100644
--- a/projects/packages/account-protection/tests/php/test-password-detection.php
+++ b/projects/packages/account-protection/tests/php/test-password-detection.php
@@ -10,7 +10,7 @@
class Password_Detection_Test extends BaseTestCase {
public function test_handle_password_detection_validation_error_redirects_to_login(): void {
- $error = new \WP_Error( Config::ERROR_CODE, Config::ERROR_MESSAGE, array( 'token' => 'my-token' ) );
+ $error = new \WP_Error( Config::PASSWORD_DETECTION_ERROR_CODE, 'Password validation failed.', array( 'token' => 'my-token' ) );
$sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit' ) );
$sut->expects( $this->once() )
@@ -96,8 +96,8 @@ public function test_login_form_password_detection_sends_email_and_returns_error
$error = $sut->login_form_password_detection( $user, 'pw' );
$this->assertInstanceOf( \WP_Error::class, $error, 'Should return a WP_Error object.' );
- $this->assertSame( Config::ERROR_MESSAGE, $error->get_error_message( Config::ERROR_CODE ), 'Should return the correct error message.' );
- $token = $error->get_error_data( Config::ERROR_CODE )['token'];
+ $this->assertSame( 'Password validation failed.', $error->get_error_message( Config::PASSWORD_DETECTION_ERROR_CODE ), 'Should return the correct error message.' );
+ $token = $error->get_error_data( Config::PASSWORD_DETECTION_ERROR_CODE )['token'];
$this->assertSame( 32, strlen( $token ), 'Token should be 32 characters long.' );
remove_filter( 'check_password', '__return_true' );
@@ -129,7 +129,7 @@ public function test_login_form_password_detection_sets_transient_error_if_unabl
$sut->login_form_password_detection( $user, 'pw' );
- $transient_data = get_transient( Config::TRANSIENT_PREFIX . "_error_{$user->ID}" );
+ $transient_data = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_error_{$user->ID}" );
$this->assertSame( 'Failed to send authentication email. Please try again.', $transient_data, 'Should have set the correct error message.' );
remove_filter( 'check_password', '__return_true' );
@@ -166,7 +166,7 @@ public function test_render_page_redirects_to_login_if_transient_data_is_not_ava
public function test_render_page_redirects_to_login_if_user_with_id_from_transient_does_not_exist(): void {
$_GET['token'] = 'my_cool_token';
- set_transient( Config::TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123 ) );
+ set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123 ) );
$sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit', 'load_user' ) );
$sut->expects( $this->once() )
@@ -189,7 +189,7 @@ public function test_render_page_checks_2fa_code_successfully(): void {
$_POST['_wpnonce_verify'] = wp_create_nonce( 'verify_action' );
set_transient(
- Config::TRANSIENT_PREFIX . '_my_cool_token',
+ Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token',
array(
'user_id' => 123,
'auth_code' => '123456',
@@ -240,7 +240,7 @@ public function test_render_page_sets_transient_error_if_2fa_code_is_wrong(): vo
$_POST['_wpnonce_verify'] = wp_create_nonce( 'verify_action' );
set_transient(
- Config::TRANSIENT_PREFIX . '_my_cool_token',
+ Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token',
array(
'user_id' => 123,
'auth_code' => '123456',
@@ -263,7 +263,7 @@ public function test_render_page_sets_transient_error_if_2fa_code_is_wrong(): vo
$sut->render_page();
- $error = get_transient( Config::TRANSIENT_PREFIX . '_error_123' );
+ $error = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_error_123' );
$this->assertSame( 'Authentication code verification failed. Please try again.', $error, 'Error message is not as expected.' );
@@ -279,7 +279,7 @@ public function test_render_page_sets_transient_error_if_2fa_nonce_is_wrong(): v
$_POST['_wpnonce_verify'] = 'wrong nonce'; // intentionally wrong
set_transient(
- Config::TRANSIENT_PREFIX . '_my_cool_token',
+ Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token',
array(
'user_id' => 123,
'auth_code' => '123456',
@@ -302,7 +302,7 @@ public function test_render_page_sets_transient_error_if_2fa_nonce_is_wrong(): v
$sut->render_page();
- $error = get_transient( Config::TRANSIENT_PREFIX . '_error_123' );
+ $error = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_error_123' );
$this->assertSame( 'Verify nonce verification failed. Please try again.', $error, 'Error message is not as expected.' );
@@ -317,7 +317,7 @@ public function test_render_page_resends_mail_successfully(): void {
$_GET['_wpnonce'] = wp_create_nonce( 'resend_email_nonce' );
set_transient(
- Config::TRANSIENT_PREFIX . '_my_cool_token',
+ Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token',
array(
'user_id' => 123,
'auth_code' => '123456',
@@ -385,7 +385,7 @@ public function test_render_content_explains_the_2fa_form(): void {
public function test_render_content_shows_transient_error_if_set(): void {
$error_message = 'This is a error message to test things with.';
- set_transient( Config::TRANSIENT_PREFIX . '_error_123', $error_message );
+ set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_error_123', $error_message );
$user = new \WP_User();
$user->ID = 123;
diff --git a/projects/packages/account-protection/tests/php/test-password-manager.php b/projects/packages/account-protection/tests/php/test-password-manager.php
new file mode 100644
index 0000000000000..718e2d4c528d1
--- /dev/null
+++ b/projects/packages/account-protection/tests/php/test-password-manager.php
@@ -0,0 +1,134 @@
+ 'admin',
+ 'user_pass' => wp_hash_password( 'oldpassword' ),
+ 'user_email' => 'admin@admin.com',
+ 'role' => 'administrator',
+ )
+ );
+
+ $errors = new \WP_Error();
+ $user = (object) array(
+ 'ID' => $user_id,
+ 'user_pass' => wp_hash_password( 'newpassword' ),
+ );
+
+ $validation_service_mock = $this->createMock( Validation_Service::class );
+ $validation_service_mock->expects( $this->once() )
+ ->method( 'return_first_validation_error' )
+ ->willReturn( '' );
+
+ $password_manager_mock = new Password_Manager( $validation_service_mock );
+ $password_manager_mock->validate_profile_update( $errors, true, $user );
+
+ $this->assertFalse( $errors->has_errors() );
+ }
+
+ public function test_validate_password_reset_with_invalid_user() {
+ $errors = new \WP_Error();
+ $user = new \WP_Error( 'invalid_user', 'Invalid user.' );
+
+ $validation_service_mock = $this->createMock( Validation_Service::class );
+ $password_manager_mock = new Password_Manager( $validation_service_mock );
+
+ $password_manager_mock->validate_password_reset( $errors, $user );
+
+ $this->assertFalse( $errors->has_errors() );
+ }
+
+ public function test_validate_password_reset_with_valid_user() {
+ $_POST['pass1'] = 'securepassword';
+
+ $errors = new \WP_Error();
+ $user = new \WP_User();
+ $user->ID = 1;
+
+ $validation_service_mock = $this->createMock( Validation_Service::class );
+ $validation_service_mock->expects( $this->once() )
+ ->method( 'return_first_validation_error' )
+ ->willReturn( '' );
+
+ $password_manager_mock = new Password_Manager( $validation_service_mock );
+ $password_manager_mock->validate_password_reset( $errors, $user );
+
+ $this->assertFalse( $errors->has_errors() );
+ }
+
+ public function test_on_profile_update_with_valid_nonce() {
+ $_POST['action'] = 'update';
+
+ $user_id = 1;
+ $old_user_data = new \WP_User();
+ $old_user_data->user_pass = 'oldhashedpassword';
+
+ $validation_service_mock = $this->createMock( Validation_Service::class );
+ $password_manager_mock = $this->getMockBuilder( Password_Manager::class )
+ ->setConstructorArgs( array( $validation_service_mock ) )
+ ->onlyMethods( array( 'save_recent_password' ) )
+ ->getMock();
+
+ $password_manager_mock->expects( $this->once() )
+ ->method( 'save_recent_password' )
+ ->with( $user_id, 'oldhashedpassword' );
+
+ $password_manager_mock->on_profile_update(
+ $user_id,
+ $old_user_data
+ );
+ }
+
+ public function test_on_password_reset_saves_recent_password() {
+ $user = new \WP_User();
+ $user->ID = 1;
+ $user->user_pass = 'hashedpassword';
+
+ $validation_service_mock = $this->createMock( Validation_Service::class );
+ $password_manager_mock = $this->getMockBuilder( Password_Manager::class )
+ ->setConstructorArgs( array( $validation_service_mock ) )
+ ->onlyMethods( array( 'save_recent_password' ) )
+ ->getMock();
+
+ $password_manager_mock->expects( $this->once() )
+ ->method( 'save_recent_password' )
+ ->with( $user->ID, 'hashedpassword' );
+
+ $password_manager_mock->on_password_reset( $user );
+ }
+
+ public function test_save_recent_password_stores_last_10_passwords() {
+ $user_id = 1;
+ $password_hashes = array(
+ 'hash1',
+ 'hash2',
+ 'hash3',
+ 'hash4',
+ 'hash5',
+ 'hash6',
+ 'hash7',
+ 'hash8',
+ 'hash9',
+ 'hash10',
+ );
+
+ update_user_meta( $user_id, Config::VALIDATION_SERVICE_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 );
+ $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 24ffbb99b0166..b7b8696bbb87a 100644
--- a/projects/packages/account-protection/tests/php/test-validation-service.php
+++ b/projects/packages/account-protection/tests/php/test-validation-service.php
@@ -22,11 +22,17 @@ public function test_returns_false_if_not_connected() {
$this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) );
}
- private function get_connected_connection_manager() {
+ private function get_connection_manager() {
$connection = $this->getMockBuilder( 'Automattic\Jetpack\Connection\Manager' )
->disableOriginalConstructor()
->getMock();
+ return $connection;
+ }
+
+ private function get_connected_connection_manager() {
+ $connection = $this->get_connection_manager();
+
$connection->expects( $this->once() )
->method( 'is_connected' )
->willReturn( true );
@@ -160,4 +166,79 @@ public function test_returns_false_if_password_is_not_weak() {
$this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) );
}
+
+ public function test_returns_true_if_password_is_current_password() {
+ $user = wp_insert_user(
+ array(
+ 'user_login' => 'admin',
+ 'user_pass' => 'somepassword',
+ 'user_email' => 'admin@admin.com',
+ 'role' => 'administrator',
+ )
+ );
+
+ $validation_service = new Validation_Service( $this->get_connection_manager() );
+ $this->assertTrue( $validation_service->is_current_password( $user, 'somepassword' ) );
+ }
+
+ public function test_returns_false_if_password_is_not_current_password() {
+ $user = wp_insert_user(
+ array(
+ 'user_login' => 'admin',
+ 'user_pass' => 'somepassword',
+ 'user_email' => 'admin@admin.com',
+ 'role' => 'administrator',
+ )
+ );
+
+ $validation_service = new Validation_Service( $this->get_connection_manager() );
+ $this->assertFalse( $validation_service->is_current_password( $user, 'anotherpassword' ) );
+ }
+
+ public function test_returns_true_if_password_was_recently_used() {
+ $user_id = 1;
+ $password_hash = wp_hash_password( 'somepassword' );
+
+ update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $password_hash ) );
+
+ $validation_service = new Validation_Service( $this->get_connection_manager() );
+ $this->assertTrue( $validation_service->is_recent_password( $user_id, 'somepassword' ) );
+ }
+
+ public function test_returns_false_if_password_was_not_recently_used() {
+ $user_id = 1;
+ $password_hash = wp_hash_password( 'somepassword' );
+
+ update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $password_hash ) );
+
+ $validation_service = new Validation_Service( $this->get_connection_manager() );
+ $this->assertFalse( $validation_service->is_recent_password( $user_id, 'anotherpassword' ) );
+ }
+
+ public function test_returns_true_if_password_matches_user_data() {
+ $user = new \WP_User();
+ $user->user_email = 'example@wordpress.com';
+
+ $validation_service = new Validation_Service( $this->get_connection_manager() );
+ $this->assertTrue( $validation_service->matches_user_data( $user, 'WordPress' ) );
+ }
+
+ public function test_returns_false_if_password_is_too_short() {
+ $short_password = 'short';
+
+ $validation_service = new Validation_Service( $this->get_connection_manager() );
+ $this->assertTrue( $validation_service->is_invalid_length( $short_password ) );
+ }
+
+ public function test_returns_false_if_password_is_too_long() {
+ $long_password = str_repeat( 'a', 151 );
+
+ $validation_service = new Validation_Service( $this->get_connection_manager() );
+ $this->assertTrue( $validation_service->is_invalid_length( $long_password ) );
+ }
+
+ public function test_returns_true_if_password_contains_backslash() {
+ $validation_service = new Validation_Service( $this->get_connection_manager() );
+ $this->assertTrue( $validation_service->contains_backslash( 'password\\' ) );
+ }
}