Skip to content

Commit

Permalink
Account Protection: Update password detection flow (#41365)
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

* Fix phan errors

* Revert prior change

* Send auth code via wpcom only

* Update method name
  • Loading branch information
dkmyta authored Jan 30, 2025
1 parent 639a306 commit 98c2064
Show file tree
Hide file tree
Showing 8 changed files with 447 additions and 405 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public function __construct( ?Modules $modules = null, ?Password_Detection $pass

/**
* Initializes the configurations needed for the account protection module.
*
* @return void
*/
public function init(): void {
$this->register_hooks();
Expand All @@ -54,6 +56,8 @@ public function init(): void {

/**
* Register hooks for module activation and environment validation.
*
* @return void
*/
private function register_hooks(): void {
// Account protection activation/deactivation hooks
Expand All @@ -67,44 +71,44 @@ private function register_hooks(): void {

/**
* Register hooks for runtime operations.
*
* @return void
*/
private function register_runtime_hooks(): void {
// Validate password after successful login
add_action( 'wp_authenticate_user', array( $this->password_detection, 'login_form_password_detection' ), 10, 2 );

// Handle password detection login failure
add_action( 'wp_login_failed', array( $this->password_detection, 'handle_password_detection_validation_error' ), 10, 2 );

// Add password detection flow
add_action( 'login_form_password-detection', array( $this->password_detection, 'render_page' ), 10, 2 );

// Remove password detection usermeta after password reset and on profile password update
add_action( 'after_password_reset', array( $this->password_detection, 'delete_usermeta_after_password_reset' ), 10, 2 );
add_action( 'profile_update', array( $this->password_detection, 'delete_usermeta_on_profile_update' ), 10, 2 );

// Register AJAX resend password reset email action
add_action( 'wp_ajax_resend_password_reset', array( $this->password_detection, 'ajax_resend_password_reset_email' ) );
}

/**
* Activate the account protection on module activation.
*
* @return void
*/
public function on_account_protection_activation(): void {
// Activation logic can be added here
}

/**
* Deactivate the account protection on module deactivation.
*
* @return void
*/
public function on_account_protection_deactivation(): void {
// Remove password detection user meta on deactivation
// TODO: Run on Jetpack and Protect deactivation
$this->password_detection->delete_all_usermeta();
// Deactivation logic can be added here
}

/**
* Determines if the account protection module is enabled on the site.
*
* @return bool
*/
public function is_enabled() {
public function is_enabled(): bool {
return $this->modules->is_active( self::ACCOUNT_PROTECTION_MODULE_NAME );
}

Expand All @@ -113,7 +117,7 @@ public function is_enabled() {
*
* @return bool
*/
public function enable() {
public function enable(): bool {
// Return true if already enabled.
if ( $this->is_enabled() ) {
return true;
Expand Down
19 changes: 19 additions & 0 deletions projects/packages/account-protection/src/class-config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
/**
* Class used to define Config.
*
* @package automattic/jetpack-account-protection
*/

namespace Automattic\Jetpack\Account_Protection;

/**
* 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;
}
114 changes: 114 additions & 0 deletions projects/packages/account-protection/src/class-email-service.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php
/**
* Class used to define Email Service.
*
* @package automattic/jetpack-account-protection
*/

namespace Automattic\Jetpack\Account_Protection;

use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Jetpack_Options;

/**
* Class Email_Service
*/
class Email_Service {
/**
* Send the email using the API.
*
* @param \WP_User $user The user.
* @param string $auth_code The authentication code.
*
* @return bool True if the email was sent successfully, false otherwise.
*/
public function api_send_auth_email( \WP_User $user, string $auth_code ): bool {
$blog_id = Jetpack_Options::get_option( 'id' );
$is_connected = ( new Connection_Manager() )->is_connected();

if ( ! $blog_id || ! $is_connected ) {
return false;
}

$body = array(
'user_login' => $user->user_login,
'user_email' => $user->user_email,
'code' => $auth_code,
);

$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/sites/%d/jetpack-protect-send-verification-code', $blog_id ),
'2',
array(
'method' => 'POST',
),
$body,
'wpcom'
);

$response_code = wp_remote_retrieve_response_code( $response );
if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
return false;
}

$body = json_decode( wp_remote_retrieve_body( $response ), true );

return $body['email_sent'] ?? false;
}

/**
* Resend email attempts.
*
* @param \WP_User $user The user.
* @param array $transient_data The transient data.
* @param string $token The token.
*
* @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 ) {
return false;
}

$auth_code = $this->generate_auth_code();
$transient_data['auth_code'] = $auth_code;

if ( ! $this->api_send_auth_email( $user, $auth_code ) ) {
return false;
}

++$transient_data['resend_attempts'];

if ( ! set_transient( Config::TRANSIENT_PREFIX . "_{$token}", $transient_data, Config::EMAIL_SENT_EXPIRATION ) ) {
return false;
}

return true;
}

/**
* Generate an auth code.
*
* @return string The generated auth code.
*/
public function generate_auth_code(): string {
return (string) wp_rand( 100000, 999999 );
}

/**
* Mask an email address like d*****@g*****.com.
*
* @param string $email The email address to mask.
*
* @return string The masked email address.
*/
public function mask_email_address( string $email ): string {
$parts = explode( '@', $email );
$name = substr( $parts[0], 0, 1 ) . str_repeat( '*', strlen( $parts[0] ) - 1 );
$domain_parts = explode( '.', $parts[1] );
$domain = substr( $domain_parts[0], 0, 1 ) . str_repeat( '*', strlen( $domain_parts[0] ) - 1 );

return "{$name}@{$domain}.{$domain_parts[1]}";
}
}
Loading

0 comments on commit 98c2064

Please sign in to comment.