From 06293a9fe65e9d37f11201adc4cdb64600067324 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 9 Jan 2025 05:40:54 -0800 Subject: [PATCH 01/50] Initialize project branch From c203587bedab234ee887a69e63eede7d22e89505 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 17 Jan 2025 15:41:31 -0800 Subject: [PATCH 02/50] Update lock files --- pnpm-lock.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9a7c3ae75a54..abbf7d436679c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1902,6 +1902,8 @@ importers: projects/packages/account-protection: {} + projects/packages/account-protection: {} + projects/packages/admin-ui: {} projects/packages/assets: From b9ad727d651b1b7c7f65f3e912e784a2b33fef90 Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Fri, 24 Jan 2025 11:57:55 -0800 Subject: [PATCH 03/50] Jetpack: Add Account Protection security settings (#40938) * Add Account Protection toggle to Jetpack security settings * Import package and run activation/deactivation on module toggle * changelog * Update changelog * Make account protection class init static * Remove user cxn req and banner * Do not enabled module by default * Add strict mode option and settings toggle * changelog * Use dynamic classes * Update class dependencies * Fix copy * Revert unrelated changes * Fix phan errors * Changelog * Update composer deps * Update lock files, add constructor method * Fix php warning * Update @package * Enable module by default --- ...tpack-account-protection-security-settings | 4 + projects/js-packages/api/index.jsx | 10 + ...tpack-account-protection-security-settings | 4 + .../packages/account-protection/composer.json | 4 +- .../src/class-account-protection.php | 89 +++++++- .../src/class-rest-controller.php | 106 ++++++++++ .../tests/php/bootstrap.php | 2 +- .../index.jsx | 44 ++++ .../_inc/client/lib/plans/constants.js | 2 + .../client/security/account-protection.jsx | 191 ++++++++++++++++++ .../_inc/client/security/allowList.jsx | 2 +- .../jetpack/_inc/client/security/index.jsx | 4 + .../jetpack/_inc/client/security/style.scss | 24 ++- .../state/account-protection/actions.js | 66 ++++++ .../client/state/account-protection/index.js | 2 + .../state/account-protection/reducer.js | 87 ++++++++ .../jetpack/_inc/client/state/action-types.js | 9 + .../jetpack/_inc/client/state/reducer.js | 2 + .../lib/class.core-rest-api-endpoints.php | 9 +- ...tpack-account-protection-security-settings | 4 + projects/plugins/jetpack/composer.json | 1 + projects/plugins/jetpack/composer.lock | 73 +++++++ ....wpcom-json-api-site-settings-endpoint.php | 2 + .../jetpack/modules/account-protection.php | 18 ++ projects/plugins/jetpack/modules/waf.php | 2 +- 25 files changed, 751 insertions(+), 10 deletions(-) create mode 100644 projects/js-packages/api/changelog/add-jetpack-account-protection-security-settings create mode 100644 projects/packages/account-protection/changelog/add-jetpack-account-protection-security-settings create mode 100644 projects/packages/account-protection/src/class-rest-controller.php create mode 100644 projects/plugins/jetpack/_inc/client/components/data/query-account-protection-settings/index.jsx create mode 100644 projects/plugins/jetpack/_inc/client/security/account-protection.jsx create mode 100644 projects/plugins/jetpack/_inc/client/state/account-protection/actions.js create mode 100644 projects/plugins/jetpack/_inc/client/state/account-protection/index.js create mode 100644 projects/plugins/jetpack/_inc/client/state/account-protection/reducer.js create mode 100644 projects/plugins/jetpack/changelog/add-jetpack-account-protection-security-settings create mode 100644 projects/plugins/jetpack/modules/account-protection.php 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 04dcf2c6325a5..f0b0314bc70f6 100644 --- a/projects/js-packages/api/index.jsx +++ b/projects/js-packages/api/index.jsx @@ -523,6 +523,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/composer.json b/projects/packages/account-protection/composer.json index 8d9bc408b7b38..6fe92cb6eb03b 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/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 0dd56070f3289..745900f5d11a2 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -1,16 +1,97 @@ modules = $modules ?? new Modules(); + } + + /** + * Initializes the configurations needed for the account protection module. + */ + public function init() { + // 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' ) ); + + // Register REST routes + add_action( 'rest_api_init', array( new REST_Controller(), 'register_rest_routes' ) ); + } + + /** + * Activate the account protection on module activation. + */ + public function on_account_protection_activation() { + // Account protection activated + } + + /** + * Deactivate the account protection on module activation. + */ + public function on_account_protection_deactivation() { + // Account protection deactivated + } + + /** + * 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() { + // Return true if already disabled. + if ( ! $this->is_enabled() ) { + return true; + } + return $this->modules->deactivate( self::ACCOUNT_PROTECTION_MODULE_NAME ); + } } diff --git a/projects/packages/account-protection/src/class-rest-controller.php b/projects/packages/account-protection/src/class-rest-controller.php new file mode 100644 index 0000000000000..762fb90570c30 --- /dev/null +++ b/projects/packages/account-protection/src/class-rest-controller.php @@ -0,0 +1,106 @@ +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() { + return rest_ensure_response( + array( + Account_Protection::STRICT_MODE_OPTION_NAME => get_option( Account_Protection::STRICT_MODE_OPTION_NAME ), + ) + ); + } + + /** + * 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/tests/php/bootstrap.php b/projects/packages/account-protection/tests/php/bootstrap.php index f8dc1879de499..76d1ee3de35c7 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 633c8476061a7..67bf7de0e487a 100644 --- a/projects/plugins/jetpack/_inc/client/state/action-types.js +++ b/projects/plugins/jetpack/_inc/client/state/action-types.js @@ -249,6 +249,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 c50656c1c4a47..80e2be96f3be8 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'; @@ -48,6 +49,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 fb20269ef3fa5..4e4ac0f3f6460 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 @@ -2335,7 +2335,14 @@ public static function get_updateable_data_list( $selector = '' ) { 'validate_callback' => __CLASS__ . '::validate_posint', 'jp_group' => 'settings', ), - + // 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 f603a280c0032..025c991718c37 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 ff20694f3c42c..c73b858672879 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -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", @@ -6016,6 +6088,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 7277ce875df59..1a797bea3d27e 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 @@ Date: Fri, 24 Jan 2025 12:15:20 -0800 Subject: [PATCH 04/50] Protect: Add Account Protection settings (#40942) * 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 * 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 * 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 * Update @package * Enable module by default * Enable module by default * Update projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts Co-authored-by: Kolja Zuelsdorf * Update lock files --------- Co-authored-by: Kolja Zuelsdorf --- .../add-protect-account-protection-settings | 4 + .../src/class-account-protection.php | 13 ++ .../src/class-rest-controller.php | 8 +- .../add-protect-account-protection-settings | 4 + projects/plugins/protect/composer.json | 3 +- projects/plugins/protect/composer.lock | 75 ++++++++++- .../protect/src/class-jetpack-protect.php | 16 ++- .../protect/src/class-rest-controller.php | 66 ++++++++++ projects/plugins/protect/src/js/api.ts | 23 +++- .../src/js/components/admin-page/index.jsx | 4 + projects/plugins/protect/src/js/constants.js | 1 + .../use-account-protection-mutation.ts | 57 ++++++++ .../use-account-protection-query.ts | 18 +++ ...ggle-account-protection-module-mutation.ts | 45 +++++++ .../use-account-protection-data/index.jsx | 59 +++++++++ projects/plugins/protect/src/js/index.tsx | 2 + .../protect/src/js/routes/settings/index.jsx | 123 ++++++++++++++++++ .../src/js/routes/settings/styles.module.scss | 53 ++++++++ .../src/js/types/account-protection.ts | 12 ++ .../plugins/protect/src/js/types/global.d.ts | 1 + 20 files changed, 577 insertions(+), 10 deletions(-) create mode 100644 projects/packages/account-protection/changelog/add-protect-account-protection-settings create mode 100644 projects/plugins/protect/changelog/add-protect-account-protection-settings create mode 100644 projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts create mode 100644 projects/plugins/protect/src/js/data/account-protection/use-account-protection-query.ts create mode 100644 projects/plugins/protect/src/js/data/account-protection/use-toggle-account-protection-module-mutation.ts create mode 100644 projects/plugins/protect/src/js/hooks/use-account-protection-data/index.jsx create mode 100644 projects/plugins/protect/src/js/routes/settings/index.jsx create mode 100644 projects/plugins/protect/src/js/routes/settings/styles.module.scss create mode 100644 projects/plugins/protect/src/js/types/account-protection.ts 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/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 745900f5d11a2..9de822a2deac3 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -94,4 +94,17 @@ public function disable() { } return $this->modules->deactivate( self::ACCOUNT_PROTECTION_MODULE_NAME ); } + + /** + * Get the account protection settings. + * + * @return array + */ + public function get_settings() { + $settings = array( + self::STRICT_MODE_OPTION_NAME => get_option( self::STRICT_MODE_OPTION_NAME, false ), + ); + + return $settings; + } } diff --git a/projects/packages/account-protection/src/class-rest-controller.php b/projects/packages/account-protection/src/class-rest-controller.php index 762fb90570c30..a49c1b1fe7c65 100644 --- a/projects/packages/account-protection/src/class-rest-controller.php +++ b/projects/packages/account-protection/src/class-rest-controller.php @@ -64,11 +64,9 @@ public function register_rest_routes() { * @return WP_REST_Response */ public function get_settings() { - return rest_ensure_response( - array( - Account_Protection::STRICT_MODE_OPTION_NAME => get_option( Account_Protection::STRICT_MODE_OPTION_NAME ), - ) - ); + $settings = ( new Account_Protection() )->get_settings(); + + return rest_ensure_response( $settings ); } /** diff --git a/projects/plugins/protect/changelog/add-protect-account-protection-settings b/projects/plugins/protect/changelog/add-protect-account-protection-settings new file mode 100644 index 0000000000000..0383c19735e86 --- /dev/null +++ b/projects/plugins/protect/changelog/add-protect-account-protection-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Adds the Account Protection module toggle diff --git a/projects/plugins/protect/composer.json b/projects/plugins/protect/composer.json index 4c3fc4da5da9b..2f1e21991d9d2 100644 --- a/projects/plugins/protect/composer.json +++ b/projects/plugins/protect/composer.json @@ -17,7 +17,8 @@ "automattic/jetpack-plans": "@dev", "automattic/jetpack-waf": "@dev", "automattic/jetpack-status": "@dev", - "automattic/jetpack-protect-status": "@dev" + "automattic/jetpack-protect-status": "@dev", + "automattic/jetpack-account-protection": "@dev" }, "require-dev": { "yoast/phpunit-polyfills": "^1.1.1", diff --git a/projects/plugins/protect/composer.lock b/projects/plugins/protect/composer.lock index 4cf6d8654fab4..2e15530947409 100644 --- a/projects/plugins/protect/composer.lock +++ b/projects/plugins/protect/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": "34bd79ff42a1f8b65263255346bb624f", + "content-hash": "bd73d22e7c4f74ba29b6f34356434a71", "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", @@ -4558,6 +4630,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 886db4b09b40f..67b0b5742f739 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(); @@ -209,7 +215,8 @@ public function initial_state() { // Always fetch the latest plan status from WPCOM. $has_plan = Plan::has_required_plan( true ); - $status = Status::get_status(); + $status = Status::get_status(); + $account_protection = new Account_Protection(); $initial_state = array( 'apiRoot' => esc_url_raw( rest_url() ), @@ -228,6 +235,10 @@ public function initial_state() { 'jetpackScan' => My_Jetpack_Products::get_product( 'scan' ), 'hasPlan' => $has_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(), @@ -282,6 +293,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 ); } /** @@ -339,7 +351,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 4cfa117c43c76..7e6d099c1b8e7 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', @@ -316,6 +341,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() )->is_enabled() ); + } + /** * 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 8a2437e629402..d286b54018064 100644 --- a/projects/plugins/protect/src/js/api.ts +++ b/projects/plugins/protect/src/js/api.ts @@ -1,9 +1,30 @@ -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 type { ProductData } from './types/products'; +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 e34e4e79b1722..7cdbcedcffce7 100644 --- a/projects/plugins/protect/src/js/components/admin-page/index.jsx +++ b/projects/plugins/protect/src/js/components/admin-page/index.jsx @@ -57,6 +57,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; }; } From b5316904929474a21e4217f6bfe76e073bffcf94 Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:22:04 -0800 Subject: [PATCH 05/50] Account Protection: Add password detection flow (#41105) * 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 * Update projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts Co-authored-by: Kolja Zuelsdorf * Update lock files --------- Co-authored-by: Kolja Zuelsdorf --- ...account-protection-password-detection-flow | 4 + .../src/assets/jetpack-logo.svg | 21 + .../src/class-account-protection.php | 119 +++++- .../src/class-password-detection.php | 388 ++++++++++++++++++ .../src/class-password-reset-email.php | 45 ++ .../src/css/password-detection.css | 49 +++ .../src/js/resend-password-reset.js | 71 ++++ ...account-protection-password-detection-flow | 4 + .../protect/src/class-rest-controller.php | 2 +- 9 files changed, 690 insertions(+), 13 deletions(-) create mode 100644 projects/packages/account-protection/changelog/add-packages-account-protection-password-detection-flow create mode 100644 projects/packages/account-protection/src/assets/jetpack-logo.svg create mode 100644 projects/packages/account-protection/src/class-password-detection.php create mode 100644 projects/packages/account-protection/src/class-password-reset-email.php create mode 100644 projects/packages/account-protection/src/css/password-detection.css create mode 100644 projects/packages/account-protection/src/js/resend-password-reset.js create mode 100644 projects/plugins/protect/changelog/add-packages-account-protection-password-detection-flow 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/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 9de822a2deac3..a63d54545889f 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -13,7 +13,6 @@ * Class Account_Protection */ class Account_Protection { - const PACKAGE_VERSION = '0.1.0-alpha'; const ACCOUNT_PROTECTION_MODULE_NAME = 'account-protection'; const STRICT_MODE_OPTION_NAME = 'jetpack_account_protection_strict_mode'; @@ -25,39 +24,83 @@ class Account_Protection { */ private $modules; + /** + * Password detection instance. + * + * @var Password_Detection + */ + private $password_detection; + /** * Account_Protection constructor. * - * @param ?Modules $modules Modules instance. + * @param ?Modules $modules Modules instance. + * @param ?Password_Detection $password_detection Password detection instance. */ - public function __construct( ?Modules $modules = null ) { - $this->modules = $modules ?? new Modules(); + public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null ) { + $this->modules = $modules ?? new Modules(); + $this->password_detection = $password_detection ?? new Password_Detection(); } /** * Initializes the configurations needed for the account protection module. */ - public function init() { + 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() { - // Account protection activated + public function on_account_protection_activation(): void { + // Activation logic can be added here } /** - * Deactivate the account protection on module activation. + * Deactivate the account protection on module deactivation. */ - public function on_account_protection_deactivation() { - // Account protection deactivated + 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(); } /** @@ -87,7 +130,7 @@ public function enable() { * * @return bool */ - public function disable() { + public function disable(): bool { // Return true if already disabled. if ( ! $this->is_enabled() ) { return true; @@ -95,12 +138,64 @@ public function disable() { 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() { + public function get_settings(): array { $settings = array( self::STRICT_MODE_OPTION_NAME => get_option( self::STRICT_MODE_OPTION_NAME, false ), ); 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' ); ?> + + + +
+ + + + is_enabled() ); + return new WP_REST_Response( ( new Account_Protection() )->get_settings() ); } /** From e1293ac93ceef186cde440d52db587e28cbad862 Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Wed, 29 Jan 2025 08:29:27 -0800 Subject: [PATCH 06/50] Account Protection: Remove strict mode (#41316) * 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 --- projects/js-packages/api/index.jsx | 10 -- .../src/class-account-protection.php | 17 --- .../src/class-rest-controller.php | 104 ------------- .../client/security/account-protection.jsx | 144 +----------------- .../jetpack/_inc/client/security/index.jsx | 2 +- .../jetpack/_inc/client/security/style.scss | 20 --- .../state/account-protection/actions.js | 66 -------- .../client/state/account-protection/index.js | 2 - .../state/account-protection/reducer.js | 87 ----------- .../jetpack/_inc/client/state/action-types.js | 9 -- .../jetpack/_inc/client/state/reducer.js | 2 - .../lib/class.core-rest-api-endpoints.php | 8 - ....wpcom-json-api-site-settings-endpoint.php | 2 - .../protect/src/class-jetpack-protect.php | 8 +- .../protect/src/class-rest-controller.php | 2 +- projects/plugins/protect/src/js/api.ts | 10 +- .../use-account-protection-mutation.ts | 57 ------- .../use-account-protection-query.ts | 3 +- ...ggle-account-protection-module-mutation.ts | 20 +-- .../use-account-protection-data/index.jsx | 59 ------- .../protect/src/js/routes/settings/index.jsx | 88 ++++------- .../src/js/types/account-protection.ts | 12 -- 22 files changed, 46 insertions(+), 686 deletions(-) delete mode 100644 projects/packages/account-protection/src/class-rest-controller.php delete mode 100644 projects/plugins/jetpack/_inc/client/state/account-protection/actions.js delete mode 100644 projects/plugins/jetpack/_inc/client/state/account-protection/index.js delete mode 100644 projects/plugins/jetpack/_inc/client/state/account-protection/reducer.js delete mode 100644 projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts delete mode 100644 projects/plugins/protect/src/js/hooks/use-account-protection-data/index.jsx delete mode 100644 projects/plugins/protect/src/js/types/account-protection.ts diff --git a/projects/js-packages/api/index.jsx b/projects/js-packages/api/index.jsx index f0b0314bc70f6..04dcf2c6325a5 100644 --- a/projects/js-packages/api/index.jsx +++ b/projects/js-packages/api/index.jsx @@ -523,16 +523,6 @@ 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/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index a63d54545889f..2db8aaf73582d 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -15,7 +15,6 @@ class Account_Protection { const PACKAGE_VERSION = '0.1.0-alpha'; const ACCOUNT_PROTECTION_MODULE_NAME = 'account-protection'; - const STRICT_MODE_OPTION_NAME = 'jetpack_account_protection_strict_mode'; /** * Modules instance. @@ -64,9 +63,6 @@ private function register_hooks(): void { // 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' ) ); } /** @@ -189,17 +185,4 @@ function ( $module ) { 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 ), - ); - - return $settings; - } } diff --git a/projects/packages/account-protection/src/class-rest-controller.php b/projects/packages/account-protection/src/class-rest-controller.php deleted file mode 100644 index a49c1b1fe7c65..0000000000000 --- a/projects/packages/account-protection/src/class-rest-controller.php +++ /dev/null @@ -1,104 +0,0 @@ -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/plugins/jetpack/_inc/client/security/account-protection.jsx b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx index 7334eb9e3ca7d..4f8f51b250f10 100644 --- a/projects/plugins/jetpack/_inc/client/security/account-protection.jsx +++ b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx @@ -1,98 +1,14 @@ -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 ); - }; +const AccountProtectionComponent = class extends Component { 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 && } - { 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 ) ); +export const AccountProtection = withModuleSettingsFormHelpers( AccountProtectionComponent ); diff --git a/projects/plugins/jetpack/_inc/client/security/index.jsx b/projects/plugins/jetpack/_inc/client/security/index.jsx index d4677461de9ea..ff1ec0efad4f2 100644 --- a/projects/plugins/jetpack/_inc/client/security/index.jsx +++ b/projects/plugins/jetpack/_inc/client/security/index.jsx @@ -12,7 +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 { AccountProtection } from './account-protection'; import AllowList from './allowList'; import Antispam from './antispam'; import BackupsScan from './backups-scan'; diff --git a/projects/plugins/jetpack/_inc/client/security/style.scss b/projects/plugins/jetpack/_inc/client/security/style.scss index 385e7feaa710f..60855aa333edb 100644 --- a/projects/plugins/jetpack/_inc/client/security/style.scss +++ b/projects/plugins/jetpack/_inc/client/security/style.scss @@ -192,23 +192,3 @@ .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 deleted file mode 100644 index feee531d78a38..0000000000000 --- a/projects/plugins/jetpack/_inc/client/state/account-protection/actions.js +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 5e3164b4c9f72..0000000000000 --- a/projects/plugins/jetpack/_inc/client/state/account-protection/index.js +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index cb42d7bccc486..0000000000000 --- a/projects/plugins/jetpack/_inc/client/state/account-protection/reducer.js +++ /dev/null @@ -1,87 +0,0 @@ -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 67bf7de0e487a..633c8476061a7 100644 --- a/projects/plugins/jetpack/_inc/client/state/action-types.js +++ b/projects/plugins/jetpack/_inc/client/state/action-types.js @@ -249,15 +249,6 @@ 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 80e2be96f3be8..c50656c1c4a47 100644 --- a/projects/plugins/jetpack/_inc/client/state/reducer.js +++ b/projects/plugins/jetpack/_inc/client/state/reducer.js @@ -1,6 +1,5 @@ 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'; @@ -49,7 +48,6 @@ 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 4e4ac0f3f6460..b83cabe4ebf9b 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 @@ -2335,14 +2335,6 @@ public static function get_updateable_data_list( $selector = '' ) { 'validate_callback' => __CLASS__ . '::validate_posint', 'jp_group' => 'settings', ), - // 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/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 1a797bea3d27e..7277ce875df59 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,7 +127,6 @@ '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', @@ -491,7 +490,6 @@ 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/protect/src/class-jetpack-protect.php b/projects/plugins/protect/src/class-jetpack-protect.php index 67b0b5742f739..eb3ba6dd2787c 100644 --- a/projects/plugins/protect/src/class-jetpack-protect.php +++ b/projects/plugins/protect/src/class-jetpack-protect.php @@ -215,8 +215,7 @@ public function initial_state() { // Always fetch the latest plan status from WPCOM. $has_plan = Plan::has_required_plan( true ); - $status = Status::get_status(); - $account_protection = new Account_Protection(); + $status = Status::get_status(); $initial_state = array( 'apiRoot' => esc_url_raw( rest_url() ), @@ -235,10 +234,7 @@ public function initial_state() { 'jetpackScan' => My_Jetpack_Products::get_product( 'scan' ), 'hasPlan' => $has_plan, 'onboardingProgress' => Onboarding::get_current_user_progress(), - 'accountProtection' => array( - 'isEnabled' => $account_protection->is_enabled(), - 'settings' => $account_protection->get_settings(), - ), + 'accountProtection' => ( new Account_Protection() )->is_enabled(), 'waf' => array( 'wafSupported' => Waf_Runner::is_supported_environment(), 'currentIp' => IP_Utils::get_ip(), diff --git a/projects/plugins/protect/src/class-rest-controller.php b/projects/plugins/protect/src/class-rest-controller.php index 3426b65554fe8..7e6d099c1b8e7 100644 --- a/projects/plugins/protect/src/class-rest-controller.php +++ b/projects/plugins/protect/src/class-rest-controller.php @@ -379,7 +379,7 @@ public static function api_toggle_account_protection() { * @return WP_Rest_Response */ public static function api_get_account_protection() { - return new WP_REST_Response( ( new Account_Protection() )->get_settings() ); + return new WP_REST_Response( ( new Account_Protection() )->is_enabled() ); } /** diff --git a/projects/plugins/protect/src/js/api.ts b/projects/plugins/protect/src/js/api.ts index d286b54018064..ff20ed9163d7b 100644 --- a/projects/plugins/protect/src/js/api.ts +++ b/projects/plugins/protect/src/js/api.ts @@ -2,11 +2,10 @@ import { type FixersStatus, type ScanStatus } from '@automattic/jetpack-scan'; import apiFetch from '@wordpress/api-fetch'; import camelize from 'camelize'; import type { ProductData } from './types/products'; -import { AccountProtectionStatus } from './types/account-protection'; import { WafStatus } from './types/waf'; const API = { - getAccountProtection: (): Promise< AccountProtectionStatus > => + getAccountProtection: () => apiFetch( { path: 'jetpack-protect/v1/account-protection', method: 'GET', @@ -18,13 +17,6 @@ const API = { 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/data/account-protection/use-account-protection-mutation.ts b/projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts deleted file mode 100644 index 592c5b983c37a..0000000000000 --- a/projects/plugins/protect/src/js/data/account-protection/use-account-protection-mutation.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 index 01dd3354432a9..8bd32f8ddf951 100644 --- 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 @@ -2,14 +2,13 @@ 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 > { +export default function useAccountProtectionQuery(): UseQueryResult { return useQuery( { queryKey: [ QUERY_ACCOUNT_PROTECTION_KEY ], queryFn: API.getAccountProtection, 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 index 2f8ca342902ea..68c5b53ee0d40 100644 --- 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 @@ -3,7 +3,6 @@ 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 @@ -19,19 +18,9 @@ export default function useToggleAccountProtectionMutation(): UseMutationResult onMutate: () => { showSavingNotice(); - // Get the current Account Protection settings. - const initialValue = queryClient.getQueryData( [ - QUERY_ACCOUNT_PROTECTION_KEY, - ] ) as AccountProtectionStatus; + const initialValue = queryClient.getQueryData( [ QUERY_ACCOUNT_PROTECTION_KEY ] ); - // Optimistically update the Account Protection settings. - queryClient.setQueryData( - [ QUERY_ACCOUNT_PROTECTION_KEY ], - ( accountProtectionStatus: AccountProtectionStatus ) => ( { - ...accountProtectionStatus, - isEnabled: ! initialValue.isEnabled, - } ) - ); + queryClient.setQueryData( [ QUERY_ACCOUNT_PROTECTION_KEY ], ! initialValue ); return { initialValue }; }, @@ -39,7 +28,10 @@ export default function useToggleAccountProtectionMutation(): UseMutationResult showSuccessNotice( __( 'Changes saved.', 'jetpack-protect' ) ); }, onError: () => { - showErrorNotice( __( 'Error savings changes.', 'jetpack-protect' ) ); + showErrorNotice( __( 'An error occurred.', 'jetpack-protect' ) ); + }, + onSettled: () => { + queryClient.invalidateQueries( { queryKey: [ QUERY_ACCOUNT_PROTECTION_KEY ] } ); }, } ); } 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 deleted file mode 100644 index 90e473c270bc6..0000000000000 --- a/projects/plugins/protect/src/js/hooks/use-account-protection-data/index.jsx +++ /dev/null @@ -1,59 +0,0 @@ -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/routes/settings/index.jsx b/projects/plugins/protect/src/js/routes/settings/index.jsx index 3fe6b735f7d3e..459bc542586b1 100644 --- a/projects/plugins/protect/src/js/routes/settings/index.jsx +++ b/projects/plugins/protect/src/js/routes/settings/index.jsx @@ -8,22 +8,27 @@ import { import { createInterpolateElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon, warning } from '@wordpress/icons'; +import React, { useCallback } from 'react'; import AdminPage from '../../components/admin-page'; -import useAccountProtectionData from '../../hooks/use-account-protection-data'; +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 '../../hooks/use-analytics-tracks'; import usePlan from '../../hooks/use-plan'; import styles from './styles.module.scss'; const SettingsPage = () => { const { hasPlan } = usePlan(); - const { - settings: { jetpackAccountProtectionStrictMode: strictMode }, - isEnabled: isAccountProtectionEnabled, - toggleAccountProtection, - toggleStrictMode, - isToggling, - isUpdating, - } = useAccountProtectionData(); + const { data: accountProtectionIsEnabled } = useAccountProtectionQuery(); + const toggleAccountProtectionMutation = useToggleAccountProtectionMutation(); + + /** + * Toggle Account Protect Module + * + * Flips the switch on the Account Protection module, and then refreshes the data. + */ + const toggleAccountProtection = useCallback( async () => { + toggleAccountProtectionMutation.mutate(); + }, [ toggleAccountProtectionMutation ] ); // Track view for Protect Account Protection page. useAnalyticsTracks( { @@ -37,9 +42,9 @@ const SettingsPage = () => {
@@ -49,47 +54,7 @@ const SettingsPage = () => { { 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 = ( -
-
- -
-
- - { __( 'Require strongs passwords', '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 - } - ) } - - - - { createInterpolateElement( - __( - 'Jetpack recommends activating this setting. Please be mindful of the risks.', + 'Protect your site with enhanced password detection and profile management security.', 'jetpack-protect' ), { @@ -97,6 +62,20 @@ const SettingsPage = () => { } ) } + { ! accountProtectionIsEnabled && ( + + + { createInterpolateElement( + __( + 'Jetpack recommends activating this setting. Please be mindful of the risks.', + 'jetpack-protect' + ), + { + link: , // TODO: Update this redirect URL + } + ) } + + ) }
); @@ -109,10 +88,7 @@ const SettingsPage = () => { -
- { accountProtectionSettings } - { isAccountProtectionEnabled && strictModeSettings } -
+
{ accountProtectionSettings }
diff --git a/projects/plugins/protect/src/js/types/account-protection.ts b/projects/plugins/protect/src/js/types/account-protection.ts deleted file mode 100644 index 37d557638982b..0000000000000 --- a/projects/plugins/protect/src/js/types/account-protection.ts +++ /dev/null @@ -1,12 +0,0 @@ -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; -}; From 1ca311f80fc7f32c7b2b5844ad120eb487361fc0 Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Thu, 30 Jan 2025 10:01:38 -0800 Subject: [PATCH 07/50] Account Protection: Update password detection flow (#41365) * 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 --- .../src/class-account-protection.php | 28 +- .../account-protection/src/class-config.php | 19 + .../src/class-email-service.php | 114 +++++ .../src/class-password-detection.php | 479 ++++++++---------- .../src/class-password-reset-email.php | 45 -- .../src/class-validation-service.php | 62 +++ .../src/css/password-detection.css | 34 +- .../src/js/resend-password-reset.js | 71 --- 8 files changed, 447 insertions(+), 405 deletions(-) create mode 100644 projects/packages/account-protection/src/class-config.php create mode 100644 projects/packages/account-protection/src/class-email-service.php delete mode 100644 projects/packages/account-protection/src/class-password-reset-email.php create mode 100644 projects/packages/account-protection/src/class-validation-service.php delete mode 100644 projects/packages/account-protection/src/js/resend-password-reset.js diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 2db8aaf73582d..72900f144154f 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -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(); @@ -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 @@ -67,24 +71,24 @@ 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 @@ -92,11 +96,11 @@ public function on_account_protection_activation(): void { /** * 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 } /** @@ -104,7 +108,7 @@ public function on_account_protection_deactivation(): void { * * @return bool */ - public function is_enabled() { + public function is_enabled(): bool { return $this->modules->is_active( self::ACCOUNT_PROTECTION_MODULE_NAME ); } @@ -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; diff --git a/projects/packages/account-protection/src/class-config.php b/projects/packages/account-protection/src/class-config.php new file mode 100644 index 0000000000000..99d461441752a --- /dev/null +++ b/projects/packages/account-protection/src/class-config.php @@ -0,0 +1,19 @@ +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]}"; + } +} diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php index ca195ed1207e6..53e035098582e 100644 --- a/projects/packages/account-protection/src/class-password-detection.php +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -7,38 +7,33 @@ namespace Automattic\Jetpack\Account_Protection; -use Automattic\Jetpack\Connection\Client; -use Automattic\Jetpack\Connection\Manager as Connection_Manager; - /** * Class Password_Detection */ class Password_Detection { - const PASSWORD_DETECTION_USER_META_KEY = 'jetpack_account_protection_password_status'; - /** - * Password reset email dependency. + * Email service dependency. * - * @var Password_Reset_Email + * @var Email_Service */ - private $password_reset_email; + private $email_service; /** - * Password_Detection constructor. + * Validation service dependency. * - * @param ?Password_Reset_Email $password_reset_email Password reset email instance. + * @var Validation_Service */ - public function __construct( ?Password_Reset_Email $password_reset_email = null ) { - $this->password_reset_email = $password_reset_email ?? new Password_Reset_Email(); - } + private $validation_service; /** - * Redirect to the password detection page. + * Password_Detection constructor. * - * @return string The URL to redirect to. + * @param ?Email_Service $email_service Email service instance. + * @param ?Validation_Service $validation_service Validation service instance. */ - public function password_detection_redirect(): string { - return home_url( '/wp-login.php?action=password-detection' ); + public function __construct( ?Email_Service $email_service = null, ?Validation_Service $validation_service = null ) { + $this->email_service = $email_service ?? new Email_Service(); + $this->validation_service = $validation_service ?? new Validation_Service(); } /** @@ -46,343 +41,295 @@ public function password_detection_redirect(): string { * * @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 ) ) { + if ( is_wp_error( $user ) || ! $this->user_requires_protection( $user, $password ) ) { return $user; } - // Ensure the password is correct for this user - if ( ! wp_check_password( $password, $user->user_pass, $user->ID ) ) { - return $user; - } + 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 ); - if ( ! $this->validate_password( $password ) ) { - // TODO: Ensure usermeta is always up to date - $this->update_usermeta( $user->ID, 'unsafe' ); + $email_sent = $this->email_service->api_send_auth_email( $user, $transient['auth_code'] ); + if ( ! $email_sent ) { + $this->set_transient_error( $user->ID, __( 'Failed to send authentication email. Please try again.', 'jetpack-account-protection' ) ); + } - // 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 new \WP_Error( + Config::ERROR_CODE, + Config::ERROR_MESSAGE, + array( 'token' => $transient['token'] ) + ); } return $user; } /** - * Render password detection page. + * Handle password detection validation error. * - * @return never + * @param string $username The username. + * @param \WP_Error $error The error object. + * + * @return void */ - 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() ); + public function handle_password_detection_validation_error( string $username, \WP_Error $error ): void { + if ( isset( $error->errors['password_detection_validation_error'] ) ) { + $token = $error->get_error_data()['token']; + wp_safe_redirect( $this->get_redirect_url( $token ) ); 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 ) { + /** + * Render password detection page. + * + * @return never + */ + public function render_page() { + if ( is_user_logged_in() ) { 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 ); + $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : null; + $transient_data = get_transient( Config::TRANSIENT_PREFIX . "_{$token}" ); + if ( ! $transient_data ) { + $this->redirect_to_login(); + } - // Initialize template variables - $reset = false; - $context = 'Your current password was found in a public leak, which means your account might be at risk.'; - $error = ''; + $user_id = $transient_data['user_id'] ?? null; + $user = $user_id ? get_user_by( 'ID', $user_id ) : null; + if ( ! $user instanceof \WP_User ) { + $this->redirect_to_login(); + } 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'; + // Handle resend email request + if ( isset( $_GET['resend_email'] ) && $_GET['resend_email'] === '1' ) { + if ( isset( $_GET['_wpnonce'] ) + && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'resend_email_nonce' ) + ) { + $email_resent = $this->email_service->resend_auth_email( $user, $transient_data, $token ); + if ( ! $email_resent ) { + $message = __( 'Failed to resend authentication email. Please try again.', 'jetpack-account-protection' ); + + if ( $transient_data['resend_attempts'] >= Config::MAX_RESEND_ATTEMPTS ) { + $message = __( 'Resend limit exceeded. Please try again later.', 'jetpack-account-protection' ); } + + $this->set_transient_error( $user->ID, $message ); } - add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_resend_password_reset_scripts' ) ); + wp_safe_redirect( $this->get_redirect_url( $token ) ); + exit; } else { - $error = 'reset_passowrd_nonce_verification_error'; + $this->set_transient_error( $user->ID, __( 'Resend nonce verification failed. Please try again.', 'jetpack-account-protection' ) ); } + } - // Handle proceed_action form submission - } elseif ( isset( $_POST['proceed'] ) ) { - $reset = true; + // Handle verify form submission + if ( isset( $_POST['verify'] ) ) { + if ( ! empty( $_POST['_wpnonce_verify'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_verify'] ) ), 'verify_action' ) ) { + $user_input = isset( $_POST['user_input'] ) ? sanitize_text_field( wp_unslash( $_POST['user_input'] ) ) : null; - // 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; + $this->handle_auth_form_submission( $user, $token, $transient_data['auth_code'] ?? null, $user_input ); } else { - $error = 'proceed_nonce_verification_error'; + $this->set_transient_error( $user->ID, __( 'Verify nonce verification failed. Please try again.', 'jetpack-account-protection' ) ); } } - $this->render_content( $reset, $context, $error, $this->password_reset_email->mask_email_address( $current_user->user_email ) ); + $this->render_content( $user, $token ); exit; } /** - * Enqueue the resend password reset email scripts. + * Render content for password detection page. * - * @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. + * @param \WP_User $user The user. + * @param string $token The token. * * @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 - ); - } + public function render_content( \WP_User $user, string $token ): void { + $transient_key = Config::TRANSIENT_PREFIX . "_error_{$user->ID}"; + $error_message = get_transient( $transient_key ); + delete_transient( $transient_key ); - /** - * 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. ' ) ); - } + defined( 'ABSPATH' ) || exit; + ?> + + + + + + <?php esc_html_e( 'Jetpack - Secure Your Account', 'jetpack-account-protection' ); ?> + + + +
+ + + + user_pass, $user->ID ); } /** - * Check if the password is in the list of common/compromised passwords. + * Generate and store a consolidated transient for the user. + * + * @param int $user_id The user ID. * - * @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. + * @return array An array of the generated token and auth code. */ - 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' + private function generate_and_store_transient_data( int $user_id ): array { + $token = wp_generate_password( 32, false, false ); + $auth_code = $this->email_service->generate_auth_code(); + + $data = array( + 'user_id' => $user_id, + 'auth_code' => $auth_code, + 'resend_attempts' => 0, ); - $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 ) ); + $transient_set = set_transient( Config::TRANSIENT_PREFIX . "_{$token}", $data, Config::EMAIL_SENT_EXPIRATION ); + if ( ! $transient_set ) { + $this->set_transient_error( $user_id, __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ) ); } - $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; + return array( + 'token' => $token, + 'auth_code' => $auth_code, + ); } /** - * Get the password detection usermeta. + * Redirect to the login page. * - * @param int $user_id The user ID. + * @return never */ - public function get_usermeta( int $user_id ) { - return get_user_meta( $user_id, self::PASSWORD_DETECTION_USER_META_KEY, true ); + private function redirect_to_login() { + wp_safe_redirect( wp_login_url() ); + exit; } /** - * Update the password detection usermeta. + * Get redirect URL. * - * @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 string $token The token. * - * @param int $user_id The user ID. + * @return string The redirect URL. */ - public function delete_usermeta( int $user_id ) { - delete_user_meta( $user_id, self::PASSWORD_DETECTION_USER_META_KEY ); + private function get_redirect_url( string $token ): string { + return home_url( '/wp-login.php?action=password-detection&token=' . $token ); } /** - * Delete the password detection usermeta after password reset. + * Handle auth form submission. * - * @param \WP_User $user The user object. + * @param \WP_User $user The current user. + * @param string $token The token. + * @param string $auth_code The expected auth code. + * @param string $user_input The user input. + * + * @return void */ - public function delete_usermeta_after_password_reset( \WP_User $user ) { - $this->delete_usermeta( $user->ID ); + 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}" ); + wp_set_auth_cookie( $user->ID, true ); + // TODO: Notify user to update their password/redirect to password update page + wp_safe_redirect( admin_url() ); + exit; + } else { + $this->set_transient_error( $user->ID, __( 'Authentication code verification failed. Please try again.', 'jetpack-account-protection' ) ); + } } /** - * Delete the password detection usermeta on profile password update. + * Set a transient error message. * - * @param int $user_id The user ID. + * @param int $user_id The user ID. + * @param string $message The error message. + * @param int $expiration The expiration time in seconds. + * + * @return void */ - 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 ); - } - } + private function set_transient_error( int $user_id, string $message, int $expiration = 60 ): void { + set_transient( Config::TRANSIENT_PREFIX . "_error_{$user_id}", esc_html( $message ), $expiration ); } /** - * Render content for password detection page. + * Enqueue the password detection page styles. * - * @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.

- -
- - - - is_connected(); + if ( ! $is_connected ) { + return false; + } + + $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 false; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + $password_suffix = substr( $hashed_password, 5 ); + if ( in_array( $password_suffix, $body['compromised'] ?? array(), true ) ) { + return true; + } + + if ( in_array( $password_suffix, $body['common'] ?? array(), true ) ) { + return true; + } + + return false; + } +} diff --git a/projects/packages/account-protection/src/css/password-detection.css b/projects/packages/account-protection/src/css/password-detection.css index d1ec425dd5da3..d568caa5b0017 100644 --- a/projects/packages/account-protection/src/css/password-detection.css +++ b/projects/packages/account-protection/src/css/password-detection.css @@ -33,17 +33,29 @@ height: 36px; cursor: pointer; width: 100%; + border-radius: 2px; } -.action-reset { - margin-top: 10px; - background-color: #0000EE; - border: 1px solid #0000EE; - color: #fff; +.action-input { + height: 30px; + cursor: pointer; + width: 412px; + text-indent: 8px; + + &::placeholder { + font-size: 13px; } - -.action-proceed { - background-color: #fff; - border: 1px solid #0000EE; - color: #0000EE; -} \ No newline at end of file +} + +.action-verify { + margin-top: 10px; + background-color: #0000EE; + border: 1px solid #0000EE; + color: #fff; + font-size: 13px; +} + +.email-status, +.error-message { + text-align: center; +} diff --git a/projects/packages/account-protection/src/js/resend-password-reset.js b/projects/packages/account-protection/src/js/resend-password-reset.js deleted file mode 100644 index 2e0cdf8a4ab0a..0000000000000 --- a/projects/packages/account-protection/src/js/resend-password-reset.js +++ /dev/null @@ -1,71 +0,0 @@ -/* 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 ); From 48c607fbcad15be094d7a866c72cf008ceef6280 Mon Sep 17 00:00:00 2001 From: Kolja Zuelsdorf Date: Fri, 31 Jan 2025 23:56:40 +0100 Subject: [PATCH 08/50] Account Protection: Add tests for newly added code (#41463) * Created new branch with cherrypicked changes from tests because something to screwed up with the base branch. * Added tests for email service. * Added tests for account protection module class. * Added tests for password detection class. * Added changelog entry. * Fix PHP 8 consistency issue in test. * Fixed phan issues. --- .../changelog/add-account-protection-tests | 4 + .../src/class-account-protection.php | 4 +- .../src/class-email-service.php | 57 ++- .../src/class-password-detection.php | 82 ++-- .../src/class-validation-service.php | 48 ++- .../tests/php/test-account-protection.php | 141 +++++++ .../tests/php/test-email-service.php | 214 ++++++++++ .../tests/php/test-password-detection.php | 398 ++++++++++++++++++ .../tests/php/test-validation-service.php | 163 +++++++ 9 files changed, 1061 insertions(+), 50 deletions(-) create mode 100644 projects/packages/account-protection/changelog/add-account-protection-tests create mode 100644 projects/packages/account-protection/tests/php/test-account-protection.php create mode 100644 projects/packages/account-protection/tests/php/test-email-service.php create mode 100644 projects/packages/account-protection/tests/php/test-password-detection.php create mode 100644 projects/packages/account-protection/tests/php/test-validation-service.php diff --git a/projects/packages/account-protection/changelog/add-account-protection-tests b/projects/packages/account-protection/changelog/add-account-protection-tests new file mode 100644 index 0000000000000..efb3b42e90075 --- /dev/null +++ b/projects/packages/account-protection/changelog/add-account-protection-tests @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Account Protection: Added unit tests for the package functionality. diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 72900f144154f..7156849eabac3 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -59,7 +59,7 @@ public function init(): void { * * @return void */ - private function register_hooks(): void { + protected 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' ) ); @@ -74,7 +74,7 @@ private function register_hooks(): void { * * @return void */ - private function register_runtime_hooks(): void { + protected 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 ); diff --git a/projects/packages/account-protection/src/class-email-service.php b/projects/packages/account-protection/src/class-email-service.php index 8f99596746788..4ca7e2fecf21e 100644 --- a/projects/packages/account-protection/src/class-email-service.php +++ b/projects/packages/account-protection/src/class-email-service.php @@ -15,6 +15,24 @@ * Class Email_Service */ class Email_Service { + /** + * Connection manager dependency. + * + * @var Connection_Manager + */ + private $connection_manager; + + /** + * Constructor for dependency injection. + * + * @param Connection_Manager|null $connection_manager Connection manager dependency. + */ + public function __construct( + ?Connection_Manager $connection_manager = null + ) { + $this->connection_manager = $connection_manager ?? new Connection_Manager(); + } + /** * Send the email using the API. * @@ -24,10 +42,9 @@ class Email_Service { * @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(); + $blog_id = Jetpack_Options::get_option( 'id' ); - if ( ! $blog_id || ! $is_connected ) { + if ( ! $blog_id || ! $this->connection_manager->is_connected() ) { return false; } @@ -37,15 +54,7 @@ public function api_send_auth_email( \WP_User $user, string $auth_code ): bool { '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 = $this->send_email_request( (int) $blog_id, $body ); $response_code = wp_remote_retrieve_response_code( $response ); if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) { @@ -57,6 +66,25 @@ public function api_send_auth_email( \WP_User $user, string $auth_code ): bool { return $body['email_sent'] ?? false; } + /** + * Dependency decoupling for the static call to the client. + * + * @param int $blog_id Blog ID. + * @param array $body The request body. + * @return array|\WP_Error Response data or error. + */ + protected function send_email_request( int $blog_id, array $body ) { + return Client::wpcom_json_api_request_as_blog( + sprintf( '/sites/%d/jetpack-protect-send-verification-code', $blog_id ), + '2', + array( + 'method' => 'POST', + ), + $body, + 'wpcom' + ); + } + /** * Resend email attempts. * @@ -109,6 +137,9 @@ public function mask_email_address( string $email ): string { $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]}"; + // Join all domain parts except the first one with dots + $tld = implode( '.', array_slice( $domain_parts, 1 ) ); + + return "{$name}@{$domain}.{$tld}"; } } diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php index 53e035098582e..6517fb27a4d1e 100644 --- a/projects/packages/account-protection/src/class-password-detection.php +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -68,6 +68,27 @@ public function login_form_password_detection( $user, string $password ) { return $user; } + /** + * Redirect and exit. + * + * @param string $redirect_location The redirect location. + * + * @return never + */ + protected function redirect_and_exit( string $redirect_location ) { + wp_safe_redirect( $redirect_location ); + $this->exit(); + } + + /** + * Exit decoupling. + * + * @return never + */ + protected function exit() { + exit; + } + /** * Handle password detection validation error. * @@ -79,32 +100,45 @@ public function login_form_password_detection( $user, string $password ) { public function handle_password_detection_validation_error( string $username, \WP_Error $error ): void { if ( isset( $error->errors['password_detection_validation_error'] ) ) { $token = $error->get_error_data()['token']; - wp_safe_redirect( $this->get_redirect_url( $token ) ); - exit; + $this->redirect_and_exit( $this->get_redirect_url( $token ) ); } } /** - * Render password detection page. + * Load user by ID. Dependency decoupling. * - * @return never + * @param int $user_id The user ID. + * + * @return \WP_User|null The user object. + */ + protected function load_user( int $user_id ) { + return get_user_by( 'ID', $user_id ); + } + + /** + * Render password detection page. */ public function render_page() { if ( is_user_logged_in() ) { - wp_safe_redirect( admin_url() ); - exit; + $this->redirect_and_exit( admin_url() ); + // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise. + return; } $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : null; $transient_data = get_transient( Config::TRANSIENT_PREFIX . "_{$token}" ); if ( ! $transient_data ) { $this->redirect_to_login(); + // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise. + return; } $user_id = $transient_data['user_id'] ?? null; - $user = $user_id ? get_user_by( 'ID', $user_id ) : null; + $user = $user_id ? $this->load_user( (int) $user_id ) : null; if ( ! $user instanceof \WP_User ) { $this->redirect_to_login(); + // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise. + return; } add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) ); @@ -125,8 +159,9 @@ public function render_page() { $this->set_transient_error( $user->ID, $message ); } - wp_safe_redirect( $this->get_redirect_url( $token ) ); - exit; + $this->redirect_and_exit( $this->get_redirect_url( $token ) ); + // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise. + return; } else { $this->set_transient_error( $user->ID, __( 'Resend nonce verification failed. Please try again.', 'jetpack-account-protection' ) ); } @@ -138,13 +173,13 @@ public function render_page() { $user_input = isset( $_POST['user_input'] ) ? sanitize_text_field( wp_unslash( $_POST['user_input'] ) ) : null; $this->handle_auth_form_submission( $user, $token, $transient_data['auth_code'] ?? null, $user_input ); + return; } else { $this->set_transient_error( $user->ID, __( 'Verify nonce verification failed. Please try again.', 'jetpack-account-protection' ) ); } } $this->render_content( $user, $token ); - exit; } /** @@ -188,15 +223,15 @@ public function render_content( \WP_User $user, string $token ): void {
@@ -216,6 +251,7 @@ class="action-input" exit(); } /** @@ -268,8 +304,7 @@ private function generate_and_store_transient_data( int $user_id ): array { * @return never */ private function redirect_to_login() { - wp_safe_redirect( wp_login_url() ); - exit; + $this->redirect_and_exit( wp_login_url() ); } /** @@ -299,8 +334,7 @@ private function handle_auth_form_submission( \WP_User $user, string $token, str delete_transient( Config::TRANSIENT_PREFIX . "_{$token}" ); wp_set_auth_cookie( $user->ID, true ); // TODO: Notify user to update their password/redirect to password update page - wp_safe_redirect( admin_url() ); - exit; + $this->redirect_and_exit( admin_url() ); } else { $this->set_transient_error( $user->ID, __( 'Authentication code verification failed. Please try again.', 'jetpack-account-protection' ) ); } @@ -316,7 +350,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}", esc_html( $message ), $expiration ); + set_transient( Config::TRANSIENT_PREFIX . "_error_{$user_id}", $message, $expiration ); } /** diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index bb8d5ef5e1692..6430bb0515bc8 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -14,6 +14,41 @@ * Class Validation_Service */ class Validation_Service { + + /** + * Connection manager dependency. + * + * @var Connection_Manager + */ + private $connection_manager; + + /** + * Constructor for dependency injection. + * + * @param Connection_Manager|null $connection_manager Connection manager dependency. + */ + public function __construct( + ?Connection_Manager $connection_manager = null + ) { + $this->connection_manager = $connection_manager ?? new Connection_Manager(); + } + + /** + * Dependency decoupling so we can test this class. + * + * @param string $password_prefix The password prefix to be checked. + * @return array|\WP_Error + */ + protected function request_suffixes( string $password_prefix ) { + return Client::wpcom_json_api_request_as_blog( + '/jetpack-protect-weak-password/' . $password_prefix, + '2', + array( 'method' => 'GET' ), + null, + 'wpcom' + ); + } + /** * Check if the password is in the list of compromised/common passwords. * @@ -22,23 +57,14 @@ class Validation_Service { * @return bool True if the password is in the list of compromised/common passwords, false otherwise. */ public function is_weak_password( string $password ): bool { - $api_url = '/jetpack-protect-weak-password'; - - $is_connected = ( new Connection_Manager() )->is_connected(); - if ( ! $is_connected ) { + if ( ! $this->connection_manager->is_connected() ) { return false; } $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 = $this->request_suffixes( $password_prefix ); $response_code = wp_remote_retrieve_response_code( $response ); diff --git a/projects/packages/account-protection/tests/php/test-account-protection.php b/projects/packages/account-protection/tests/php/test-account-protection.php new file mode 100644 index 0000000000000..0bbb8c831d5ce --- /dev/null +++ b/projects/packages/account-protection/tests/php/test-account-protection.php @@ -0,0 +1,141 @@ +createMock( Modules::class ); + $modules_mock->expects( $this->once() ) + ->method( 'is_active' ) + ->with( Account_Protection::ACCOUNT_PROTECTION_MODULE_NAME ) + ->willReturn( true ); + + $sut = new Account_Protection( $modules_mock ); + $this->assertTrue( $sut->is_enabled(), 'Module should be enabled.' ); + } + + public function test_init_registers_hooks_and_runtime_hooks_if_module_enabled(): void { + $sut = $this->createPartialMock( Account_Protection::class, array( 'is_enabled', 'register_hooks', 'register_runtime_hooks' ) ); + $sut->expects( $this->once() ) + ->method( 'is_enabled' ) + ->willReturn( true ); + + $sut->expects( $this->once() ) + ->method( 'register_hooks' ); + + $sut->expects( $this->once() ) + ->method( 'register_runtime_hooks' ); + + $sut->init(); + } + + public function test_init_registers_hooks_but_not_runtime_hooks_if_module_disabled(): void { + $sut = $this->createPartialMock( Account_Protection::class, array( 'is_enabled', 'register_hooks', 'register_runtime_hooks' ) ); + $sut->expects( $this->once() ) + ->method( 'is_enabled' ) + ->willReturn( false ); + + $sut->expects( $this->once() ) + ->method( 'register_hooks' ); + + $sut->expects( $this->never() ) + ->method( 'register_runtime_hooks' ); + + $sut->init(); + } + + public function test_enable_activates_module_if_not_activated_yet(): void { + $modules_mock = $this->createMock( Modules::class ); + $modules_mock->expects( $this->once() ) + ->method( 'is_active' ) + ->willReturn( false ); + + $modules_mock->expects( $this->once() ) + ->method( 'activate' ) + ->with( Account_Protection::ACCOUNT_PROTECTION_MODULE_NAME, false, false ) + ->willReturn( true ); + + $sut = new Account_Protection( $modules_mock ); + $this->assertTrue( $sut->enable(), 'Module should be enabled successfully.' ); + } + + public function test_enable_does_nothing_if_module_already_activated(): void { + $modules_mock = $this->createMock( Modules::class ); + $modules_mock->expects( $this->once() ) + ->method( 'is_active' ) + ->willReturn( true ); + + $modules_mock->expects( $this->never() ) + ->method( 'activate' ); + + $sut = new Account_Protection( $modules_mock ); + $this->assertTrue( $sut->enable(), 'Module should be enabled successfully.' ); + } + + public function test_disable_deactivates_module_if_active(): void { + $modules_mock = $this->createMock( Modules::class ); + $modules_mock->expects( $this->once() ) + ->method( 'is_active' ) + ->willReturn( true ); + + $modules_mock->expects( $this->once() ) + ->method( 'deactivate' ) + ->with( Account_Protection::ACCOUNT_PROTECTION_MODULE_NAME ) + ->willReturn( true ); + + $sut = new Account_Protection( $modules_mock ); + $this->assertTrue( $sut->disable(), 'Module should be disabled successfully.' ); + } + + public function test_disable_does_nothing_if_module_already_inactive(): void { + $modules_mock = $this->createMock( Modules::class ); + $modules_mock->expects( $this->once() ) + ->method( 'is_active' ) + ->willReturn( false ); + + $modules_mock->expects( $this->never() ) + ->method( 'deactivate' ); + + $sut = new Account_Protection( $modules_mock ); + $this->assertTrue( $sut->disable(), 'Module should be disabled successfully.' ); + } + + public function test_remove_module_on_unsupported_environments_removes_itself_correctly(): void { + $sut = $this->createPartialMock( Account_Protection::class, array( 'is_supported_environment' ) ); + $sut->expects( $this->once() ) + ->method( 'is_supported_environment' ) + ->willReturn( false ); + + $all_modules = array( + 'something-else' => 'should_remain', + Account_Protection::ACCOUNT_PROTECTION_MODULE_NAME => 'should_be_removed', + ); + + $all_modules = $sut->remove_module_on_unsupported_environments( $all_modules ); + + $this->assertArrayNotHasKey( Account_Protection::ACCOUNT_PROTECTION_MODULE_NAME, $all_modules, 'The module should have removed itself.' ); + } + + public function test_remove_standalone_module_on_unsupported_environments_removes_itself_correctly(): void { + $sut = $this->createPartialMock( Account_Protection::class, array( 'is_supported_environment' ) ); + $sut->expects( $this->once() ) + ->method( 'is_supported_environment' ) + ->willReturn( false ); + + $all_modules = array( + 'some_other_module', + Account_Protection::ACCOUNT_PROTECTION_MODULE_NAME, + ); + + $all_modules = $sut->remove_standalone_module_on_unsupported_environments( $all_modules ); + + $this->assertNotContains( Account_Protection::ACCOUNT_PROTECTION_MODULE_NAME, $all_modules, 'The module should have removed itself.' ); + } +} diff --git a/projects/packages/account-protection/tests/php/test-email-service.php b/projects/packages/account-protection/tests/php/test-email-service.php new file mode 100644 index 0000000000000..a71d02fafa8c4 --- /dev/null +++ b/projects/packages/account-protection/tests/php/test-email-service.php @@ -0,0 +1,214 @@ +assertMatchesRegularExpression( '/^[0-9]{6}$/', $sut->generate_auth_code() ); + } + + /** + * @dataProvider email_masking_data_provider + */ + public function test_mask_email_address_masks_correctly( $plain_email, $expected_masked_email ): void { + $sut = new Email_Service(); + $this->assertEquals( $expected_masked_email, $sut->mask_email_address( $plain_email ) ); + } + + public function email_masking_data_provider(): array { + return array( + 'john.doe@example.com' => array( 'john.doe@example.com', 'j*******@e******.com' ), + 'mary.smith@gmail.com' => array( 'mary.smith@gmail.com', 'm*********@g****.com' ), + 'support@company.co.uk' => array( 'support@company.co.uk', 's******@c******.co.uk' ), + 'test.user123@domain.org' => array( 'test.user123@domain.org', 't***********@d*****.org' ), + ); + } + + public function test_resend_auth_mail_does_not_resend_if_too_many_attempts(): void { + $sut = new Email_Service(); + + $this->assertFalse( + $sut->resend_auth_email( + new \WP_User(), + array( + 'resend_attempts' => 5, + ), + '' + ) + ); + } + + public function test_resend_auth_mail_sends_mail_and_remembers_2fa_token_successfully(): void { + $user = new \WP_User(); + + $sut = $this->createPartialMock( Email_Service::class, array( 'api_send_auth_email' ) ); + $sut->expects( $this->once() )->method( 'api_send_auth_email' ) + ->with( $user, $this->matchesRegularExpression( '/^[0-9]{6}$/' ) ) + ->willReturn( true ); + + $transient_data = array( + 'resend_attempts' => 0, + ); + + $my_token = 'my_token'; + + $result = $sut->resend_auth_email( $user, $transient_data, $my_token ); + + // Verify the mail was sent + $this->assertTrue( $result, 'Resending auth mail should return true as success indicator.' ); + + // Verify the transient has the expected data + $new_transient = get_transient( Config::TRANSIENT_PREFIX . "_{$my_token}" ); + $this->assertSame( 1, $new_transient['resend_attempts'], 'Resend attempts should be 1.' ); + $this->assertMatchesRegularExpression( '/^[0-9]{6}$/', $new_transient['auth_code'], 'Auth code should be 6 digits.' ); + } + + public function test_api_send_auth_email_returns_false_if_blog_id_not_available(): void { + Jetpack_Options::delete_option( 'id' ); + $sut = new Email_Service(); + $this->assertFalse( $sut->api_send_auth_email( new \WP_User(), '123456' ) ); + } + + public function test_api_send_auth_email_returns_false_if_not_connected(): void { + Jetpack_Options::update_option( 'id', 123 ); + + $connection = $this->getMockBuilder( 'Automattic\Jetpack\Connection\Manager' ) + ->disableOriginalConstructor() + ->getMock(); + + $connection->expects( $this->once() ) + ->method( 'is_connected' ) + ->willReturn( false ); + + $sut = new Email_Service( $connection ); + $this->assertFalse( $sut->api_send_auth_email( new \WP_User(), '123456' ) ); + } + + private function get_connected_connection_manager() { + $connection = $this->getMockBuilder( 'Automattic\Jetpack\Connection\Manager' ) + ->disableOriginalConstructor() + ->getMock(); + + $connection->expects( $this->once() ) + ->method( 'is_connected' ) + ->willReturn( true ); + + return $connection; + } + + public function test_api_send_auth_email_sends_email_successfully(): void { + Jetpack_Options::update_option( 'id', 123 ); + + $sut = $this->getMockBuilder( Email_Service::class ) + ->onlyMethods( array( 'send_email_request' ) ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->getMock(); + + $sut->expects( $this->once() ) + ->method( 'send_email_request' ) + ->with( + 123, + $this->callback( + function ( $body ) { + return $body['user_login'] === 'john.doe' + && $body['user_email'] === 'john.doe@example.com' + && $body['code'] === '123456'; + } + ) + )->willReturn( + array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'email_sent' => true ) ), + ) + ); + + $user = new \WP_User(); + $user->user_login = 'john.doe'; + $user->user_email = 'john.doe@example.com'; + + $this->assertTrue( $sut->api_send_auth_email( $user, '123456' ), 'Email should have been sent.' ); + } + + public function test_api_send_auth_email_returns_false_if_response_is_error(): void { + Jetpack_Options::update_option( 'id', 123 ); + + $sut = $this->getMockBuilder( Email_Service::class ) + ->onlyMethods( array( 'send_email_request' ) ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->getMock(); + + $sut->expects( $this->once() ) + ->method( 'send_email_request' ) + ->willReturn( new \WP_Error( 'some_error' ) ); + + $this->assertFalse( $sut->api_send_auth_email( new \WP_User(), '123456' ) ); + } + + public function test_api_send_auth_email_returns_false_if_response_code_is_not_200(): void { + Jetpack_Options::update_option( 'id', 123 ); + + $sut = $this->getMockBuilder( Email_Service::class ) + ->onlyMethods( array( 'send_email_request' ) ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->getMock(); + + $sut->expects( $this->once() ) + ->method( 'send_email_request' ) + ->willReturn( + array( + 'response' => array( 'code' => 404 ), + 'body' => json_encode( array( 'email_sent' => true ) ), + ) + ); + + $this->assertFalse( $sut->api_send_auth_email( new \WP_User(), '123456' ) ); + } + + public function test_api_send_auth_email_returns_false_if_response_body_is_empty(): void { + Jetpack_Options::update_option( 'id', 123 ); + + $sut = $this->getMockBuilder( Email_Service::class ) + ->onlyMethods( array( 'send_email_request' ) ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->getMock(); + + $sut->expects( $this->once() ) + ->method( 'send_email_request' ) + ->willReturn( + array( + 'response' => array( 'code' => 200 ), + 'body' => '', + ) + ); + + $this->assertFalse( $sut->api_send_auth_email( new \WP_User(), '123456' ) ); + } + + public function test_api_send_auth_email_returns_false_if_response_from_api_is_false(): void { + Jetpack_Options::update_option( 'id', 123 ); + + $sut = $this->getMockBuilder( Email_Service::class ) + ->onlyMethods( array( 'send_email_request' ) ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->getMock(); + + $sut->expects( $this->once() ) + ->method( 'send_email_request' ) + ->willReturn( + array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'email_sent' => false ) ), + ) + ); + + $this->assertFalse( $sut->api_send_auth_email( new \WP_User(), '123456' ) ); + } +} diff --git a/projects/packages/account-protection/tests/php/test-password-detection.php b/projects/packages/account-protection/tests/php/test-password-detection.php new file mode 100644 index 0000000000000..fc9021d53e4f0 --- /dev/null +++ b/projects/packages/account-protection/tests/php/test-password-detection.php @@ -0,0 +1,398 @@ + 'my-token' ) ); + + $sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit' ) ); + $sut->expects( $this->once() ) + ->method( 'redirect_and_exit' ) + ->with( 'http://example.org/wp-login.php?action=password-detection&token=my-token' ); + + $sut->handle_password_detection_validation_error( 'username', $error ); + } + + public function test_login_form_password_detection_does_not_ask_validation_service_if_user_doesnt_require_protection(): void { + $validation_service_mock = $this->createMock( Validation_Service::class ); + $validation_service_mock->expects( $this->never() ) + ->method( 'is_weak_password' ); + + $sut = new Password_Detection( null, $validation_service_mock ); + + $user = new \WP_User(); + $return = $sut->login_form_password_detection( $user, 'pw' ); + $this->assertSame( $user, $return, 'User should be returned.' ); + } + + public function test_login_form_password_detection_does_not_ask_validation_service_if_user_has_wrong_password(): void { + $validation_service_mock = $this->createMock( Validation_Service::class ); + $validation_service_mock->expects( $this->never() ) + ->method( 'is_weak_password' ); + + $sut = new Password_Detection( null, $validation_service_mock ); + + $user = new \WP_User(); + $user->user_pass = 'pw'; + $user->add_cap( 'publish_posts' ); + $return = $sut->login_form_password_detection( $user, 'pw' ); + $this->assertSame( $user, $return, 'User should be returned.' ); + } + + public function test_login_form_password_detection_asks_validation_service_if_user_has_correct_password(): void { + add_filter( 'check_password', '__return_true' ); + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $validation_service_mock->expects( $this->once() ) + ->method( 'is_weak_password' ) + ->with( 'pw' ) + ->willReturn( false ); + + $sut = new Password_Detection( null, $validation_service_mock ); + + $user = new \WP_User(); + $user->user_pass = 'pw'; + $user->add_cap( 'publish_posts' ); + $return = $sut->login_form_password_detection( $user, 'pw' ); + + $this->assertSame( $user, $return, 'User should be returned.' ); + + remove_filter( 'check_password', '__return_true' ); + } + + public function test_login_form_password_detection_sends_email_and_returns_error_for_weak_password(): void { + add_filter( 'check_password', '__return_true' ); + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $validation_service_mock->expects( $this->once() ) + ->method( 'is_weak_password' ) + ->with( 'pw' ) + ->willReturn( true ); + + $auth_code = '123456'; + + $user = new \WP_User(); + $user->user_pass = 'pw'; + $user->add_cap( 'publish_posts' ); + + $email_service_mock = $this->createMock( Email_Service::class ); + $email_service_mock->expects( $this->once() ) + ->method( 'generate_auth_code' ) + ->willReturn( $auth_code ); + $email_service_mock->expects( $this->once() ) + ->method( 'api_send_auth_email' ) + ->with( $user, $auth_code ) + ->willReturn( true ); + + $sut = new Password_Detection( $email_service_mock, $validation_service_mock ); + + $error = $sut->login_form_password_detection( $user, 'pw' ); + + $this->assertInstanceOf( \WP_Error::class, $error, 'Should return a WP_Error object.' ); + $this->assertSame( Config::ERROR_MESSAGE, $error->get_error_message( Config::ERROR_CODE ), 'Should return the correct error message.' ); + $token = $error->get_error_data( Config::ERROR_CODE )['token']; + $this->assertSame( 32, strlen( $token ), 'Token should be 32 characters long.' ); + + remove_filter( 'check_password', '__return_true' ); + } + + public function test_login_form_password_detection_sets_transient_error_if_unable_to_send_mail(): void { + add_filter( 'check_password', '__return_true' ); + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $validation_service_mock->expects( $this->once() ) + ->method( 'is_weak_password' ) + ->with( 'pw' ) + ->willReturn( true ); + + $user = new \WP_User(); + $user->user_pass = 'pw'; + $user->add_cap( 'publish_posts' ); + + $email_service_mock = $this->createMock( Email_Service::class ); + $email_service_mock->expects( $this->once() ) + ->method( 'generate_auth_code' ) + ->willReturn( '123456' ); + $email_service_mock->expects( $this->once() ) + ->method( 'api_send_auth_email' ) + ->with( $user, '123456' ) + ->willReturn( false ); + + $sut = new Password_Detection( $email_service_mock, $validation_service_mock ); + + $sut->login_form_password_detection( $user, 'pw' ); + + $transient_data = get_transient( Config::TRANSIENT_PREFIX . "_error_{$user->ID}" ); + $this->assertSame( 'Failed to send authentication email. Please try again.', $transient_data, 'Should have set the correct error message.' ); + + remove_filter( 'check_password', '__return_true' ); + } + + public function test_render_page_redirects_to_admin_page_if_user_already_logged_in(): void { + $sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit' ) ); + $sut->expects( $this->once() ) + ->method( 'redirect_and_exit' ) + ->with( 'http://example.org/wp-admin/' ); + + $mock_user = $this->createMock( \WP_User::class ); + $mock_user->expects( $this->once() ) + ->method( 'exists' ) + ->willReturn( true ); + + global $current_user; + $previous_current_user = $current_user; + $GLOBALS['current_user'] = $mock_user; + + $sut->render_page(); + + $GLOBALS['current_user'] = $previous_current_user; + } + + public function test_render_page_redirects_to_login_if_transient_data_is_not_available(): void { + $sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit' ) ); + $sut->expects( $this->once() ) + ->method( 'redirect_and_exit' ) + ->with( 'http://example.org/wp-login.php' ); + + $sut->render_page(); + } + + public function test_render_page_redirects_to_login_if_user_with_id_from_transient_does_not_exist(): void { + $_GET['token'] = 'my_cool_token'; + set_transient( Config::TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123 ) ); + + $sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit', 'load_user' ) ); + $sut->expects( $this->once() ) + ->method( 'load_user' ) + ->with( 123 ) + ->willReturn( false ); + $sut->expects( $this->once() ) + ->method( 'redirect_and_exit' ) + ->with( 'http://example.org/wp-login.php' ); + + $sut->render_page(); + + unset( $_GET['token'] ); + } + + public function test_render_page_checks_2fa_code_successfully(): void { + $_GET['token'] = 'my_cool_token'; + $_POST['verify'] = '1'; + $_POST['user_input'] = '123456'; + $_POST['_wpnonce_verify'] = wp_create_nonce( 'verify_action' ); + + set_transient( + Config::TRANSIENT_PREFIX . '_my_cool_token', + array( + 'user_id' => 123, + 'auth_code' => '123456', + ) + ); + + $user = new \WP_User(); + $user->ID = 123; + $user->user_pass = 'pw'; + $user->add_cap( 'publish_posts' ); + + $sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit', 'load_user' ) ); + $sut->expects( $this->once() ) + ->method( 'load_user' ) + ->with( 123 ) + ->willReturn( $user ); + $sut->expects( $this->once() ) + ->method( 'redirect_and_exit' ) + ->with( 'http://example.org/wp-admin/' ); + + $calls = 0; + $call_counter = function () use ( &$calls ) { + ++$calls; + return false; + }; + + add_filter( 'send_auth_cookies', $call_counter ); + + $sut->render_page(); + + remove_filter( 'send_auth_cookies', $call_counter ); + + $this->assertSame( 1, $calls, 'send_auth_cookies filter should have been called once' ); + + unset( $_GET['token'] ); + unset( $_POST['verify'] ); + unset( $_POST['user_input'] ); + unset( $_POST['_wpnonce_verify'] ); + } + + public function test_render_page_sets_transient_error_if_2fa_code_is_wrong(): void { + $_GET['token'] = 'my_cool_token'; + $_POST['verify'] = '1'; + $_POST['user_input'] = '837467'; // intentionally wrong + $_POST['_wpnonce_verify'] = wp_create_nonce( 'verify_action' ); + + set_transient( + Config::TRANSIENT_PREFIX . '_my_cool_token', + array( + 'user_id' => 123, + 'auth_code' => '123456', + ) + ); + + $user = new \WP_User(); + $user->ID = 123; + $user->user_pass = 'pw'; + $user->add_cap( 'publish_posts' ); + + $sut = $this->createPartialMock( Password_Detection::class, array( 'load_user' ) ); + $sut->expects( $this->once() ) + ->method( 'load_user' ) + ->with( 123 ) + ->willReturn( $user ); + + $sut->render_page(); + + $error = get_transient( Config::TRANSIENT_PREFIX . '_error_123' ); + + $this->assertSame( 'Authentication code verification failed. Please try again.', $error, 'Error message is not as expected.' ); + + unset( $_GET['token'] ); + unset( $_POST['verify'] ); + unset( $_POST['user_input'] ); + unset( $_POST['_wpnonce_verify'] ); + } + + public function test_render_page_sets_transient_error_if_2fa_nonce_is_wrong(): void { + $_GET['token'] = 'my_cool_token'; + $_POST['verify'] = '1'; + $_POST['_wpnonce_verify'] = 'wrong nonce'; // intentionally wrong + + set_transient( + Config::TRANSIENT_PREFIX . '_my_cool_token', + array( + 'user_id' => 123, + 'auth_code' => '123456', + ) + ); + + $user = new \WP_User(); + $user->ID = 123; + $user->user_pass = 'pw'; + $user->add_cap( 'publish_posts' ); + + $sut = $this->createPartialMock( Password_Detection::class, array( 'load_user', 'render_content' ) ); + $sut->expects( $this->once() ) + ->method( 'load_user' ) + ->with( 123 ) + ->willReturn( $user ); + $sut->expects( $this->once() ) + ->method( 'render_content' ) + ->with( $user, 'my_cool_token' ); + + $sut->render_page(); + + $error = get_transient( Config::TRANSIENT_PREFIX . '_error_123' ); + + $this->assertSame( 'Verify nonce verification failed. Please try again.', $error, 'Error message is not as expected.' ); + + unset( $_GET['token'] ); + unset( $_POST['verify'] ); + unset( $_POST['_wpnonce_verify'] ); + } + + public function test_render_page_resends_mail_successfully(): void { + $_GET['token'] = 'my_cool_token'; + $_GET['resend_email'] = '1'; + $_GET['_wpnonce'] = wp_create_nonce( 'resend_email_nonce' ); + + set_transient( + Config::TRANSIENT_PREFIX . '_my_cool_token', + array( + 'user_id' => 123, + 'auth_code' => '123456', + ) + ); + + $user = new \WP_User(); + $user->ID = 123; + $user->user_pass = 'pw'; + $user->add_cap( 'publish_posts' ); + + $email_service_mock = $this->createMock( Email_Service::class ); + $email_service_mock->expects( $this->once() ) + ->method( 'resend_auth_email' ) + ->with( + $user, + array( + 'user_id' => 123, + 'auth_code' => '123456', + ), + 'my_cool_token' + ) + ->willReturn( true ); + + $sut = $this->getMockBuilder( Password_Detection::class ) + ->setConstructorArgs( array( $email_service_mock ) ) + ->onlyMethods( array( 'load_user', 'redirect_and_exit' ) ) + ->getMock(); + $sut->expects( $this->once() ) + ->method( 'load_user' ) + ->with( 123 ) + ->willReturn( $user ); + $sut->expects( $this->once() ) + ->method( 'redirect_and_exit' ) + ->with( 'http://example.org/wp-login.php?action=password-detection&token=my_cool_token' ); + + $sut->render_page(); + + unset( $_GET['token'] ); + unset( $_GET['resend_email'] ); + unset( $_GET['_wpnonce'] ); + } + + public function test_render_content_explains_the_2fa_form(): void { + $user = new \WP_User(); + $user->ID = 123; + $user->user_email = 'john.doe@example.com'; + + $sut = $this->getMockBuilder( Password_Detection::class ) + ->onlyMethods( array( 'exit' ) ) + ->getMock(); + + $sut->expects( $this->once() ) + ->method( 'exit' ); + + $sentence = htmlentities( + 'We\'ve noticed that your current password may have been compromised in a public leak. To keep your account safe, we\'ve added an extra layer of security.', + ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 + ); + + $this->expectOutputRegex( '@' . $sentence . '@' ); + $sut->render_content( $user, 'my_cool_token' ); + } + + public function test_render_content_shows_transient_error_if_set(): void { + $error_message = 'This is a error message to test things with.'; + + set_transient( Config::TRANSIENT_PREFIX . '_error_123', $error_message ); + + $user = new \WP_User(); + $user->ID = 123; + $user->user_email = 'john.doe@example.com'; + + $sut = $this->getMockBuilder( Password_Detection::class ) + ->onlyMethods( array( 'exit' ) ) + ->getMock(); + + $sut->expects( $this->once() ) + ->method( 'exit' ); + + $this->expectOutputRegex( '@' . $error_message . '@' ); + $sut->render_content( $user, 'my_cool_token' ); + } +} diff --git a/projects/packages/account-protection/tests/php/test-validation-service.php b/projects/packages/account-protection/tests/php/test-validation-service.php new file mode 100644 index 0000000000000..24ffbb99b0166 --- /dev/null +++ b/projects/packages/account-protection/tests/php/test-validation-service.php @@ -0,0 +1,163 @@ +getMockBuilder( 'Automattic\Jetpack\Connection\Manager' ) + ->disableOriginalConstructor() + ->getMock(); + + $connection->expects( $this->once() ) + ->method( 'is_connected' ) + ->willReturn( false ); + + $validation_service = new Validation_Service( $connection ); + $this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) ); + } + + private function get_connected_connection_manager() { + $connection = $this->getMockBuilder( 'Automattic\Jetpack\Connection\Manager' ) + ->disableOriginalConstructor() + ->getMock(); + + $connection->expects( $this->once() ) + ->method( 'is_connected' ) + ->willReturn( true ); + + return $connection; + } + + public function test_returns_false_if_remote_request_fails() { + + $validation_service = $this->getMockBuilder( Validation_Service::class ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->onlyMethods( array( 'request_suffixes' ) ) + ->getMock(); + + $validation_service->expects( $this->once() ) + ->method( 'request_suffixes' ) + ->willReturn( new \WP_Error( 'something went wrong' ) ); + + $this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) ); + } + + public function test_returns_false_if_response_code_is_not_200() { + + $validation_service = $this->getMockBuilder( Validation_Service::class ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->onlyMethods( array( 'request_suffixes' ) ) + ->getMock(); + + $validation_service->expects( $this->once() ) + ->method( 'request_suffixes' ) + ->willReturn( + array( + 'response' => array( + 'code' => 404, + ), + ) + ); + + $this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) ); + } + + public function test_returns_false_if_response_code_is_empty_body() { + $validation_service = $this->getMockBuilder( Validation_Service::class ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->onlyMethods( array( 'request_suffixes' ) ) + ->getMock(); + + $validation_service->expects( $this->once() ) + ->method( 'request_suffixes' ) + ->willReturn( + array( + 'response' => array( + 'code' => 200, + ), + 'body' => '', + ) + ); + + $this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) ); + } + + public function test_returns_true_if_password_is_compromised() { + $validation_service = $this->getMockBuilder( Validation_Service::class ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->onlyMethods( array( 'request_suffixes' ) ) + ->getMock(); + + $validation_service->expects( $this->once() ) + ->method( 'request_suffixes' ) + ->willReturn( + array( + 'response' => array( + 'code' => 200, + ), + 'body' => json_encode( + array( + 'compromised' => array( 'c90fcfd699f0ddbdcb30c2c9183d2d933ea' ), + ) + ), + ) + ); + + $this->assertTrue( $validation_service->is_weak_password( 'somepassword' ) ); + } + + public function test_returns_true_if_password_is_common() { + $validation_service = $this->getMockBuilder( Validation_Service::class ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->onlyMethods( array( 'request_suffixes' ) ) + ->getMock(); + + $validation_service->expects( $this->once() ) + ->method( 'request_suffixes' ) + ->willReturn( + array( + 'response' => array( + 'code' => 200, + ), + 'body' => json_encode( + array( + 'common' => array( 'c90fcfd699f0ddbdcb30c2c9183d2d933ea' ), + ) + ), + ) + ); + + $this->assertTrue( $validation_service->is_weak_password( 'somepassword' ) ); + } + + public function test_returns_false_if_password_is_not_weak() { + $validation_service = $this->getMockBuilder( Validation_Service::class ) + ->setConstructorArgs( array( $this->get_connected_connection_manager() ) ) + ->onlyMethods( array( 'request_suffixes' ) ) + ->getMock(); + + $validation_service->expects( $this->once() ) + ->method( 'request_suffixes' ) + ->willReturn( + array( + 'response' => array( + 'code' => 200, + ), + 'body' => json_encode( + array( + 'compromised' => array( '1234' ), + 'common' => array(), + ) + ), + ) + ); + + $this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) ); + } +} From 022c552b8fbcd34353b88b8fb43c81959abb453f Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:00:02 -0800 Subject: [PATCH 09/50] Jetpack: Update Account Protection copy (#41404) * Update description copy * Update copy --- .../client/security/account-protection.jsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/security/account-protection.jsx b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx index 4f8f51b250f10..62cafbe901792 100644 --- a/projects/plugins/jetpack/_inc/client/security/account-protection.jsx +++ b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx @@ -1,3 +1,4 @@ +import { createInterpolateElement } from '@wordpress/element'; import { __, _x } from '@wordpress/i18n'; import React, { Component } from 'react'; import { withModuleSettingsFormHelpers } from 'components/module-settings/with-module-settings-form-helpers'; @@ -23,10 +24,24 @@ const AccountProtectionComponent = class extends Component { disableInSiteConnectionMode module={ this.props.getModule( 'account-protection' ) } support={ { - text: this.props.getModule( 'account-protection' ).description, - link: '#', // TODO: Update this redirect URL + text: __( + 'Jetpack recommends enabling this feature. Please be mindful of the risks', + 'jetpack' + ), + link: '#', // TODO: Update link once doc is avaiable } } > +

+ { createInterpolateElement( + __( + 'Enabling this setting enhances account security by detecting compromised passwords and enforcing additional verification when needed. Learn more about how this protects your site.', + 'jetpack' + ), + { + link: , // TODO: Update link once doc is avaiable + } + ) } +

{ __( - 'Protect your site with enhanced password detection and profile management security.', + 'Protect your site with advanced password detection and profile management protection.', 'jetpack' ) } From 6534ee80ddb620f1267d3cd3210e0114eabfba15 Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:01:17 -0800 Subject: [PATCH 10/50] Protect: Update Account Protection copy (#41402) * Update copy as per design input * Fix typo * Remove learn more copy to maintain consistency with Jetpack * Update copy * Optimize css --- .../protect/src/js/routes/settings/index.jsx | 18 ++++++++++++------ .../src/js/routes/settings/styles.module.scss | 19 +++++-------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/projects/plugins/protect/src/js/routes/settings/index.jsx b/projects/plugins/protect/src/js/routes/settings/index.jsx index 459bc542586b1..c96d684d75111 100644 --- a/projects/plugins/protect/src/js/routes/settings/index.jsx +++ b/projects/plugins/protect/src/js/routes/settings/index.jsx @@ -7,7 +7,7 @@ import { } from '@automattic/jetpack-components'; import { createInterpolateElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Icon, warning } from '@wordpress/icons'; +import { Icon, info } from '@wordpress/icons'; import React, { useCallback } from 'react'; import AdminPage from '../../components/admin-page'; import useAccountProtectionQuery from '../../data/account-protection/use-account-protection-query'; @@ -54,24 +54,30 @@ const SettingsPage = () => { { createInterpolateElement( __( - 'Protect your site with enhanced password detection and profile management security.', + 'Enabling this setting enhances account security by detecting compromised passwords and enforcing additional verification when needed. Learn more about how this protects your site.', 'jetpack-protect' ), { - link: , // TODO: Update this redirect URL + link: , // TODO: Update this redirect URL once document exists } ) } + + { __( + 'Protect your site with advanced password detection and profile management protection.', + 'jetpack-protect' + ) } + { ! accountProtectionIsEnabled && ( - - + + { createInterpolateElement( __( 'Jetpack recommends activating this setting. Please be mindful of the risks.', 'jetpack-protect' ), { - link: , // TODO: Update this redirect URL + link: , // TODO: Update this redirect URL once document exists } ) } diff --git a/projects/plugins/protect/src/js/routes/settings/styles.module.scss b/projects/plugins/protect/src/js/routes/settings/styles.module.scss index 3b43e636ffd96..8fd4cb29ea9a2 100644 --- a/projects/plugins/protect/src/js/routes/settings/styles.module.scss +++ b/projects/plugins/protect/src/js/routes/settings/styles.module.scss @@ -23,7 +23,8 @@ width: 100%; } - &__description { + &__description, + &__info { a { color: inherit; @@ -33,20 +34,10 @@ } } - &__warning { - color: var( --jp-red-50 ); - - a { - color: var( --jp-red-50 ); - - &:hover { - color: var( --jp-red-70 ) - } - } - + &__info { svg { - fill: var( --jp-red-50 ); - margin-bottom: calc( -1 * var( --spacing-base ) * 3/4 ); // -6px + fill: var( --jp-gray-70 ); + margin-bottom: calc( -0.75 * var( --spacing-base ) ); // -6px margin-right: calc( var( --spacing-base ) / 4 ); // 2px } } From 1c68a00f146427b5f77ae246e615c7187fae0810 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Sun, 2 Feb 2025 13:07:35 -0800 Subject: [PATCH 11/50] Add wordbless dep, update consumer lock files --- projects/packages/account-protection/composer.json | 2 +- projects/plugins/jetpack/composer.lock | 10 ++-------- projects/plugins/protect/composer.lock | 10 ++-------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/projects/packages/account-protection/composer.json b/projects/packages/account-protection/composer.json index 6fe92cb6eb03b..1bbe2c9d45402 100644 --- a/projects/packages/account-protection/composer.json +++ b/projects/packages/account-protection/composer.json @@ -11,7 +11,7 @@ "require-dev": { "yoast/phpunit-polyfills": "^1.1.1", "automattic/jetpack-changelogger": "@dev", - "automattic/jetpack-test-environment": "@dev" + "automattic/wordbless": "^0.4.2" }, "autoload": { "classmap": [ diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index c73b858672879..9c63d7acb5859 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -65,7 +65,7 @@ "dist": { "type": "path", "url": "../../packages/account-protection", - "reference": "badc1036552f26a900a69608df22284e603981ed" + "reference": "6268e14d1032bf8de48544964fa3224a17f7f129" }, "require": { "automattic/jetpack-connection": "@dev", @@ -74,7 +74,7 @@ }, "require-dev": { "automattic/jetpack-changelogger": "@dev", - "automattic/wordbless": "dev-master", + "automattic/wordbless": "^0.4.2", "yoast/phpunit-polyfills": "^1.1.1" }, "suggest": { @@ -110,12 +110,6 @@ "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\"" ], diff --git a/projects/plugins/protect/composer.lock b/projects/plugins/protect/composer.lock index 2e15530947409..a5ac4e50621dc 100644 --- a/projects/plugins/protect/composer.lock +++ b/projects/plugins/protect/composer.lock @@ -65,7 +65,7 @@ "dist": { "type": "path", "url": "../../packages/account-protection", - "reference": "badc1036552f26a900a69608df22284e603981ed" + "reference": "6268e14d1032bf8de48544964fa3224a17f7f129" }, "require": { "automattic/jetpack-connection": "@dev", @@ -74,7 +74,7 @@ }, "require-dev": { "automattic/jetpack-changelogger": "@dev", - "automattic/wordbless": "dev-master", + "automattic/wordbless": "^0.4.2", "yoast/phpunit-polyfills": "^1.1.1" }, "suggest": { @@ -110,12 +110,6 @@ "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\"" ], From 708190e9f71cdb546ae2454ba4482d85b597b983 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Sun, 2 Feb 2025 13:19:10 -0800 Subject: [PATCH 12/50] Allow core installer --- projects/packages/account-protection/composer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/projects/packages/account-protection/composer.json b/projects/packages/account-protection/composer.json index 1bbe2c9d45402..df609b5a06b34 100644 --- a/projects/packages/account-protection/composer.json +++ b/projects/packages/account-protection/composer.json @@ -58,5 +58,10 @@ }, "suggest": { "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "config": { + "allow-plugins": { + "roots/wordpress-core-installer": true + } } } From 2a7cf79a5872fc12c1831b3a16d96896e7bf4b73 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Sun, 2 Feb 2025 13:23:06 -0800 Subject: [PATCH 13/50] Update lock files --- projects/plugins/jetpack/composer.lock | 2 +- projects/plugins/protect/composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 9c63d7acb5859..2a4efba7ce30c 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -65,7 +65,7 @@ "dist": { "type": "path", "url": "../../packages/account-protection", - "reference": "6268e14d1032bf8de48544964fa3224a17f7f129" + "reference": "56916f64dcadec2e82dce12ad49beac8b9a3e018" }, "require": { "automattic/jetpack-connection": "@dev", diff --git a/projects/plugins/protect/composer.lock b/projects/plugins/protect/composer.lock index a5ac4e50621dc..0c51aa086258c 100644 --- a/projects/plugins/protect/composer.lock +++ b/projects/plugins/protect/composer.lock @@ -65,7 +65,7 @@ "dist": { "type": "path", "url": "../../packages/account-protection", - "reference": "6268e14d1032bf8de48544964fa3224a17f7f129" + "reference": "56916f64dcadec2e82dce12ad49beac8b9a3e018" }, "require": { "automattic/jetpack-connection": "@dev", From d64ef26e06ff3a4e669d2a8d957b795983c661db Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:33:20 -0800 Subject: [PATCH 14/50] Account Protection: Fix invalid auth early return (#41652) * Remove early return after auth code validation to render error * Fix tests * Improve tests * Reapply return type and type hints * Fix spacing --- .../src/class-password-detection.php | 1 - .../tests/php/test-password-detection.php | 10 ++++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php index 6517fb27a4d1e..613b982f5a0be 100644 --- a/projects/packages/account-protection/src/class-password-detection.php +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -173,7 +173,6 @@ public function render_page() { $user_input = isset( $_POST['user_input'] ) ? sanitize_text_field( wp_unslash( $_POST['user_input'] ) ) : null; $this->handle_auth_form_submission( $user, $token, $transient_data['auth_code'] ?? null, $user_input ); - return; } else { $this->set_transient_error( $user->ID, __( 'Verify nonce verification failed. Please try again.', 'jetpack-account-protection' ) ); } diff --git a/projects/packages/account-protection/tests/php/test-password-detection.php b/projects/packages/account-protection/tests/php/test-password-detection.php index fc9021d53e4f0..f8e7aac1d3866 100644 --- a/projects/packages/account-protection/tests/php/test-password-detection.php +++ b/projects/packages/account-protection/tests/php/test-password-detection.php @@ -201,7 +201,7 @@ public function test_render_page_checks_2fa_code_successfully(): void { $user->user_pass = 'pw'; $user->add_cap( 'publish_posts' ); - $sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit', 'load_user' ) ); + $sut = $this->createPartialMock( Password_Detection::class, array( 'load_user', 'redirect_and_exit', 'render_content' ) ); $sut->expects( $this->once() ) ->method( 'load_user' ) ->with( 123 ) @@ -209,6 +209,9 @@ public function test_render_page_checks_2fa_code_successfully(): void { $sut->expects( $this->once() ) ->method( 'redirect_and_exit' ) ->with( 'http://example.org/wp-admin/' ); + $sut->expects( $this->once() ) + ->method( 'render_content' ) + ->with( $user, 'my_cool_token' ); $calls = 0; $call_counter = function () use ( &$calls ) { @@ -249,11 +252,14 @@ public function test_render_page_sets_transient_error_if_2fa_code_is_wrong(): vo $user->user_pass = 'pw'; $user->add_cap( 'publish_posts' ); - $sut = $this->createPartialMock( Password_Detection::class, array( 'load_user' ) ); + $sut = $this->createPartialMock( Password_Detection::class, array( 'load_user', 'render_content' ) ); $sut->expects( $this->once() ) ->method( 'load_user' ) ->with( 123 ) ->willReturn( $user ); + $sut->expects( $this->once() ) + ->method( 'render_content' ) + ->with( $user, 'my_cool_token' ); $sut->render_page(); From 588e1d94898cf566e26a032c9cb2d4e9deb5fb2d Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:36:15 -0800 Subject: [PATCH 15/50] Reset to base (#41691) --- .../account-protection/src/css/password-detection.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/projects/packages/account-protection/src/css/password-detection.css b/projects/packages/account-protection/src/css/password-detection.css index d568caa5b0017..be2bdcfdc0c21 100644 --- a/projects/packages/account-protection/src/css/password-detection.css +++ b/projects/packages/account-protection/src/css/password-detection.css @@ -1,6 +1,7 @@ .password-detection-wrapper { background-color: #f0f0f1; min-width: 0; + margin: auto 30px; color: #3c434a; font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 13px; @@ -9,7 +10,7 @@ .password-detection { background: #fff; - width: 420px; + max-width: 420px; margin: 124px auto; padding: 26px 24px; font-weight: 400; @@ -37,9 +38,10 @@ } .action-input { - height: 30px; + height: 36px; cursor: pointer; - width: 412px; + width: 100%; + box-sizing: border-box; text-indent: 8px; &::placeholder { From 8139f4b6422b12e17eba067efd2c35ee6f29e49d Mon Sep 17 00:00:00 2001 From: Nate Weller Date: Tue, 11 Feb 2025 19:29:47 -0700 Subject: [PATCH 16/50] Account Protection: Restore JetpackTestEnvironment (#41736) * Restore JetpackTestEnvironment * Update lock files --- projects/packages/account-protection/composer.json | 7 +------ projects/plugins/jetpack/composer.lock | 4 ++-- projects/plugins/protect/composer.lock | 4 ++-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/projects/packages/account-protection/composer.json b/projects/packages/account-protection/composer.json index df609b5a06b34..6fe92cb6eb03b 100644 --- a/projects/packages/account-protection/composer.json +++ b/projects/packages/account-protection/composer.json @@ -11,7 +11,7 @@ "require-dev": { "yoast/phpunit-polyfills": "^1.1.1", "automattic/jetpack-changelogger": "@dev", - "automattic/wordbless": "^0.4.2" + "automattic/jetpack-test-environment": "@dev" }, "autoload": { "classmap": [ @@ -58,10 +58,5 @@ }, "suggest": { "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." - }, - "config": { - "allow-plugins": { - "roots/wordpress-core-installer": true - } } } diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 2a4efba7ce30c..2f3454a68c9f8 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -65,7 +65,7 @@ "dist": { "type": "path", "url": "../../packages/account-protection", - "reference": "56916f64dcadec2e82dce12ad49beac8b9a3e018" + "reference": "b58e285f9a88c029d4026b6b03756d7a69f7f6c2" }, "require": { "automattic/jetpack-connection": "@dev", @@ -74,7 +74,7 @@ }, "require-dev": { "automattic/jetpack-changelogger": "@dev", - "automattic/wordbless": "^0.4.2", + "automattic/jetpack-test-environment": "@dev", "yoast/phpunit-polyfills": "^1.1.1" }, "suggest": { diff --git a/projects/plugins/protect/composer.lock b/projects/plugins/protect/composer.lock index 0c51aa086258c..2d14cdcae8ed1 100644 --- a/projects/plugins/protect/composer.lock +++ b/projects/plugins/protect/composer.lock @@ -65,7 +65,7 @@ "dist": { "type": "path", "url": "../../packages/account-protection", - "reference": "56916f64dcadec2e82dce12ad49beac8b9a3e018" + "reference": "b58e285f9a88c029d4026b6b03756d7a69f7f6c2" }, "require": { "automattic/jetpack-connection": "@dev", @@ -74,7 +74,7 @@ }, "require-dev": { "automattic/jetpack-changelogger": "@dev", - "automattic/wordbless": "^0.4.2", + "automattic/jetpack-test-environment": "@dev", "yoast/phpunit-polyfills": "^1.1.1" }, "suggest": { From 27634b30124596aa274c349630842b6975da618e Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:48:19 -0800 Subject: [PATCH 17/50] Account Protection: Add password validation (#41401) * 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 --- .../src/class-account-protection.php | 34 +++- .../account-protection/src/class-config.php | 11 +- .../src/class-email-service.php | 4 +- .../src/class-password-detection.php | 35 ++-- .../src/class-password-manager.php | 155 +++++++++++++++ .../src/class-validation-service.php | 188 ++++++++++++++++++ .../tests/php/test-account-protection.php | 5 + .../tests/php/test-email-service.php | 4 +- .../tests/php/test-password-detection.php | 24 +-- .../tests/php/test-password-manager.php | 134 +++++++++++++ .../tests/php/test-validation-service.php | 83 +++++++- 11 files changed, 637 insertions(+), 40 deletions(-) create mode 100644 projects/packages/account-protection/src/class-password-manager.php create mode 100644 projects/packages/account-protection/tests/php/test-password-manager.php diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 7156849eabac3..9e028ee993853 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -16,6 +16,13 @@ class Account_Protection { const PACKAGE_VERSION = '0.1.0-alpha'; const ACCOUNT_PROTECTION_MODULE_NAME = 'account-protection'; + /** + * Flag to track if hooks have been registered. + * + * @var bool + */ + private static $hooks_registered = false; + /** * Modules instance. * @@ -30,15 +37,24 @@ class Account_Protection { */ private $password_detection; + /** + * Password manager instance + * + * @var Password_Manager + */ + private $password_manager; + /** * Account_Protection constructor. * * @param ?Modules $modules Modules instance. * @param ?Password_Detection $password_detection Password detection instance. + * @param ?Password_Manager $password_manager Validation service instance. */ - public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null ) { + public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null, ?Password_Manager $password_manager = null ) { $this->modules = $modules ?? new Modules(); $this->password_detection = $password_detection ?? new Password_Detection(); + $this->password_manager = $password_manager ?? new Password_Manager(); } /** @@ -47,11 +63,17 @@ public function __construct( ?Modules $modules = null, ?Password_Detection $pass * @return void */ public function init(): void { + if ( self::$hooks_registered ) { + return; + } + $this->register_hooks(); if ( $this->is_enabled() ) { $this->register_runtime_hooks(); } + + self::$hooks_registered = true; } /** @@ -83,6 +105,16 @@ protected function register_runtime_hooks(): void { // Add password detection flow add_action( 'login_form_password-detection', array( $this->password_detection, 'render_page' ), 10, 2 ); + add_action( 'wp_enqueue_scripts', array( $this->password_detection, 'enqueue_styles' ) ); + + // Add password validation + + add_action( 'user_profile_update_errors', array( $this->password_manager, 'validate_profile_update' ), 10, 3 ); + add_action( 'validate_password_reset', array( $this->password_manager, 'validate_password_reset' ), 10, 2 ); + + // Update recent passwords list + add_action( 'profile_update', array( $this->password_manager, 'on_profile_update' ), 10, 2 ); + add_action( 'after_password_reset', array( $this->password_manager, 'on_password_reset' ), 10, 1 ); } /** diff --git a/projects/packages/account-protection/src/class-config.php b/projects/packages/account-protection/src/class-config.php index 99d461441752a..97020daac1f90 100644 --- a/projects/packages/account-protection/src/class-config.php +++ b/projects/packages/account-protection/src/class-config.php @@ -11,9 +11,10 @@ * Class Config */ class Config { - public const TRANSIENT_PREFIX = 'password_detection'; - public const ERROR_CODE = 'password_detection_validation_error'; - public const ERROR_MESSAGE = 'Password validation failed.'; - public const EMAIL_SENT_EXPIRATION = 600; // 10 minutes - public const MAX_RESEND_ATTEMPTS = 3; + public const PASSWORD_DETECTION_TRANSIENT_PREFIX = 'password_detection'; + public const PASSWORD_DETECTION_ERROR_CODE = 'password_detection_validation_error'; + public const PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION = 600; // 10 minutes + public const PASSWORD_DETECTION_MAX_RESEND_ATTEMPTS = 3; + + public const VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY = 'jetpack_account_protection_recent_password_hashes'; } diff --git a/projects/packages/account-protection/src/class-email-service.php b/projects/packages/account-protection/src/class-email-service.php index 4ca7e2fecf21e..aa07beff85d40 100644 --- a/projects/packages/account-protection/src/class-email-service.php +++ b/projects/packages/account-protection/src/class-email-service.php @@ -95,7 +95,7 @@ protected function send_email_request( int $blog_id, array $body ) { * @return bool True if the email was resent successfully, false otherwise. */ public function resend_auth_email( \WP_User $user, array $transient_data, string $token ): bool { - if ( $transient_data['resend_attempts'] >= Config::MAX_RESEND_ATTEMPTS ) { + if ( $transient_data['resend_attempts'] >= Config::PASSWORD_DETECTION_MAX_RESEND_ATTEMPTS ) { return false; } @@ -108,7 +108,7 @@ public function resend_auth_email( \WP_User $user, array $transient_data, string ++$transient_data['resend_attempts']; - if ( ! set_transient( Config::TRANSIENT_PREFIX . "_{$token}", $transient_data, Config::EMAIL_SENT_EXPIRATION ) ) { + if ( ! set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$token}", $transient_data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) { return false; } diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php index 613b982f5a0be..95f8a0c288975 100644 --- a/projects/packages/account-protection/src/class-password-detection.php +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -50,7 +50,6 @@ public function login_form_password_detection( $user, string $password ) { } if ( $this->validation_service->is_weak_password( $password ) ) { - // TODO: Every time the user logs in we generate a new token based transient. This might not be ideal. $transient = $this->generate_and_store_transient_data( $user->ID ); $email_sent = $this->email_service->api_send_auth_email( $user, $transient['auth_code'] ); @@ -59,8 +58,8 @@ public function login_form_password_detection( $user, string $password ) { } return new \WP_Error( - Config::ERROR_CODE, - Config::ERROR_MESSAGE, + Config::PASSWORD_DETECTION_ERROR_CODE, + __( 'Password validation failed.', 'jetpack-account-protection' ), array( 'token' => $transient['token'] ) ); } @@ -126,7 +125,7 @@ public function render_page() { } $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : null; - $transient_data = get_transient( Config::TRANSIENT_PREFIX . "_{$token}" ); + $transient_data = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$token}" ); if ( ! $transient_data ) { $this->redirect_to_login(); // @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise. @@ -141,8 +140,6 @@ public function render_page() { return; } - add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) ); - // Handle resend email request if ( isset( $_GET['resend_email'] ) && $_GET['resend_email'] === '1' ) { if ( isset( $_GET['_wpnonce'] ) @@ -152,7 +149,7 @@ public function render_page() { if ( ! $email_resent ) { $message = __( 'Failed to resend authentication email. Please try again.', 'jetpack-account-protection' ); - if ( $transient_data['resend_attempts'] >= Config::MAX_RESEND_ATTEMPTS ) { + if ( $transient_data['resend_attempts'] >= Config::PASSWORD_DETECTION_MAX_RESEND_ATTEMPTS ) { $message = __( 'Resend limit exceeded. Please try again later.', 'jetpack-account-protection' ); } @@ -190,7 +187,7 @@ public function render_page() { * @return void */ public function render_content( \WP_User $user, string $token ): void { - $transient_key = Config::TRANSIENT_PREFIX . "_error_{$user->ID}"; + $transient_key = Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_error_{$user->ID}"; $error_message = get_transient( $transient_key ); delete_transient( $transient_key ); @@ -286,7 +283,7 @@ private function generate_and_store_transient_data( int $user_id ): array { 'resend_attempts' => 0, ); - $transient_set = set_transient( Config::TRANSIENT_PREFIX . "_{$token}", $data, Config::EMAIL_SENT_EXPIRATION ); + $transient_set = set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$token}", $data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ); if ( ! $transient_set ) { $this->set_transient_error( $user_id, __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ) ); } @@ -330,7 +327,7 @@ private function get_redirect_url( string $token ): string { private function handle_auth_form_submission( \WP_User $user, string $token, string $auth_code, string $user_input ): void { if ( $auth_code && $auth_code === $user_input ) { // TODO: Ensure all transient are also removed on module and/or plugin deactivation - delete_transient( Config::TRANSIENT_PREFIX . "_{$token}" ); + delete_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$token}" ); wp_set_auth_cookie( $user->ID, true ); // TODO: Notify user to update their password/redirect to password update page $this->redirect_and_exit( admin_url() ); @@ -349,7 +346,7 @@ private function handle_auth_form_submission( \WP_User $user, string $token, str * @return void */ private function set_transient_error( int $user_id, string $message, int $expiration = 60 ): void { - set_transient( Config::TRANSIENT_PREFIX . "_error_{$user_id}", $message, $expiration ); + set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_error_{$user_id}", $message, $expiration ); } /** @@ -358,11 +355,15 @@ private function set_transient_error( int $user_id, string $message, int $expira * @return void */ public function enqueue_styles(): void { - wp_enqueue_style( - 'password-detection-styles', - plugin_dir_url( __FILE__ ) . 'css/password-detection.css', - array(), - Account_Protection::PACKAGE_VERSION - ); + // No nonce verification necessary - reading only + // phpcs:disable WordPress.Security.NonceVerification + if ( ( isset( $GLOBALS['pagenow'] ) && $GLOBALS['pagenow'] === 'wp-login.php' ) && ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) ) { + wp_enqueue_style( + 'password-detection-styles', + plugin_dir_url( __FILE__ ) . 'css/password-detection.css', + array(), + Account_Protection::PACKAGE_VERSION + ); + } } } diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php new file mode 100644 index 0000000000000..42fa92d615c36 --- /dev/null +++ b/projects/packages/account-protection/src/class-password-manager.php @@ -0,0 +1,155 @@ +validation_service = $validation_service ?? new Validation_Service(); + } + + /** + * Validate the profile update. + * + * @param \WP_Error $errors The error object. + * @param bool $update Whether the user is being updated. + * @param \stdClass $user A copy of the new user object. + * + * @return void + */ + public function validate_profile_update( \WP_Error $errors, bool $update, \stdClass $user ): void { + if ( empty( $user->user_pass ) ) { + return; + } + + // If bypass is enabled, do not validate the password + // phpcs:ignore WordPress.Security.NonceVerification + if ( isset( $_POST['pw_weak'] ) && 'on' === $_POST['pw_weak'] ) { + return; + } + + if ( $update ) { + if ( $this->validation_service->is_current_password( $user->ID, $user->user_pass ) ) { + $errors->add( 'password_error', __( 'Error: The password was used recently.', 'jetpack-account-protection' ) ); + return; + } + } + + $context = $update ? 'update' : 'create-user'; + $error = $this->validation_service->return_first_validation_error( $user, $user->user_pass, $context ); + + if ( ! empty( $error ) ) { + $errors->add( 'password_error', $error ); + return; + } + } + + /** + * Validate the password reset. + * + * @param \WP_Error $errors The error object. + * @param \WP_User|\WP_Error $user The user object. + * + * @return void + */ + public function validate_password_reset( \WP_Error $errors, $user ): void { + if ( is_wp_error( $user ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification + if ( empty( $_POST['pass1'] ) ) { + return; + } + + // If bypass is enabled, do not validate the password + // phpcs:ignore WordPress.Security.NonceVerification + if ( isset( $_POST['pw_weak'] ) && 'on' === $_POST['pw_weak'] ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification + $password = sanitize_text_field( wp_unslash( $_POST['pass1'] ) ); + if ( $this->validation_service->is_current_password( $user->ID, $password ) ) { + $errors->add( 'password_error', __( 'Error: The password was used recently.', 'jetpack-account-protection' ) ); + return; + } + + $error = $this->validation_service->return_first_validation_error( $user, $password, 'reset' ); + if ( ! empty( $error ) ) { + $errors->add( 'password_error', $error ); + return; + } + } + + /** + * Handle the profile update. + * + * @param int $user_id The user ID. + * @param \WP_User $old_user_data Object containing user data prior to update. + * + * @return void + */ + public function on_profile_update( int $user_id, \WP_User $old_user_data ): void { + // phpcs:ignore WordPress.Security.NonceVerification + if ( isset( $_POST['action'] ) && $_POST['action'] === 'update' ) { + $this->save_recent_password( $user_id, $old_user_data->user_pass ); + } + } + + /** + * Handle the password reset. + * + * @param \WP_User $user The user. + * + * @return void + */ + public function on_password_reset( $user ): void { + $this->save_recent_password( $user->ID, $user->user_pass ); + } + + /** + * Save the new password hash to the user's recent passwords list. + * + * @param int $user_id The user ID. + * @param string $password_hash The password hash to store. + * + * @return void + */ + public function save_recent_password( int $user_id, string $password_hash ): void { + $recent_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); + + if ( ! is_array( $recent_passwords ) ) { + $recent_passwords = array(); + } + + if ( in_array( $password_hash, $recent_passwords, true ) ) { + return; + } + + // Add the new hashed password and keep only the last 10 + array_unshift( $recent_passwords, $password_hash ); + $recent_passwords = array_slice( $recent_passwords, 0, 10 ); + + update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, $recent_passwords ); + } +} diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index 6430bb0515bc8..a64b5065c4fff 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -49,6 +49,153 @@ protected function request_suffixes( string $password_prefix ) { ); } + /** + * Return all validation errors. + * + * @param \WP_User|\stdClass $user The user object or a copy. + * @param string $password The password to check. + * + * @return array An array of validation errors (if any). + */ + public function return_all_validation_errors( $user, string $password ): array { + $errors = array(); + + if ( $this->contains_backslash( $password ) ) { + $errors[] = __( 'Doesn\'t contain a backslash (\\) character', 'jetpack-account-protection' ); + } + + if ( $this->is_invalid_length( $password ) ) { + $errors[] = __( 'Between 6 and 150 characters', 'jetpack-account-protection' ); + } + + if ( $this->matches_user_data( $user, $password ) ) { + $errors[] = __( 'Doesn\'t match user data', 'jetpack-account-protection' ); + } + + if ( $this->is_recent_password( $user->ID, $password ) ) { + $errors[] = __( 'Not used recently', 'jetpack-account-protection' ); + } + + if ( $this->is_weak_password( $password ) ) { + $errors[] = __( 'Not a leaked password.', 'jetpack-account-protection' ); + } + + return $errors; + } + + /** + * Return first validation error. + * + * @param \WP_User|\stdClass $user The user object or a copy. + * @param string $password The password to check. + * @param 'create-user'|'update'|'reset' $context The context the validation is run in. + * + * @return string The first validation errors (if any). + */ + public function return_first_validation_error( $user, string $password, $context ): string { + // Reset form includes this validation in core + if ( 'reset' !== $context ) { + if ( empty( $password ) ) { + return __( 'Error: The password cannot be a space or all spaces.', 'jetpack-account-protection' ); + } + } + + // Update and create-user forms include this validation in core + if ( 'reset' === $context ) { + if ( $this->contains_backslash( $password ) ) { + return __( 'Error: The password cannot contain a backslash (\\) character.', 'jetpack-account-protection' ); + } + } + + if ( $this->is_invalid_length( $password ) ) { + return __( 'Error: The password must be between 6 and 150 characters.', 'jetpack-account-protection' ); + } + + if ( $this->matches_user_data( $user, $password ) ) { + return __( 'Error: The password matches user data.', 'jetpack-account-protection' ); + } + + if ( 'create-user' !== $context ) { + if ( $this->is_recent_password( $user->ID, $password ) ) { + return __( 'Error: The password was used recently.', 'jetpack-account-protection' ); + } + } + + if ( $this->is_weak_password( $password ) ) { + return __( 'Error: The password was found in a public leak.', 'jetpack-account-protection' ); + } + + return ''; + } + + /** + * Check if the password contains a backslash. + * + * @param string $password The password to check. + * + * @return bool True if the password contains a backslash, false otherwise. + */ + public function contains_backslash( string $password ): bool { + return strpos( $password, '\\' ) !== false; + } + + /** + * Check if the password length is within the allowed range. + * + * @param string $password The password to check. + * + * @return bool True if the password is between 6 and 150 characters, false otherwise. + */ + public function is_invalid_length( string $password ): bool { + $length = strlen( $password ); + return $length < 6 || $length > 150; + } + + /** + * Check if the password matches any user data. + * + * @param \WP_User|\stdClass $user The user. + * @param string $password The password to check. + * + * @return bool True if the password matches any user data, false otherwise. + */ + public function matches_user_data( $user, string $password ): bool { + if ( ! $user ) { + return false; + } + + $email_parts = explode( '@', $user->user_email ); // test@example.com + $email_username = $email_parts[0]; // 'test' + $email_domain = $email_parts[1]; // 'example.com' + $email_provider = explode( '.', $email_domain )[0]; // 'example' + + $user_data = array( + $user->user_login ?? '', + $user->display_name ?? '', + $user->first_name ?? '', + $user->last_name ?? '', + $user->user_email ?? '', + $email_username ?? '', + $email_provider ?? '', + $user->nickname ?? '', + ); + + $password_lower = strtolower( $password ); + + foreach ( $user_data as $data ) { + // Skip if $data is 3 characters or less. + if ( strlen( $data ) <= 3 ) { + continue; + } + + if ( ! empty( $data ) && strpos( $password_lower, strtolower( $data ) ) !== false ) { + return true; + } + } + + return false; + } + /** * Check if the password is in the list of compromised/common passwords. * @@ -85,4 +232,45 @@ public function is_weak_password( string $password ): bool { return false; } + + /** + * Check if the password is the current password for the user. + * + * @param int $user_id The user ID. + * @param string $password The password to check. + * + * @return bool True if the password is the current password, false otherwise. + */ + public function is_current_password( int $user_id, string $password ): bool { + $user = get_userdata( $user_id ); + if ( ! $user ) { + return false; + } + + return wp_check_password( $password, $user->user_pass, $user->ID ); + } + + /** + * Check if the password has been used recently by the user. + * + * @param int $user_id The user ID. + * @param string $password The password to check. + * + * @return bool True if the password hash was recently used, false otherwise. + */ + public function is_recent_password( int $user_id, string $password ): bool { + $recent_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); + + if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) { + return false; + } + + foreach ( $recent_passwords as $old_hashed_password ) { + if ( wp_check_password( $password, $old_hashed_password ) ) { + return true; + } + } + + return false; + } } diff --git a/projects/packages/account-protection/tests/php/test-account-protection.php b/projects/packages/account-protection/tests/php/test-account-protection.php index 0bbb8c831d5ce..fbb481b26023b 100644 --- a/projects/packages/account-protection/tests/php/test-account-protection.php +++ b/projects/packages/account-protection/tests/php/test-account-protection.php @@ -37,6 +37,11 @@ public function test_init_registers_hooks_and_runtime_hooks_if_module_enabled(): } public function test_init_registers_hooks_but_not_runtime_hooks_if_module_disabled(): void { + $reflection = new \ReflectionClass( Account_Protection::class ); + $property = $reflection->getProperty( 'hooks_registered' ); + $property->setAccessible( true ); + $property->setValue( false ); + $sut = $this->createPartialMock( Account_Protection::class, array( 'is_enabled', 'register_hooks', 'register_runtime_hooks' ) ); $sut->expects( $this->once() ) ->method( 'is_enabled' ) diff --git a/projects/packages/account-protection/tests/php/test-email-service.php b/projects/packages/account-protection/tests/php/test-email-service.php index a71d02fafa8c4..795f3aceb6582 100644 --- a/projects/packages/account-protection/tests/php/test-email-service.php +++ b/projects/packages/account-protection/tests/php/test-email-service.php @@ -6,7 +6,7 @@ use WorDBless\BaseTestCase; /** - * Tests for the Account_Protection class. + * Tests for the Email_Service class. */ class Email_Service_Test extends BaseTestCase { @@ -66,7 +66,7 @@ public function test_resend_auth_mail_sends_mail_and_remembers_2fa_token_success $this->assertTrue( $result, 'Resending auth mail should return true as success indicator.' ); // Verify the transient has the expected data - $new_transient = get_transient( Config::TRANSIENT_PREFIX . "_{$my_token}" ); + $new_transient = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_{$my_token}" ); $this->assertSame( 1, $new_transient['resend_attempts'], 'Resend attempts should be 1.' ); $this->assertMatchesRegularExpression( '/^[0-9]{6}$/', $new_transient['auth_code'], 'Auth code should be 6 digits.' ); } diff --git a/projects/packages/account-protection/tests/php/test-password-detection.php b/projects/packages/account-protection/tests/php/test-password-detection.php index f8e7aac1d3866..0417a6f6f0b29 100644 --- a/projects/packages/account-protection/tests/php/test-password-detection.php +++ b/projects/packages/account-protection/tests/php/test-password-detection.php @@ -10,7 +10,7 @@ class Password_Detection_Test extends BaseTestCase { public function test_handle_password_detection_validation_error_redirects_to_login(): void { - $error = new \WP_Error( Config::ERROR_CODE, Config::ERROR_MESSAGE, array( 'token' => 'my-token' ) ); + $error = new \WP_Error( Config::PASSWORD_DETECTION_ERROR_CODE, 'Password validation failed.', array( 'token' => 'my-token' ) ); $sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit' ) ); $sut->expects( $this->once() ) @@ -96,8 +96,8 @@ public function test_login_form_password_detection_sends_email_and_returns_error $error = $sut->login_form_password_detection( $user, 'pw' ); $this->assertInstanceOf( \WP_Error::class, $error, 'Should return a WP_Error object.' ); - $this->assertSame( Config::ERROR_MESSAGE, $error->get_error_message( Config::ERROR_CODE ), 'Should return the correct error message.' ); - $token = $error->get_error_data( Config::ERROR_CODE )['token']; + $this->assertSame( 'Password validation failed.', $error->get_error_message( Config::PASSWORD_DETECTION_ERROR_CODE ), 'Should return the correct error message.' ); + $token = $error->get_error_data( Config::PASSWORD_DETECTION_ERROR_CODE )['token']; $this->assertSame( 32, strlen( $token ), 'Token should be 32 characters long.' ); remove_filter( 'check_password', '__return_true' ); @@ -129,7 +129,7 @@ public function test_login_form_password_detection_sets_transient_error_if_unabl $sut->login_form_password_detection( $user, 'pw' ); - $transient_data = get_transient( Config::TRANSIENT_PREFIX . "_error_{$user->ID}" ); + $transient_data = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . "_error_{$user->ID}" ); $this->assertSame( 'Failed to send authentication email. Please try again.', $transient_data, 'Should have set the correct error message.' ); remove_filter( 'check_password', '__return_true' ); @@ -166,7 +166,7 @@ public function test_render_page_redirects_to_login_if_transient_data_is_not_ava public function test_render_page_redirects_to_login_if_user_with_id_from_transient_does_not_exist(): void { $_GET['token'] = 'my_cool_token'; - set_transient( Config::TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123 ) ); + set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123 ) ); $sut = $this->createPartialMock( Password_Detection::class, array( 'redirect_and_exit', 'load_user' ) ); $sut->expects( $this->once() ) @@ -189,7 +189,7 @@ public function test_render_page_checks_2fa_code_successfully(): void { $_POST['_wpnonce_verify'] = wp_create_nonce( 'verify_action' ); set_transient( - Config::TRANSIENT_PREFIX . '_my_cool_token', + Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123, 'auth_code' => '123456', @@ -240,7 +240,7 @@ public function test_render_page_sets_transient_error_if_2fa_code_is_wrong(): vo $_POST['_wpnonce_verify'] = wp_create_nonce( 'verify_action' ); set_transient( - Config::TRANSIENT_PREFIX . '_my_cool_token', + Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123, 'auth_code' => '123456', @@ -263,7 +263,7 @@ public function test_render_page_sets_transient_error_if_2fa_code_is_wrong(): vo $sut->render_page(); - $error = get_transient( Config::TRANSIENT_PREFIX . '_error_123' ); + $error = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_error_123' ); $this->assertSame( 'Authentication code verification failed. Please try again.', $error, 'Error message is not as expected.' ); @@ -279,7 +279,7 @@ public function test_render_page_sets_transient_error_if_2fa_nonce_is_wrong(): v $_POST['_wpnonce_verify'] = 'wrong nonce'; // intentionally wrong set_transient( - Config::TRANSIENT_PREFIX . '_my_cool_token', + Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123, 'auth_code' => '123456', @@ -302,7 +302,7 @@ public function test_render_page_sets_transient_error_if_2fa_nonce_is_wrong(): v $sut->render_page(); - $error = get_transient( Config::TRANSIENT_PREFIX . '_error_123' ); + $error = get_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_error_123' ); $this->assertSame( 'Verify nonce verification failed. Please try again.', $error, 'Error message is not as expected.' ); @@ -317,7 +317,7 @@ public function test_render_page_resends_mail_successfully(): void { $_GET['_wpnonce'] = wp_create_nonce( 'resend_email_nonce' ); set_transient( - Config::TRANSIENT_PREFIX . '_my_cool_token', + Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_my_cool_token', array( 'user_id' => 123, 'auth_code' => '123456', @@ -385,7 +385,7 @@ public function test_render_content_explains_the_2fa_form(): void { public function test_render_content_shows_transient_error_if_set(): void { $error_message = 'This is a error message to test things with.'; - set_transient( Config::TRANSIENT_PREFIX . '_error_123', $error_message ); + set_transient( Config::PASSWORD_DETECTION_TRANSIENT_PREFIX . '_error_123', $error_message ); $user = new \WP_User(); $user->ID = 123; diff --git a/projects/packages/account-protection/tests/php/test-password-manager.php b/projects/packages/account-protection/tests/php/test-password-manager.php new file mode 100644 index 0000000000000..718e2d4c528d1 --- /dev/null +++ b/projects/packages/account-protection/tests/php/test-password-manager.php @@ -0,0 +1,134 @@ + 'admin', + 'user_pass' => wp_hash_password( 'oldpassword' ), + 'user_email' => 'admin@admin.com', + 'role' => 'administrator', + ) + ); + + $errors = new \WP_Error(); + $user = (object) array( + 'ID' => $user_id, + 'user_pass' => wp_hash_password( 'newpassword' ), + ); + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $validation_service_mock->expects( $this->once() ) + ->method( 'return_first_validation_error' ) + ->willReturn( '' ); + + $password_manager_mock = new Password_Manager( $validation_service_mock ); + $password_manager_mock->validate_profile_update( $errors, true, $user ); + + $this->assertFalse( $errors->has_errors() ); + } + + public function test_validate_password_reset_with_invalid_user() { + $errors = new \WP_Error(); + $user = new \WP_Error( 'invalid_user', 'Invalid user.' ); + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $password_manager_mock = new Password_Manager( $validation_service_mock ); + + $password_manager_mock->validate_password_reset( $errors, $user ); + + $this->assertFalse( $errors->has_errors() ); + } + + public function test_validate_password_reset_with_valid_user() { + $_POST['pass1'] = 'securepassword'; + + $errors = new \WP_Error(); + $user = new \WP_User(); + $user->ID = 1; + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $validation_service_mock->expects( $this->once() ) + ->method( 'return_first_validation_error' ) + ->willReturn( '' ); + + $password_manager_mock = new Password_Manager( $validation_service_mock ); + $password_manager_mock->validate_password_reset( $errors, $user ); + + $this->assertFalse( $errors->has_errors() ); + } + + public function test_on_profile_update_with_valid_nonce() { + $_POST['action'] = 'update'; + + $user_id = 1; + $old_user_data = new \WP_User(); + $old_user_data->user_pass = 'oldhashedpassword'; + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $password_manager_mock = $this->getMockBuilder( Password_Manager::class ) + ->setConstructorArgs( array( $validation_service_mock ) ) + ->onlyMethods( array( 'save_recent_password' ) ) + ->getMock(); + + $password_manager_mock->expects( $this->once() ) + ->method( 'save_recent_password' ) + ->with( $user_id, 'oldhashedpassword' ); + + $password_manager_mock->on_profile_update( + $user_id, + $old_user_data + ); + } + + public function test_on_password_reset_saves_recent_password() { + $user = new \WP_User(); + $user->ID = 1; + $user->user_pass = 'hashedpassword'; + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $password_manager_mock = $this->getMockBuilder( Password_Manager::class ) + ->setConstructorArgs( array( $validation_service_mock ) ) + ->onlyMethods( array( 'save_recent_password' ) ) + ->getMock(); + + $password_manager_mock->expects( $this->once() ) + ->method( 'save_recent_password' ) + ->with( $user->ID, 'hashedpassword' ); + + $password_manager_mock->on_password_reset( $user ); + } + + public function test_save_recent_password_stores_last_10_passwords() { + $user_id = 1; + $password_hashes = array( + 'hash1', + 'hash2', + 'hash3', + 'hash4', + 'hash5', + 'hash6', + 'hash7', + 'hash8', + 'hash9', + 'hash10', + ); + + update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, $password_hashes ); + + $validation_service_mock = $this->createMock( Validation_Service::class ); + $password_manager_mock = new Password_Manager( $validation_service_mock ); + $password_manager_mock->save_recent_password( $user_id, 'new_hash' ); + + $stored_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); + $this->assertCount( 10, $stored_passwords ); + $this->assertEquals( 'new_hash', $stored_passwords[0] ); + } +} diff --git a/projects/packages/account-protection/tests/php/test-validation-service.php b/projects/packages/account-protection/tests/php/test-validation-service.php index 24ffbb99b0166..b7b8696bbb87a 100644 --- a/projects/packages/account-protection/tests/php/test-validation-service.php +++ b/projects/packages/account-protection/tests/php/test-validation-service.php @@ -22,11 +22,17 @@ public function test_returns_false_if_not_connected() { $this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) ); } - private function get_connected_connection_manager() { + private function get_connection_manager() { $connection = $this->getMockBuilder( 'Automattic\Jetpack\Connection\Manager' ) ->disableOriginalConstructor() ->getMock(); + return $connection; + } + + private function get_connected_connection_manager() { + $connection = $this->get_connection_manager(); + $connection->expects( $this->once() ) ->method( 'is_connected' ) ->willReturn( true ); @@ -160,4 +166,79 @@ public function test_returns_false_if_password_is_not_weak() { $this->assertFalse( $validation_service->is_weak_password( 'somepassword' ) ); } + + public function test_returns_true_if_password_is_current_password() { + $user = wp_insert_user( + array( + 'user_login' => 'admin', + 'user_pass' => 'somepassword', + 'user_email' => 'admin@admin.com', + 'role' => 'administrator', + ) + ); + + $validation_service = new Validation_Service( $this->get_connection_manager() ); + $this->assertTrue( $validation_service->is_current_password( $user, 'somepassword' ) ); + } + + public function test_returns_false_if_password_is_not_current_password() { + $user = wp_insert_user( + array( + 'user_login' => 'admin', + 'user_pass' => 'somepassword', + 'user_email' => 'admin@admin.com', + 'role' => 'administrator', + ) + ); + + $validation_service = new Validation_Service( $this->get_connection_manager() ); + $this->assertFalse( $validation_service->is_current_password( $user, 'anotherpassword' ) ); + } + + public function test_returns_true_if_password_was_recently_used() { + $user_id = 1; + $password_hash = wp_hash_password( 'somepassword' ); + + update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $password_hash ) ); + + $validation_service = new Validation_Service( $this->get_connection_manager() ); + $this->assertTrue( $validation_service->is_recent_password( $user_id, 'somepassword' ) ); + } + + public function test_returns_false_if_password_was_not_recently_used() { + $user_id = 1; + $password_hash = wp_hash_password( 'somepassword' ); + + update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $password_hash ) ); + + $validation_service = new Validation_Service( $this->get_connection_manager() ); + $this->assertFalse( $validation_service->is_recent_password( $user_id, 'anotherpassword' ) ); + } + + public function test_returns_true_if_password_matches_user_data() { + $user = new \WP_User(); + $user->user_email = 'example@wordpress.com'; + + $validation_service = new Validation_Service( $this->get_connection_manager() ); + $this->assertTrue( $validation_service->matches_user_data( $user, 'WordPress' ) ); + } + + public function test_returns_false_if_password_is_too_short() { + $short_password = 'short'; + + $validation_service = new Validation_Service( $this->get_connection_manager() ); + $this->assertTrue( $validation_service->is_invalid_length( $short_password ) ); + } + + public function test_returns_false_if_password_is_too_long() { + $long_password = str_repeat( 'a', 151 ); + + $validation_service = new Validation_Service( $this->get_connection_manager() ); + $this->assertTrue( $validation_service->is_invalid_length( $long_password ) ); + } + + public function test_returns_true_if_password_contains_backslash() { + $validation_service = new Validation_Service( $this->get_connection_manager() ); + $this->assertTrue( $validation_service->contains_backslash( 'password\\' ) ); + } } From 5a29c1dfdba5024dd96dc3cbd77e209040d901ff Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:35:44 -0800 Subject: [PATCH 18/50] Protect: Fix Account Protection initial toggle state on activation (#41699) * Invalidate account protection query on connection * Ensure account protection query exists before invalidating --- projects/plugins/protect/src/class-jetpack-protect.php | 4 ++-- .../data/account-protection/use-account-protection-query.ts | 3 +-- .../plugins/protect/src/js/data/use-connection-mutation.ts | 4 ++++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/projects/plugins/protect/src/class-jetpack-protect.php b/projects/plugins/protect/src/class-jetpack-protect.php index eb3ba6dd2787c..334128d0bc41c 100644 --- a/projects/plugins/protect/src/class-jetpack-protect.php +++ b/projects/plugins/protect/src/class-jetpack-protect.php @@ -287,9 +287,9 @@ public static function do_plugin_activation_activities() { */ public static function activate_modules() { delete_option( self::JETPACK_PROTECT_ACTIVATION_OPTION ); + ( new Modules() )->activate( self::JETPACK_ACCOUNT_PROTECTION_MODULE_SLUG, false, false ); ( 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 ); } /** @@ -347,7 +347,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, self::JETPACK_ACCOUNT_PROTECTION_MODULE_SLUG ), $modules ); + return array_merge( array( self::JETPACK_ACCOUNT_PROTECTION_MODULE_SLUG, self::JETPACK_WAF_MODULE_SLUG, self::JETPACK_BRUTE_FORCE_PROTECTION_MODULE_SLUG ), $modules ); } /** 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 index 8bd32f8ddf951..e8e55a93a2feb 100644 --- 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 @@ -1,5 +1,4 @@ import { useQuery, UseQueryResult } from '@tanstack/react-query'; -import camelize from 'camelize'; import API from '../../api'; import { QUERY_ACCOUNT_PROTECTION_KEY } from '../../constants'; @@ -12,6 +11,6 @@ export default function useAccountProtectionQuery(): UseQueryResult { return useQuery( { queryKey: [ QUERY_ACCOUNT_PROTECTION_KEY ], queryFn: API.getAccountProtection, - initialData: camelize( window?.jetpackProtectInitialState?.accountProtection ), + initialData: !! window?.jetpackProtectInitialState?.accountProtection, } ); } diff --git a/projects/plugins/protect/src/js/data/use-connection-mutation.ts b/projects/plugins/protect/src/js/data/use-connection-mutation.ts index 2bd7d1fb7e772..2fdd6ee76c424 100644 --- a/projects/plugins/protect/src/js/data/use-connection-mutation.ts +++ b/projects/plugins/protect/src/js/data/use-connection-mutation.ts @@ -9,6 +9,7 @@ import { QUERY_SCAN_STATUS_KEY, QUERY_WAF_KEY, SCAN_STATUS_OPTIMISTICALLY_SCANNING, + QUERY_ACCOUNT_PROTECTION_KEY, } from '../constants'; import useNotices from '../hooks/use-notices'; @@ -44,6 +45,9 @@ export default function useConnectSiteMutation(): UseMutationResult { queryClient.invalidateQueries( { queryKey: [ QUERY_WAF_KEY ] } ); queryClient.invalidateQueries( { queryKey: [ QUERY_HAS_PLAN_KEY ] } ); queryClient.invalidateQueries( { queryKey: [ QUERY_CREDENTIALS_KEY ] } ); + + queryClient.ensureQueryData( { queryKey: [ QUERY_ACCOUNT_PROTECTION_KEY ] } ); + queryClient.invalidateQueries( { queryKey: [ QUERY_ACCOUNT_PROTECTION_KEY ] } ); }, onError: () => { showErrorNotice( __( 'Could not connect site.', 'jetpack-protect' ) ); From ac4f572d6b37997b9ecd4d13591f83202c09983b Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:37:02 -0800 Subject: [PATCH 19/50] Fix BFP recovery process conflict (#41739) --- .../account-protection/src/class-password-detection.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php index 95f8a0c288975..32f15aaee04b0 100644 --- a/projects/packages/account-protection/src/class-password-detection.php +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -49,6 +49,12 @@ public function login_form_password_detection( $user, string $password ) { return $user; } + // Skip if we're validating a Brute force protection recovery token + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['validate_jetpack_protect_recovery'] ) ) { + return $user; + } + if ( $this->validation_service->is_weak_password( $password ) ) { $transient = $this->generate_and_store_transient_data( $user->ID ); From 3d1cc9420f7bbbb833e68adeae0bf51652f53a1d Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Thu, 13 Feb 2025 08:38:25 -0800 Subject: [PATCH 20/50] Account Protection: Add custom password strength meter (#41485) * 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 * Add foundation for the custom password strength meter * Fix tests * Add ajax request for password validation * Improve matches user data logic * Remove password reset nonce verification code * Updates and fixes * Updates and improvements * Include tests for new validation methods * Include tests for new validation methods * Add password manager class tests * Add password validation status handling and hook up ajax callback * Update variables names * Add loading state * Remove todos * Add nonce to ajax request * 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 * Improve logic * Improvements and reorg * Add info popovers * Add core req to initial validation state * Generalize core info popover message * Fix core strength meter status * Remove testing code * Ensure save enabled when appropriate * Update todos * Center validation items * Fix tests * Save alt approach * Fix styling, centralize core references * Reorg * Use global pagenow for context, restrict user specific check to profile updates * Compartmentalize generating and appending validation meter and status initial states * Optimization and reorg improvements * Remove todos * Remove unneeded comments * Ensure info popover fits in all form views * Fix test * Fix test * Update methods, removes nonce checks, fix tests * Fix test * Remove comment * Fix bindEvents * Correct colors * Add aria-live attr to strength-meter * Remove core input mods and use custom selectors to apply strength meter margins * Update core validation item message, and display only on failure * Add clarifying comment * Remove unnecessary user->ID check, and redundant method --- .../account-protection/src/assets/check.svg | 4 + .../account-protection/src/assets/cross.svg | 4 + .../account-protection/src/assets/info.svg | 3 + .../src/assets/jetpack-logo.svg | 2 + .../account-protection/src/assets/loading.svg | 12 + .../src/class-account-protection.php | 32 +- .../account-protection/src/class-config.php | 9 +- .../src/class-password-detection.php | 18 +- .../src/class-password-manager.php | 23 +- .../src/class-password-strength-meter.php | 142 ++++++++ .../src/class-validation-service.php | 143 +++++--- .../src/css/strength-meter.css | 101 ++++++ .../src/js/jetpack-password-strength-meter.js | 331 ++++++++++++++++++ .../tests/php/test-password-manager.php | 8 +- .../tests/php/test-validation-service.php | 18 +- .../client/security/account-protection.jsx | 2 +- 16 files changed, 757 insertions(+), 95 deletions(-) create mode 100644 projects/packages/account-protection/src/assets/check.svg create mode 100644 projects/packages/account-protection/src/assets/cross.svg create mode 100644 projects/packages/account-protection/src/assets/info.svg create mode 100644 projects/packages/account-protection/src/assets/loading.svg create mode 100644 projects/packages/account-protection/src/class-password-strength-meter.php create mode 100644 projects/packages/account-protection/src/css/strength-meter.css create mode 100644 projects/packages/account-protection/src/js/jetpack-password-strength-meter.js diff --git a/projects/packages/account-protection/src/assets/check.svg b/projects/packages/account-protection/src/assets/check.svg new file mode 100644 index 0000000000000..d88457419a8b1 --- /dev/null +++ b/projects/packages/account-protection/src/assets/check.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/cross.svg b/projects/packages/account-protection/src/assets/cross.svg new file mode 100644 index 0000000000000..3c33e4931cada --- /dev/null +++ b/projects/packages/account-protection/src/assets/cross.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/info.svg b/projects/packages/account-protection/src/assets/info.svg new file mode 100644 index 0000000000000..67e27b83571a6 --- /dev/null +++ b/projects/packages/account-protection/src/assets/info.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/jetpack-logo.svg b/projects/packages/account-protection/src/assets/jetpack-logo.svg index b91e3c5c216f5..dd6ad79d05a91 100644 --- a/projects/packages/account-protection/src/assets/jetpack-logo.svg +++ b/projects/packages/account-protection/src/assets/jetpack-logo.svg @@ -3,10 +3,12 @@ x="0px" y="0px" height="32px" + viewBox='0 0 118 32' aria-labelledby="jetpack-logo" role="img" > "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 9e028ee993853..b1eb031854e26 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -44,17 +44,26 @@ class Account_Protection { */ private $password_manager; + /** + * Password_Strength_Meter instance + * + * @var Password_Strength_Meter + */ + private $password_strength_meter; + /** * Account_Protection constructor. * - * @param ?Modules $modules Modules instance. - * @param ?Password_Detection $password_detection Password detection instance. - * @param ?Password_Manager $password_manager Validation service instance. + * @param ?Modules $modules Modules instance. + * @param ?Password_Detection $password_detection Password detection instance. + * @param ?Password_Manager $password_manager Password manager instance. + * @param ?Password_Strength_Meter $password_strength_meter Password strength meter instance. */ - 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(); + public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null, ?Password_Manager $password_manager = null, ?Password_Strength_Meter $password_strength_meter = null ) { + $this->modules = $modules ?? new Modules(); + $this->password_detection = $password_detection ?? new Password_Detection(); + $this->password_manager = $password_manager ?? new Password_Manager(); + $this->password_strength_meter = $password_strength_meter ?? new Password_Strength_Meter(); } /** @@ -108,13 +117,20 @@ protected function register_runtime_hooks(): void { 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 ); + + // Enqueue password strength meter scripts + add_action( 'admin_enqueue_scripts', array( $this->password_strength_meter, 'enqueue_jetpack_password_strength_meter_profile_script' ) ); + add_action( 'login_enqueue_scripts', array( $this->password_strength_meter, 'enqueue_jetpack_password_strength_meter_reset_script' ) ); + + // AJAX endpoint for password validation + add_action( 'wp_ajax_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); + add_action( 'wp_ajax_nopriv_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); } /** diff --git a/projects/packages/account-protection/src/class-config.php b/projects/packages/account-protection/src/class-config.php index 97020daac1f90..54ea114ba99eb 100644 --- a/projects/packages/account-protection/src/class-config.php +++ b/projects/packages/account-protection/src/class-config.php @@ -11,10 +11,17 @@ * Class Config */ class Config { + // Password Detection Constants 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'; + // Password Manager Constants + public const PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY = 'jetpack_account_protection_recent_password_hashes'; + public const PASSWORD_MANAGER_RECENT_PASSWORDS_LIMIT = 10; + + // Validation Service Constants + public const VALIDATION_SERVICE_MIN_LENGTH = 6; + public const VALIDATION_SERVICE_MAX_LENGTH = 150; } diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php index 32f15aaee04b0..e8348082324c0 100644 --- a/projects/packages/account-protection/src/class-password-detection.php +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -361,15 +361,19 @@ private function set_transient_error( int $user_id, string $message, int $expira * @return void */ public function enqueue_styles(): void { + global $pagenow; + if ( ! isset( $pagenow ) || $pagenow !== 'wp-login.php' ) { + return; + } // 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 - ); + if ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) { + wp_enqueue_style( + 'password-detection-styles', + plugin_dir_url( __FILE__ ) . 'css/password-detection.css', + array(), + Account_Protection::PACKAGE_VERSION + ); } } } diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php index 42fa92d615c36..bebb3debf31cf 100644 --- a/projects/packages/account-protection/src/class-password-manager.php +++ b/projects/packages/account-protection/src/class-password-manager.php @@ -47,15 +47,7 @@ public function validate_profile_update( \WP_Error $errors, bool $update, \stdCl return; } - if ( $update ) { - if ( $this->validation_service->is_current_password( $user->ID, $user->user_pass ) ) { - $errors->add( 'password_error', __( 'Error: The password was used recently.', 'jetpack-account-protection' ) ); - return; - } - } - - $context = $update ? 'update' : 'create-user'; - $error = $this->validation_service->return_first_validation_error( $user, $user->user_pass, $context ); + $error = $this->validation_service->get_first_validation_error( $user->user_pass, true, $user ); if ( ! empty( $error ) ) { $errors->add( 'password_error', $error ); @@ -89,12 +81,7 @@ public function validate_password_reset( \WP_Error $errors, $user ): void { // phpcs:ignore WordPress.Security.NonceVerification $password = sanitize_text_field( wp_unslash( $_POST['pass1'] ) ); - if ( $this->validation_service->is_current_password( $user->ID, $password ) ) { - $errors->add( 'password_error', __( 'Error: The password was used recently.', 'jetpack-account-protection' ) ); - return; - } - - $error = $this->validation_service->return_first_validation_error( $user, $password, 'reset' ); + $error = $this->validation_service->get_first_validation_error( $password ); if ( ! empty( $error ) ) { $errors->add( 'password_error', $error ); return; @@ -136,7 +123,7 @@ public function on_password_reset( $user ): void { * @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 ); + $recent_passwords = get_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); if ( ! is_array( $recent_passwords ) ) { $recent_passwords = array(); @@ -148,8 +135,8 @@ public function save_recent_password( int $user_id, string $password_hash ): voi // 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 ); + $recent_passwords = array_slice( $recent_passwords, 0, Config::PASSWORD_MANAGER_RECENT_PASSWORDS_LIMIT ); - update_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, $recent_passwords ); + update_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, $recent_passwords ); } } diff --git a/projects/packages/account-protection/src/class-password-strength-meter.php b/projects/packages/account-protection/src/class-password-strength-meter.php new file mode 100644 index 0000000000000..fbeda04429e37 --- /dev/null +++ b/projects/packages/account-protection/src/class-password-strength-meter.php @@ -0,0 +1,142 @@ +validation_service = $validation_service ?? new Validation_Service(); + } + + /** + * AJAX endpoint for password validation. + * + * @return void + */ + public function validate_password_ajax(): void { + if ( ! isset( $_POST['password'] ) ) { + wp_send_json_error( array( 'message' => __( 'No password provided.', 'jetpack-account-protection' ) ) ); + } + + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'validate_password_nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'jetpack-account-protection' ) ) ); + } + + $user_specific = false; + if ( isset( $_POST['user_specific'] ) ) { + $user_specific = filter_var( sanitize_text_field( wp_unslash( $_POST['user_specific'] ) ), FILTER_VALIDATE_BOOLEAN ); + } + + $password = sanitize_text_field( wp_unslash( $_POST['password'] ) ); + $state = $this->validation_service->get_validation_state( $password, $user_specific ); + + wp_send_json_success( array( 'state' => $state ) ); + } + + /** + * Enqueue the password strength meter script on the profile page. + * + * @return void + */ + public function enqueue_jetpack_password_strength_meter_profile_script(): void { + global $pagenow; + + if ( ! isset( $pagenow ) || ! in_array( $pagenow, array( 'profile.php', 'user-new.php', 'user-edit.php' ), true ) ) { + return; + } + + $this->enqueue_script(); + $this->enqueue_styles(); + + // Only profile page should run user specific checks. + $this->localize_jetpack_data( 'profile.php' === $pagenow ); + } + + /** + * Enqueue the password strength meter script on the reset password page. + * + * @return void + */ + public function enqueue_jetpack_password_strength_meter_reset_script(): void { + // No nonce verification necessary as the action includes a robust verification process + // phpcs:disable WordPress.Security.NonceVerification + if ( isset( $_GET['action'] ) && ( 'rp' === $_GET['action'] || 'resetpass' === $_GET['action'] ) ) { + $this->enqueue_script(); + $this->enqueue_styles(); + $this->localize_jetpack_data(); + } + } + + /** + * Localize the Jetpack data for the password strength meter. + * + * @param bool $user_specific Whether or not to run user specific checks. + * + * @return void + */ + public function localize_jetpack_data( bool $user_specific = false ): void { + wp_localize_script( + 'jetpack-password-strength-meter', + 'jetpackData', + array( + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'validate_password_nonce' ), + 'userSpecific' => $user_specific, + 'logo' => plugin_dir_url( __FILE__ ) . 'assets/jetpack-logo.svg', + 'infoIcon' => plugin_dir_url( __FILE__ ) . 'assets/info.svg', + 'checkIcon' => plugin_dir_url( __FILE__ ) . 'assets/check.svg', + 'crossIcon' => plugin_dir_url( __FILE__ ) . 'assets/cross.svg', + 'loadingIcon' => plugin_dir_url( __FILE__ ) . 'assets/loading.svg', + 'validationInitialState' => $this->validation_service->get_validation_initial_state( $user_specific ), + ) + ); + } + + /** + * Enqueue the password strength meter script. + * + * @return void + */ + public function enqueue_script(): void { + wp_enqueue_script( + 'jetpack-password-strength-meter', + plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', + array( 'jquery' ), + Account_Protection::PACKAGE_VERSION, + true + ); + } + + /** + * Enqueue the password strength meter styles. + * + * @return void + */ + public function enqueue_styles(): void { + wp_enqueue_style( + 'strength-meter-styles', + plugin_dir_url( __FILE__ ) . 'css/strength-meter.css', + array(), + Account_Protection::PACKAGE_VERSION + ); + } +} diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index a64b5065c4fff..bd83739e513ef 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -50,58 +50,95 @@ protected function request_suffixes( string $password_prefix ) { } /** - * Return all validation errors. + * Return validation initial state. * - * @param \WP_User|\stdClass $user The user object or a copy. - * @param string $password The password to check. + * @param bool $user_specific Whether or not to include user specific checks. * - * @return array An array of validation errors (if any). + * @return array An array of all validation statuses and messages. */ - public function return_all_validation_errors( $user, string $password ): array { - $errors = array(); + public function get_validation_initial_state( $user_specific ): array { + $base_conditions = array( + 'core' => array( + 'status' => null, + 'message' => __( 'Strong password', 'jetpack-account-protection' ), + 'info' => __( 'Passwords should meet WordPress core security requirements to enhance account protection.', 'jetpack-account-protection' ), + ), + 'contains_backslash' => array( + 'status' => null, + 'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ), + 'info' => null, + ), + 'invalid_length' => array( + 'status' => null, + 'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ), + 'info' => null, + ), + 'weak' => array( + 'status' => null, + 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), + 'info' => __( 'If found in a public breach, this password may already be known to attackers.', 'jetpack-account-protection' ), + ), + ); - if ( $this->contains_backslash( $password ) ) { - $errors[] = __( 'Doesn\'t contain a backslash (\\) character', 'jetpack-account-protection' ); + if ( ! $user_specific ) { + return $base_conditions; } - if ( $this->is_invalid_length( $password ) ) { - $errors[] = __( 'Between 6 and 150 characters', 'jetpack-account-protection' ); - } + $user_specific_conditions = array( + 'matches_user_data' => array( + 'status' => null, + 'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ), + 'info' => __( 'Using a password similar to your username or email makes it easier to guess.', 'jetpack-account-protection' ), + ), + 'recent' => array( + 'status' => null, + 'message' => __( 'Not used recently', 'jetpack-account-protection' ), + 'info' => __( 'Reusing old passwords may increase security risks. A fresh password improves protection.', 'jetpack-account-protection' ), + ), + ); - if ( $this->matches_user_data( $user, $password ) ) { - $errors[] = __( 'Doesn\'t match user data', 'jetpack-account-protection' ); - } + return array_merge( $base_conditions, $user_specific_conditions ); + } - if ( $this->is_recent_password( $user->ID, $password ) ) { - $errors[] = __( 'Not used recently', 'jetpack-account-protection' ); - } + /** + * Return validation state - client-side. + * + * @param string $password The password to check. + * @param bool $user_specific Whether or not to run user specific checks. + * + * @return array An array of the status of each check. + */ + public function get_validation_state( string $password, $user_specific ): array { + $validation_state = $this->get_validation_initial_state( $user_specific ); - if ( $this->is_weak_password( $password ) ) { - $errors[] = __( 'Not a leaked password.', 'jetpack-account-protection' ); + $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password ); + $validation_state['invalid_length']['status'] = $this->is_invalid_length( $password ); + $validation_state['weak']['status'] = $this->is_weak_password( $password ); + + if ( ! $user_specific ) { + return $validation_state; } - return $errors; + // Run checks on existing user data + $user = wp_get_current_user(); + $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); + $validation_state['recent']['status'] = $this->is_recent_password( $user, $password ); + + return $validation_state; } /** - * Return first validation error. + * Return first validation error - server-side. * - * @param \WP_User|\stdClass $user The user object or a copy. - * @param string $password The password to check. - * @param 'create-user'|'update'|'reset' $context The context the validation is run in. + * @param string $password The password to check. + * @param bool $user_specific Whether or not to run user specific checks. + * @param \stdClass|null $user The user data or null. * * @return string The first validation errors (if any). */ - public function return_first_validation_error( $user, string $password, $context ): string { - // Reset form includes this validation in core - if ( 'reset' !== $context ) { - if ( empty( $password ) ) { - return __( 'Error: The password cannot be a space or all spaces.', 'jetpack-account-protection' ); - } - } - - // Update and create-user forms include this validation in core - if ( 'reset' === $context ) { + public function get_first_validation_error( string $password, $user_specific = false, $user = null ): string { + // Update and create-user forms include backlash validation + if ( ! $user_specific ) { if ( $this->contains_backslash( $password ) ) { return __( 'Error: The password cannot contain a backslash (\\) character.', 'jetpack-account-protection' ); } @@ -111,18 +148,24 @@ public function return_first_validation_error( $user, string $password, $context return __( 'Error: The password must be between 6 and 150 characters.', 'jetpack-account-protection' ); } - if ( $this->matches_user_data( $user, $password ) ) { - return __( 'Error: The password matches user data.', 'jetpack-account-protection' ); + if ( $this->is_weak_password( $password ) ) { + return __( 'Error: The password was found in a public leak.', 'jetpack-account-protection' ); } - if ( 'create-user' !== $context ) { - if ( $this->is_recent_password( $user->ID, $password ) ) { - return __( 'Error: The password was used recently.', 'jetpack-account-protection' ); + // Skip user-specific checks during password reset + if ( $user_specific ) { + // Reset form includes empty validation + if ( empty( $password ) ) { + return __( 'Error: The password cannot be a space or all spaces.', 'jetpack-account-protection' ); } - } - if ( $this->is_weak_password( $password ) ) { - return __( 'Error: The password was found in a public leak.', 'jetpack-account-protection' ); + // Run checks on new user data + if ( $this->matches_user_data( $user, $password ) ) { + return __( 'Error: The password matches new user data.', 'jetpack-account-protection' ); + } + if ( $this->is_recent_password( $user, $password ) ) { + return __( 'Error: The password was used recently.', 'jetpack-account-protection' ); + } } return ''; @@ -148,7 +191,7 @@ public function contains_backslash( string $password ): bool { */ public function is_invalid_length( string $password ): bool { $length = strlen( $password ); - return $length < 6 || $length > 150; + return $length < Config::VALIDATION_SERVICE_MIN_LENGTH || $length > Config::VALIDATION_SERVICE_MAX_LENGTH; } /** @@ -253,14 +296,18 @@ public function is_current_password( int $user_id, string $password ): bool { /** * Check if the password has been used recently by the user. * - * @param int $user_id The user ID. - * @param string $password The password to check. + * @param \WP_User|\stdClass $user The user data. + * @param string $password The password to check. * - * @return bool True if the password hash was recently used, false otherwise. + * @return bool True if the password was recently used, false otherwise. */ - public function is_recent_password( int $user_id, string $password ): bool { - $recent_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); + public function is_recent_password( $user, string $password ): bool { + $user_data = $user instanceof \WP_User ? $user : get_userdata( $user->ID ); + if ( $this->is_current_password( $user_data->ID, $password ) ) { + return true; + } + $recent_passwords = get_user_meta( $user->ID, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) { return false; } diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css new file mode 100644 index 0000000000000..30034fe371d53 --- /dev/null +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -0,0 +1,101 @@ +.validation-checklist { + display: flex; + flex-direction: column; + gap: 8px; + margin: 16px 0; +} + +.validation-item { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 0; + + .validation-icon { + height: 24px; + } + + .validation-message { + margin-top: 0; + } +} + +.info-popover { + position: relative; + display: inline-block; + height: 20px; +} + +.info-icon { + height: 20px; + cursor: pointer; +} + +.popover { + display: none; + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 6px 10px; + border-radius: 4px; + white-space: normal; + width: 150px; + font-size: 12px; + box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); + z-index: 10; + text-align: center; +} + +.popover-arrow { + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid #333; +} + +#your-profile .strength-meter, +#createuser .strength-meter { + margin: 0 1px; +} + +.strength-meter { + display: flex; + justify-content: space-between; + align-items: center; + height: 30px; + padding: 0 16px; + margin-bottom: 16px; + border-radius: 0 0 4px 4px; + background-color: #C3C4C7; +} + +.strength-meter .strength { + display: flex; + align-items: center; + font-size: 12px; + font-weight: 500; + color: black; + margin: 0; +} + +.branding { + display: flex; + align-items: center; + gap: 4px; +} + +.branding .powered-by { + font-size: 12px; + color: black; + margin: 0; +} + +.branding img { + height: 18px; +} diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js new file mode 100644 index 0000000000000..a31f1d638a555 --- /dev/null +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -0,0 +1,331 @@ +/* global jQuery, jetpackData */ + +jQuery( document ).ready( function ( $ ) { + const UIComponents = { + core: { + passwordInputWrapper: $( '.user-pass1-wrap' ), + passwordInput: $( '#pass1' ), + passwordStrengthResults: $( '#pass-strength-result' ), + weakPasswordConfirmation: $( '.pw-weak' ), + weakPasswordConfirmationCheckbox: $( '.pw-weak input[type="checkbox"]' ), + submitButtons: $( '#submit, #createusersub, #wp-submit' ), + }, + passwordValidationStatus: $( '
', { id: 'password-validation-status' } ), + validationCheckList: $( '