Skip to content

Commit

Permalink
Account Protection: Add password validation (#41401)
Browse files Browse the repository at this point in the history
* Add Account Protection toggle to Jetpack security settings

* Import package and run activation/deactivation on module toggle

* changelog

* Add Protect Settings page and hook up Account Protection toggle

* changelog

* Update changelog

* Register modules on plugin activation

* Ensure package is initialized on plugin activation

* Make account protection class init static

* Add auth hooks, redirect and a custom login action template

* Reorg, add Password_Detection class

* Remove user cxn req and banner

* Do not enabled module by default

* Add strict mode option and settings toggle

* changelog

* Add strict mode toggle

* Add strict mode toggle and endpoints

* Reorg and add kill switch and is supported check

* Add testing infrastructure

* Add email handlings, resend AJAX action, and attempt limitations

* Add nonces, checks and template error handling

* Use method over template to avoid lint errors

* Improve render_password_detection_template, update SVG file ext

* Remove template file and include

* Prep for validation endpoints

* Update classes to be dynamic

* Add constructors

* Reorg user meta methods

* Add type declarations and hinting

* Simplify method naming

* Use dynamic classes

* Update class dependencies

* Fix copy

* Revert unrelated changes

* Revert unrelated changes

* Fix method calls

* Do not activate by default

* Fix phan errors

* Changelog

* Update composer deps

* Update lock files, add constructor method

* Fix php warning

* Update lock file

* Changelog

* Fix Password_Detection constructor

* Changelog

* More changelogs

* Remove comments

* Fix static analysis errors

* Remove top level phpunit.xml.dist

* Remove never return type

* Revert tests dir changes in favour of a dedicated task

* Add tests dir

* Reapply default test infrastructure

* Reorg and rename

* Update @Package

* Use never phpdoc return type as per static analysis error

* Enable module by default

* Enable module by default

* Remove all reference to and functionality of strict mode

* Remove unneeded strict mode code, update Protect settings UI

* Updates/fixes

* Fix import

* Update placeholder content

* Revert unrelated changes

* Remove missed code

* Update reset email to two factor auth email

* Updates and improvements

* Reorg

* Optimizations and reorganizations

* Hook up email service

* Update error handling todos, fix weak password check

* Test

* Localize text content

* Fix lint warnings/errors

* Update todos

* Add error handling, enforce input restrictions

* Move main constants back entry file

* Fix package version check

* Optimize setting error transient

* Add nonce check for resend email action

* Fix spacing

* Fix resend nonce handling

* Email service fixes

* Fixes, improvements to doc consistency

* Add remaining password validation

* Update weak password check returns

* Fix phan errors

* Revert prior change

* Fix meta key

* Add process for add/updating recent pass list

* Send auth code via wpcom only

* Update method name

* Optimize validation

* Fix key, remove testing code

* Fix docs

* Fix tests

* Improve matches user data logic

* Remove password reset nonce verification code

* Updates and fixes

* Include tests for new validation methods

* Include tests for new validation methods

* Add password manager class tests

* Remove custom nonce, add core create-user nonce check

* Remove todos - always run server side validation

* Update constant naming

* Translate error message

* Ensure styles are enqueued when viewing the password detection page

* Use global page now and action check to enqueue styles

* Skip recent password checks during create user action

* Additional skips, and comment clarification

* Revert skips of user specific reset form validation, hook provides access to this

* Revert unintended additions

* Return early if update is irrelevant

* Only verify nonce if pass is set

* Skip validation if bypass enabled

* Fix test

* Update methods, removes nonce checks, fix tests

* Fix test

* Remove comment
  • Loading branch information
dkmyta authored Feb 12, 2025
1 parent 9032fde commit 933f0db
Show file tree
Hide file tree
Showing 11 changed files with 637 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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();
}

/**
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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 );
}

/**
Expand Down
11 changes: 6 additions & 5 deletions projects/packages/account-protection/src/class-config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );
Expand All @@ -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'] )
);
}
Expand Down Expand Up @@ -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.
Expand All @@ -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'] )
Expand All @@ -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' );
}

Expand Down Expand Up @@ -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 );

Expand Down Expand Up @@ -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' ) );
}
Expand Down Expand Up @@ -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() );
Expand All @@ -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 );
}

/**
Expand All @@ -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
);
}
}
}
155 changes: 155 additions & 0 deletions projects/packages/account-protection/src/class-password-manager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php
/**
* Class used to define Password Manager.
*
* @package automattic/jetpack-account-protection
*/

namespace Automattic\Jetpack\Account_Protection;

/**
* Class Password_Manager
*/
class Password_Manager {
/**
* Validaton service instance
*
* @var Validation_Service
*/
private $validation_service;

/**
* Validation_Service constructor.
*
* @param ?Validation_Service $validation_service Password manager instance.
*/
public function __construct( ?Validation_Service $validation_service = null ) {
$this->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', __( '<strong>Error:</strong> 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', __( '<strong>Error:</strong> 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 );
}
}
Loading

0 comments on commit 933f0db

Please sign in to comment.