1550 lines
47 KiB
PHP
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 );
|
|
}
|
|
}
|