diff --git a/projects/js-packages/api/changelog/add-jetpack-account-protection-security-settings b/projects/js-packages/api/changelog/add-jetpack-account-protection-security-settings new file mode 100644 index 0000000000000..778ccde6854ed --- /dev/null +++ b/projects/js-packages/api/changelog/add-jetpack-account-protection-security-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Adds Account Protection requests diff --git a/projects/js-packages/api/index.jsx b/projects/js-packages/api/index.jsx index 8233d0ba8a616..6f6cdffe0b325 100644 --- a/projects/js-packages/api/index.jsx +++ b/projects/js-packages/api/index.jsx @@ -510,6 +510,16 @@ function JetpackRestApiClient( root, nonce ) { getRequest( `${ wpcomOriginApiUrl }jetpack/v4/search/stats`, getParams ) .then( checkStatus ) .then( parseJsonResponse ), + fetchAccountProtectionSettings: () => + getRequest( `${ apiRoot }jetpack/v4/account-protection`, getParams ) + .then( checkStatus ) + .then( parseJsonResponse ), + updateAccountProtectionSettings: newSettings => + postRequest( `${ apiRoot }jetpack/v4/account-protection`, postParams, { + body: JSON.stringify( newSettings ), + } ) + .then( checkStatus ) + .then( parseJsonResponse ), fetchWafSettings: () => getRequest( `${ apiRoot }jetpack/v4/waf`, getParams ) .then( checkStatus ) diff --git a/projects/packages/account-protection/changelog/add-jetpack-account-protection-security-settings b/projects/packages/account-protection/changelog/add-jetpack-account-protection-security-settings new file mode 100644 index 0000000000000..af516388c3c6c --- /dev/null +++ b/projects/packages/account-protection/changelog/add-jetpack-account-protection-security-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Adds handling for module activation and deactivation diff --git a/projects/packages/account-protection/changelog/add-packages-account-protection-password-detection-flow b/projects/packages/account-protection/changelog/add-packages-account-protection-password-detection-flow new file mode 100644 index 0000000000000..dde7e7363b212 --- /dev/null +++ b/projects/packages/account-protection/changelog/add-packages-account-protection-password-detection-flow @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Adds the password detection flow diff --git a/projects/packages/account-protection/changelog/add-protect-account-protection-settings b/projects/packages/account-protection/changelog/add-protect-account-protection-settings new file mode 100644 index 0000000000000..fc22c90153950 --- /dev/null +++ b/projects/packages/account-protection/changelog/add-protect-account-protection-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Moves get_settings method to primary class diff --git a/projects/packages/account-protection/composer.json b/projects/packages/account-protection/composer.json index b6a0271497be0..42431a12e7a10 100644 --- a/projects/packages/account-protection/composer.json +++ b/projects/packages/account-protection/composer.json @@ -4,7 +4,9 @@ "type": "jetpack-library", "license": "GPL-2.0-or-later", "require": { - "php": ">=7.2" + "php": ">=7.2", + "automattic/jetpack-connection": "@dev", + "automattic/jetpack-status": "@dev" }, "require-dev": { "yoast/phpunit-polyfills": "^1.1.1", diff --git a/projects/packages/account-protection/src/assets/jetpack-logo.svg b/projects/packages/account-protection/src/assets/jetpack-logo.svg new file mode 100644 index 0000000000000..b91e3c5c216f5 --- /dev/null +++ b/projects/packages/account-protection/src/assets/jetpack-logo.svg @@ -0,0 +1,21 @@ + + "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 0dd56070f3289..a63d54545889f 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -1,16 +1,205 @@ modules = $modules ?? new Modules(); + $this->password_detection = $password_detection ?? new Password_Detection(); + } + + /** + * Initializes the configurations needed for the account protection module. + */ + public function init(): void { + $this->register_hooks(); + + if ( $this->is_enabled() ) { + $this->register_runtime_hooks(); + } + } + + /** + * Register hooks for module activation and environment validation. + */ + private function register_hooks(): void { + // Account protection activation/deactivation hooks + add_action( 'jetpack_activate_module_' . self::ACCOUNT_PROTECTION_MODULE_NAME, array( $this, 'on_account_protection_activation' ) ); + add_action( 'jetpack_deactivate_module_' . self::ACCOUNT_PROTECTION_MODULE_NAME, array( $this, 'on_account_protection_deactivation' ) ); + + // Do not run in unsupported environments + add_action( 'jetpack_get_available_modules', array( $this, 'remove_module_on_unsupported_environments' ) ); + add_action( 'jetpack_get_available_standalone_modules', array( $this, 'remove_standalone_module_on_unsupported_environments' ) ); + + // Register REST routes + add_action( 'rest_api_init', array( new REST_Controller(), 'register_rest_routes' ) ); + } + + /** + * Register hooks for runtime operations. + */ + 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 ); + + // 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. + */ + public function on_account_protection_activation(): void { + // Activation logic can be added here + } + + /** + * Deactivate the account protection on module deactivation. + */ + 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(); + } + + /** + * Determines if the account protection module is enabled on the site. + * + * @return bool + */ + public function is_enabled() { + return $this->modules->is_active( self::ACCOUNT_PROTECTION_MODULE_NAME ); + } + + /** + * Enables the account protection module. + * + * @return bool + */ + public function enable() { + // Return true if already enabled. + if ( $this->is_enabled() ) { + return true; + } + return $this->modules->activate( self::ACCOUNT_PROTECTION_MODULE_NAME, false, false ); + } + + /** + * Disables the account protection module. + * + * @return bool + */ + public function disable(): bool { + // Return true if already disabled. + if ( ! $this->is_enabled() ) { + return true; + } + return $this->modules->deactivate( self::ACCOUNT_PROTECTION_MODULE_NAME ); + } + + /** + * Determines if Account Protection is supported in the current environment. + * + * @return bool + */ + public function is_supported_environment(): bool { + // Do not run when killswitch is enabled + if ( defined( 'DISABLE_JETPACK_ACCOUNT_PROTECTION' ) && DISABLE_JETPACK_ACCOUNT_PROTECTION ) { + return false; + } + + return true; + } + + /** + * Disables the Account Protection module when on an unsupported platform in Jetpack. + * + * @param array $modules Filterable value for `jetpack_get_available_modules`. + * + * @return array Array of module slugs. + */ + public function remove_module_on_unsupported_environments( array $modules ): array { + if ( ! $this->is_supported_environment() ) { + // Account protection should never be available on unsupported platforms. + unset( $modules[ self::ACCOUNT_PROTECTION_MODULE_NAME ] ); + } + + return $modules; + } + + /** + * Disables the Account Protection module when on an unsupported platform in a standalone plugin. + * + * @param array $modules Filterable value for `jetpack_get_available_standalone_modules`. + * + * @return array Array of module slugs. + */ + public function remove_standalone_module_on_unsupported_environments( array $modules ): array { + if ( ! $this->is_supported_environment() ) { + // Account Protection should never be available on unsupported platforms. + $modules = array_filter( + $modules, + function ( $module ) { + return $module !== self::ACCOUNT_PROTECTION_MODULE_NAME; + } + ); + + } + + return $modules; + } + + /** + * Get the account protection settings. + * + * @return array + */ + public function get_settings(): array { + $settings = array( + self::STRICT_MODE_OPTION_NAME => get_option( self::STRICT_MODE_OPTION_NAME, false ), + ); - const PACKAGE_VERSION = '0.1.0-alpha'; + return $settings; + } } diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php new file mode 100644 index 0000000000000..ca195ed1207e6 --- /dev/null +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -0,0 +1,388 @@ +password_reset_email = $password_reset_email ?? new Password_Reset_Email(); + } + + /** + * Redirect to the password detection page. + * + * @return string The URL to redirect to. + */ + public function password_detection_redirect(): string { + return home_url( '/wp-login.php?action=password-detection' ); + } + + /** + * Check if the password is safe after login. + * + * @param \WP_User|\WP_Error $user The user or error object. + * @param string $password The password. + * @return \WP_User|\WP_Error The user object. + */ + public function login_form_password_detection( $user, string $password ) { + // Check if the user is already a WP_Error object + if ( is_wp_error( $user ) ) { + return $user; + } + + // Ensure the password is correct for this user + if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) { + return $user; + } + + if ( ! $this->validate_password( $password ) ) { + // TODO: Ensure usermeta is always up to date + $this->update_usermeta( $user->ID, 'unsafe' ); + + // Redirect to the password detection page + add_filter( 'login_redirect', array( $this, 'password_detection_redirect' ), 10, 3 ); + } else { + $this->update_usermeta( $user->ID, 'safe' ); + } + + return $user; + } + + /** + * Render password detection page. + * + * @return never + */ + public function render_page() { + // Restrict direct access to logged in users + $current_user = wp_get_current_user(); + if ( 0 === $current_user->ID ) { + wp_safe_redirect( wp_login_url() ); + exit; + } + + // Restrict direct access to users with unsafe passwords + $user_password_status = $this->get_usermeta( $current_user->ID ); + if ( ! $user_password_status || 'safe' === $user_password_status ) { + wp_safe_redirect( admin_url() ); + exit; + } + + // Use a transient to track email sent status + $transient_key = 'password_reset_email_sent_' . $current_user->ID; + $email_sent_flag = get_transient( $transient_key ); + + // Initialize template variables + $reset = false; + $context = 'Your current password was found in a public leak, which means your account might be at risk.'; + $error = ''; + + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) ); + + // Handle reset_password_action form submission + if ( isset( $_POST['reset-password'] ) ) { + $reset = true; + + // Verify nonce + if ( isset( $_POST['_wpnonce_reset_password'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_reset_password'] ) ), 'reset_password_action' ) ) { + // Send password reset email + if ( ! $email_sent_flag ) { + $email_sent = $this->password_reset_email->send(); + if ( $email_sent ) { + // Set transient to mark the email as sent + set_transient( $transient_key, true, 15 * MINUTE_IN_SECONDS ); + } else { + $error = 'email_send_error'; + } + } + + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_resend_password_reset_scripts' ) ); + } else { + $error = 'reset_passowrd_nonce_verification_error'; + } + + // Handle proceed_action form submission + } elseif ( isset( $_POST['proceed'] ) ) { + $reset = true; + + // Verify nonce + if ( isset( $_POST['_wpnonce_proceed'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_proceed'] ) ), 'proceed_action' ) ) { + wp_safe_redirect( admin_url() ); + exit; + } else { + $error = 'proceed_nonce_verification_error'; + } + } + + $this->render_content( $reset, $context, $error, $this->password_reset_email->mask_email_address( $current_user->user_email ) ); + exit; + } + + /** + * Enqueue the resend password reset email scripts. + * + * @return void + */ + public function enqueue_resend_password_reset_scripts(): void { + wp_enqueue_script( 'resend-password-reset', plugin_dir_url( __FILE__ ) . 'js/resend-password-reset.js', array( 'jquery' ), Account_Protection::PACKAGE_VERSION, true ); + + // Pass AJAX URL and nonce to the script + wp_localize_script( + 'resend-password-reset', + 'ajaxObject', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'resend_password_reset_nonce' ), + ) + ); + } + + /** + * Enqueue the password detection page styles. + * + * @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 + ); + } + + /** + * Run AJAX request to resend password reset email. + */ + public function ajax_resend_password_reset_email() { + // Verify the nonce for security + check_ajax_referer( 'resend_password_reset_nonce', 'security' ); + + // Check if the user is logged in + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => 'User not authenticated' ) ); + } + + // Resend the email + $email_sent = $this->password_reset_email->send(); + if ( $email_sent ) { + wp_send_json_success( array( 'message' => 'Resend successful.' ) ); + } else { + wp_send_json_error( array( 'message' => 'Resend failed. ' ) ); + } + } + + /** + * Password validation. + * + * @param string $password The password to validate. + * @return bool True if the password is valid, false otherwise. + */ + public function validate_password( string $password ): bool { + // TODO: Uncomment out once endpoint is live + // Check compromised and common passwords + // $weak_password = self::check_weak_passwords( $password ); + + return $password ? false : true; + } + + /** + * Check if the password is in the list of common/compromised passwords. + * + * @param string $password The password to check. + * @return bool|\WP_Error True if the password is in the list of common/compromised passwords, false otherwise. + */ + public function check_weak_passwords( string $password ) { + $api_url = '/jetpack-protect-weak-password'; + + $is_connected = ( new Connection_Manager() )->is_connected(); + + if ( ! $is_connected ) { + return new \WP_Error( 'site_not_connected' ); + } + + // Hash pass with sha1, and pass first 5 characters to the API + $hashed_password = sha1( $password ); + $password_prefix = substr( $hashed_password, 0, 5 ); + + $response = Client::wpcom_json_api_request_as_blog( + $api_url . '/' . $password_prefix, + '2', + array( 'method' => 'GET' ), + null, + 'wpcom' + ); + + $response_code = wp_remote_retrieve_response_code( $response ); + + if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) { + return new \WP_Error( 'failed_fetching_weak_passwords', 'Failed to fetch weak passwords from the server', array( 'status' => $response_code ) ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + // Check if the password is in the list of common/compromised passwords + $password_suffix = substr( $hashed_password, 5 ); + if ( in_array( $password_suffix, $body['compromised'] ?? array(), true ) ) { + return true; + } + + return false; + } + + /** + * Get the password detection usermeta. + * + * @param int $user_id The user ID. + */ + public function get_usermeta( int $user_id ) { + return get_user_meta( $user_id, self::PASSWORD_DETECTION_USER_META_KEY, true ); + } + + /** + * Update the password detection usermeta. + * + * @param int $user_id The user ID. + * @param string $setting The password detection setting. + */ + public function update_usermeta( int $user_id, string $setting ) { + update_user_meta( $user_id, self::PASSWORD_DETECTION_USER_META_KEY, $setting ); + } + + /** + * Delete password detection usermeta for all users. + */ + public function delete_all_usermeta() { + $users = get_users(); + foreach ( $users as $user ) { + $this->delete_usermeta( $user->ID ); + } + } + + /** + * Delete the password detection usermeta. + * + * @param int $user_id The user ID. + */ + public function delete_usermeta( int $user_id ) { + delete_user_meta( $user_id, self::PASSWORD_DETECTION_USER_META_KEY ); + } + + /** + * Delete the password detection usermeta after password reset. + * + * @param \WP_User $user The user object. + */ + public function delete_usermeta_after_password_reset( \WP_User $user ) { + $this->delete_usermeta( $user->ID ); + } + + /** + * Delete the password detection usermeta on profile password update. + * + * @param int $user_id The user ID. + */ + public function delete_usermeta_on_profile_update( int $user_id ) { + if ( + ! empty( $_POST['_wpnonce'] ) && + wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'update-user_' . $user_id ) + ) { + if ( isset( $_POST['pass1'] ) && ! empty( $_POST['pass1'] ) ) { + $this->delete_usermeta( $user_id ); + } + } + } + + /** + * Render content for password detection page. + * + * @param bool $reset Whether the user is resetting their password. + * @param string $context The context for the password detection page. + * @param string $error The error message to display. + * @param string $masked_email The masked email address. + * @return void + */ + public function render_content( bool $reset, string $context, string $error, string $masked_email ): void { + defined( 'ABSPATH' ) || exit; + ?> + + + + + + <?php echo esc_html( $reset ? 'Jetpack - Stay Secure' : 'Jetpack - Secure Your Account' ); ?> + + + +
+ +

+ +

+ + +

We've encountered an issue verifying your request to proceed without updating your password.

+ +

+ + While attempting to send a verification email to , an error occurred. +

+ + +

Don't worry - To keep your account safe, we've sent a verification email to . After that, we'll guide you through updating your password.

+ +

Please check your inbox and click the link to verify it's you. Alternatively, you can update your password from your account profile.

+

+ Didn't get the email? + Resend email +

+ +

+

It is highly recommended that you update your password.

+
+
+ + +
+
+ + +
+
+

Learn more about the risks of using weak passwords and how to protect your account.

+ +
+ + + + routes_registered ) { + return; + } + + register_rest_route( + 'jetpack/v4', + '/account-protection', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_settings' ), + 'permission_callback' => array( $this, 'permissions_callback' ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/account-protection', + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_settings' ), + 'permission_callback' => array( $this, 'permissions_callback' ), + ) + ); + + $this->routes_registered = true; + } + + /** + * Account Protection Settings Endpoint + * + * @return WP_REST_Response + */ + public function get_settings() { + $settings = ( new Account_Protection() )->get_settings(); + + return rest_ensure_response( $settings ); + } + + /** + * Update Account Protection Settings Endpoint + * + * @param WP_REST_Request $request The API request. + * + * @return WP_REST_Response|WP_Error + */ + public function update_settings( $request ) { + // Strict Mode + if ( isset( $request[ Account_Protection::STRICT_MODE_OPTION_NAME ] ) ) { + update_option( Account_Protection::STRICT_MODE_OPTION_NAME, $request[ Account_Protection::STRICT_MODE_OPTION_NAME ] ? '1' : '' ); + } + + return $this->get_settings(); + } + + /** + * Account Protection Endpoint Permissions Callback + * + * @return bool|WP_Error True if user can view the Jetpack admin page. + */ + public function permissions_callback() { + if ( current_user_can( 'manage_options' ) ) { + return true; + } + + return new WP_Error( + 'invalid_user_permission_manage_options', + REST_Connector::get_user_permissions_error_msg(), + array( 'status' => rest_authorization_required_code() ) + ); + } +} diff --git a/projects/packages/account-protection/src/css/password-detection.css b/projects/packages/account-protection/src/css/password-detection.css new file mode 100644 index 0000000000000..d1ec425dd5da3 --- /dev/null +++ b/projects/packages/account-protection/src/css/password-detection.css @@ -0,0 +1,49 @@ +.password-detection-wrapper { + background-color: #f0f0f1; + min-width: 0; + color: #3c434a; + font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 13px; + line-height: 1.4; +} + +.password-detection { + background: #fff; + width: 420px; + margin: 124px auto; + padding: 26px 24px; + font-weight: 400; + overflow: hidden; + border: 1px solid #c3c4c7; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.password-detection-title { + font-size: 24px; + font-weight: 500; +} + +.actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.action { + height: 36px; + cursor: pointer; + width: 100%; +} + +.action-reset { + margin-top: 10px; + background-color: #0000EE; + border: 1px solid #0000EE; + color: #fff; + } + +.action-proceed { + background-color: #fff; + border: 1px solid #0000EE; + color: #0000EE; +} \ No newline at end of file diff --git a/projects/packages/account-protection/src/js/resend-password-reset.js b/projects/packages/account-protection/src/js/resend-password-reset.js new file mode 100644 index 0000000000000..2e0cdf8a4ab0a --- /dev/null +++ b/projects/packages/account-protection/src/js/resend-password-reset.js @@ -0,0 +1,71 @@ +/* global jQuery, ajaxObject */ +( function ( $ ) { + $( document ).ready( function () { + const attemptLimit = 3; + let attempts = 0; + + $( '#resend-password-reset' ).on( 'click', function ( e ) { + e.preventDefault(); // Prevent the default action + + const message = $( '#resend-password-reset-message' ); + const button = $( this ); + + // Store the original text of the message + const originalMessageText = message.text(); + + // Update message and hide button while resending + message.text( 'Resending email...' ); + button.hide(); + + attempts++; + + // Perform the AJAX request + $.ajax( { + url: ajaxObject.ajax_url, + type: 'POST', + data: { + action: 'resend_password_reset', + security: ajaxObject.nonce, + }, + success: function ( response ) { + if ( response.success ) { + // Show success message + message.text( response.data.message ).show(); + + // Hide the status message and show the button after 5 seconds + setTimeout( function () { + let messageText = originalMessageText; + if ( attempts < attemptLimit ) { + button.show(); + } else { + messageText += 'Please try again later.'; + } + message.text( messageText ).show(); + }, 5000 ); + } else { + // Show error message + let messageText = 'An error occurred. '; + if ( attempts < attemptLimit ) { + button.text( 'Please try again' ).show(); + } else { + messageText += 'Please contact support.'; // TODO: Add support redirect + } + + message.text( messageText ).show(); + } + }, + error: function () { + // Show error message + let messageText = 'An error occurred. '; + if ( attempts < attemptLimit ) { + button.text( 'Please try again' ).show(); + } else { + messageText += 'Please contact support.'; // TODO: Add support redirect + } + + message.text( messageText ).show(); + }, + } ); + } ); + } ); +} )( jQuery ); diff --git a/projects/packages/account-protection/tests/php/bootstrap.php b/projects/packages/account-protection/tests/php/bootstrap.php index 46763b04a2cdb..c53f9cb5415c3 100644 --- a/projects/packages/account-protection/tests/php/bootstrap.php +++ b/projects/packages/account-protection/tests/php/bootstrap.php @@ -2,7 +2,7 @@ /** * Bootstrap. * - * @package automattic/ + * @package automattic/jetpack-account-protection */ /** diff --git a/projects/plugins/jetpack/_inc/client/components/data/query-account-protection-settings/index.jsx b/projects/plugins/jetpack/_inc/client/components/data/query-account-protection-settings/index.jsx new file mode 100644 index 0000000000000..d86ec79e0917b --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/components/data/query-account-protection-settings/index.jsx @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { + fetchAccountProtectionSettings, + isFetchingAccountProtectionSettings, +} from 'state/account-protection'; +import { isOfflineMode } from 'state/connection'; + +class QueryAccountProtectionSettings extends Component { + static propTypes = { + isFetchingAccountProtectionSettings: PropTypes.bool, + isOfflineMode: PropTypes.bool, + }; + + static defaultProps = { + isFetchingAccountProtectionSettings: false, + isOfflineMode: false, + }; + + componentDidMount() { + if ( ! this.props.isFetchingAccountProtectionSettings && ! this.props.isOfflineMode ) { + this.props.fetchAccountProtectionSettings(); + } + } + + render() { + return null; + } +} + +export default connect( + state => { + return { + isFetchingAccountProtectionSettings: isFetchingAccountProtectionSettings( state ), + isOfflineMode: isOfflineMode( state ), + }; + }, + dispatch => { + return { + fetchAccountProtectionSettings: () => dispatch( fetchAccountProtectionSettings() ), + }; + } +)( QueryAccountProtectionSettings ); diff --git a/projects/plugins/jetpack/_inc/client/lib/plans/constants.js b/projects/plugins/jetpack/_inc/client/lib/plans/constants.js index 0a486259173e5..12a0743eb48cc 100644 --- a/projects/plugins/jetpack/_inc/client/lib/plans/constants.js +++ b/projects/plugins/jetpack/_inc/client/lib/plans/constants.js @@ -417,6 +417,7 @@ export const FEATURE_POST_BY_EMAIL = 'post-by-email-jetpack'; export const FEATURE_JETPACK_SOCIAL = 'social-jetpack'; export const FEATURE_JETPACK_BLAZE = 'blaze-jetpack'; export const FEATURE_JETPACK_EARN = 'earn-jetpack'; +export const FEATURE_JETPACK_ACCOUNT_PROTECTION = 'account-protection-jetpack'; // Upsells export const JETPACK_FEATURE_PRODUCT_UPSELL_MAP = { @@ -439,6 +440,7 @@ export const JETPACK_FEATURE_PRODUCT_UPSELL_MAP = { [ FEATURE_VIDEOPRESS ]: PLAN_JETPACK_VIDEOPRESS, [ FEATURE_NEWSLETTER_JETPACK ]: PLAN_JETPACK_CREATOR_YEARLY, [ FEATURE_WORDADS_JETPACK ]: PLAN_JETPACK_SECURITY_T1_YEARLY, + [ FEATURE_JETPACK_ACCOUNT_PROTECTION ]: PLAN_JETPACK_FREE, }; /** diff --git a/projects/plugins/jetpack/_inc/client/security/account-protection.jsx b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx new file mode 100644 index 0000000000000..7334eb9e3ca7d --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx @@ -0,0 +1,191 @@ +import { ToggleControl } from '@automattic/jetpack-components'; +import { ExternalLink } from '@wordpress/components'; +import { createInterpolateElement } from '@wordpress/element'; +import { __, _x } from '@wordpress/i18n'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { FormFieldset } from 'components/forms'; +import { createNotice, removeNotice } from 'components/global-notices/state/notices/actions'; +import { withModuleSettingsFormHelpers } from 'components/module-settings/with-module-settings-form-helpers'; +import { ModuleToggle } from 'components/module-toggle'; +import SettingsCard from 'components/settings-card'; +import SettingsGroup from 'components/settings-group'; +import QueryAccountProtectionSettings from '../components/data/query-account-protection-settings'; +import InfoPopover from '../components/info-popover'; +import { FEATURE_JETPACK_ACCOUNT_PROTECTION } from '../lib/plans/constants'; +import { updateAccountProtectionSettings } from '../state/account-protection/actions'; +import { + getAccountProtectionSettings, + isFetchingAccountProtectionSettings, + isUpdatingAccountProtectionSettings, +} from '../state/account-protection/reducer'; + +const AccountProtection = class extends Component { + /** + * Get options for initial state. + * + * @return {object} + */ + state = { + strictMode: this.props.settings?.strictMode, + }; + + /** + * Keep the form values in sync with updates to the settings prop. + * + * @param {object} prevProps - Next render props. + */ + componentDidUpdate = prevProps => { + // Sync the form values with the settings prop. + if ( this.props.settings !== prevProps.settings ) { + this.setState( { + ...this.state, + strictMode: this.props.settings?.strictMode, + } ); + } + }; + + /** + * Handle settings updates. + * + * @return {void} + */ + onSubmit = () => { + this.props.removeNotice( 'module-setting-update' ); + this.props.removeNotice( 'module-setting-update-success' ); + + this.props.createNotice( 'is-info', __( 'Updating settingsā€¦', 'jetpack' ), { + id: 'module-setting-update', + } ); + this.props + .updateAccountProtectionSettings( this.state ) + .then( () => { + this.props.removeNotice( 'module-setting-update' ); + this.props.createNotice( 'is-success', __( 'Updated Settings.', 'jetpack' ), { + id: 'module-setting-update-success', + } ); + } ) + .catch( () => { + this.props.removeNotice( 'module-setting-update' ); + this.props.createNotice( 'is-error', __( 'Error updating settings.', 'jetpack' ), { + id: 'module-setting-update', + } ); + } ); + }; + + /** + * Toggle strict mode. + */ + toggleStrictMode = () => { + const state = { + ...this.state, + strictMode: ! this.state.strictMode, + }; + + this.setState( state, this.onSubmit ); + }; + + render() { + const isAccountProtectionActive = this.props.getOptionValue( 'account-protection' ), + unavailableInOfflineMode = this.props.isUnavailableInOfflineMode( 'account-protection' ); + const baseInputDisabledCase = + ! isAccountProtectionActive || + unavailableInOfflineMode || + this.props.isFetchingAccountProtectionSettings || + this.props.isSavingAnyOption( [ 'account-protection' ] ); + + return ( + + { isAccountProtectionActive && } + + + + { __( + 'Protect your site with enhanced password detection and profile management security.', + 'jetpack' + ) } + + + { isAccountProtectionActive && ( + +
+ + + { __( 'Require strong passwords', 'jetpack' ) } + + + { createInterpolateElement( + __( + 'Allow Jetpack to enforce strict password rules. Learn more
Privacy Information', + 'jetpack' + ), + { + ExternalLink: , // TODO: Update this redirect URL + hr:
, + } + ) } +
+
+ } + /> + +
+ ) } +
+
+ ); + } +}; + +export default connect( + state => { + return { + isFetchingSettings: isFetchingAccountProtectionSettings( state ), + isUpdatingAccountProtectionSettings: isUpdatingAccountProtectionSettings( state ), + settings: getAccountProtectionSettings( state ), + }; + }, + dispatch => { + return { + updateAccountProtectionSettings: newSettings => + dispatch( updateAccountProtectionSettings( newSettings ) ), + createNotice: ( type, message, props ) => dispatch( createNotice( type, message, props ) ), + removeNotice: notice => dispatch( removeNotice( notice ) ), + }; + } +)( withModuleSettingsFormHelpers( AccountProtection ) ); diff --git a/projects/plugins/jetpack/_inc/client/security/allowList.jsx b/projects/plugins/jetpack/_inc/client/security/allowList.jsx index e102a89cd8918..8f9d8621477ab 100644 --- a/projects/plugins/jetpack/_inc/client/security/allowList.jsx +++ b/projects/plugins/jetpack/_inc/client/security/allowList.jsx @@ -155,7 +155,7 @@ const AllowList = class extends Component { label={ { __( - "Prevent Jetpack's security features from blocking specific IP addresses", + "Prevent Jetpack's security features from blocking specific IP addresses.", 'jetpack' ) } diff --git a/projects/plugins/jetpack/_inc/client/security/index.jsx b/projects/plugins/jetpack/_inc/client/security/index.jsx index f6e2c9369fc53..d4677461de9ea 100644 --- a/projects/plugins/jetpack/_inc/client/security/index.jsx +++ b/projects/plugins/jetpack/_inc/client/security/index.jsx @@ -12,6 +12,7 @@ import { isModuleFound } from 'state/search'; import { getSettings } from 'state/settings'; import { siteHasFeature } from 'state/site'; import { isPluginActive, isPluginInstalled } from 'state/site/plugins'; +import AccountProtection from './account-protection'; import AllowList from './allowList'; import Antispam from './antispam'; import BackupsScan from './backups-scan'; @@ -91,6 +92,8 @@ export class Security extends Component { ); + const foundAccountProtection = this.props.isModuleFound( 'account-protection' ); + return (
@@ -112,6 +115,7 @@ export class Security extends Component { ) } + { foundAccountProtection && } { foundWaf && } { foundProtect && } { ( foundWaf || foundProtect ) && } diff --git a/projects/plugins/jetpack/_inc/client/security/style.scss b/projects/plugins/jetpack/_inc/client/security/style.scss index 9a4608ee3bf57..385e7feaa710f 100644 --- a/projects/plugins/jetpack/_inc/client/security/style.scss +++ b/projects/plugins/jetpack/_inc/client/security/style.scss @@ -56,7 +56,9 @@ } &__share-data-popover { - margin-left: 8px; + display: flex; + align-items: center; + margin-left: 4px; } &__upgrade-popover { @@ -189,4 +191,24 @@ .jp-form-settings-group p { margin-bottom: 0.5rem; +} + +.account-protection__settings { + &__toggle-setting { + flex-wrap: wrap; + display: flex; + margin-bottom: 24px; + + &__label { + display: flex; + align-items: center; + } + } + + &__strict-mode-popover { + display: flex; + align-items: center; + margin-left: 4px; + } + } \ No newline at end of file diff --git a/projects/plugins/jetpack/_inc/client/state/account-protection/actions.js b/projects/plugins/jetpack/_inc/client/state/account-protection/actions.js new file mode 100644 index 0000000000000..feee531d78a38 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/state/account-protection/actions.js @@ -0,0 +1,66 @@ +import restApi from '@automattic/jetpack-api'; +import { + ACCOUNT_PROTECTION_SETTINGS_FETCH, + ACCOUNT_PROTECTION_SETTINGS_FETCH_RECEIVE, + ACCOUNT_PROTECTION_SETTINGS_FETCH_FAIL, + ACCOUNT_PROTECTION_SETTINGS_UPDATE, + ACCOUNT_PROTECTION_SETTINGS_UPDATE_SUCCESS, + ACCOUNT_PROTECTION_SETTINGS_UPDATE_FAIL, +} from 'state/action-types'; + +export const fetchAccountProtectionSettings = () => { + return dispatch => { + dispatch( { + type: ACCOUNT_PROTECTION_SETTINGS_FETCH, + } ); + return restApi + .fetchAccountProtectionSettings() + .then( settings => { + dispatch( { + type: ACCOUNT_PROTECTION_SETTINGS_FETCH_RECEIVE, + settings, + } ); + return settings; + } ) + .catch( error => { + dispatch( { + type: ACCOUNT_PROTECTION_SETTINGS_FETCH_FAIL, + error: error, + } ); + } ); + }; +}; + +/** + * Update Account Protection Settings + * + * @param {object} newSettings - The new settings to be saved. + * @param {boolean} newSettings.strictMode - Whether strict mode is enabled. + * @return {Function} - The action. + */ +export const updateAccountProtectionSettings = newSettings => { + return dispatch => { + dispatch( { + type: ACCOUNT_PROTECTION_SETTINGS_UPDATE, + } ); + return restApi + .updateAccountProtectionSettings( { + jetpack_account_protection_strict_mode: newSettings.strictMode, + } ) + .then( settings => { + dispatch( { + type: ACCOUNT_PROTECTION_SETTINGS_UPDATE_SUCCESS, + settings, + } ); + return settings; + } ) + .catch( error => { + dispatch( { + type: ACCOUNT_PROTECTION_SETTINGS_UPDATE_FAIL, + error: error, + } ); + + throw error; + } ); + }; +}; diff --git a/projects/plugins/jetpack/_inc/client/state/account-protection/index.js b/projects/plugins/jetpack/_inc/client/state/account-protection/index.js new file mode 100644 index 0000000000000..5e3164b4c9f72 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/state/account-protection/index.js @@ -0,0 +1,2 @@ +export * from './reducer'; +export * from './actions'; diff --git a/projects/plugins/jetpack/_inc/client/state/account-protection/reducer.js b/projects/plugins/jetpack/_inc/client/state/account-protection/reducer.js new file mode 100644 index 0000000000000..cb42d7bccc486 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/state/account-protection/reducer.js @@ -0,0 +1,87 @@ +import { assign, get } from 'lodash'; +import { combineReducers } from 'redux'; +import { + ACCOUNT_PROTECTION_SETTINGS_FETCH, + ACCOUNT_PROTECTION_SETTINGS_FETCH_RECEIVE, + ACCOUNT_PROTECTION_SETTINGS_FETCH_FAIL, + ACCOUNT_PROTECTION_SETTINGS_UPDATE, + ACCOUNT_PROTECTION_SETTINGS_UPDATE_SUCCESS, + ACCOUNT_PROTECTION_SETTINGS_UPDATE_FAIL, +} from 'state/action-types'; + +export const data = ( state = {}, action ) => { + switch ( action.type ) { + case ACCOUNT_PROTECTION_SETTINGS_FETCH_RECEIVE: + case ACCOUNT_PROTECTION_SETTINGS_UPDATE_SUCCESS: + return assign( {}, state, { + strictMode: Boolean( action.settings?.jetpack_account_protection_strict_mode ), + } ); + default: + return state; + } +}; + +export const initialRequestsState = { + isFetchingAccountProtectionSettings: false, + isUpdatingAccountProtectionSettings: false, +}; + +export const requests = ( state = initialRequestsState, action ) => { + switch ( action.type ) { + case ACCOUNT_PROTECTION_SETTINGS_FETCH: + return assign( {}, state, { + isFetchingAccountProtectionSettings: true, + } ); + case ACCOUNT_PROTECTION_SETTINGS_FETCH_RECEIVE: + case ACCOUNT_PROTECTION_SETTINGS_FETCH_FAIL: + return assign( {}, state, { + isFetchingAccountProtectionSettings: false, + } ); + case ACCOUNT_PROTECTION_SETTINGS_UPDATE: + return assign( {}, state, { + isUpdatingAccountProtectionSettings: true, + } ); + case ACCOUNT_PROTECTION_SETTINGS_UPDATE_SUCCESS: + case ACCOUNT_PROTECTION_SETTINGS_UPDATE_FAIL: + return assign( {}, state, { + isUpdatingAccountProtectionSettings: false, + } ); + default: + return state; + } +}; + +export const reducer = combineReducers( { + data, + requests, +} ); + +/** + * Returns true if currently requesting the account protection settings. Otherwise false. + * + * @param {object} state - Global state tree + * @return {boolean} Whether the account protection settings are being requested + */ +export function isFetchingAccountProtectionSettings( state ) { + return !! state.jetpack.accountProtection.requests.isFetchingAccountProtectionSettings; +} + +/** + * Returns true if currently updating the account protection settings. Otherwise false. + * + * @param {object} state - Global state tree + * @return {boolean} Whether the account protection settings are being requested + */ +export function isUpdatingAccountProtectionSettings( state ) { + return !! state.jetpack.accountProtection.requests.isUpdatingAccountProtectionSettings; +} + +/** + * Returns the account protection's settings. + * + * @param {object} state - Global state tree + * @return {string} File path to bootstrap.php + */ +export function getAccountProtectionSettings( state ) { + return get( state.jetpack.accountProtection, [ 'data' ], {} ); +} diff --git a/projects/plugins/jetpack/_inc/client/state/action-types.js b/projects/plugins/jetpack/_inc/client/state/action-types.js index c4785d4a2ced5..7da1fbb07cf1d 100644 --- a/projects/plugins/jetpack/_inc/client/state/action-types.js +++ b/projects/plugins/jetpack/_inc/client/state/action-types.js @@ -245,6 +245,15 @@ export const JETPACK_LICENSING_GET_USER_LICENSES_FAILURE = export const JETPACK_CONNECTION_HAS_SEEN_WC_CONNECTION_MODAL = 'JETPACK_CONNECTION_HAS_SEEN_WC_CONNECTION_MODAL'; +export const ACCOUNT_PROTECTION_SETTINGS_FETCH = 'ACCOUNT_PROTECTION_SETTINGS_FETCH'; +export const ACCOUNT_PROTECTION_SETTINGS_FETCH_RECEIVE = + 'ACCOUNT_PROTECTION_SETTINGS_FETCH_RECEIVE'; +export const ACCOUNT_PROTECTION_SETTINGS_FETCH_FAIL = 'ACCOUNT_PROTECTION_SETTINGS_FETCH_FAIL'; +export const ACCOUNT_PROTECTION_SETTINGS_UPDATE = 'ACCOUNT_PROTECTION_SETTINGS_UPDATE'; +export const ACCOUNT_PROTECTION_SETTINGS_UPDATE_SUCCESS = + 'ACCOUNT_PROTECTION_SETTINGS_UPDATE_SUCCESS'; +export const ACCOUNT_PROTECTION_SETTINGS_UPDATE_FAIL = 'ACCOUNT_PROTECTION_SETTINGS_UPDATE_FAIL'; + export const WAF_SETTINGS_FETCH = 'WAF_SETTINGS_FETCH'; export const WAF_SETTINGS_FETCH_RECEIVE = 'WAF_SETTINGS_FETCH_RECEIVE'; export const WAF_SETTINGS_FETCH_FAIL = 'WAF_SETTINGS_FETCH_FAIL'; diff --git a/projects/plugins/jetpack/_inc/client/state/reducer.js b/projects/plugins/jetpack/_inc/client/state/reducer.js index 5ff156b807a49..14e85f0fb5289 100644 --- a/projects/plugins/jetpack/_inc/client/state/reducer.js +++ b/projects/plugins/jetpack/_inc/client/state/reducer.js @@ -1,5 +1,6 @@ import { combineReducers } from 'redux'; import { globalNotices } from 'components/global-notices/state/notices/reducer'; +import { reducer as accountProtection } from 'state/account-protection/reducer'; import { dashboard } from 'state/at-a-glance/reducer'; import { reducer as connection } from 'state/connection/reducer'; import { reducer as devCard } from 'state/dev-version/reducer'; @@ -46,6 +47,7 @@ const jetpackReducer = combineReducers( { disconnectSurvey, trackingSettings, licensing, + accountProtection, waf, introOffers, } ); diff --git a/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php b/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php index 500069fb7ca1e..06cf25d1268f7 100644 --- a/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php +++ b/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php @@ -2358,7 +2358,14 @@ public static function get_updateable_data_list( $selector = '' ) { 'validate_callback' => __CLASS__ . '::validate_posint', 'jp_group' => 'custom-content-types', ), - + // Account Protection. + 'jetpack_account_protection_strict_mode' => array( + 'description' => esc_html__( 'Strict mode - Require strong passwords.', 'jetpack' ), + 'type' => 'boolean', + 'default' => 0, + 'validate_callback' => __CLASS__ . '::validate_boolean', + 'jp_group' => 'account-protection', + ), // WAF. 'jetpack_waf_automatic_rules' => array( 'description' => esc_html__( 'Enable automatic rules - Protect your site against untrusted traffic sources with automatic security rules.', 'jetpack' ), diff --git a/projects/plugins/jetpack/changelog/add-jetpack-account-protection-security-settings b/projects/plugins/jetpack/changelog/add-jetpack-account-protection-security-settings new file mode 100644 index 0000000000000..4c36bca9e49ec --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-jetpack-account-protection-security-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Adds the Account Protection module toggle diff --git a/projects/plugins/jetpack/composer.json b/projects/plugins/jetpack/composer.json index 7092b747b3737..84db645d3aef0 100644 --- a/projects/plugins/jetpack/composer.json +++ b/projects/plugins/jetpack/composer.json @@ -12,6 +12,7 @@ "ext-json": "*", "ext-openssl": "*", "automattic/jetpack-a8c-mc-stats": "@dev", + "automattic/jetpack-account-protection": "@dev", "automattic/jetpack-admin-ui": "@dev", "automattic/jetpack-assets": "@dev", "automattic/jetpack-autoloader": "@dev", diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 2c210154d384a..2118c86f2f421 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7b6c53f88fcb9c7098d80137fd6d13c1", + "content-hash": "cbb88a4e4e1b0088ff12393af82e5cdc", "packages": [ { "name": "automattic/jetpack-a8c-mc-stats", @@ -59,6 +59,78 @@ "relative": true } }, + { + "name": "automattic/jetpack-account-protection", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/account-protection", + "reference": "badc1036552f26a900a69608df22284e603981ed" + }, + "require": { + "automattic/jetpack-connection": "@dev", + "automattic/jetpack-status": "@dev", + "php": ">=7.2" + }, + "require-dev": { + "automattic/jetpack-changelogger": "@dev", + "automattic/wordbless": "dev-master", + "yoast/phpunit-polyfills": "^1.1.1" + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-account-protection/compare/v${old}...v${new}" + }, + "mirror-repo": "Automattic/jetpack-account-protection", + "textdomain": "jetpack-account-protection", + "version-constants": { + "::PACKAGE_VERSION": "src/class-account-protection.php" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "echo 'Add your build step to composer.json, please!'" + ], + "build-production": [ + "echo 'Add your build step to composer.json, please!'" + ], + "phpunit": [ + "./vendor/phpunit/phpunit/phpunit --colors=always" + ], + "post-install-cmd": [ + "WorDBless\\Composer\\InstallDropin::copy" + ], + "post-update-cmd": [ + "WorDBless\\Composer\\InstallDropin::copy" + ], + "test-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit --coverage-php \"$COVERAGE_DIR/php.cov\"" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Account protection", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-admin-ui", "version": "dev-trunk", @@ -6084,6 +6156,7 @@ "minimum-stability": "dev", "stability-flags": { "automattic/jetpack-a8c-mc-stats": 20, + "automattic/jetpack-account-protection": 20, "automattic/jetpack-admin-ui": 20, "automattic/jetpack-assets": 20, "automattic/jetpack-autoloader": 20, diff --git a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-site-settings-endpoint.php b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-site-settings-endpoint.php index 9852478a7c53d..4b931dadb330f 100644 --- a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-site-settings-endpoint.php +++ b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-site-settings-endpoint.php @@ -127,6 +127,7 @@ 'jetpack_subscriptions_login_navigation_enabled' => '(bool) Whether the Subscriber Login block navigation placement is enabled', 'jetpack_subscriptions_subscribe_navigation_enabled' => '(Bool) Whether the Subscribe block navigation placement is enabled', 'wpcom_ai_site_prompt' => '(string) User input in the AI site prompt', + 'jetpack_account_protection_strict_mode' => '(bool) Whether to enforce strict password requirements', 'jetpack_waf_automatic_rules' => '(bool) Whether the WAF should enforce automatic firewall rules', 'jetpack_waf_ip_allow_list' => '(string) List of IP addresses to always allow', 'jetpack_waf_ip_allow_list_enabled' => '(bool) Whether the IP allow list is enabled', @@ -490,6 +491,7 @@ function ( $newsletter_category ) { 'jetpack_comment_form_color_scheme' => (string) get_option( 'jetpack_comment_form_color_scheme' ), 'in_site_migration_flow' => (string) get_option( 'in_site_migration_flow', '' ), 'migration_source_site_domain' => (string) get_option( 'migration_source_site_domain' ), + 'jetpack_account_protection_strict_mode' => (bool) get_option( 'jetpack_account_protection_strict_mode' ), 'jetpack_waf_automatic_rules' => (bool) get_option( 'jetpack_waf_automatic_rules' ), 'jetpack_waf_ip_allow_list' => (string) get_option( 'jetpack_waf_ip_allow_list' ), 'jetpack_waf_ip_allow_list_enabled' => (bool) get_option( 'jetpack_waf_ip_allow_list_enabled' ), diff --git a/projects/plugins/jetpack/modules/account-protection.php b/projects/plugins/jetpack/modules/account-protection.php new file mode 100644 index 0000000000000..c552efec4cc41 --- /dev/null +++ b/projects/plugins/jetpack/modules/account-protection.php @@ -0,0 +1,18 @@ +init(); diff --git a/projects/plugins/jetpack/modules/waf.php b/projects/plugins/jetpack/modules/waf.php index 1d5a5984f4bab..0df3856fb1948 100644 --- a/projects/plugins/jetpack/modules/waf.php +++ b/projects/plugins/jetpack/modules/waf.php @@ -1,7 +1,7 @@ =7.2" + }, + "require-dev": { + "automattic/jetpack-changelogger": "@dev", + "automattic/wordbless": "dev-master", + "yoast/phpunit-polyfills": "^1.1.1" + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-account-protection/compare/v${old}...v${new}" + }, + "mirror-repo": "Automattic/jetpack-account-protection", + "textdomain": "jetpack-account-protection", + "version-constants": { + "::PACKAGE_VERSION": "src/class-account-protection.php" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "echo 'Add your build step to composer.json, please!'" + ], + "build-production": [ + "echo 'Add your build step to composer.json, please!'" + ], + "phpunit": [ + "./vendor/phpunit/phpunit/phpunit --colors=always" + ], + "post-install-cmd": [ + "WorDBless\\Composer\\InstallDropin::copy" + ], + "post-update-cmd": [ + "WorDBless\\Composer\\InstallDropin::copy" + ], + "test-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit --coverage-php \"$COVERAGE_DIR/php.cov\"" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Account protection", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-admin-ui", "version": "dev-trunk", @@ -4628,6 +4700,7 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { + "automattic/jetpack-account-protection": 20, "automattic/jetpack-admin-ui": 20, "automattic/jetpack-assets": 20, "automattic/jetpack-autoloader": 20, diff --git a/projects/plugins/protect/src/class-jetpack-protect.php b/projects/plugins/protect/src/class-jetpack-protect.php index 660045d426534..ea02244a44e5a 100644 --- a/projects/plugins/protect/src/class-jetpack-protect.php +++ b/projects/plugins/protect/src/class-jetpack-protect.php @@ -9,6 +9,7 @@ exit( 0 ); } +use Automattic\Jetpack\Account_Protection\Account_Protection; use Automattic\Jetpack\Admin_UI\Admin_Menu; use Automattic\Jetpack\Assets; use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State; @@ -58,6 +59,7 @@ class Jetpack_Protect { ); const JETPACK_WAF_MODULE_SLUG = 'waf'; const JETPACK_BRUTE_FORCE_PROTECTION_MODULE_SLUG = 'protect'; + const JETPACK_ACCOUNT_PROTECTION_MODULE_SLUG = 'account-protection'; const JETPACK_PROTECT_ACTIVATION_OPTION = JETPACK_PROTECT_SLUG . '_activated'; /** @@ -112,6 +114,9 @@ function () { // Web application firewall package. $config->ensure( 'waf' ); + + // Account protection package. + $config->ensure( 'account_protection' ); }, 1 ); @@ -133,6 +138,7 @@ public function init() { REST_Controller::init(); My_Jetpack_Initializer::init(); Site_Health::init(); + ( new Account_Protection() )->init(); // Sets up JITMS. JITM::configure(); @@ -208,6 +214,7 @@ public function initial_state() { // phpcs:disable WordPress.Security.NonceVerification.Recommended $refresh_status_from_wpcom = isset( $_GET['checkPlan'] ); $status = Status::get_status( $refresh_status_from_wpcom ); + $account_protection = new Account_Protection(); $initial_state = array( 'apiRoot' => esc_url_raw( rest_url() ), @@ -226,6 +233,10 @@ public function initial_state() { 'jetpackScan' => My_Jetpack_Products::get_product( 'scan' ), 'hasPlan' => Plan::has_required_plan(), 'onboardingProgress' => Onboarding::get_current_user_progress(), + 'accountProtection' => array( + 'isEnabled' => $account_protection->is_enabled(), + 'settings' => $account_protection->get_settings(), + ), 'waf' => array( 'wafSupported' => Waf_Runner::is_supported_environment(), 'currentIp' => IP_Utils::get_ip(), @@ -281,6 +292,7 @@ public static function activate_modules() { delete_option( self::JETPACK_PROTECT_ACTIVATION_OPTION ); ( new Modules() )->activate( self::JETPACK_WAF_MODULE_SLUG, false, false ); ( new Modules() )->activate( self::JETPACK_BRUTE_FORCE_PROTECTION_MODULE_SLUG, false, false ); + ( new Modules() )->activate( self::JETPACK_ACCOUNT_PROTECTION_MODULE_SLUG, false, false ); } /** @@ -338,7 +350,7 @@ public function admin_bar( $wp_admin_bar ) { * @return array */ public function protect_filter_available_modules( $modules ) { - return array_merge( array( self::JETPACK_WAF_MODULE_SLUG, self::JETPACK_BRUTE_FORCE_PROTECTION_MODULE_SLUG ), $modules ); + return array_merge( array( self::JETPACK_WAF_MODULE_SLUG, self::JETPACK_BRUTE_FORCE_PROTECTION_MODULE_SLUG, self::JETPACK_ACCOUNT_PROTECTION_MODULE_SLUG ), $modules ); } /** diff --git a/projects/plugins/protect/src/class-rest-controller.php b/projects/plugins/protect/src/class-rest-controller.php index 0aa752ddfd6d1..b6ddb432afa23 100644 --- a/projects/plugins/protect/src/class-rest-controller.php +++ b/projects/plugins/protect/src/class-rest-controller.php @@ -9,6 +9,7 @@ namespace Automattic\Jetpack\Protect; +use Automattic\Jetpack\Account_Protection\Account_Protection; use Automattic\Jetpack\Connection\Rest_Authentication as Connection_Rest_Authentication; use Automattic\Jetpack\IP\Utils as IP_Utils; use Automattic\Jetpack\Protect_Status\REST_Controller as Protect_Status_REST_Controller; @@ -117,6 +118,30 @@ public static function register_rest_endpoints() { ) ); + register_rest_route( + 'jetpack-protect/v1', + 'toggle-account-protection', + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => __CLASS__ . '::api_toggle_account_protection', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ) + ); + + register_rest_route( + 'jetpack-protect/v1', + 'account-protection', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::api_get_account_protection', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ) + ); + register_rest_route( 'jetpack-protect/v1', 'toggle-waf', @@ -340,6 +365,47 @@ public static function api_scan() { return new WP_REST_Response( 'Scan enqueued.' ); } + /** + * Toggles the Account Protection module on or off for the API endpoint + * + * @return WP_REST_Response|WP_Error + */ + public static function api_toggle_account_protection() { + $account_protection = new Account_Protection(); + if ( $account_protection->is_enabled() ) { + $disabled = $account_protection->disable(); + if ( ! $disabled ) { + return new WP_Error( + 'account_protection_disable_failed', + __( 'An error occurred disabling account protection.', 'jetpack-protect' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( true ); + } + + $enabled = $account_protection->enable(); + if ( ! $enabled ) { + return new WP_Error( + 'account_protection_enable_failed', + __( 'An error occurred enabling account protection.', 'jetpack-protect' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( true ); + } + + /** + * Get Account Protection data for the API endpoint + * + * @return WP_Rest_Response + */ + public static function api_get_account_protection() { + return new WP_REST_Response( ( new Account_Protection() )->get_settings() ); + } + /** * Toggles the WAF module on or off for the API endpoint * diff --git a/projects/plugins/protect/src/js/api.ts b/projects/plugins/protect/src/js/api.ts index 2b98a6164bf8b..186ac89c2c513 100644 --- a/projects/plugins/protect/src/js/api.ts +++ b/projects/plugins/protect/src/js/api.ts @@ -1,8 +1,29 @@ -import { type FixersStatus, type ScanStatus, type WafStatus } from '@automattic/jetpack-scan'; +import { type FixersStatus, type ScanStatus } from '@automattic/jetpack-scan'; import apiFetch from '@wordpress/api-fetch'; import camelize from 'camelize'; +import { AccountProtectionStatus } from './types/account-protection'; +import { WafStatus } from './types/waf'; const API = { + getAccountProtection: (): Promise< AccountProtectionStatus > => + apiFetch( { + path: 'jetpack-protect/v1/account-protection', + method: 'GET', + } ), + + toggleAccountProtection: () => + apiFetch( { + method: 'POST', + path: 'jetpack-protect/v1/toggle-account-protection', + } ), + + updateAccountProtection: data => + apiFetch( { + method: 'POST', + path: 'jetpack/v4/account-protection', + data, + } ).then( camelize ), + getWaf: (): Promise< WafStatus > => apiFetch( { path: 'jetpack-protect/v1/waf', diff --git a/projects/plugins/protect/src/js/components/admin-page/index.jsx b/projects/plugins/protect/src/js/components/admin-page/index.jsx index 4579831b5f0a5..95e34eb79daa8 100644 --- a/projects/plugins/protect/src/js/components/admin-page/index.jsx +++ b/projects/plugins/protect/src/js/components/admin-page/index.jsx @@ -69,6 +69,10 @@ const AdminPage = ( { children } ) => { } /> + { __( 'Settings', 'jetpack-protect' ) } } + /> { children } diff --git a/projects/plugins/protect/src/js/constants.js b/projects/plugins/protect/src/js/constants.js index 5ec94bdccafc9..f643493fd37bd 100644 --- a/projects/plugins/protect/src/js/constants.js +++ b/projects/plugins/protect/src/js/constants.js @@ -31,3 +31,4 @@ export const QUERY_ONBOARDING_PROGRESS_KEY = 'onboarding progress'; export const QUERY_PRODUCT_DATA_KEY = 'product data'; export const QUERY_SCAN_STATUS_KEY = 'scan status'; export const QUERY_WAF_KEY = 'waf'; +export const QUERY_ACCOUNT_PROTECTION_KEY = 'account protection'; diff --git a/projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts b/projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts new file mode 100644 index 0000000000000..592c5b983c37a --- /dev/null +++ b/projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts @@ -0,0 +1,57 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import camelize from 'camelize'; +import API from '../../api'; +import { QUERY_ACCOUNT_PROTECTION_KEY } from '../../constants'; +import useNotices from '../../hooks/use-notices'; +import { AccountProtectionStatus } from '../../types/account-protection'; + +/** + * Account Protection Mutatation Hook + * + * @return {UseMutationResult} useMutation result. + */ +export default function useAccountProtectionMutation(): UseMutationResult< + unknown, + { [ key: string ]: unknown }, + unknown, + { initialValue: AccountProtectionStatus } +> { + const queryClient = useQueryClient(); + const { showSuccessNotice, showSavingNotice, showErrorNotice } = useNotices(); + + return useMutation( { + mutationFn: API.updateAccountProtection, + onMutate: settings => { + showSavingNotice(); + + // Get the current Account Protection settings. + const initialValue = queryClient.getQueryData( [ + QUERY_ACCOUNT_PROTECTION_KEY, + ] ) as AccountProtectionStatus; + + // Optimistically update the Account Protection settings. + queryClient.setQueryData( + [ QUERY_ACCOUNT_PROTECTION_KEY ], + ( accountProtectionStatus: AccountProtectionStatus ) => ( { + ...accountProtectionStatus, + settings: { + ...accountProtectionStatus.settings, + ...camelize( settings ), + }, + } ) + ); + + return { initialValue }; + }, + onSuccess: () => { + showSuccessNotice( __( 'Changes saved.', 'jetpack-protect' ) ); + }, + onError: ( error, variables, context ) => { + // Reset the account protection config to its previous state. + queryClient.setQueryData( [ QUERY_ACCOUNT_PROTECTION_KEY ], context.initialValue ); + + showErrorNotice( __( 'Error saving changes.', 'jetpack-protect' ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/data/account-protection/use-account-protection-query.ts b/projects/plugins/protect/src/js/data/account-protection/use-account-protection-query.ts new file mode 100644 index 0000000000000..01dd3354432a9 --- /dev/null +++ b/projects/plugins/protect/src/js/data/account-protection/use-account-protection-query.ts @@ -0,0 +1,18 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import camelize from 'camelize'; +import API from '../../api'; +import { QUERY_ACCOUNT_PROTECTION_KEY } from '../../constants'; +import { AccountProtectionStatus } from '../../types/account-protection'; + +/** + * Account Protection Query Hook + * + * @return {UseQueryResult} useQuery result. + */ +export default function useAccountProtectionQuery(): UseQueryResult< AccountProtectionStatus > { + return useQuery( { + queryKey: [ QUERY_ACCOUNT_PROTECTION_KEY ], + queryFn: API.getAccountProtection, + initialData: camelize( window?.jetpackProtectInitialState?.accountProtection ), + } ); +} diff --git a/projects/plugins/protect/src/js/data/account-protection/use-toggle-account-protection-module-mutation.ts b/projects/plugins/protect/src/js/data/account-protection/use-toggle-account-protection-module-mutation.ts new file mode 100644 index 0000000000000..2f8ca342902ea --- /dev/null +++ b/projects/plugins/protect/src/js/data/account-protection/use-toggle-account-protection-module-mutation.ts @@ -0,0 +1,45 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import API from '../../api'; +import { QUERY_ACCOUNT_PROTECTION_KEY } from '../../constants'; +import useNotices from '../../hooks/use-notices'; +import { AccountProtectionStatus } from '../../types/account-protection'; + +/** + * Toggle Account Protection Mutatation + * + * @return {UseMutationResult} useMutation result. + */ +export default function useToggleAccountProtectionMutation(): UseMutationResult { + const queryClient = useQueryClient(); + const { showSavingNotice, showSuccessNotice, showErrorNotice } = useNotices(); + + return useMutation( { + mutationFn: API.toggleAccountProtection, + onMutate: () => { + showSavingNotice(); + + // Get the current Account Protection settings. + const initialValue = queryClient.getQueryData( [ + QUERY_ACCOUNT_PROTECTION_KEY, + ] ) as AccountProtectionStatus; + + // Optimistically update the Account Protection settings. + queryClient.setQueryData( + [ QUERY_ACCOUNT_PROTECTION_KEY ], + ( accountProtectionStatus: AccountProtectionStatus ) => ( { + ...accountProtectionStatus, + isEnabled: ! initialValue.isEnabled, + } ) + ); + + return { initialValue }; + }, + onSuccess: () => { + showSuccessNotice( __( 'Changes saved.', 'jetpack-protect' ) ); + }, + onError: () => { + showErrorNotice( __( 'Error savings changes.', 'jetpack-protect' ) ); + }, + } ); +} diff --git a/projects/plugins/protect/src/js/hooks/use-account-protection-data/index.jsx b/projects/plugins/protect/src/js/hooks/use-account-protection-data/index.jsx new file mode 100644 index 0000000000000..90e473c270bc6 --- /dev/null +++ b/projects/plugins/protect/src/js/hooks/use-account-protection-data/index.jsx @@ -0,0 +1,59 @@ +import { useCallback } from 'react'; +import useAccountProtectionMutation from '../../data/account-protection/use-account-protection-mutation'; +import useAccountProtectionQuery from '../../data/account-protection/use-account-protection-query'; +import useToggleAccountProtectionMutation from '../../data/account-protection/use-toggle-account-protection-module-mutation'; +import useAnalyticsTracks from '../use-analytics-tracks'; + +/** + * Use Account Protection Data Hook + * + * @return {object} Account Protection data and methods for interacting with it. + */ +const useAccountProtectionData = () => { + const { recordEvent } = useAnalyticsTracks(); + const { data: accountProtection } = useAccountProtectionQuery(); + const accountProtectionMutation = useAccountProtectionMutation(); + const toggleAccountProtectionMutation = useToggleAccountProtectionMutation(); + + /** + * Toggle Account Protection Module + * + * Flips the switch on the Account Protection module, and then refreshes the data. + */ + const toggleAccountProtection = useCallback( async () => { + toggleAccountProtectionMutation.mutate(); + }, [ toggleAccountProtectionMutation ] ); + + /** + * Toggle Strict Mode + * + * Flips the switch on the strict mode option, and then refreshes the data. + */ + const toggleStrictMode = useCallback( async () => { + const value = ! accountProtection.settings.jetpackAccountProtectionStrictMode; + const mutationObj = { jetpack_account_protection_strict_mode: value }; + if ( ! value ) { + mutationObj.jetpack_account_protection_strict_mode = false; + } + await accountProtectionMutation.mutateAsync( mutationObj ); + recordEvent( + mutationObj + ? 'jetpack_account_protection_strict_mode_enabled' + : 'jetpack_account_protection_strict_mode_disabled' + ); + }, [ + recordEvent, + accountProtection.settings.jetpackAccountProtectionStrictMode, + accountProtectionMutation, + ] ); + + return { + ...accountProtection, + isUpdating: accountProtectionMutation.isPending, + isToggling: toggleAccountProtectionMutation.isPending, + toggleAccountProtection, + toggleStrictMode, + }; +}; + +export default useAccountProtectionData; diff --git a/projects/plugins/protect/src/js/index.tsx b/projects/plugins/protect/src/js/index.tsx index b8983d65bb836..3ffe20e853986 100644 --- a/projects/plugins/protect/src/js/index.tsx +++ b/projects/plugins/protect/src/js/index.tsx @@ -13,6 +13,7 @@ import { CheckoutProvider } from './hooks/use-plan'; import FirewallRoute from './routes/firewall'; import ScanRoute from './routes/scan'; import ScanHistoryRoute from './routes/scan/history'; +import SettingsRoute from './routes/settings'; import SetupRoute from './routes/setup'; import './styles.module.scss'; @@ -56,6 +57,7 @@ function render() { + } /> } /> } /> { + const { hasPlan } = usePlan(); + const { + settings: { jetpackAccountProtectionStrictMode: strictMode }, + isEnabled: isAccountProtectionEnabled, + toggleAccountProtection, + toggleStrictMode, + isToggling, + isUpdating, + } = useAccountProtectionData(); + + // Track view for Protect Account Protection page. + useAnalyticsTracks( { + pageViewEventName: 'protect_account_protection', + pageViewEventProperties: { + has_plan: hasPlan, + }, + } ); + + const accountProtectionSettings = ( +
+
+ +
+
+ + { __( 'Account protection', 'jetpack-protect' ) } + + + { createInterpolateElement( + __( + 'When enabled, users can only set passwords that meet strong security standards, helping protect their accounts and your site.', + 'jetpack-protect' + ), + { + link: , // TODO: Update this redirect URL + } + ) } + +
+
+ ); + + const strictModeSettings = ( + + ); + + /** + * Render + */ + return ( + + + + +
+ { accountProtectionSettings } + { isAccountProtectionEnabled && strictModeSettings } +
+ +
+
+
+ ); +}; + +export default SettingsPage; diff --git a/projects/plugins/protect/src/js/routes/settings/styles.module.scss b/projects/plugins/protect/src/js/routes/settings/styles.module.scss new file mode 100644 index 0000000000000..3b43e636ffd96 --- /dev/null +++ b/projects/plugins/protect/src/js/routes/settings/styles.module.scss @@ -0,0 +1,53 @@ +.container { + width: 100%; + max-width: calc( 744px + ( var( --spacing-base ) * 6 ) ); // 744px + 48px (desired inner width + horizontal padding) +} + +.toggle-section { + display: flex; + + &:not(:first-child) { + margin-top: calc( var( --spacing-base ) * 7 ); // 56px + } + + &__control { + padding-top: calc( var( --spacing-base ) / 2 ); // 4px + margin-right: calc( var( --spacing-base ) * 2 ); // 16px + + @media ( min-width: 600px ) { + margin-right: calc( var( --spacing-base ) * 5 ); // 48px + } + } + + &__content { + width: 100%; + } + + &__description { + a { + color: inherit; + + &:hover { + color: var( --jp-black ); + } + } + } + + &__warning { + color: var( --jp-red-50 ); + + a { + color: var( --jp-red-50 ); + + &:hover { + color: var( --jp-red-70 ) + } + } + + svg { + fill: var( --jp-red-50 ); + margin-bottom: calc( -1 * var( --spacing-base ) * 3/4 ); // -6px + margin-right: calc( var( --spacing-base ) / 4 ); // 2px + } + } +} \ No newline at end of file diff --git a/projects/plugins/protect/src/js/types/account-protection.ts b/projects/plugins/protect/src/js/types/account-protection.ts new file mode 100644 index 0000000000000..37d557638982b --- /dev/null +++ b/projects/plugins/protect/src/js/types/account-protection.ts @@ -0,0 +1,12 @@ +export type AccountProtectionStatus = { + /** Whether the "account-protection" module is enabled. */ + isEnabled: boolean; + + /** The current Account Protetion settings. */ + settings: AccountProtectionSettings; +}; + +export type AccountProtectionSettings = { + /** Whether the user has enabled strict mode. */ + jetpackAccountProtectionStrictMode: boolean; +}; diff --git a/projects/plugins/protect/src/js/types/global.d.ts b/projects/plugins/protect/src/js/types/global.d.ts index 826b133869a7a..2f01fad2c7a54 100644 --- a/projects/plugins/protect/src/js/types/global.d.ts +++ b/projects/plugins/protect/src/js/types/global.d.ts @@ -29,6 +29,7 @@ declare global { jetpackScan: ProductData; hasPlan: boolean; onboardingProgress: string[]; + accountProtection: boolean; waf: WafStatus; }; }