robotstxt-2fa/includes/login/class-login-form-manager.php
2026-08-24 08:18:14 +00:00

1550 lines
47 KiB
PHP

<?php
/**
* Manage login form integrations.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA\Login;
use Robotstxt\TwoFA\Frequency_Options;
use Robotstxt\TwoFA\User\Grace_Period;
use Robotstxt\TwoFA\User\OTP_Manager;
use Robotstxt\TwoFA\User\Recovery_Codes;
use Robotstxt\TwoFA\User\Trusted_Devices;
use Robotstxt\TwoFA\User\Two_Factor_Config;
use Robotstxt\TwoFA\User\User_Settings_Repository;
use WP_User;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles the login form additional 2FA fields.
*/
class Login_Form_Manager {
/**
* Nonce action for login form submissions.
*/
private const LOGIN_NONCE_ACTION = 'robotstxt_2fa_login';
/**
* Nonce field name embedded in the login form.
*/
private const LOGIN_NONCE_FIELD = 'robotstxt_2fa_login_nonce';
/**
* Query argument identifying the second authentication stage.
*/
private const QUERY_STAGE = 'robotstxt-2fa';
/**
* Query argument storing the verification method.
*/
private const QUERY_METHOD = 'robotstxt-2fa-method';
/**
* Query argument carrying the nonce that validates the verification stage.
*/
private const QUERY_STAGE_TOKEN = 'robotstxt-2fa-stage-token';
/**
* Query argument requesting a new verification code delivery.
*/
private const QUERY_RESEND = 'robotstxt-2fa-resend';
/**
* Query argument providing additional contextual notices for the stage.
*/
private const QUERY_NOTICE = 'robotstxt-2fa-notice';
/**
* Query argument requesting a verification method switch during the stage.
*/
private const QUERY_SWITCH = 'robotstxt-2fa-switch';
/**
* Query argument carrying the opaque pending-stage token (replaces user_login in URLs).
*/
private const QUERY_PENDING = 'robotstxt-2fa-pending';
/**
* Form field containing the active stage marker.
*/
private const FIELD_STAGE = 'robotstxt_2fa_stage';
/**
* Form field carrying the opaque pending-stage token.
*/
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.
*/
private const FIELD_STAGE_TOKEN = 'robotstxt_2fa_stage_token';
/**
* Identifier for the verification stage.
*/
private const STAGE_VERIFY = 'verify';
/**
* Nonce action used to secure verification stage requests.
*/
private const STAGE_TOKEN_ACTION = 'robotstxt_2fa_stage_token';
/**
* Email code sender instance.
*
* @var Email_Code_Sender
*/
private Email_Code_Sender $email_code_sender;
/**
* OTP manager instance.
*
* @var OTP_Manager
*/
private OTP_Manager $otp_manager;
/**
* Recovery codes manager.
*
* @var Recovery_Codes
*/
private Recovery_Codes $recovery_codes;
/**
* User settings repository.
*
* @var User_Settings_Repository
*/
private User_Settings_Repository $user_settings_repository;
/**
* Global two-factor configuration helper.
*
* @var Two_Factor_Config
*/
private Two_Factor_Config $config;
/**
* Trusted devices manager.
*
* @var Trusted_Devices
*/
private Trusted_Devices $trusted_devices;
/**
* Grace period manager.
*
* @var Grace_Period
*/
private Grace_Period $grace_period;
/**
* Constructor.
*/
public function __construct() {
$this->email_code_sender = new Email_Code_Sender();
$this->otp_manager = new OTP_Manager();
$this->recovery_codes = new Recovery_Codes();
$this->user_settings_repository = new User_Settings_Repository();
$this->config = new Two_Factor_Config();
$this->trusted_devices = new Trusted_Devices();
$this->grace_period = new Grace_Period();
}
/**
* 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();
$user = $this->resolve_pending_user();
if ( null === $user ) {
return;
}
$stage_token = $this->get_requested_stage_token();
if ( '' === $stage_token || ! $this->validate_stage_token( $user, $stage_token ) ) {
return;
}
$available_methods = $this->get_enabled_verification_methods( $user );
if ( ! in_array( $method, $available_methods, true ) ) {
return;
}
$redirect_args = array(
self::QUERY_METHOD => $method,
self::QUERY_NOTICE => null,
self::QUERY_RESEND => null,
self::QUERY_SWITCH => null,
);
if ( 'email' === $method ) {
if ( ! $this->email_code_sender->send_code( $user ) ) {
$redirect_args[ self::QUERY_NOTICE ] = 'failed';
} else {
$redirect_args[ self::QUERY_NOTICE ] = 'resent';
}
}
$redirect_url = $this->get_stage_url( $redirect_args );
wp_safe_redirect( $redirect_url );
exit;
}
/**
* Register WordPress hooks.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'login_init', array( $this, 'maybe_process_stage_requests' ) );
add_action( 'login_form', array( $this, 'render_form_fields' ) );
add_action( 'login_head', array( $this, 'render_stage_styles' ) );
add_action( 'login_footer', array( $this, 'render_stage_scripts' ) );
add_filter( 'login_message', array( $this, 'filter_login_message' ) );
add_filter( 'authenticate', array( $this, 'maybe_complete_verification' ), 5, 3 );
add_filter( 'authenticate', array( $this, 'enforce_verification_challenge' ), 30, 3 );
add_action( 'init', array( $this, 'maybe_remove_altcha_interceptor' ), 0 );
}
/**
* Disable the ALTCHA Spam Protection login interceptor during the 2FA stage.
*
* ALTCHA's "Protect login" feature rejects any wp-login.php POST that does
* not carry a fresh proof-of-work payload (HTTP 403). Its JavaScript only
* produces that payload for classic username + password submissions, while
* the verification stage intentionally omits the password field, so every
* legitimate 2FA submission would otherwise be blocked. Human verification
* already happened on the first login step; this stage is protected by the
* pending-stage token, the stage nonce, and the failed-attempts lockout.
*
* @since 1.6.2
*
* @return void
*/
public function maybe_remove_altcha_interceptor(): void {
if ( ! $this->is_verification_stage() ) {
return;
}
if ( ! has_action( 'init', 'altcha_interceptor' ) ) {
return;
}
global $wp_filter;
if ( ! isset( $wp_filter['init'] ) || ! $wp_filter['init'] instanceof \WP_Hook ) {
return;
}
foreach ( array_keys( $wp_filter['init']->callbacks ) as $priority ) {
if ( isset( $wp_filter['init']->callbacks[ $priority ]['altcha_interceptor'] ) ) {
remove_action( 'init', 'altcha_interceptor', (int) $priority );
return;
}
}
}
/**
* Render custom fields on the login form.
*
* @since 1.0.0
*
* @return void
*/
public function render_form_fields(): void {
if ( ! $this->is_verification_stage() ) {
return;
}
$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 ( $pending_user instanceof WP_User ) {
$alternates = $this->get_alternate_method_links( $pending_user, $method );
}
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 ) );
printf( '<input type="hidden" name="%1$s" value="%2$s" />', esc_attr( self::FIELD_STAGE ), esc_attr( self::STAGE_VERIFY ) );
$stage_token = $this->get_requested_stage_token();
if ( '' !== $stage_token ) {
printf( '<input type="hidden" name="%1$s" value="%2$s" />', esc_attr( self::FIELD_STAGE_TOKEN ), esc_attr( $stage_token ) );
}
wp_nonce_field( self::LOGIN_NONCE_ACTION, self::LOGIN_NONCE_FIELD );
?>
<p class="robotstxt-2fa-login-field robotstxt-2fa-stage-field">
<label for="robotstxt-2fa-token"><?php esc_html_e( 'Verification code', 'robotstxt-2fa' ); ?><br />
<input type="tel" name="robotstxt_2fa_token" id="robotstxt-2fa-token" class="input" value="" size="<?php echo absint( $code_length ); ?>" maxlength="<?php echo absint( $code_length ); ?>" pattern="\d{<?php echo absint( $code_length ); ?>}" inputmode="numeric" autocomplete="one-time-code" required />
</label>
</p>
<?php
$instruction = '';
switch ( $method ) {
case 'otp':
$instruction = sprintf(
/* translators: %d: verification code length. */
__( 'Enter the %d-digit code from your authenticator app.', 'robotstxt-2fa' ),
(int) $code_length
);
break;
case 'recovery':
$instruction = sprintf(
/* translators: %d: verification code length. */
__( 'Enter one of your %d-character recovery codes.', 'robotstxt-2fa' ),
(int) $code_length
);
break;
case 'email':
default:
$instruction = sprintf(
/* translators: %d: verification code length. */
__( 'Enter the %d-digit verification code we sent to your email.', 'robotstxt-2fa' ),
(int) $code_length
);
}
if ( '' !== $instruction ) {
printf(
'<p class="robotstxt-2fa-login-instructions description robotstxt-2fa-stage-field">%s</p>',
esc_html( $instruction )
);
}
// Trust-device duration follows the user's profile verification frequency.
$trust_days = $pending_user instanceof WP_User ? $this->get_trust_days_for_user( $pending_user ) : 0;
if ( $trust_days > 0 ) {
?>
<p class="robotstxt-2fa-trust-field robotstxt-2fa-stage-field">
<label>
<input type="checkbox" name="robotstxt_2fa_trust_device" value="1" />
<?php
/* translators: %d: number of days the device trust lasts. */
$trust_label = _n(
'Remember this browser for %d day',
'Remember this browser for %d days',
$trust_days,
'robotstxt-2fa'
);
echo esc_html( sprintf( $trust_label, absint( $trust_days ) ) );
?>
</label>
</p>
<?php
}
$back_url = wp_login_url( $this->get_requested_redirect() );
?>
<ul class="robotstxt-2fa-login-links robotstxt-2fa-stage-field">
<?php if ( 'email' === $method && '' !== $stage_token ) : ?>
<li>
<a class="robotstxt-2fa-resend-link" href="
<?php
echo esc_url(
$this->get_stage_url(
array(
self::QUERY_RESEND => '1',
self::QUERY_NOTICE => null,
)
)
);
?>
">
<?php esc_html_e( 'Send the code again', 'robotstxt-2fa' ); ?>
</a>
</li>
<?php endif; ?>
<?php foreach ( $alternates as $alternate ) : ?>
<li>
<a href="<?php echo esc_url( $alternate['url'] ); ?>"><?php echo esc_html( $alternate['label'] ); ?></a>
</li>
<?php endforeach; ?>
<li><a class="robotstxt-2fa-back-link" href="<?php echo esc_url( $back_url ); ?>"><?php esc_html_e( 'Back to login', 'robotstxt-2fa' ); ?></a></li>
</ul>
<?php
}
/**
* Output inline styles used during the verification stage.
*
* @return void
*/
public function render_stage_styles(): void {
if ( ! $this->is_verification_stage() ) {
return;
}
?>
<style id="robotstxt-2fa-stage-styles">
#loginform .user-pass-wrap,
#loginform .login-password,
#loginform label[for="user_pass"],
#loginform #user_pass,
#loginform .wp-pwd,
#loginform .wp-hide-pw {
display: none !important;
}
.robotstxt-2fa-login-links {
list-style: none;
margin: 0.5em 0 0;
padding: 0;
}
.robotstxt-2fa-login-links li {
margin: 0.4em 0;
}
</style>
<?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 {
if ( ! $this->is_verification_stage() ) {
return $message;
}
$user = $this->resolve_pending_user();
$method = $this->get_requested_method();
if ( ! $user instanceof WP_User ) {
return $message;
}
$notice = $this->get_requested_notice();
if ( 'email' === $method ) {
$challenge = $this->user_settings_repository->get_email_challenge( $user->ID );
if ( empty( $challenge['sent_at'] ) ) {
return $message;
}
$message .= '<p class="message robotstxt-2fa-stage-message">' . esc_html__( 'We sent a verification code to your email. Enter it below to continue.', 'robotstxt-2fa' ) . '</p>';
if ( 'resent' === $notice ) {
$message .= '<p class="message robotstxt-2fa-stage-message">' . esc_html__( 'We sent you a fresh verification code. Check your inbox.', 'robotstxt-2fa' ) . '</p>';
} elseif ( 'failed' === $notice ) {
$message .= '<p class="message robotstxt-2fa-stage-message">' . esc_html__( 'We could not send a new verification code. Please try again in a moment.', 'robotstxt-2fa' ) . '</p>';
}
} elseif ( 'otp' === $method ) {
$message .= '<p class="message robotstxt-2fa-stage-message">' . esc_html__( 'Open your authenticator app and enter the code to continue.', 'robotstxt-2fa' ) . '</p>';
} elseif ( 'recovery' === $method ) {
$message .= '<p class="message robotstxt-2fa-stage-message">' . esc_html__( 'Enter one of your unused recovery codes to continue.', 'robotstxt-2fa' ) . '</p>';
}
return $message;
}
/**
* Output helper scripts used during the verification stage.
*
* @since 1.0.0
*
* @return void
*/
public function render_stage_scripts(): void {
if ( ! $this->is_verification_stage() ) {
return;
}
$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 && $pending_user instanceof \WP_User ) {
$challenge = $this->user_settings_repository->get_email_challenge( $pending_user->ID );
$sent_at = $challenge['sent_at'];
}
?>
<script>
(function() {
var stageFields = document.querySelectorAll('.robotstxt-2fa-stage-field');
for ( var i = 0; i < stageFields.length; i++ ) {
stageFields[ i ].style.display = '';
}
var loginField = document.getElementById('user_login');
if ( loginField ) {
if ( '<?php echo esc_js( $login ); ?>' ) {
loginField.value = '<?php echo esc_js( $login ); ?>';
}
loginField.readOnly = true;
loginField.setAttribute('autocomplete', 'username');
var loginFieldWrapper = loginField.closest('p');
if ( loginFieldWrapper ) {
loginFieldWrapper.style.display = 'none';
}
}
var passwordField = document.getElementById('user_pass');
if ( passwordField ) {
var passwordWrapper = passwordField.closest('p');
if ( passwordWrapper ) {
passwordWrapper.style.display = 'none';
}
passwordField.value = '';
passwordField.removeAttribute('required');
}
var rememberMe = document.querySelector('#loginform p.forgetmenot');
if ( rememberMe ) {
rememberMe.style.display = 'none';
}
var submitWrapper = document.querySelector('#loginform p.submit');
if ( submitWrapper ) {
submitWrapper.classList.add('robotstxt-2fa-stage-field');
var submitButton = submitWrapper.querySelector('input[type="submit"]');
if ( submitButton ) {
submitButton.value = '<?php echo esc_js( __( 'Verify code', 'robotstxt-2fa' ) ); ?>';
}
}
var navLinks = document.getElementById('nav');
if ( navLinks ) {
navLinks.style.display = 'none';
}
var tokenField = document.getElementById('robotstxt-2fa-token');
if ( tokenField ) {
tokenField.focus();
}
/* Resend link cooldown */
var resendLink = document.querySelector('.robotstxt-2fa-resend-link');
if ( resendLink ) {
var sentAt = <?php echo absint( $sent_at ); ?>;
var cooldown = <?php echo absint( $this->email_code_sender->get_resend_interval() ); ?>;
var label = resendLink.textContent.trim();
function updateResend() {
var remaining = cooldown - ( Math.floor( Date.now() / 1000 ) - sentAt );
if ( remaining <= 0 ) {
resendLink.removeAttribute('aria-disabled');
resendLink.style.opacity = '';
resendLink.style.pointerEvents = '';
resendLink.textContent = label;
} else {
resendLink.setAttribute('aria-disabled', 'true');
resendLink.style.opacity = '0.45';
resendLink.style.pointerEvents = 'none';
resendLink.textContent = label + ' (' + remaining + 's)';
setTimeout( updateResend, 1000 );
}
}
updateResend();
}
})();
</script>
<?php
}
/**
* 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.
*
* @return \WP_User|\WP_Error|null
*/
public function maybe_complete_verification( $user, string $username, string $password ) {
if ( ! $this->is_verification_submission() ) {
return $user;
}
unset( $password );
if ( ! isset( $_POST[ self::LOGIN_NONCE_FIELD ] ) || ! is_string( $_POST[ self::LOGIN_NONCE_FIELD ] ) ) {
return new \WP_Error( 'robotstxt_2fa_missing_nonce', __( 'The verification form is missing a security token. Please try again.', 'robotstxt-2fa' ) );
}
$nonce = sanitize_text_field( wp_unslash( $_POST[ self::LOGIN_NONCE_FIELD ] ) );
if ( ! wp_verify_nonce( $nonce, self::LOGIN_NONCE_ACTION ) ) {
return new \WP_Error( 'robotstxt_2fa_invalid_nonce', __( 'The verification request expired. Please sign in again to request a new code.', 'robotstxt-2fa' ) );
}
$authenticated_user = $this->resolve_pending_user();
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' ) );
}
// Brute-force: check if the account is currently locked out.
$lock_key = 'robotstxt_2fa_locked_' . $authenticated_user->ID;
$locked = get_transient( $lock_key );
if ( false !== $locked ) {
$remaining_seconds = max( 0, ( is_numeric( $locked ) ? (int) $locked : 0 ) - time() );
$remaining_minutes = (int) ceil( $remaining_seconds / 60 );
return new \WP_Error(
'robotstxt_2fa_locked',
sprintf(
/* translators: %d: minutes remaining. */
_n(
'Too many failed attempts. Please try again in %d minute.',
'Too many failed attempts. Please try again in %d minutes.',
$remaining_minutes,
'robotstxt-2fa'
),
$remaining_minutes
)
);
}
$stage_token = $this->get_requested_stage_token();
if ( '' === $stage_token || ! $this->validate_stage_token( $authenticated_user, $stage_token ) ) {
return new \WP_Error( 'robotstxt_2fa_invalid_stage', __( 'Your verification session expired. Please sign in again.', 'robotstxt-2fa' ) );
}
$method = $this->get_requested_method();
$allowed_methods = $this->get_enabled_verification_methods( $authenticated_user );
if ( ! in_array( $method, $allowed_methods, true ) ) {
return new \WP_Error( 'robotstxt_2fa_method_unavailable', __( 'The selected verification method is not available right now.', 'robotstxt-2fa' ) );
}
$token = '';
if ( isset( $_POST['robotstxt_2fa_token'] ) && is_string( $_POST['robotstxt_2fa_token'] ) ) {
$token = sanitize_text_field( wp_unslash( $_POST['robotstxt_2fa_token'] ) );
}
if ( '' === $token ) {
return new \WP_Error( 'robotstxt_2fa_missing_code', __( 'Enter the verification code to continue.', 'robotstxt-2fa' ) );
}
$is_valid = false;
switch ( $method ) {
case 'email':
$is_valid = $this->email_code_sender->verify_code( $authenticated_user, $token );
if ( $is_valid ) {
$this->user_settings_repository->delete_email_challenge( $authenticated_user->ID );
}
break;
case 'recovery':
$is_valid = $this->recovery_codes->consume_code( $authenticated_user, $token );
break;
case 'otp':
$is_valid = $this->otp_manager->verify_code( $authenticated_user, $token );
break;
default:
return new \WP_Error( 'robotstxt_2fa_method_unavailable', __( 'The selected verification method is not available right now.', 'robotstxt-2fa' ) );
}
if ( ! $is_valid ) {
/**
* Fires when a 2FA verification attempt fails.
*
* @since 1.0.0
*
* @param \WP_User $user User who failed the challenge.
* @param string $method Verification method slug that was attempted.
*/
do_action( 'robotstxt_2fa_verification_failed', $authenticated_user, $method );
// Brute-force: increment attempt counter and lock if threshold reached.
$max_attempts = $this->config->get_max_attempts();
if ( $max_attempts > 0 ) {
$attempts_key = 'robotstxt_2fa_attempts_' . $authenticated_user->ID;
$raw_attempts = get_transient( $attempts_key );
$attempts = ( is_numeric( $raw_attempts ) ? (int) $raw_attempts : 0 ) + 1;
set_transient( $attempts_key, $attempts, 30 * MINUTE_IN_SECONDS );
if ( $attempts >= $max_attempts ) {
delete_transient( $attempts_key );
$expires_at = time() + $this->config->get_lockout_duration() * MINUTE_IN_SECONDS;
set_transient( $lock_key, $expires_at, $this->config->get_lockout_duration() * MINUTE_IN_SECONDS );
}
}
return new \WP_Error( 'robotstxt_2fa_invalid_code', __( 'The verification code is incorrect or has expired.', 'robotstxt-2fa' ) );
}
// 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,
$this->get_login_context_key(),
time()
);
// Trusted devices: set cookie when user checked "Remember this browser".
// Duration complies with the user's profile verification frequency.
if ( isset( $_POST['robotstxt_2fa_trust_device'] ) && '1' === $_POST['robotstxt_2fa_trust_device'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce already verified above.
$trust_days = $this->get_trust_days_for_user( $authenticated_user );
if ( $trust_days > 0 ) {
$this->trusted_devices->trust_current_device( $authenticated_user, $trust_days );
}
}
/**
* Fires after a successful 2FA verification.
*
* @since 1.0.0
*
* @param \WP_User $user Authenticated user.
* @param string $method Verification method slug that succeeded.
*/
do_action( 'robotstxt_2fa_verification_success', $authenticated_user, $method );
return $authenticated_user;
}
/**
* 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.
*
* @return \WP_User|\WP_Error|null
*/
public function enforce_verification_challenge( $user, string $username, string $password ) {
unset( $username, $password );
if ( $this->is_verification_stage() || $this->is_verification_submission() ) {
return $user;
}
if ( ! $user instanceof \WP_User ) {
return $user;
}
/**
* Filter whether to skip the 2FA challenge for the current request.
*
* Return true to bypass the challenge entirely. Use this for REST API
* requests, WP-CLI, Application Passwords, trusted IPs, etc.
*
* @since 1.0.0
*
* @param bool $skip Whether to skip the challenge. Default false.
* @param \WP_User $user Authenticated user about to be challenged.
*/
if ( (bool) apply_filters( 'robotstxt_2fa_skip_challenge', false, $user ) ) {
return $user;
}
// Application Passwords: skip challenge for requests authenticated via Application Passwords.
if ( $this->config->is_app_passwords_exempt() && (bool) did_action( 'application_password_did_authenticate' ) ) {
return $user;
}
// Trusted devices: skip challenge when the browser carries a valid trust cookie.
if ( $this->config->get_trust_device_days() > 0 && $this->trusted_devices->is_trusted( $user ) ) {
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.
if ( ! empty( $required_methods ) ) {
$user_settings = $this->user_settings_repository->get_user_settings( $user->ID );
$has_any_method = ! empty( $user_settings['methods'] );
if ( ! $has_any_method ) {
$grace_days = $this->config->get_grace_period_days();
if ( $grace_days > 0 && $this->grace_period->is_active( $user, $grace_days ) ) {
// Still within grace period — let the user through.
$remaining = $this->grace_period->get_days_remaining( $user, $grace_days );
set_transient(
'robotstxt_2fa_grace_notice_' . $user->ID,
$remaining,
MINUTE_IN_SECONDS
);
return $user;
}
if ( $grace_days > 0 ) {
// Grace period has expired.
if ( 'wizard' === $this->config->get_grace_period_action() ) {
$this->grace_period->set_pending_setup( $user->ID );
return $user;
}
// Block mode.
return new \WP_Error(
'robotstxt_2fa_setup_required',
__( 'Your account requires two-factor authentication. Please contact your administrator to complete the setup.', 'robotstxt-2fa' )
);
}
}
}
$method = $this->determine_required_verification_method( $user );
if ( '' === $method ) {
return $user;
}
if ( 'email' === $method && ! $this->email_code_sender->send_code( $user ) ) {
return new \WP_Error( 'robotstxt_2fa_email_failed', __( 'We could not send a verification code. Please contact the site administrator.', 'robotstxt-2fa' ) );
}
$redirect_to = $this->get_requested_redirect();
$stage_token = wp_create_nonce( self::STAGE_TOKEN_ACTION . '|' . $user->ID );
$query_args = array(
self::QUERY_STAGE => self::STAGE_VERIFY,
self::QUERY_METHOD => $method,
self::QUERY_PENDING => $this->issue_pending_token( $user->ID ),
self::QUERY_STAGE_TOKEN => $stage_token,
);
$login_url = wp_login_url( $redirect_to );
$login_url = add_query_arg( $query_args, $login_url );
wp_safe_redirect( $login_url );
exit;
}
/**
* Process additional verification stage actions triggered from the login screen.
*
* @return void
*/
public function maybe_process_stage_requests(): void {
if ( ! $this->is_verification_stage() ) {
return;
}
$has_switch = ! empty( $_GET[ self::QUERY_SWITCH ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Query flag only; nonce/stage-token verified inside process_method_switch().
$has_resend = ! empty( $_GET[ self::QUERY_RESEND ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Query flag only; stage-token verified before sending.
if ( $has_switch ) {
$this->process_method_switch();
return;
}
if ( ! $has_resend ) {
return;
}
$method = $this->get_requested_method();
if ( 'email' !== $method ) {
return;
}
$user = $this->resolve_pending_user();
if ( null === $user ) {
return;
}
$stage_token = $this->get_requested_stage_token();
if ( '' === $stage_token || ! $this->validate_stage_token( $user, $stage_token ) ) {
return;
}
$result = $this->email_code_sender->send_code( $user );
$notice_value = $result ? 'resent' : 'failed';
$redirect_url = remove_query_arg(
self::QUERY_RESEND,
$this->get_stage_url(
array(
self::QUERY_NOTICE => $notice_value,
)
)
);
wp_safe_redirect( $redirect_url );
exit;
}
/**
* Retrieve the verification method requested by the current stage.
*
* @return string
*/
private function get_requested_method(): string {
$method = '';
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Stage helper; nonce verified in calling context.
if ( isset( $_REQUEST[ self::FIELD_METHOD ] ) && is_string( $_REQUEST[ self::FIELD_METHOD ] ) ) {
$method = sanitize_key( wp_unslash( $_REQUEST[ self::FIELD_METHOD ] ) );
}
if ( '' === $method && isset( $_REQUEST[ self::QUERY_METHOD ] ) && is_string( $_REQUEST[ self::QUERY_METHOD ] ) ) {
$method = sanitize_key( wp_unslash( $_REQUEST[ self::QUERY_METHOD ] ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( '' === $method ) {
$method = 'email';
}
return $method;
}
/**
* Determine the expected code length for a verification method.
*
* @param string $method Verification method identifier.
*
* @return int
*/
private function get_method_code_length( string $method ): int {
switch ( $method ) {
case 'otp':
return max( 1, (int) $this->otp_manager->get_code_length() );
case 'recovery':
return max( 1, (int) $this->recovery_codes->get_code_length() );
case 'email':
default:
return max( 1, (int) $this->email_code_sender->get_code_length() );
}
}
/**
* Retrieve the verification methods that are active for the provided user.
*
* Role enforcement is treated as a floor, not a ceiling: when 2FA is
* enforced by role, the required methods are guaranteed to be set up on the
* profile (enforced at save time), but the user may authenticate with ANY
* method they have configured — not only the role-required ones. If the
* user has not configured any method yet, the role-required methods that
* the system can deliver (e.g. email) are returned so the login can still
* be challenged.
*
* @param \WP_User $user User attempting to authenticate.
* @param array{enabled: bool, methods: array<int, string>, frequency: string, preferred_method: string}|null $user_settings Optional preloaded settings.
*
* @return array<int, string>
*/
private function get_enabled_verification_methods( \WP_User $user, ?array $user_settings = null ): array {
if ( null === $user_settings ) {
$user_settings = $this->user_settings_repository->get_user_settings( $user->ID );
}
$required_methods = $this->config->get_required_methods_for_user( $user );
$is_forced = ! empty( $required_methods );
$enabled = $is_forced ? true : $user_settings['enabled'];
if ( ! $enabled ) {
return array();
}
// Normalize user's profile methods to login-stage slugs.
$profile_methods = array();
foreach ( $user_settings['methods'] as $method_key ) {
$slug = $this->normalize_method_slug( $method_key );
if ( '' !== $slug ) {
$profile_methods[] = $slug;
}
}
$profile_methods = array_values( array_unique( $profile_methods ) );
if ( $is_forced && empty( $profile_methods ) ) {
// User has not configured any method yet — fall back to the methods the
// system can deliver from the required set so a challenge is still possible.
$required_slugs = array();
foreach ( $required_methods as $method ) {
$slug = $this->normalize_method_slug( $method );
if ( '' !== $slug ) {
$required_slugs[] = $slug;
}
}
$candidate_methods = array_values( array_unique( $required_slugs ) );
} else {
// Offer every method the user has configured (role enforcement is a floor).
$candidate_methods = $profile_methods;
}
// Filter to methods the system can actually deliver right now.
$available = array();
foreach ( $candidate_methods as $method ) {
if ( 'email' === $method ) {
if ( '' !== sanitize_email( $user->user_email ) ) {
$available[] = 'email';
}
} elseif ( 'otp' === $method ) {
if ( '' !== $this->otp_manager->get_secret( $user->ID ) ) {
$available[] = 'otp';
}
} elseif ( 'recovery' === $method ) {
if ( $this->recovery_codes->has_active_codes( $user->ID ) ) {
$available[] = 'recovery';
}
}
}
// Return methods in canonical priority order: OTP first, email second, recovery last.
// The user's preferred method is honored separately in select_preferred_method().
$priority = array( 'otp', 'email', 'recovery' );
$sorted = array();
foreach ( $priority as $p ) {
if ( in_array( $p, $available, true ) ) {
$sorted[] = $p;
}
}
return $sorted;
}
/**
* Decide which verification method should be enforced for the user.
*
* @param \WP_User $user User attempting to authenticate.
*
* @return string
*/
private function determine_required_verification_method( \WP_User $user ): string {
$user_settings = $this->user_settings_repository->get_user_settings( $user->ID );
$methods = $this->get_enabled_verification_methods( $user, $user_settings );
if ( empty( $methods ) ) {
return '';
}
$frequency = $this->get_effective_frequency( $user, $user_settings );
if ( $this->has_recent_verification( $user, $methods, $frequency ) ) {
/**
* Filter whether to force a fresh 2FA challenge regardless of the frequency setting.
*
* Return true to always challenge the user, overriding the frequency-based skip.
* Used by GeoIP restrictions to mandate verification from new countries.
*
* @since 1.5.0
*
* @param bool $force Whether to force the challenge. Default false.
* @param \WP_User $user User about to be challenged.
*/
if ( ! (bool) apply_filters( 'robotstxt_2fa_force_challenge', false, $user ) ) {
return '';
}
}
return $this->select_preferred_method( $methods, (string) $user_settings['preferred_method'] );
}
/**
* Check whether any verification method was recently validated within the configured interval.
*
* @param \WP_User $user User attempting to authenticate.
* @param array<int, string> $methods Active verification methods.
* @param string $frequency Selected frequency slug.
*
* @return bool
*/
private function has_recent_verification( \WP_User $user, array $methods, string $frequency ): bool {
if ( empty( $methods ) ) {
return false;
}
if ( Frequency_Options::FREQUENCY_SESSION === $frequency ) {
return false;
}
$interval_seconds = Frequency_Options::get_interval_seconds( $frequency );
if ( $interval_seconds <= 0 ) {
return false;
}
$context_key = $this->get_login_context_key();
foreach ( $methods as $method ) {
$last_verified = $this->user_settings_repository->get_last_verification_timestamp( $user->ID, $method, $context_key );
if ( $last_verified > 0 && ( $last_verified + $interval_seconds ) > time() ) {
return true;
}
}
return false;
}
/**
* Determine which method should be requested when a challenge is required.
*
* Honors the user's preferred method (set on their profile) when it is part
* of the available methods; otherwise falls back to the canonical order.
*
* @since 1.0.0
*
* @param array<int, string> $methods Active verification methods (stage slugs).
* @param string $preferred_method Optional user-chosen preferred method key.
*
* @return string
*/
private function select_preferred_method( array $methods, string $preferred_method = '' ): string {
if ( empty( $methods ) ) {
return '';
}
if ( '' !== $preferred_method ) {
$preferred_slug = $this->normalize_method_slug( $preferred_method );
if ( '' !== $preferred_slug && in_array( $preferred_slug, $methods, true ) ) {
return $preferred_slug;
}
}
// Prefer OTP (most secure), then email, then recovery codes (last resort).
foreach ( array( 'otp', 'email' ) as $method ) {
if ( in_array( $method, $methods, true ) ) {
return $method;
}
}
// Use recovery only when it is the sole option.
if ( in_array( 'recovery', $methods, true ) ) {
return 'recovery';
}
return $methods[0];
}
/**
* Normalize stored method identifiers to the stage slugs used during verification.
*
* @param string $method Raw method identifier.
*
* @return string
*/
private function normalize_method_slug( string $method ): string {
$method = sanitize_key( $method );
if ( '' === $method ) {
return '';
}
if ( 'recovery_codes' === $method || 'recovery' === $method ) {
return 'recovery';
}
if ( in_array( $method, array( 'email', 'otp' ), true ) ) {
return $method;
}
return '';
}
/**
* Build the list of alternate verification method links for the login stage.
*
* @param \WP_User $user User attempting to authenticate.
* @param string $current_method Active verification method slug.
*
* @return array<int, array<string, string>>
*/
private function get_alternate_method_links( \WP_User $user, string $current_method ): array {
$available_methods = $this->get_enabled_verification_methods( $user );
$available_methods = array_values(
array_diff( $available_methods, array( $current_method ) )
);
if ( empty( $available_methods ) ) {
return array();
}
$labels = array(
'email' => __( 'Use an email code instead', 'robotstxt-2fa' ),
'otp' => __( 'Use your authenticator app instead', 'robotstxt-2fa' ),
'recovery' => __( 'Use a recovery code instead', 'robotstxt-2fa' ),
);
$links = array();
foreach ( $available_methods as $method ) {
if ( ! isset( $labels[ $method ] ) ) {
continue;
}
$links[] = array(
'url' => $this->get_stage_url(
array(
self::QUERY_METHOD => $method,
self::QUERY_NOTICE => null,
self::QUERY_RESEND => null,
self::QUERY_SWITCH => '1',
)
),
'label' => $labels[ $method ],
);
}
return $links;
}
/**
* Determine whether the login request is currently displaying the verification stage.
*
* @return bool
*/
private function is_verification_stage(): bool {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Stage-detection only; nonce verified in calling context.
if ( isset( $_REQUEST[ self::FIELD_STAGE ] ) && is_string( $_REQUEST[ self::FIELD_STAGE ] ) ) {
$stage = sanitize_key( wp_unslash( $_REQUEST[ self::FIELD_STAGE ] ) );
if ( self::STAGE_VERIFY === $stage ) {
return true;
}
}
if ( isset( $_REQUEST[ self::QUERY_STAGE ] ) && is_string( $_REQUEST[ self::QUERY_STAGE ] ) ) {
$stage = sanitize_key( wp_unslash( $_REQUEST[ self::QUERY_STAGE ] ) );
if ( self::STAGE_VERIFY === $stage ) {
return true;
}
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return false;
}
/**
* Check whether the login POST submission belongs to the verification stage.
*
* @return bool
*/
private function is_verification_submission(): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Stage flag only; nonce verified in maybe_complete_verification().
if ( ! isset( $_POST[ self::FIELD_STAGE ] ) || ! is_string( $_POST[ self::FIELD_STAGE ] ) ) {
return false;
}
$stage = sanitize_key( wp_unslash( $_POST[ self::FIELD_STAGE ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
return self::STAGE_VERIFY === $stage;
}
/**
* 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_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 ( 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 '';
}
/**
* 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;
}
/**
* Retrieve the stage nonce carried in the current request.
*
* @return string
*/
private function get_requested_stage_token(): string {
$token = '';
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Stage-token helper; token itself is the nonce verified in validate_stage_token().
if ( isset( $_REQUEST[ self::FIELD_STAGE_TOKEN ] ) && is_string( $_REQUEST[ self::FIELD_STAGE_TOKEN ] ) ) {
$token = sanitize_text_field( wp_unslash( $_REQUEST[ self::FIELD_STAGE_TOKEN ] ) );
}
if ( '' === $token && isset( $_REQUEST[ self::QUERY_STAGE_TOKEN ] ) && is_string( $_REQUEST[ self::QUERY_STAGE_TOKEN ] ) ) {
$token = sanitize_text_field( wp_unslash( $_REQUEST[ self::QUERY_STAGE_TOKEN ] ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return $token;
}
/**
* Retrieve the notice slug carried in the current request.
*
* @return string
*/
private function get_requested_notice(): string {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Notice slug helper; display-only, not security-sensitive.
if ( ! isset( $_REQUEST[ self::QUERY_NOTICE ] ) || ! is_string( $_REQUEST[ self::QUERY_NOTICE ] ) ) {
return '';
}
return sanitize_key( wp_unslash( $_REQUEST[ self::QUERY_NOTICE ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
/**
* Validate whether the provided stage token is valid for the given user.
*
* @param \WP_User $user Authenticated user.
* @param string $stage_token Submitted stage token.
*
* @return bool
*/
private function validate_stage_token( \WP_User $user, string $stage_token ): bool {
return (bool) wp_verify_nonce( $stage_token, self::STAGE_TOKEN_ACTION . '|' . $user->ID );
}
/**
* Retrieve the sanitized redirect_to parameter from the current request.
*
* @return string
*/
private function get_requested_redirect(): string {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- redirect_to is a standard WP login parameter; value is passed through esc_url_raw.
if ( ! isset( $_REQUEST['redirect_to'] ) || ! is_string( $_REQUEST['redirect_to'] ) ) {
return '';
}
return esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
* Build a URL pointing to the active verification stage.
*
* @param array<string, string|null> $extra_args Optional additional query parameters to merge.
*
* @return string
*/
private function get_stage_url( array $extra_args = array() ): string {
$redirect_to = $this->get_requested_redirect();
$login_url = wp_login_url( $redirect_to );
$args = array(
self::QUERY_STAGE => self::STAGE_VERIFY,
self::QUERY_METHOD => $this->get_requested_method(),
);
$pending_token = $this->get_requested_pending_token();
if ( '' !== $pending_token ) {
$args[ self::QUERY_PENDING ] = $pending_token;
}
$stage_token = $this->get_requested_stage_token();
if ( '' !== $stage_token ) {
$args[ self::QUERY_STAGE_TOKEN ] = $stage_token;
}
foreach ( $extra_args as $key => $value ) {
if ( null === $value || '' === $key ) {
$login_url = remove_query_arg( $key, $login_url );
continue;
}
$args[ $key ] = $value;
}
$args = array_filter(
$args,
static function ( $value ) {
return '' !== $value;
}
);
return add_query_arg( $args, $login_url );
}
/**
* Determine the effective verification frequency for the current user.
*
* @param \WP_User $user Authenticated user object.
* @param array{enabled: bool, methods: array<int, string>, frequency: string} $user_settings Stored per-user preferences.
*
* @return string
*/
private function get_effective_frequency( \WP_User $user, array $user_settings ): string {
unset( $user );
if ( $this->config->is_frequency_forced() ) {
return $this->config->get_frequency();
}
if ( '' !== $user_settings['frequency'] ) {
return Frequency_Options::sanitize( $user_settings['frequency'] );
}
return $this->config->get_frequency();
}
/**
* Resolve the "Remember this browser" trust duration (in days) for a user.
*
* The feature must be enabled by the administrator (trust_device_days > 0)
* and the user's effective verification frequency must define a non-zero
* interval; the duration is taken from that frequency so the checkbox always
* complies with the user's profile setting.
*
* @since 1.5.3
*
* @param \WP_User $user User attempting to authenticate.
*
* @return int Number of days, or 0 when the feature is disabled or the
* user's frequency is per-session.
*/
private function get_trust_days_for_user( \WP_User $user ): int {
if ( $this->config->get_trust_device_days() <= 0 ) {
return 0;
}
$user_settings = $this->user_settings_repository->get_user_settings( $user->ID );
return Frequency_Options::get_interval_days( $this->get_effective_frequency( $user, $user_settings ) );
}
/**
* Build a hashed context key representing the current login environment.
*
* @return string
*/
private function get_login_context_key(): string {
$ip = '';
if ( isset( $_SERVER['REMOTE_ADDR'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below.
$raw_addr = is_string( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : '';
$ip = sanitize_text_field( wp_unslash( $raw_addr ) );
$ip = wp_privacy_anonymize_ip( $ip );
}
$user_agent = '';
if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below.
$raw_ua = is_string( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
$user_agent = sanitize_text_field( wp_unslash( $raw_ua ) );
}
$user_agent = strtolower( substr( $user_agent, 0, 255 ) );
if ( '' === $ip && '' === $user_agent ) {
return sha1( 'unknown' );
}
return sha1( $ip . '|' . $user_agent );
}
}