diff --git a/changelog.txt b/changelog.txt index 4b3c2fc..5b519e6 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,37 @@ == Changelog == += 1.3.0 = + +_Release date: 2026-06-05_ + +**Security** + +* TOTP replay prevention: accepted counter step stored in a 90-second transient; same code rejected on second submission within the ±1 window. +* Login username removed from 2FA redirect URLs: `robotstxt-2fa-login` query parameter replaced with an opaque 32-character token resolved server-side. Username never appears in browser history, logs, or referrer headers. + +**Added** + +* WP-CLI command family `wp 2fa` (loaded only when `WP_CLI` is defined): + * `wp 2fa status ` — show 2FA configuration. + * `wp 2fa enable [--method=]` — enable 2FA. + * `wp 2fa disable ` — disable 2FA, preserving secrets and codes. + * `wp 2fa reset-recovery ` — regenerate and display recovery codes. + * `wp 2fa list [--role=] [--without-2fa] [--format=table|csv|json]` — list users with 2FA status. + * `wp 2fa force-setup [] [--role=] [--method=]` — enforce 2FA. + * `wp 2fa bypass [--days=]` — grant temporary bypass (max 30 days). + +**Compatibility** + +* WordPress: 6.4 – 7.1 +* PHP: 8.0 – 8.5 + +**Tests** + +* PHP Coding Standards: PHP_CodeSniffer 3.13.5 / WPCS 3.3.0 +* PHPStan: level 9 — 0 errors +* PHPCompatibility: 8.0–8.5 — 0 issues +* PHPUnit: 9.6.34 — 42 tests, 109 assertions + = 1.2.1 = _Release date: 2026-06-05_ diff --git a/includes/class-plugin.php b/includes/class-plugin.php index d8d8966..afc1fab 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -8,6 +8,7 @@ namespace Robotstxt\TwoFA; use Robotstxt\TwoFA\Admin\Settings_Page; +use Robotstxt\TwoFA\Cli\Cli_Command; use Robotstxt\TwoFA\Login\Login_Form_Manager; use Robotstxt\TwoFA\User\Frontend_Profile; use Robotstxt\TwoFA\User\Grace_Period; @@ -119,6 +120,23 @@ class Plugin { $this->frontend_profile->register_hooks(); $this->trusted_devices->register_hooks(); $this->grace_period->register_hooks(); + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + add_action( 'cli_init', array( $this, 'register_cli_commands' ) ); + } + } + + /** + * Register WP-CLI commands. + * + * Called only when WP_CLI is active. + * + * @since 1.3.0 + * + * @return void + */ + public function register_cli_commands(): void { + \WP_CLI::add_command( '2fa', new Cli_Command() ); } /** diff --git a/includes/cli/class-cli-command.php b/includes/cli/class-cli-command.php new file mode 100644 index 0000000..f9c2f6b --- /dev/null +++ b/includes/cli/class-cli-command.php @@ -0,0 +1,453 @@ +repository = new User_Settings_Repository(); + $this->recovery_codes = new Recovery_Codes(); + $this->otp_manager = new OTP_Manager(); + $this->config = new Two_Factor_Config(); + } + + /** + * Show the 2FA configuration for a user. + * + * ## OPTIONS + * + * + * : User ID to inspect. + * + * ## EXAMPLES + * + * wp 2fa status 42 + * + * @since 1.3.0 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function status( array $args, array $assoc_args ): void { + $user = $this->get_user( $args[0] ?? '' ); + + $settings = $this->repository->get_user_settings( $user->ID ); + $required_methods = $this->config->get_required_methods_for_user( $user ); + $unused_codes = $this->recovery_codes->count_unused_codes( $user->ID ); + $has_otp = '' !== $this->otp_manager->get_secret( $user->ID ); + + \WP_CLI::line( 'User: ' . $user->user_login . ' (ID ' . $user->ID . ')' ); + \WP_CLI::line( '2FA enabled: ' . ( $settings['enabled'] ? 'yes' : 'no' ) ); + \WP_CLI::line( 'Methods: ' . ( ! empty( $settings['methods'] ) ? implode( ', ', $settings['methods'] ) : '(none)' ) ); + \WP_CLI::line( 'Frequency: ' . ( '' !== $settings['frequency'] ? $settings['frequency'] : 'session' ) ); + \WP_CLI::line( 'Forced by role: ' . ( ! empty( $required_methods ) ? 'yes (' . implode( ', ', $required_methods ) . ')' : 'no' ) ); + \WP_CLI::line( 'OTP secret: ' . ( $has_otp ? 'set' : 'not set' ) ); + \WP_CLI::line( 'Unused codes: ' . $unused_codes . ' / 10' ); + } + + /** + * Enable 2FA for a user and optionally set the active method. + * + * ## OPTIONS + * + * + * : User ID to enable 2FA for. + * + * [--method=] + * : Verification method to activate. Accepts: email, otp, recovery. + * Default: email. + * + * ## EXAMPLES + * + * wp 2fa enable 42 + * wp 2fa enable 42 --method=email + * + * @since 1.3.0 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function enable( array $args, array $assoc_args ): void { + $user = $this->get_user( $args[0] ?? '' ); + $method = isset( $assoc_args['method'] ) ? sanitize_key( $assoc_args['method'] ) : 'email'; + $allowed = array( 'email', 'otp', 'recovery' ); + + if ( ! in_array( $method, $allowed, true ) ) { + \WP_CLI::error( "Unknown method '{$method}'. Accepted values: email, otp, recovery." ); + return; + } + + if ( 'otp' === $method && '' === $this->otp_manager->get_secret( $user->ID ) ) { + \WP_CLI::error( 'OTP requires a confirmed secret. The user must set up an authenticator app first.' ); + return; + } + + if ( 'recovery' === $method && ! $this->recovery_codes->has_active_codes( $user->ID ) ) { + \WP_CLI::error( 'Recovery codes require a confirmed batch. Use `wp 2fa reset-recovery` to generate one.' ); + return; + } + + $settings = $this->repository->get_user_settings( $user->ID ); + $settings['enabled'] = true; + + if ( ! in_array( $method, $settings['methods'], true ) ) { + $settings['methods'][] = $method; + } + + $this->repository->save_user_settings( $user->ID, $settings ); + + \WP_CLI::success( "2FA enabled for user {$user->ID} ({$user->user_login}) using {$method}." ); + } + + /** + * Disable 2FA for a user (preserves stored secrets and codes). + * + * ## OPTIONS + * + * + * : User ID to disable 2FA for. + * + * ## EXAMPLES + * + * wp 2fa disable 42 + * + * @since 1.3.0 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function disable( array $args, array $assoc_args ): void { + $user = $this->get_user( $args[0] ?? '' ); + $settings = $this->repository->get_user_settings( $user->ID ); + + $settings['enabled'] = false; + $this->repository->save_user_settings( $user->ID, $settings ); + + \WP_CLI::success( "2FA disabled for user {$user->ID} ({$user->user_login}). Secrets and codes are preserved." ); + } + + /** + * Regenerate recovery codes for a user and display them once. + * + * ## OPTIONS + * + * + * : User ID to regenerate codes for. + * + * ## EXAMPLES + * + * wp 2fa reset-recovery 42 + * + * @subcommand reset-recovery + * + * @since 1.3.0 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function reset_recovery( array $args, array $assoc_args ): void { + $user = $this->get_user( $args[0] ?? '' ); + $result = $this->recovery_codes->regenerate_codes_for_user( $user ); + + \WP_CLI::warning( 'Store these codes securely. They will not be shown again.' ); + \WP_CLI::line( '' ); + \WP_CLI::line( "New recovery codes for user {$user->ID} ({$user->user_login}):" ); + \WP_CLI::line( '' ); + + foreach ( $result['codes'] as $index => $code ) { + \WP_CLI::line( ' ' . str_pad( (string) ( $index + 1 ), 2, ' ', STR_PAD_LEFT ) . '. ' . $code ); + } + + \WP_CLI::line( '' ); + } + + /** + * List users and their 2FA status. + * + * ## OPTIONS + * + * [--role=] + * : Filter by WordPress role. + * + * [--without-2fa] + * : Only show users who do not have 2FA enabled. + * + * [--format=] + * : Output format. Accepts: table, csv, json. Default: table. + * + * ## EXAMPLES + * + * wp 2fa list + * wp 2fa list --role=editor --without-2fa + * wp 2fa list --format=csv + * + * @subcommand list + * + * @since 1.3.0 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function list_users( array $args, array $assoc_args ): void { + $query_args = array( 'number' => -1 ); + + if ( isset( $assoc_args['role'] ) && '' !== $assoc_args['role'] ) { + $query_args['role'] = sanitize_key( $assoc_args['role'] ); + } + + $users = get_users( $query_args ); + $format = isset( $assoc_args['format'] ) ? sanitize_key( $assoc_args['format'] ) : 'table'; + $rows = array(); + + foreach ( $users as $user ) { + if ( ! $user instanceof \WP_User ) { + continue; + } + + $settings = $this->repository->get_user_settings( $user->ID ); + $required = $this->config->get_required_methods_for_user( $user ); + $is_enabled = $settings['enabled']; + $is_forced = ! empty( $required ); + + if ( isset( $assoc_args['without-2fa'] ) && $is_enabled ) { + continue; + } + + if ( $is_enabled ) { + $status = 'enabled (' . implode( ', ', $settings['methods'] ) . ')'; + } elseif ( $is_forced ) { + $status = 'disabled (required)'; + } else { + $status = 'disabled'; + } + + $rows[] = array( + 'ID' => (string) $user->ID, + 'Login' => $user->user_login, + 'Email' => $user->user_email, + '2FA' => $status, + 'Frequency' => '' !== $settings['frequency'] ? $settings['frequency'] : 'session', + ); + } + + if ( 'json' === $format ) { + $encoded = wp_json_encode( $rows ); + \WP_CLI::line( is_string( $encoded ) ? $encoded : '[]' ); + return; + } + + if ( 'csv' === $format ) { + $headers = array( 'ID', 'Login', 'Email', '2FA', 'Frequency' ); + \WP_CLI::line( implode( ',', array_map( 'addslashes', $headers ) ) ); + foreach ( $rows as $row ) { + \WP_CLI::line( implode( ',', array_map( 'addslashes', array_values( $row ) ) ) ); + } + return; + } + + \WP_CLI\Utils\format_items( 'table', $rows, array( 'ID', 'Login', 'Email', '2FA', 'Frequency' ) ); + } + + /** + * Enforce 2FA for a specific user or all users of a role. + * + * ## OPTIONS + * + * [] + * : User ID to enforce. Required when --role is not set. + * + * [--role=] + * : WordPress role slug. When supplied, updates the global role setting + * to require email verification for that role. + * + * [--method=] + * : Method to require when using --role. Default: email. + * + * ## EXAMPLES + * + * wp 2fa force-setup 42 + * wp 2fa force-setup --role=administrator + * wp 2fa force-setup --role=editor --method=otp + * + * @subcommand force-setup + * + * @since 1.3.0 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function force_setup( array $args, array $assoc_args ): void { + if ( isset( $assoc_args['role'] ) ) { + $role = sanitize_key( $assoc_args['role'] ); + $method = isset( $assoc_args['method'] ) ? sanitize_key( $assoc_args['method'] ) : 'email'; + + if ( ! in_array( $method, array( 'email', 'otp', 'recovery' ), true ) ) { + \WP_CLI::error( "Unknown method '{$method}'." ); + return; + } + + $raw_settings = get_option( 'robotstxt_2fa_settings' ); + $settings = is_array( $raw_settings ) ? $raw_settings : array(); + + if ( ! isset( $settings['role_methods'] ) || ! is_array( $settings['role_methods'] ) ) { + $settings['role_methods'] = array(); + } + + $settings['role_methods'][ $role ] = array( $method ); + update_option( 'robotstxt_2fa_settings', $settings, false ); + + if ( is_multisite() ) { + update_site_option( 'robotstxt_2fa_settings', $settings ); + } + + \WP_CLI::success( "2FA enforced for role '{$role}' ({$method} required)." ); + return; + } + + $user = $this->get_user( $args[0] ?? '' ); + $settings = $this->repository->get_user_settings( $user->ID ); + + $settings['enabled'] = true; + + if ( empty( $settings['methods'] ) ) { + $settings['methods'] = array( 'email' ); + } + + $this->repository->save_user_settings( $user->ID, $settings ); + + \WP_CLI::success( "2FA enabled for user {$user->ID} ({$user->user_login})." ); + } + + /** + * Grant a temporary bypass that skips the 2FA challenge for a user. + * + * ## OPTIONS + * + * + * : User ID to grant the bypass for. + * + * [--days=] + * : Number of days the bypass is valid. Maximum 30. Default: 1. + * + * ## EXAMPLES + * + * wp 2fa bypass 42 + * wp 2fa bypass 42 --days=7 + * + * @since 1.3.0 + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * + * @return void + */ + public function bypass( array $args, array $assoc_args ): void { + $user = $this->get_user( $args[0] ?? '' ); + $days = isset( $assoc_args['days'] ) && is_numeric( $assoc_args['days'] ) + ? max( 1, min( 30, (int) $assoc_args['days'] ) ) + : 1; + + $expires = time() + $days * DAY_IN_SECONDS; + set_transient( 'robotstxt_2fa_bypass_' . $user->ID, $expires, $days * DAY_IN_SECONDS ); + + $until = gmdate( 'Y-m-d H:i:s', $expires ) . ' UTC'; + + \WP_CLI::success( "2FA bypass granted for user {$user->ID} ({$user->user_login}) until {$until}." ); + \WP_CLI::warning( 'Remember to allow the bypass to expire or revoke it once access is restored.' ); + } + + /** + * Resolve a user ID or login string to a WP_User object. + * + * Halts with a WP-CLI error when the user cannot be found. + * + * @since 1.3.0 + * + * @param string $identifier User ID or login name. + * + * @return \WP_User + */ + private function get_user( string $identifier ): \WP_User { + $identifier = sanitize_text_field( $identifier ); + + if ( '' === $identifier ) { + \WP_CLI::error( 'Please provide a user ID.' ); + exit( 1 ); // Unreachable; satisfies static analysis. + } + + $user = is_numeric( $identifier ) + ? get_user_by( 'ID', (int) $identifier ) + : get_user_by( 'login', $identifier ); + + if ( ! $user instanceof \WP_User ) { + \WP_CLI::error( "User '{$identifier}' not found." ); + exit( 1 ); // Unreachable; satisfies static analysis. + } + + return $user; + } +} diff --git a/includes/login/class-login-form-manager.php b/includes/login/class-login-form-manager.php index ce4ff32..16f3de3 100644 --- a/includes/login/class-login-form-manager.php +++ b/includes/login/class-login-form-manager.php @@ -65,9 +65,9 @@ class Login_Form_Manager { private const QUERY_SWITCH = 'robotstxt-2fa-switch'; /** - * Query argument carrying the pending login username. + * Query argument carrying the opaque pending-stage token (replaces user_login in URLs). */ - private const QUERY_LOGIN = 'robotstxt-2fa-login'; + private const QUERY_PENDING = 'robotstxt-2fa-pending'; /** * Form field containing the active stage marker. @@ -75,15 +75,25 @@ class Login_Form_Manager { private const FIELD_STAGE = 'robotstxt_2fa_stage'; /** - * Form field containing the pending login username. + * Form field carrying the opaque pending-stage token. */ - private const FIELD_LOGIN = 'robotstxt_2fa_login'; + private const FIELD_PENDING = 'robotstxt_2fa_pending'; /** * Form field specifying the verification method in use. */ private const FIELD_METHOD = 'robotstxt_2fa_method'; + /** + * Transient key prefix for pending-stage tokens. + */ + private const PENDING_TRANSIENT_PREFIX = 'robotstxt_2fa_stage_'; + + /** + * Pending-stage token TTL in seconds — matches WP nonce lifetime (24 h). + */ + private const PENDING_TTL = DAY_IN_SECONDS; + /** * Form field carrying the nonce that validates the verification stage. */ @@ -164,20 +174,16 @@ class Login_Form_Manager { /** * Handle verification method switches requested on the login stage. * + * @since 1.0.0 + * * @return void */ private function process_method_switch(): void { $method = $this->get_requested_method(); - $login = $this->get_requested_login(); + $user = $this->resolve_pending_user(); - if ( '' === $login ) { - return; - } - - $user = get_user_by( 'login', $login ); - - if ( ! $user instanceof \WP_User ) { + if ( null === $user ) { return; } @@ -232,6 +238,8 @@ class Login_Form_Manager { /** * Render custom fields on the login form. * + * @since 1.0.0 + * * @return void */ public function render_form_fields(): void { @@ -239,21 +247,18 @@ class Login_Form_Manager { return; } - $login = $this->get_requested_login(); - $method = $this->get_requested_method(); - $code_length = $this->get_method_code_length( $method ); - $alternates = array(); + $pending_user = $this->resolve_pending_user(); + $pending_token = $this->get_requested_pending_token(); + $method = $this->get_requested_method(); + $code_length = $this->get_method_code_length( $method ); + $alternates = array(); - if ( '' !== $login ) { - $user = get_user_by( 'login', $login ); - - if ( $user instanceof WP_User ) { - $alternates = $this->get_alternate_method_links( $user, $method ); - } + if ( $pending_user instanceof WP_User ) { + $alternates = $this->get_alternate_method_links( $pending_user, $method ); } - if ( '' !== $login ) { - printf( '', esc_attr( self::FIELD_LOGIN ), esc_attr( $login ) ); + if ( '' !== $pending_token ) { + printf( '', esc_attr( self::FIELD_PENDING ), esc_attr( $pending_token ) ); } printf( '', esc_attr( self::FIELD_METHOD ), esc_attr( $method ) ); @@ -393,6 +398,8 @@ class Login_Form_Manager { /** * Display a contextual login message during the verification stage. * + * @since 1.0.0 + * * @param string $message Existing login form message HTML. * * @return string @@ -402,15 +409,9 @@ class Login_Form_Manager { return $message; } - $login = $this->get_requested_login(); + $user = $this->resolve_pending_user(); $method = $this->get_requested_method(); - if ( '' === $login ) { - return $message; - } - - $user = get_user_by( 'login', $login ); - if ( ! $user instanceof WP_User ) { return $message; } @@ -443,6 +444,8 @@ class Login_Form_Manager { /** * Output helper scripts used during the verification stage. * + * @since 1.0.0 + * * @return void */ public function render_stage_scripts(): void { @@ -450,17 +453,14 @@ class Login_Form_Manager { return; } - $login = $this->get_requested_login(); - $method = $this->get_requested_method(); - $sent_at = 0; + $pending_user = $this->resolve_pending_user(); + $method = $this->get_requested_method(); + $login = $pending_user instanceof \WP_User ? $pending_user->user_login : ''; + $sent_at = 0; - if ( 'email' === $method && '' !== $login ) { - $user = get_user_by( 'login', $login ); - - if ( $user instanceof \WP_User ) { - $challenge = $this->user_settings_repository->get_email_challenge( $user->ID ); - $sent_at = $challenge['sent_at']; - } + if ( 'email' === $method && $pending_user instanceof \WP_User ) { + $challenge = $this->user_settings_repository->get_email_challenge( $pending_user->ID ); + $sent_at = $challenge['sent_at']; } ?>