Compare commits

...
Author SHA1 Message Date
c3f0eac50c v1.6.7 2026-09-18 04:36:07 +00:00
f9c073a582 v1.6.6 2026-09-17 10:58:22 +00:00
c72a96e2a0 v1.6.5 2026-09-17 10:57:29 +00:00
e568b54336 v1.6.4 2026-09-11 10:29:29 +00:00
9 changed files with 270 additions and 54 deletions

View file

@ -1,5 +1,86 @@
== Changelog ==
= 1.6.7 =
_Release date: 2026-09-17_
**Fixed**
* The forced-setup wizard felt like a broken redirect loop: the wizard redirect bounced users to the profile with no explanation (it now carries a flag that renders a clear "activate at least one verification method, then save" warning), and saving with an expired form nonce silently did nothing (it now raises a visible "form session expired" error so users know to try again).
**Compatibility**
* WordPress: 5.6 7.1
* PHP: 8.0 8.5
**Tests**
* PHP Coding Standards: PHP_CodeSniffer 3.13.6 / WPCS 3.4.1 — 0 errors
* PHPStan: level 9 — 0 errors
* PHPCompatibility: 8.08.5 — 0 issues
* PHPUnit: 9.6.36 — 98 tests, 197 assertions
= 1.6.6 =
_Release date: 2026-09-17_
**Added**
* Forced-enrollment warning on the profile 2FA section: when a user's role enforces 2FA but they have no verification method configured yet, the 2FA section of their profile (wp-admin and the frontend shortcode) opens with a warning notice asking them to activate at least one method. While a grace period is active, the notice appends the remaining days.
**Compatibility**
* WordPress: 5.6 7.1
* PHP: 8.0 8.5
**Tests**
* PHP Coding Standards: PHP_CodeSniffer 3.13.6 / WPCS 3.4.1 — 0 errors
* PHPStan: level 9 — 0 errors
* PHPCompatibility: 8.08.5 — 0 issues
* PHPUnit: 9.6.36 — 93 tests, 189 assertions
= 1.6.5 =
_Release date: 2026-09-17_
**Fixed**
* Fatal error on the login screen with newer WordPress versions: the `login_message` filter can deliver null when no message is set (observed on WordPress 7.x with PHP 8.4, TypeError on a plain visit to wp-login.php). The message callback now accepts `string|null` and coalesces null to an empty string.
* Hardened all externally-fed filter callbacks against null payloads: the four `authenticate` callbacks now accept `string|null` credentials (custom REST/SSO endpoints are known to apply the filter with null), and the `wp_redirect` filter callback coalesces a null location. Verified safe as-is: shortcode callback, Settings API sanitizer, activation hooks, and admin-hook parameters.
**Compatibility**
* WordPress: 5.6 7.1
* PHP: 8.0 8.5
**Tests**
* PHP Coding Standards: PHP_CodeSniffer 3.13.6 / WPCS 3.4.1 — 0 errors
* PHPStan: level 9 — 0 errors
* PHPCompatibility: 8.08.5 — 0 issues
* PHPUnit: 9.6.36 — 87 tests, 180 assertions
= 1.6.4 =
_Release date: 2026-09-11_
**Fixed**
* Compatibility with Restrict Content Pro's "Hijack Login URL" option: RCP's `login_url` filter made every `wp_login_url()` call return a membership page, so the 2FA verification redirect landed on a restricted page where the verification form cannot render, and the visitor was bounced to the registration page. Verification-stage URLs are now built from the canonical `wp-login.php`, mirroring WordPress core's URL construction before the filterable output. The "Back to login" link keeps the site-configured login URL.
**Compatibility**
* WordPress: 5.6 7.1
* PHP: 8.0 8.5
**Tests**
* PHP Coding Standards: PHP_CodeSniffer 3.13.6 / WPCS 3.4.1 — 0 errors
* PHPStan: level 9 — 0 errors
* PHPCompatibility: 8.08.5 — 0 issues
* PHPUnit: 9.6.36 — 78 tests, 171 assertions
= 1.6.3 =
_Release date: 2026-08-24_

View file

@ -85,13 +85,13 @@ class Geo_Restrictions {
*
* @since 1.5.0
*
* @param mixed $user Previously authenticated user or null.
* @param string $username Submitted username.
* @param string $password Submitted password.
* @param mixed $user Previously authenticated user or null.
* @param string|null $username Submitted username.
* @param string|null $password Submitted password.
*
* @return mixed
*/
public function maybe_block_denied_country( mixed $user, string $username, string $password ): mixed {
public function maybe_block_denied_country( mixed $user, ?string $username, ?string $password ): mixed {
unset( $username, $password );
$deny_list = $this->config->get_geoip_country_deny();

View file

@ -82,13 +82,13 @@ class IP_Restrictions {
*
* @since 1.3.0
*
* @param mixed $user Previously authenticated user or error.
* @param string $username Submitted username.
* @param string $password Submitted password.
* @param mixed $user Previously authenticated user or error.
* @param string|null $username Submitted username.
* @param string|null $password Submitted password.
*
* @return mixed
*/
public function maybe_block_denied_ip( mixed $user, string $username, string $password ): mixed {
public function maybe_block_denied_ip( mixed $user, ?string $username, ?string $password ): mixed {
unset( $username, $password );
$deny_list = $this->config->get_ip_deny();

View file

@ -236,6 +236,33 @@ class Login_Form_Manager {
add_action( 'init', array( $this, 'maybe_remove_altcha_interceptor' ), 0 );
}
/**
* Retrieve the canonical wp-login.php URL used for 2FA stage navigation.
*
* Builds the URL from the real wp-login.php path instead of wp_login_url().
* The `login_url` filter applied by wp_login_url() can be hijacked by
* third-party plugins (for example Restrict Content Pro's "Hijack Login
* URL" option) to return a membership page, which would send the user to a
* page where the verification form cannot render: core only fires the
* login_init hook when wp-login.php itself loads. This mirrors the URL
* construction wp_login_url() performs before its filter runs.
*
* @since 1.6.4
*
* @param string $redirect_to Optional redirect_to value to carry over.
*
* @return string Absolute wp-login.php URL with optional redirect_to parameter.
*/
public static function get_canonical_login_url( string $redirect_to = '' ): string {
$url = site_url( 'wp-login.php', 'login' );
if ( '' !== $redirect_to ) {
$url = add_query_arg( 'redirect_to', urlencode( $redirect_to ), $url );
}
return $url;
}
/**
* Disable the ALTCHA Spam Protection login interceptor during the 2FA stage.
*
@ -436,16 +463,21 @@ class Login_Form_Manager {
<?php
}
/**
* Display a contextual login message during the verification stage.
*
* @since 1.0.0
*
* @param string $message Existing login form message HTML.
*
* @return string
*/
public function filter_login_message( string $message ): string {
/**
* Display a contextual login message during the verification stage.
*
* @since 1.0.0
* @since 1.6.5 Accepts a null $message: newer WordPress versions pass
* null through the `login_message` filter when no message
* is set, which fatalled the string-typed parameter.
*
* @param string|null $message Existing login form message HTML.
*
* @return string
*/
public function filter_login_message( ?string $message ): string {
$message = $message ?? '';
if ( ! $this->is_verification_stage() ) {
return $message;
}
@ -592,12 +624,12 @@ class Login_Form_Manager {
* Attempt to finish the verification stage during authentication.
*
* @param \WP_User|\WP_Error|null $user Previously authenticated user or error.
* @param string $username Submitted username.
* @param string $password Submitted password.
* @param string|null $username Submitted username.
* @param string|null $password Submitted password.
*
* @return \WP_User|\WP_Error|null
*/
public function maybe_complete_verification( $user, string $username, string $password ) {
public function maybe_complete_verification( $user, ?string $username, ?string $password ) {
if ( ! $this->is_verification_submission() ) {
return $user;
}
@ -758,12 +790,12 @@ class Login_Form_Manager {
* Enforce the verification step for eligible users.
*
* @param \WP_User|\WP_Error|null $user Previously authenticated user or error.
* @param string $username Submitted username.
* @param string $password Submitted password.
* @param string|null $username Submitted username.
* @param string|null $password Submitted password.
*
* @return \WP_User|\WP_Error|null
*/
public function enforce_verification_challenge( $user, string $username, string $password ) {
public function enforce_verification_challenge( $user, ?string $username, ?string $password ) {
unset( $username, $password );
if ( $this->is_verification_stage() || $this->is_verification_submission() ) {
@ -863,7 +895,7 @@ class Login_Form_Manager {
self::QUERY_STAGE_TOKEN => $stage_token,
);
$login_url = wp_login_url( $redirect_to );
$login_url = self::get_canonical_login_url( $redirect_to );
$login_url = add_query_arg( $query_args, $login_url );
wp_safe_redirect( $login_url );
@ -1433,7 +1465,7 @@ class Login_Form_Manager {
*/
private function get_stage_url( array $extra_args = array() ): string {
$redirect_to = $this->get_requested_redirect();
$login_url = wp_login_url( $redirect_to );
$login_url = self::get_canonical_login_url( $redirect_to );
$args = array(
self::QUERY_STAGE => self::STAGE_VERIFY,

View file

@ -41,6 +41,7 @@ class Grace_Period {
public function register_hooks(): void {
add_action( 'admin_init', array( $this, 'maybe_redirect_to_setup_wizard' ) );
add_action( 'robotstxt_2fa_method_enabled', array( $this, 'clear_pending_setup' ), 10, 2 );
add_action( 'admin_notices', array( $this, 'maybe_show_setup_required_notice' ) );
}
/**
@ -167,10 +168,45 @@ class Grace_Period {
return;
}
wp_safe_redirect( admin_url( 'profile.php#robotstxt-2fa-settings' ) );
// The query flag lets the profile show WHY the user was redirected,
// so the mandatory setup does not feel like a silent loop.
$profile_url = add_query_arg( 'robotstxt-2fa-setup-required', '1', admin_url( 'profile.php' ) );
wp_safe_redirect( $profile_url . '#robotstxt-2fa-settings' );
exit;
}
/**
* Explain the setup wizard redirect on the profile screen.
*
* Rendered after {@see self::maybe_redirect_to_setup_wizard()} bounced the
* user back to their profile. Without it, users land on a regular profile
* page with no hint that 2FA enrollment is mandatory before they can
* continue to the dashboard which reads as a broken redirect loop.
*
* @since 1.6.7
*
* @return void
*/
public function maybe_show_setup_required_notice(): void {
if ( ! $this->is_pending_setup() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag; no state change.
if ( ! isset( $_GET['robotstxt-2fa-setup-required'] ) ) {
return;
}
?>
<div class="notice notice-warning robotstxt-2fa-setup-required">
<p>
<strong><?php esc_html_e( 'Two-factor authentication is required.', 'robotstxt-2fa' ); ?></strong>
<?php esc_html_e( 'You must activate at least one verification method in the Two-Factor Authentication section of this page, then save, before you can continue to the dashboard.', 'robotstxt-2fa' ); ?>
</p>
</div>
<?php
}
/**
* Retrieve the grace period start timestamp, creating it on first call.
*

View file

@ -97,6 +97,13 @@ class Profile_Settings {
*/
private Trusted_Devices $trusted_devices;
/**
* Grace period manager.
*
* @var Grace_Period
*/
private Grace_Period $grace_period;
/**
* Whether the post-save redirect should focus the 2FA section.
*
@ -113,6 +120,7 @@ class Profile_Settings {
$this->otp_manager = new OTP_Manager();
$this->recovery_codes = new Recovery_Codes();
$this->trusted_devices = new Trusted_Devices();
$this->grace_period = new Grace_Period();
}
/**
@ -170,6 +178,57 @@ class Profile_Settings {
}
/**
* Render the forced-enrollment warning on the profile 2FA section.
*
* Shown when the user's role enforces 2FA but no verification method is
* configured yet, so the requirement is visible before the login flow
* (grace notice, setup wizard, or login block) kicks in. When a grace
* period is active, the number of remaining days is appended.
*
* @since 1.6.6
*
* @param \WP_User $user User whose profile section is being rendered.
*
* @return void
*/
public function render_forced_enrollment_warning( \WP_User $user ): void {
$user_settings = $this->user_settings_repository->get_user_settings( $user->ID );
if ( empty( $this->config->get_required_methods_for_user( $user ) ) || ! empty( $user_settings['methods'] ) ) {
return;
}
$grace_note = '';
$grace_days = $this->config->get_grace_period_days();
if ( $grace_days > 0 && $this->grace_period->is_active( $user, $grace_days ) ) {
$remaining = $this->grace_period->get_days_remaining( $user, $grace_days );
/* translators: %d: number of days remaining to complete the setup. */
$grace_note = sprintf(
_n(
'You have %d day left to complete the setup.',
'You have %d days left to complete the setup.',
$remaining,
'robotstxt-2fa'
),
$remaining
);
}
?>
<div class="notice notice-warning inline robotstxt-2fa-forced-warning">
<p>
<strong><?php esc_html_e( 'Two-factor authentication is required for your account.', 'robotstxt-2fa' ); ?></strong>
<?php esc_html_e( 'You have not set up any verification method yet. Activate at least one method below to keep access to your account.', 'robotstxt-2fa' ); ?>
<?php if ( '' !== $grace_note ) : ?>
<em><?php echo esc_html( $grace_note ); ?></em>
<?php endif; ?>
</p>
</div>
<?php
}
/**
* Render profile settings UI.
*
@ -313,6 +372,7 @@ class Profile_Settings {
}
?>
<h2 id="<?php echo esc_attr( self::SECTION_ANCHOR ); ?>"><?php esc_html_e( 'Two-Factor Authentication', 'robotstxt-2fa' ); ?></h2>
<?php $this->render_forced_enrollment_warning( $user ); ?>
<?php wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); ?>
<table class="form-table" role="presentation">
<tbody>
@ -798,6 +858,20 @@ class Profile_Settings {
$nonce = sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) );
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
/*
* A stale form (nonce expired) previously returned silently, which
* left wizard-trapped users clicking Save with no visible result
* while the setup-required redirect kept bouncing them back to the
* profile. Fail loudly so the user knows to try again.
*/
$this->focus_section = true;
add_settings_error(
'robotstxt-2fa',
'robotstxt-2fa-nonce-expired',
__( 'Your form session expired and nothing was saved. Please try saving again.', 'robotstxt-2fa' ),
'error'
);
return;
}
@ -1113,14 +1187,16 @@ class Profile_Settings {
/**
* Append the 2FA section anchor to redirects when requested.
*
* @param string $location Redirect destination.
* @param int $status HTTP status code.
* @param string|null $location Redirect destination (null from sloppy wp_redirect() callers).
* @param int|null $status HTTP status code.
*
* @return string
*/
public function maybe_append_section_anchor( string $location, int $status ): string {
public function maybe_append_section_anchor( ?string $location, ?int $status = null ): string {
unset( $status );
$location = $location ?? '';
if ( ! $this->focus_section ) {
return $location;
}

View file

@ -4,7 +4,7 @@ Tags: security, two-factor authentication, login, otp
Requires at least: 5.6
Tested up to: 7.0
Requires PHP: 8.0
Stable tag: 1.6.3
Stable tag: 1.6.7
License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
@ -59,39 +59,30 @@ Yes. Activate the plugin at the network level. Network administrators can set an
== Changelog ==
= 1.6.3 =
= 1.6.7 =
_Release date: 2026-08-24_
**Changed**
* Manager detection now uses the ecosystem presence constant (`ROBOTSTXT_MANAGER_NOTICED`, defined by Manager 1.6.2+) with a fallback to the plugin-list scan for older Manager versions.
_Release date: 2026-09-17_
**Fixed**
* Compatibility with the ALTCHA Spam Protection plugin: when "Protect login" was enabled, submitting the 2FA verification code failed with "[ALTCHA] Sorry, your request could not be processed.". The ALTCHA interceptor is now disabled while the verification screen is shown; the first login step keeps its ALTCHA check.
* The forced-setup wizard felt like a broken redirect loop: the wizard redirect bounced users to the profile with no explanation (it now carries a flag that renders a clear "activate at least one verification method, then save" warning), and saving with an expired form nonce silently did nothing (it now raises a visible "form session expired" error so users know to try again).
= 1.6.2 =
= 1.6.6 =
_Release date: 2026-08-17_
_Release date: 2026-09-17_
**Added**
* Recommendation notice for the Manager (by ROBOTSTXT) plugin: when it is not installed and active, a dismissible notice appears on the Plugins screen and a permanent notice is shown on the plugin settings page, since updates are delivered through the Manager plugin.
* Forced-enrollment warning on the profile 2FA section: when a user's role enforces 2FA but they have no verification method configured yet, the 2FA section of their profile (wp-admin and the frontend shortcode) opens with a warning notice asking them to activate at least one method. While a grace period is active, the notice appends the remaining days.
**Changed**
= 1.6.5 =
* Updates are now handled by the Manager (by ROBOTSTXT) plugin. The bundled self-updater (`robotstxt-updater.php` and `update.json`) has been removed.
* Plugin and update URLs moved to `robotstxt.software`.
* Minimum WordPress version lowered from 6.4 to 5.6 after a full compatibility review (the code only requires WordPress 5.3+ functions, and WordPress 5.6 is the first release that runs on the required PHP 8.0).
= 1.6.1 =
_Release date: 2026-08-14_
_Release date: 2026-09-17_
**Fixed**
* Fatal error during editor autosaves: the `[robotstxt_2fa_profile]` shortcode expanded when WordPress applied content filters to a post revision via REST (for example, an article that merely mentions the shortcode in its text). In that context the wp-admin render helpers are not loaded, causing a fatal error. The shortcode now bails out early on REST requests and loads the required wp-admin includes on demand elsewhere.
* Fatal error on the login screen with newer WordPress versions: the `login_message` filter can deliver null when no message is set (observed on WordPress 7.x with PHP 8.4, TypeError on a plain visit to wp-login.php). The message callback now accepts `string|null` and coalesces null to an empty string.
* Hardened all externally-fed filter callbacks against null payloads: the four `authenticate` callbacks now accept `string|null` credentials (custom REST/SSO endpoints are known to apply the filter with null), and the `wp_redirect` filter callback coalesces a null location.
= Previous versions =

View file

@ -4,7 +4,7 @@
* Plugin URI: https://www.robotstxt.software/plugins/robotstxt-2fa/
* Update URI: https://www.robotstxt.software/plugins/robotstxt-2fa/
* Description: Adds two-factor authentication to the WordPress login flow.
* Version: 1.6.3
* Version: 1.6.7
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.software/
* Text Domain: robotstxt-2fa
@ -25,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
if ( ! defined( 'ROBOTSTXT_2FA_VERSION' ) ) {
define( 'ROBOTSTXT_2FA_VERSION', '1.6.3' );
define( 'ROBOTSTXT_2FA_VERSION', '1.6.7' );
}
if ( ! defined( 'ROBOTSTXT_2FA_FILE' ) ) {

View file

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