This commit is contained in:
Javier Casares 2026-06-05 18:39:14 +00:00
commit 8e9c6bc472
9 changed files with 668 additions and 105 deletions

View file

@ -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 <user_id>` — show 2FA configuration.
* `wp 2fa enable <user_id> [--method=<method>]` — enable 2FA.
* `wp 2fa disable <user_id>` — disable 2FA, preserving secrets and codes.
* `wp 2fa reset-recovery <user_id>` — regenerate and display recovery codes.
* `wp 2fa list [--role=<role>] [--without-2fa] [--format=table|csv|json]` — list users with 2FA status.
* `wp 2fa force-setup [<user_id>] [--role=<role>] [--method=<method>]` — enforce 2FA.
* `wp 2fa bypass <user_id> [--days=<n>]` — 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.08.5 — 0 issues
* PHPUnit: 9.6.34 — 42 tests, 109 assertions
= 1.2.1 =
_Release date: 2026-06-05_

View file

@ -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() );
}
/**

View file

@ -0,0 +1,453 @@
<?php
/**
* WP-CLI commands for the robotstxt-2FA plugin.
*
* This file is only loaded when WP_CLI is defined and truthy.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\Cli;
use Robotstxt\TwoFA\User\OTP_Manager;
use Robotstxt\TwoFA\User\Recovery_Codes;
use Robotstxt\TwoFA\User\Two_Factor_Config;
use Robotstxt\TwoFA\User\User_Settings_Repository;
use WP_CLI_Command;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Manages two-factor authentication from the command line.
*
* @since 1.3.0
*/
class Cli_Command extends WP_CLI_Command {
/**
* User settings repository.
*
* @var User_Settings_Repository
*/
private User_Settings_Repository $repository;
/**
* Recovery codes manager.
*
* @var Recovery_Codes
*/
private Recovery_Codes $recovery_codes;
/**
* OTP manager.
*
* @var OTP_Manager
*/
private OTP_Manager $otp_manager;
/**
* Plugin configuration reader.
*
* @var Two_Factor_Config
*/
private Two_Factor_Config $config;
/**
* Constructor.
*/
public function __construct() {
$this->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>
* : User ID to inspect.
*
* ## EXAMPLES
*
* wp 2fa status 42
*
* @since 1.3.0
*
* @param array<int, string> $args Positional arguments.
* @param array<string, string> $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>
* : User ID to enable 2FA for.
*
* [--method=<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<int, string> $args Positional arguments.
* @param array<string, string> $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>
* : User ID to disable 2FA for.
*
* ## EXAMPLES
*
* wp 2fa disable 42
*
* @since 1.3.0
*
* @param array<int, string> $args Positional arguments.
* @param array<string, string> $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>
* : User ID to regenerate codes for.
*
* ## EXAMPLES
*
* wp 2fa reset-recovery 42
*
* @subcommand reset-recovery
*
* @since 1.3.0
*
* @param array<int, string> $args Positional arguments.
* @param array<string, string> $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=<role>]
* : Filter by WordPress role.
*
* [--without-2fa]
* : Only show users who do not have 2FA enabled.
*
* [--format=<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<int, string> $args Positional arguments.
* @param array<string, string> $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>]
* : User ID to enforce. Required when --role is not set.
*
* [--role=<role>]
* : WordPress role slug. When supplied, updates the global role setting
* to require email verification for that role.
*
* [--method=<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<int, string> $args Positional arguments.
* @param array<string, string> $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>
* : User ID to grant the bypass for.
*
* [--days=<n>]
* : 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<int, string> $args Positional arguments.
* @param array<string, string> $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;
}
}

View file

@ -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( '<input type="hidden" name="%1$s" value="%2$s" />', esc_attr( self::FIELD_LOGIN ), esc_attr( $login ) );
if ( '' !== $pending_token ) {
printf( '<input type="hidden" name="%1$s" value="%2$s" />', esc_attr( self::FIELD_PENDING ), esc_attr( $pending_token ) );
}
printf( '<input type="hidden" name="%1$s" value="%2$s" />', 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'];
}
?>
<script>
@ -573,22 +573,12 @@ class Login_Form_Manager {
return new \WP_Error( 'robotstxt_2fa_invalid_nonce', __( 'The verification request expired. Please sign in again to request a new code.', 'robotstxt-2fa' ) );
}
$login = $this->get_requested_login();
$authenticated_user = $this->resolve_pending_user();
if ( '' === $login && '' !== $username ) {
$login = sanitize_user( $username, true );
}
if ( '' === $login ) {
if ( null === $authenticated_user ) {
return new \WP_Error( 'robotstxt_2fa_unknown_user', __( 'We could not determine which account you are trying to access.', 'robotstxt-2fa' ) );
}
$authenticated_user = get_user_by( 'login', $login );
if ( ! $authenticated_user instanceof \WP_User ) {
return new \WP_Error( 'robotstxt_2fa_unknown_user', __( 'We could not load the account requesting verification.', 'robotstxt-2fa' ) );
}
// Brute-force: check if the account is currently locked out.
$lock_key = 'robotstxt_2fa_locked_' . $authenticated_user->ID;
$locked = get_transient( $lock_key );
@ -687,6 +677,12 @@ class Login_Form_Manager {
// Brute-force: clear the attempt counter on success.
delete_transient( 'robotstxt_2fa_attempts_' . $authenticated_user->ID );
// Consume the pending token so it cannot be replayed.
$pending_token = $this->get_requested_pending_token();
if ( '' !== $pending_token ) {
delete_transient( self::PENDING_TRANSIENT_PREFIX . $pending_token );
}
$this->user_settings_repository->record_last_verification(
$authenticated_user->ID,
$method,
@ -754,6 +750,12 @@ class Login_Form_Manager {
return $user;
}
// Temporary bypass: administrators can grant a time-limited bypass via WP-CLI.
$bypass_until = get_transient( 'robotstxt_2fa_bypass_' . $user->ID );
if ( false !== $bypass_until && is_numeric( $bypass_until ) && (int) $bypass_until > time() ) {
return $user;
}
$required_methods = $this->config->get_required_methods_for_user( $user );
// Grace period: if forced but no methods configured yet, check whether the user is within their grace window.
@ -808,7 +810,7 @@ class Login_Form_Manager {
$query_args = array(
self::QUERY_STAGE => self::STAGE_VERIFY,
self::QUERY_METHOD => $method,
self::QUERY_LOGIN => sanitize_user( $user->user_login, true ),
self::QUERY_PENDING => $this->issue_pending_token( $user->ID ),
self::QUERY_STAGE_TOKEN => $stage_token,
);
@ -848,15 +850,9 @@ class Login_Form_Manager {
return;
}
$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;
}
@ -1230,24 +1226,67 @@ class Login_Form_Manager {
}
/**
* Retrieve the login identifier carried across requests.
* Issue an opaque pending-stage token that maps to a user ID.
*
* Stores the user ID in a short-lived transient so the login username
* is never transmitted in the URL query string.
*
* @since 1.3.0
*
* @param int $user_id User identifier.
*
* @return string 32-character alphanumeric token.
*/
private function issue_pending_token( int $user_id ): string {
$token = wp_generate_password( 32, false, false );
set_transient( self::PENDING_TRANSIENT_PREFIX . $token, $user_id, self::PENDING_TTL );
return $token;
}
/**
* Retrieve the raw pending-stage token from the current request.
*
* @since 1.3.0
*
* @return string
*/
private function get_requested_login(): string {
$login = '';
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Login identifier helper; nonce verified in calling context.
if ( isset( $_REQUEST[ self::FIELD_LOGIN ] ) && is_string( $_REQUEST[ self::FIELD_LOGIN ] ) ) {
$login = sanitize_user( wp_unslash( $_REQUEST[ self::FIELD_LOGIN ] ), true );
private function get_requested_pending_token(): string {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Opaque token helper; token-to-user mapping validated in resolve_pending_user().
if ( isset( $_REQUEST[ self::FIELD_PENDING ] ) && is_string( $_REQUEST[ self::FIELD_PENDING ] ) ) {
return sanitize_text_field( wp_unslash( $_REQUEST[ self::FIELD_PENDING ] ) );
}
if ( '' === $login && isset( $_REQUEST[ self::QUERY_LOGIN ] ) && is_string( $_REQUEST[ self::QUERY_LOGIN ] ) ) {
$login = sanitize_user( wp_unslash( $_REQUEST[ self::QUERY_LOGIN ] ), true );
if ( isset( $_REQUEST[ self::QUERY_PENDING ] ) && is_string( $_REQUEST[ self::QUERY_PENDING ] ) ) {
return sanitize_text_field( wp_unslash( $_REQUEST[ self::QUERY_PENDING ] ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return $login;
return '';
}
/**
* Resolve the pending-stage token to its associated WP_User.
*
* @since 1.3.0
*
* @return \WP_User|null Null when no valid token is present.
*/
private function resolve_pending_user(): ?\WP_User {
$token = $this->get_requested_pending_token();
if ( '' === $token ) {
return null;
}
$user_id_raw = get_transient( self::PENDING_TRANSIENT_PREFIX . $token );
if ( ! is_numeric( $user_id_raw ) ) {
return null;
}
$user = get_user_by( 'ID', (int) $user_id_raw );
return $user instanceof \WP_User ? $user : null;
}
/**
@ -1328,10 +1367,10 @@ class Login_Form_Manager {
self::QUERY_METHOD => $this->get_requested_method(),
);
$login = $this->get_requested_login();
$pending_token = $this->get_requested_pending_token();
if ( '' !== $login ) {
$args[ self::QUERY_LOGIN ] = $login;
if ( '' !== $pending_token ) {
$args[ self::QUERY_PENDING ] = $pending_token;
}
$stage_token = $this->get_requested_stage_token();

View file

@ -102,6 +102,11 @@ class OTP_Manager {
/**
* Verify an OTP code against the stored secret.
*
* Accepted codes are recorded in a short-lived transient so the same
* counter step cannot be replayed within the ±1 time window.
*
* @since 1.0.0
*
* @param WP_User $user User attempting to authenticate.
* @param string $code Submitted verification code.
*
@ -131,9 +136,23 @@ class OTP_Manager {
for ( $offset = -1; $offset <= 1; $offset++ ) {
$otp = $this->generate_otp_for_counter( $binary_secret, $current_step + $offset );
if ( hash_equals( $otp, $code ) ) {
return true;
if ( ! hash_equals( $otp, $code ) ) {
continue;
}
// Replay guard: reject if this counter step was already accepted.
$step = $current_step + $offset;
$used_key = 'robotstxt_2fa_otp_used_' . $user->ID;
$last = get_transient( $used_key );
if ( false !== $last && is_numeric( $last ) && (int) $last === $step ) {
return false;
}
// Record the accepted step for the duration of 3 time windows (±1 + current).
set_transient( $used_key, $step, 3 * self::TIME_STEP );
return true;
}
return false;

View file

@ -4,7 +4,7 @@ Tags: security, two-factor authentication, login, otp
Requires at least: 6.4
Tested up to: 7.0
Requires PHP: 8.0
Stable tag: 1.2.1
Stable tag: 1.3.0
License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
@ -59,6 +59,27 @@ Yes. Activate the plugin at the network level. Network administrators can set an
== Changelog ==
= 1.3.0 =
_Release date: 2026-06-05_
**Security**
* TOTP codes are now protected against replay attacks within the same 30-second time window. An accepted counter step is recorded in a transient for 90 seconds; a second submission of the same code is rejected.
* The login username is no longer exposed in the `robotstxt-2fa-login` URL query parameter during the verification stage. It is replaced by an opaque 32-character token that resolves to the user server-side. All redirect URLs, hidden form fields, and method-switch links use this token.
**Added**
* WP-CLI command family registered as `wp 2fa`:
* `wp 2fa status <user_id>` — show 2FA configuration for a user.
* `wp 2fa enable <user_id> [--method=<method>]` — enable 2FA, optionally specifying the active method.
* `wp 2fa disable <user_id>` — disable 2FA while preserving stored secrets and codes.
* `wp 2fa reset-recovery <user_id>` — regenerate recovery codes and display them once.
* `wp 2fa list [--role=<role>] [--without-2fa] [--format=<format>]` — list users and 2FA status.
* `wp 2fa force-setup [<user_id>] [--role=<role>] [--method=<method>]` — enforce 2FA for a user or role.
* `wp 2fa bypass <user_id> [--days=<n>]` — grant a temporary bypass for a locked-out user.
* WP-CLI commands are only loaded when the `WP_CLI` constant is defined and truthy.
= 1.2.1 =
_Release date: 2026-06-05_
@ -86,25 +107,6 @@ _Release date: 2026-06-05_
* "Regenerate codes" button on the profile when recovery codes are active.
* Top-up mode for recovery codes: generates only the missing codes to refill the batch.
= 1.0.0 =
_Release date: 2026-06-05_
**Fixed**
* Enable 2FA checkbox now defaults to email method on first activation.
* QR code now renders correctly (data URI was stripped by esc_url).
* Resend link shows a 60-second live countdown to prevent email flooding.
**Changed**
* Recovery codes section redesigned — plain list, no green notice box.
* 2FA login links now a vertical list instead of inline bullets.
**Removed**
* Redundant "Generate new secret" button from OTP section.
= Previous versions =
For the full changelog see the [changelog.txt](https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/raw/branch/main/changelog.txt) file.

View file

@ -3,7 +3,7 @@
* Plugin Name: 2FA (by ROBOTSTXT)
* Plugin URI: https://www.robotstxt.es/plugins/robotstxt-2fa/
* Description: Adds two-factor authentication to the WordPress login flow.
* Version: 1.2.1
* Version: 1.3.0
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
* Text Domain: robotstxt-2fa
@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
if ( ! defined( 'ROBOTSTXT_2FA_VERSION' ) ) {
define( 'ROBOTSTXT_2FA_VERSION', '1.2.1' );
define( 'ROBOTSTXT_2FA_VERSION', '1.3.0' );
}
if ( ! defined( 'ROBOTSTXT_2FA_FILE' ) ) {

View file

@ -1,8 +1,8 @@
{
"name": "2FA (by ROBOTSTXT)",
"slug": "robotstxt-2fa",
"version": "1.2.1",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.2.1/robotstxt-2fa-1.2.1.zip",
"version": "1.3.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.3.0/robotstxt-2fa-1.3.0.zip",
"requires": "6.4",
"requires_php": "8.0",
"tested": "7.1",
@ -11,10 +11,10 @@
"author_profile": "https://www.robotstxt.es/",
"homepage": "https://www.robotstxt.es/plugins/robotstxt-2fa/",
"description": "Adds per-role two-factor authentication to the WordPress login flow. Supports email codes, authenticator apps (TOTP), and recovery codes.",
"changelog": "<h3>1.2.1 - 2026-06-05</h3><ul><li><strong>Fixed:</strong> Recovery code confirmation without checkbox now correctly activates the method</li><li><strong>Fixed:</strong> Fatal error in multisite when saving profile (add_settings_error not available at init)</li><li><strong>Fixed:</strong> Text inputs (OTP code, recovery confirm) no longer disabled by JS when Enable toggle is off</li><li><strong>Fixed:</strong> Regenerate codes now requires re-confirmation before reactivating</li><li><strong>Fixed:</strong> Entering a valid OTP or recovery code activates the method even without checking the checkbox</li></ul>",
"changelog": "<h3>1.3.0 - 2026-06-05</h3><ul><li><strong>Security:</strong> TOTP codes now protected against replay attacks within the same 30-second window</li><li><strong>Security:</strong> Login username no longer exposed in the 2FA redirect URL; replaced with an opaque short-lived token</li><li><strong>Added:</strong> WP-CLI command family: wp 2fa status, enable, disable, reset-recovery, list, force-setup, bypass</li></ul>",
"sections": {
"description": "Adds per-role two-factor authentication to the WordPress login flow. Supports email codes, authenticator apps (TOTP), and recovery codes.",
"changelog": "<h3>1.2.1 - 2026-06-05</h3><ul><li><strong>Fixed:</strong> Recovery code confirmation without checkbox now correctly activates the method</li><li><strong>Fixed:</strong> Fatal error in multisite when saving profile (add_settings_error not available at init)</li><li><strong>Fixed:</strong> Text inputs (OTP code, recovery confirm) no longer disabled by JS when Enable toggle is off</li><li><strong>Fixed:</strong> Regenerate codes now requires re-confirmation before reactivating</li><li><strong>Fixed:</strong> Entering a valid OTP or recovery code activates the method even without checking the checkbox</li></ul>"
"changelog": "<h3>1.3.0 - 2026-06-05</h3><ul><li><strong>Security:</strong> TOTP codes now protected against replay attacks within the same 30-second window</li><li><strong>Security:</strong> Login username no longer exposed in the 2FA redirect URL; replaced with an opaque short-lived token</li><li><strong>Added:</strong> WP-CLI command family: wp 2fa status, enable, disable, reset-recovery, list, force-setup, bypass</li></ul>"
},
"banners": {
"low": "",

View file

@ -3,7 +3,7 @@
'name' => 'robotstxt/robotstxt-2fa',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'c3e85cb9ff673d9102bf843668c66141b25f5db6',
'reference' => '0bf0a51a1ff8eb582f6e6283e80a909fa9a55b54',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -31,7 +31,7 @@
'robotstxt/robotstxt-2fa' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'c3e85cb9ff673d9102bf843668c66141b25f5db6',
'reference' => '0bf0a51a1ff8eb582f6e6283e80a909fa9a55b54',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),