This commit is contained in:
Javier Casares 2026-06-05 16:40:56 +00:00
commit 17078a91bd
8 changed files with 123 additions and 49 deletions

View file

@ -1,5 +1,30 @@
== Changelog ==
= 1.2.1 =
_Release date: 2026-06-05_
**Fixed**
* Recovery code confirmation without the method checkbox now correctly activates the method.
* Fatal error on admin profile pages (add_settings_error not available at init in multisite).
* OTP and recovery text inputs no longer disabled by JS when Enable toggle is off.
* Regenerate codes now requires re-confirmation before the method reactivates.
* Entering a valid OTP or recovery code activates the method even without checking the checkbox.
* Recovery regeneration field validated with strict value check.
**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.1.0 =
_Release date: 2026-06-05_

View file

@ -62,6 +62,16 @@ class Frontend_Profile {
* @return void
*/
public function maybe_handle_frontend_save(): void {
// On wp-admin pages the profile form is already handled by the
// personal_options_update / edit_user_profile_update hooks that fire
// later in the admin bootstrap (when all admin includes are loaded).
// Running here too would call save_profile_settings() twice and
// trigger a fatal because add_settings_error() is not yet available
// at init time in admin context.
if ( is_admin() ) {
return;
}
if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
return;
}

View file

@ -383,7 +383,7 @@ class Profile_Settings {
</p>
<?php endif; ?>
<p class="description">
<?php esc_html_e( 'Enter the current six-digit code from your authenticator app to activate this method.', 'robotstxt-2fa' ); ?>
<?php esc_html_e( 'Enter the current six-digit code from your authenticator app to activate this method. Submitting a valid code activates the authenticator app even if the Enable checkbox above is not checked.', 'robotstxt-2fa' ); ?>
</p>
<label class="screen-reader-text" for="robotstxt-2fa-otp-code">
<?php esc_html_e( 'Authenticator code', 'robotstxt-2fa' ); ?>
@ -532,7 +532,10 @@ class Profile_Settings {
function applyState() {
var on = toggle.checked;
methods.querySelectorAll( 'input, select, button' ).forEach( function ( el ) {
// Only disable checkboxes and select elements — text inputs (OTP code,
// recovery confirmation) must remain submittable so entering a code
// activates the method even when the Enable toggle is off.
methods.querySelectorAll( 'input[type="checkbox"], select' ).forEach( function ( el ) {
var isRequired = el.closest( '[data-2fa-required]' );
if ( ! isRequired ) {
el.disabled = ! on;
@ -588,8 +591,18 @@ class Profile_Settings {
$raw_fmt = get_option( 'date_format' );
$fmt = is_string( $raw_fmt ) ? $raw_fmt : 'Y-m-d';
?>
<td><?php echo esc_html( wp_date( $fmt, $device['created_at'] ) ?: '' ); ?></td>
<td><?php echo esc_html( wp_date( $fmt, $device['expires_at'] ) ?: '' ); ?></td>
<td>
<?php
$created = wp_date( $fmt, $device['created_at'] );
echo esc_html( false !== $created ? $created : '' );
?>
</td>
<td>
<?php
$expires = wp_date( $fmt, $device['expires_at'] );
echo esc_html( false !== $expires ? $expires : '' );
?>
</td>
<td>
<button type="submit" class="button button-small" name="<?php echo esc_attr( self::REVOKE_DEVICE_FIELD ); ?>" value="<?php echo esc_attr( $token_hash ); ?>">
<?php esc_html_e( 'Revoke', 'robotstxt-2fa' ); ?>
@ -717,6 +730,14 @@ class Profile_Settings {
* @return void
*/
public function save_profile_settings( int $user_id ): void {
// add_settings_error() is defined in wp-admin/includes/template.php which
// may not be loaded when this method is called outside of a standard admin
// request (e.g. via the [robotstxt_2fa_profile] shortcode on the frontend
// or during the init hook on multisite). Load it on demand.
if ( ! function_exists( 'add_settings_error' ) ) {
require_once ABSPATH . 'wp-admin/includes/template.php';
}
if ( ! current_user_can( 'edit_user', $user_id ) ) {
return;
}
@ -795,7 +816,7 @@ class Profile_Settings {
$recovery_was_enabled = in_array( 'recovery_codes', $previous_methods, true );
// Handle recovery code regeneration request — replaces the full batch.
if ( isset( $_POST[ self::RECOVERY_REGENERATE_FIELD ] ) && $recovery_was_enabled ) {
if ( '1' === ( isset( $_POST[ self::RECOVERY_REGENERATE_FIELD ] ) && is_string( $_POST[ self::RECOVERY_REGENERATE_FIELD ] ) ? $_POST[ self::RECOVERY_REGENERATE_FIELD ] : '' ) && $recovery_was_enabled ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
try {
$result = $this->recovery_codes->regenerate_codes_for_user( $user );
} catch ( \Throwable $exception ) {
@ -812,6 +833,19 @@ class Profile_Settings {
}
$this->remember_recovery_preview( $user->ID, $result['codes'], $result['generated_at'] );
// Temporarily remove recovery_codes from active methods so the confirmation
// field is shown, exactly as in the initial-activation flow.
$methods_without_recovery = array_values( array_diff( $previous_methods, array( 'recovery_codes' ) ) );
$this->user_settings_repository->save_user_settings(
$user_id,
array(
'enabled' => $is_forced || ! empty( $methods_without_recovery ),
'methods' => $methods_without_recovery,
'frequency' => $user_settings['frequency'],
)
);
$this->focus_section = true;
add_settings_error( 'robotstxt-2fa', 'robotstxt-2fa-recovery-regenerated', __( 'New recovery codes generated. Copy them and confirm one to activate.', 'robotstxt-2fa' ), 'updated' );
return;
@ -837,7 +871,13 @@ class Profile_Settings {
$selected_methods[] = 'email';
}
$otp_requested = in_array( 'otp', $requested_methods, true );
$otp_requested = in_array( 'otp', $requested_methods, true );
// If the user entered a code without checking the checkbox, treat it as an activation attempt.
if ( ! $otp_was_enabled && ! $otp_requested && '' !== $otp_code ) {
$otp_requested = true;
}
$otp_should_remain_enabled = false;
if ( $otp_was_enabled ) {
@ -868,6 +908,7 @@ class Profile_Settings {
);
} elseif ( $this->otp_manager->verify_code( $user, $otp_code ) ) {
$selected_methods[] = 'otp';
$enabled = true;
$this->focus_section = true;
add_settings_error(
'robotstxt-2fa',
@ -933,7 +974,8 @@ class Profile_Settings {
);
}
} elseif ( $recovery_confirmation_success ) {
$selected_methods[] = 'recovery_codes';
$selected_methods[] = 'recovery_codes';
$enabled = true;
} elseif ( $recovery_requested ) {
$this->focus_section = true;
add_settings_error(

View file

@ -92,10 +92,7 @@ class Two_Factor_Config {
* @param array<int, string> $required Method slugs required by the user's roles.
* @param \WP_User $user User being authenticated.
*/
/**
* @var array<int, string> $filtered
* Note: at runtime a callback may return any type; the map below sanitizes it.
*/
// @phpstan-var array<int, string> $filtered — apply_filters returns mixed; sanitized below.
$filtered = apply_filters( 'robotstxt_2fa_required_methods_for_user', $required, $user );
return array_values( array_filter( $filtered ) );

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.0
Stable tag: 1.2.1
License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
@ -59,51 +59,51 @@ Yes. Activate the plugin at the network level. Network administrators can set an
== Changelog ==
= 1.2.1 =
_Release date: 2026-06-05_
**Fixed**
* Recovery code confirmation without checking the method checkbox now correctly activates the method and enables 2FA.
* Fatal error on admin profile pages in multisite — `maybe_handle_frontend_save()` now returns early when `is_admin()` is true.
* OTP and recovery confirmation text inputs were disabled by JS when the Enable toggle was off, preventing values from reaching the server.
* "Regenerate codes" now removes recovery codes from active methods and requires re-confirmation before reactivating.
* Entering a valid OTP code or recovery confirmation code activates the method even without checking the Enable checkbox.
**Security**
* `RECOVERY_REGENERATE_FIELD` POST value now validated with strict `=== '1'` check.
= 1.1.0 =
_Release date: 2026-06-05_
* Added `[robotstxt_2fa_profile]` shortcode for frontend 2FA management without wp-admin.
* Added developer action/filter hooks: `robotstxt_2fa_skip_challenge`, `robotstxt_2fa_verification_success`, `robotstxt_2fa_verification_failed`, `robotstxt_2fa_code_length`, `robotstxt_2fa_code_ttl`, and more.
* Added "Regenerate codes" button on the profile when recovery codes are active.
* Added top-up mode for recovery codes: generates only the missing codes to refill the batch.
**Added**
* `[robotstxt_2fa_profile]` shortcode for frontend 2FA management without wp-admin.
* Developer action/filter hooks: `robotstxt_2fa_skip_challenge`, `robotstxt_2fa_verification_success`, `robotstxt_2fa_verification_failed`, `robotstxt_2fa_code_length`, `robotstxt_2fa_code_ttl`, and more.
* "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.
* Fixed: QR code now renders correctly (data URI was stripped by esc_url).
* Fixed: Resend link shows a 60-second live countdown to prevent email flooding.
* Changed: Recovery codes section redesigned — plain list, no green notice box.
* Changed: 2FA login links now a vertical list instead of inline bullets.
* Removed: Redundant "Generate new secret" button from OTP section.
**Fixed**
= 0.3.0 =
* 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.
_Release date: 2026-06-05_
**Changed**
* Added delete-on-uninstall option (data preserved by default per AGENTS.md).
* Security: email verification codes now use `random_int()` (CSPRNG) instead of `wp_rand()`.
* Security: recovery code preview transient now expires after 5 minutes.
* Fixed network admin settings page form submission.
* Fixed settings option set to `autoload=false` (not needed on every page load).
* Recovery codes section redesigned — plain list, no green notice box.
* 2FA login links now a vertical list instead of inline bullets.
= 0.2.0 =
**Removed**
_Release date: 2026-06-05_
* Added per-role 2FA method matrix in admin settings with select-all row and column controls.
* Added authenticator app (TOTP) support with QR provisioning and manual setup key.
* Added recovery codes: 10 single-use 8-digit codes with confirmation workflow.
* Added configurable verification frequency remembered per device.
* Added network-wide multisite support.
* Changed profile method checkboxes to require explicit confirmation before activation.
* Fixed recovery-code preview persistence and QR generation error handling.
= 0.1.0 =
Initial release.
* Redundant "Generate new secret" button from OTP section.
= Previous versions =

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.0
* Version: 1.2.1
* 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.0' );
define( 'ROBOTSTXT_2FA_VERSION', '1.2.1' );
}
if ( ! defined( 'ROBOTSTXT_2FA_FILE' ) ) {

View file

@ -1,8 +1,8 @@
{
"name": "2FA (by ROBOTSTXT)",
"slug": "robotstxt-2fa",
"version": "1.2.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.0.0/robotstxt-2fa-1.0.0.zip",
"version": "1.2.1",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-2fa/releases/download/1.2.1/robotstxt-2fa-1.2.1.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.0.0 - 2026-06-05</h3><ul><li><strong>Fixed:</strong> Enable 2FA toggle defaults to email on first activation</li><li><strong>Fixed:</strong> QR code now displays correctly in user profiles</li><li><strong>Fixed:</strong> Resend link shows 60-second countdown</li><li><strong>Changed:</strong> Recovery codes redesigned as plain list</li><li><strong>Changed:</strong> Login links now vertical list</li></ul>",
"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>",
"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.0.0 - 2026-06-05</h3><ul><li><strong>Fixed:</strong> Enable 2FA toggle defaults to email on first activation</li><li><strong>Fixed:</strong> QR code now displays correctly in user profiles</li><li><strong>Fixed:</strong> Resend link shows 60-second countdown</li><li><strong>Changed:</strong> Recovery codes redesigned as plain list</li><li><strong>Changed:</strong> Login links now vertical list</li></ul>"
"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>"
},
"banners": {
"low": "",

View file

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