settings = $settings;
$this->database = $database;
$this->activity_log = $activity_log;
$this->login_security = $login_security;
$login_options = $settings->get_section( 'login_security' );
$this->options = $login_options['two_factor'] ?? array();
if ( $this->is_active() ) {
$this->init_hooks();
}
}
/**
* Check if TOTP method is active
*
* @return bool
*/
public function is_active() {
return ! empty( $this->options['enabled'] )
&& 'totp' === ( $this->options['method'] ?? 'email' );
}
/**
* Initialize hooks
*/
private function init_hooks() {
$this->init_session_hooks();
// Intercept authentication
add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 );
// Handle TOTP verification form
add_action( 'login_form_vigilante_2fa', array( $this, 'handle_2fa_form' ) );
// Show TOTP form on login page
add_action( 'login_form', array( $this, 'maybe_show_2fa_form' ) );
// Enqueue login assets
add_action( 'login_enqueue_scripts', array( $this, 'enqueue_login_assets' ) );
// Filter login errors
add_filter( 'login_errors', array( $this, 'filter_login_errors' ), 100 );
// User profile section (TOTP setup)
add_action( 'show_user_profile', array( $this, 'render_user_profile_section' ) );
add_action( 'edit_user_profile', array( $this, 'render_user_profile_section' ) );
// AJAX handlers for TOTP setup
add_action( 'wp_ajax_vigilante_totp_verify_setup', array( $this, 'ajax_verify_setup' ) );
add_action( 'wp_ajax_vigilante_totp_regenerate_backup', array( $this, 'ajax_regenerate_backup_codes' ) );
add_action( 'wp_ajax_vigilante_totp_reconfigure', array( $this, 'ajax_reconfigure' ) );
// Admin profile scripts
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_profile_assets' ) );
// Grace period admin notice
add_action( 'admin_notices', array( $this, 'show_grace_period_notice' ) );
// Force redirect to profile when grace period is expired and TOTP not configured
add_action( 'admin_init', array( $this, 'force_totp_setup_redirect' ) );
}
/**
* Filter login errors to hide default messages during 2FA
*
* Resolved from the pending token only; the lookup by IP address that used
* to live here leaked one user's pending state to another behind a proxy (S3).
*
* @param string $errors Login error messages.
* @return string
*/
public function filter_login_errors( $errors ) {
$user_id = $this->get_pending_user_id();
if ( ! $user_id ) {
return $errors;
}
// Only filter errors if user has TOTP configured (verification form will be shown)
// Don't filter if user needs to set up TOTP (they need to see the setup message)
$totp_data = $this->database->get_totp_data( $user_id );
if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
return '';
}
return $errors;
}
/**
* Check if user requires 2FA and if TOTP is configured
*
* @param WP_User|WP_Error $user User object or error.
* @param string $username Username.
* @param string $password Password.
* @return WP_User|WP_Error
*/
public function check_2fa_requirement( $user, $username, $password ) {
if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
return $user;
}
// An application password is a second factor of its own. The core
// action that flags it only fires when those were the credentials (S16).
if ( $this->authenticated_with_app_password( $user ) ) {
return $user;
}
/*
* There is deliberately no "already verifying, let it through" shortcut
* here any more. Until 2.11.0 a request carrying action=vigilante_2fa,
* the form nonce and a pending token returned $user at this point, and
* all three are in the hands of whoever knows the password: the nonce
* is printed on the form served to the pending visitor, and the token is
* issued to that same visitor. wp-login.php never reached this filter
* with that action, because login_form_vigilante_2fa ends the request,
* but any other login form that calls wp_signon(), the WooCommerce one
* for instance, does reach it and completed the login without a second
* factor (S19, found in the 2.11.0 cross review and reproduced). The
* verification form authenticates on its own path, handle_2fa_form(),
* which never passes through wp_authenticate(): nothing legitimate
* needed the shortcut.
*/
// Check if user requires 2FA
if ( ! $this->user_requires_2fa( $user ) ) {
return $user;
}
// Check if device is trusted
if ( $this->is_device_trusted( $user->ID ) ) {
return $user;
}
// Check if TOTP is configured for this user
$totp_data = $this->database->get_totp_data( $user->ID );
if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
// TOTP not yet set up - always allow login
// Enforcement happens inside admin via force_totp_setup_redirect()
if ( ! $totp_data ) {
// First time - create grace period placeholder
$grace_days = absint( $this->options['grace_period_days'] ?? 3 );
$grace_expires = ( $grace_days > 0 )
? gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) )
: gmdate( 'Y-m-d H:i:s', time() );
$this->database->create_totp_placeholder( $user->ID, $grace_expires );
}
return $user;
}
// TOTP is configured - require verification. REST and XML-RPC have no
// form to show, so the login is refused without a pending session (S16).
if ( $this->is_api_request() ) {
return $this->api_requires_2fa_error();
}
$this->set_pending_verification( $user->ID );
$this->log_event( 'totp_verification_requested', $user->ID, __( 'TOTP verification requested at login', 'vigilante' ) );
return new WP_Error(
'vigilante_2fa_required',
__( 'Please enter the verification code from your authenticator app.', 'vigilante' )
);
}
/**
* Check if user requires 2FA
*
* @param WP_User $user User object.
* @return bool
*/
public function user_requires_2fa( $user ) {
$excluded_users = $this->options['excluded_users'] ?? array();
if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
return false;
}
$enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
foreach ( $user->roles as $role ) {
if ( in_array( $role, $enforced_roles, true ) ) {
return true;
}
}
return false;
}
/**
* Check if user is within the grace period
*
* @param int $user_id User ID.
* @return bool
*/
private function is_within_grace_period( $user_id ) {
$grace_days = absint( $this->options['grace_period_days'] ?? 3 );
if ( 0 === $grace_days ) {
return false;
}
$totp_data = $this->database->get_totp_data( $user_id );
// If no TOTP row exists, create one with grace period start
if ( ! $totp_data ) {
$grace_expires = gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) );
$this->database->create_totp_placeholder( $user_id, $grace_expires );
return true;
}
// Check grace period expiration
if ( ! empty( $totp_data['grace_period_expires'] ) ) {
return strtotime( $totp_data['grace_period_expires'] ) > time();
}
return false;
}
/**
* Handle 2FA verification form submission
*/
public function handle_2fa_form() {
// The pending user is resolved first so that a failed nonce can be
// explained on the form and recorded (S15). Both failure paths end the
// request: a bare return would let wp-login.php fall through to its
// default case and call wp_signon(), completing the login without the
// second factor.
$user_id = $this->get_pending_user_id();
if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
$this->handle_invalid_nonce( $user_id );
}
if ( ! $user_id ) {
wp_safe_redirect( wp_login_url() );
exit;
}
// Attempt limit per pending session (S2). Until 2.11.0 nothing counted
// here: the lockout only runs on the authenticate filter, which this
// form never passes through. The limit is checked before any code is
// verified so that a session past it costs nothing, since a backup
// code check alone is up to ten wp_check_password() calls.
$max_attempts = absint( $this->options['max_attempts'] ?? 3 );
if ( $max_attempts < 1 ) {
$max_attempts = 3;
}
if ( $this->get_pending_attempts() >= $max_attempts ) {
$this->log_event( 'totp_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
$this->clear_pending_verification();
wp_safe_redirect( wp_login_url() );
exit;
}
$code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
$remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
// Try TOTP code first, then backup code
$result = $this->verify_totp_code( $user_id, $code );
if ( is_wp_error( $result ) ) {
// Try as backup code
$backup_result = $this->verify_backup_code( $user_id, $code );
if ( is_wp_error( $backup_result ) ) {
// Both failed
$this->increment_pending_attempts();
set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
exit;
}
// Backup code succeeded
$this->log_event( 'totp_backup_code_used', $user_id, __( 'Backup code used for authentication', 'vigilante' ), 'warning' );
}
// Verification successful
$this->clear_pending_verification();
// Trust device if requested (and if the option allows it, see trust_device)
if ( $remember_device && $this->trust_device( $user_id ) ) {
$this->log_event( 'totp_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
}
$this->log_event( 'totp_verification_success', $user_id, __( 'TOTP verification successful', 'vigilante' ) );
// Complete login
$user = get_user_by( 'ID', $user_id );
wp_set_current_user( $user_id, $user->user_login );
wp_set_auth_cookie( $user_id, false );
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook
do_action( 'wp_login', $user->user_login, $user );
wp_safe_redirect( admin_url() );
exit;
}
/**
* Show 2FA form on login page
*/
public function maybe_show_2fa_form() {
// Don't show 2FA form on logout or other non-auth actions
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking URL params for display logic
if ( isset( $_GET['loggedout'] ) || isset( $_GET['action'] ) ) {
$action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['loggedout'] ) || in_array( $action, array( 'logout', 'lostpassword', 'register', 'rp', 'resetpass' ), true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
}
// Only the visitor presenting the pending token gets the form. There is
// no fallback by IP address and no lookup of the token by user (S3).
$session = $this->get_pending_session();
if ( ! $session ) {
return;
}
$user_id = $session['user_id'];
$token = $session['token'];
// Only show if user has TOTP configured
$totp_data = $this->database->get_totp_data( $user_id );
if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
return;
}
$error = get_transient( 'vigilante_2fa_error_' . $user_id );
delete_transient( 'vigilante_2fa_error_' . $user_id );
$remember_days = absint( $this->options['remember_device_days'] ?? 30 );
// Check remaining backup codes
$backup_remaining = $this->count_remaining_backup_codes( $user_id );
?>
0 ) : ?>
options['allow_remember_device'] ) ) : ?>
base32_encode( $bytes );
}
/**
* Generate TOTP code for a given time
*
* @param string $secret Base32-encoded secret.
* @param int|null $time Unix timestamp (null = current time).
* @return string 6-digit code.
*/
public function generate_code( $secret, $time = null ) {
if ( null === $time ) {
$time = time();
}
$counter = intval( floor( $time / self::TIME_STEP ) );
// Pack counter as 8-byte big-endian
$counter_bytes = pack( 'N*', 0, $counter );
// Decode secret from Base32
$key = $this->base32_decode( $secret );
// HMAC-SHA1
$hash = hash_hmac( 'sha1', $counter_bytes, $key, true );
// Dynamic truncation
$offset = ord( $hash[19] ) & 0x0f;
$code = (
( ( ord( $hash[ $offset ] ) & 0x7f ) << 24 ) |
( ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 ) |
( ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 ) |
( ( ord( $hash[ $offset + 3 ] ) & 0xff ) )
) % pow( 10, self::CODE_LENGTH );
return str_pad( (string) $code, self::CODE_LENGTH, '0', STR_PAD_LEFT );
}
/**
* Verify a TOTP code against stored secret
*
* @param int $user_id User ID.
* @param string $code Submitted code.
* @return true|WP_Error
*/
public function verify_totp_code( $user_id, $code ) {
// Validate code format (6 digits for TOTP)
if ( ! preg_match( '/^[0-9]{6}$/', $code ) ) {
return new WP_Error( 'invalid_format', __( 'Invalid code format. Enter the 6-digit code from your authenticator app.', 'vigilante' ) );
}
$totp_data = $this->database->get_totp_data( $user_id );
if ( ! $totp_data || empty( $totp_data['secret'] ) ) {
return new WP_Error( 'not_configured', __( 'TOTP is not configured. Please contact the site administrator.', 'vigilante' ) );
}
$secret = $this->decrypt_secret( $totp_data['secret'] );
if ( ! $secret ) {
return new WP_Error( 'decrypt_failed', __( 'Authentication error. Please contact the site administrator.', 'vigilante' ) );
}
// Check code against current and adjacent time steps (clock skew tolerance)
$now = time();
for ( $i = -self::TIME_WINDOW; $i <= self::TIME_WINDOW; $i++ ) {
$expected = $this->generate_code( $secret, $now + ( $i * self::TIME_STEP ) );
if ( hash_equals( $expected, $code ) ) {
// Prevent replay: check if this code was already used
$last_used = get_transient( 'vigilante_totp_last_' . $user_id );
if ( $last_used === $code ) {
return new WP_Error( 'code_reused', __( 'This code has already been used. Wait for a new code.', 'vigilante' ) );
}
set_transient( 'vigilante_totp_last_' . $user_id, $code, self::TIME_STEP * 2 );
// Update last used timestamp
$this->database->update_totp_last_used( $user_id );
return true;
}
}
// Track failed attempts
$remaining = -1;
if ( $this->login_security ) {
$user = get_user_by( 'ID', $user_id );
if ( $user ) {
$this->login_security->record_failed_attempt( $user->user_login, '2fa' );
$remaining = $this->login_security->get_remaining_attempts();
}
}
$this->log_event( 'totp_verification_failed', $user_id, __( 'Invalid TOTP code entered', 'vigilante' ), 'warning' );
if ( $remaining > 0 ) {
return new WP_Error(
'invalid_code',
sprintf(
/* translators: %d: Number of attempts remaining */
__( 'Invalid verification code. %d attempts remaining before lockout.', 'vigilante' ),
$remaining
)
);
}
return new WP_Error(
'invalid_code',
__( 'Invalid verification code. If you have lost access to your authenticator app, contact the site administrator.', 'vigilante' )
);
}
// =========================================================================
// Backup codes
// =========================================================================
/**
* Generate backup codes for a user
*
* @param int $user_id User ID.
* @return array Plain text backup codes (show to user once).
*/
public function generate_backup_codes( $user_id ) {
$codes = array();
$hashed = array();
$charset = 'abcdefghjkmnpqrstuvwxyz23456789'; // Avoid confusable chars
for ( $i = 0; $i < self::BACKUP_CODE_COUNT; $i++ ) {
$code = '';
for ( $j = 0; $j < self::BACKUP_CODE_LENGTH; $j++ ) {
$code .= $charset[ wp_rand( 0, strlen( $charset ) - 1 ) ];
}
$codes[] = $code;
$hashed[] = wp_hash_password( $code );
}
// Store hashed codes
$this->database->store_totp_backup_codes( $user_id, wp_json_encode( $hashed ) );
$this->log_event( 'totp_backup_codes_generated', $user_id, __( 'Backup codes generated', 'vigilante' ) );
return $codes;
}
/**
* Verify a backup code
*
* @param int $user_id User ID.
* @param string $code Submitted backup code.
* @return true|WP_Error
*/
private function verify_backup_code( $user_id, $code ) {
// Backup codes are 8 chars, lowercase alphanumeric
$code = strtolower( trim( $code ) );
if ( strlen( $code ) !== self::BACKUP_CODE_LENGTH ) {
return new WP_Error( 'invalid_backup', __( 'Invalid backup code.', 'vigilante' ) );
}
$totp_data = $this->database->get_totp_data( $user_id );
if ( ! $totp_data || empty( $totp_data['backup_codes'] ) ) {
return new WP_Error( 'no_backup_codes', __( 'No backup codes available.', 'vigilante' ) );
}
$stored_hashes = json_decode( $totp_data['backup_codes'], true );
if ( ! is_array( $stored_hashes ) ) {
return new WP_Error( 'corrupt_data', __( 'Backup codes data is corrupt.', 'vigilante' ) );
}
// Check each stored hash
foreach ( $stored_hashes as $idx => $hash ) {
if ( wp_check_password( $code, $hash ) ) {
// Remove used code
unset( $stored_hashes[ $idx ] );
$stored_hashes = array_values( $stored_hashes );
$this->database->store_totp_backup_codes( $user_id, wp_json_encode( $stored_hashes ) );
return true;
}
}
return new WP_Error( 'invalid_backup', __( 'Invalid backup code.', 'vigilante' ) );
}
/**
* Count remaining backup codes for a user
*
* @param int $user_id User ID.
* @return int
*/
public function count_remaining_backup_codes( $user_id ) {
$totp_data = $this->database->get_totp_data( $user_id );
if ( ! $totp_data || empty( $totp_data['backup_codes'] ) ) {
return 0;
}
$codes = json_decode( $totp_data['backup_codes'], true );
return is_array( $codes ) ? count( $codes ) : 0;
}
// =========================================================================
// Secret encryption
// =========================================================================
/**
* Encrypt TOTP secret for database storage
*
* @param string $secret Plain Base32 secret.
* @return string Encrypted string (base64), or empty string without a key.
*/
public function encrypt_secret( $secret ) {
$key = $this->get_encryption_key();
if ( '' === $key ) {
return '';
}
$iv = openssl_random_pseudo_bytes( 16 );
$encrypted = openssl_encrypt( $secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
if ( false === $encrypted ) {
return '';
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for safe binary storage
return base64_encode( $iv . $encrypted );
}
/**
* Decrypt TOTP secret from database
*
* @param string $encrypted Encrypted string (base64).
* @return string|false Plain Base32 secret or false.
*/
public function decrypt_secret( $encrypted ) {
$key = $this->get_encryption_key();
if ( '' === $key ) {
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required for binary data retrieval
$data = base64_decode( $encrypted, true );
if ( false === $data || strlen( $data ) < 17 ) {
return false;
}
$iv = substr( $data, 0, 16 );
$text = substr( $data, 16 );
$decrypted = openssl_decrypt( $text, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
return ( false !== $decrypted ) ? $decrypted : false;
}
/**
* Whether the site defines the key the authenticator secret is encrypted with
*
* @return bool
*/
public function has_encryption_key() {
return defined( 'AUTH_KEY' )
&& is_string( AUTH_KEY )
&& '' !== AUTH_KEY
&& 'put your unique phrase here' !== AUTH_KEY;
}
/**
* Get encryption key derived from WordPress salts
*
* Until 2.11.0 a site without AUTH_KEY fell back to a literal written in
* this file, which gave every such site the same key and made the
* encryption cosmetic (S13). Without AUTH_KEY there is no key: setup
* refuses and says why, and nothing is encrypted with a known value.
*
* @return string 32-byte key, or empty string when the site has none.
*/
private function get_encryption_key() {
if ( ! $this->has_encryption_key() ) {
return '';
}
return hash( 'sha256', AUTH_KEY . 'vigilante_totp', true );
}
/**
* Base32 encode
*
* @param string $data Raw binary data.
* @return string Base32-encoded string.
*/
private function base32_encode( $data ) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$binary = '';
for ( $i = 0; $i < strlen( $data ); $i++ ) {
$binary .= str_pad( decbin( ord( $data[ $i ] ) ), 8, '0', STR_PAD_LEFT );
}
$result = '';
for ( $i = 0; $i < strlen( $binary ); $i += 5 ) {
$chunk = substr( $binary, $i, 5 );
$chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT );
$result .= $alphabet[ intval( $chunk, 2 ) ];
}
return $result;
}
/**
* Base32 decode
*
* @param string $data Base32-encoded string.
* @return string Raw binary data.
*/
private function base32_decode( $data ) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$data = strtoupper( rtrim( $data, '=' ) );
$binary = '';
for ( $i = 0; $i < strlen( $data ); $i++ ) {
$pos = strpos( $alphabet, $data[ $i ] );
if ( false === $pos ) {
continue;
}
$binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT );
}
$result = '';
for ( $i = 0; $i + 8 <= strlen( $binary ); $i += 8 ) {
$result .= chr( intval( substr( $binary, $i, 8 ), 2 ) );
}
return $result;
}
// =========================================================================
// TOTP URI and QR code
// =========================================================================
/**
* Generate otpauth:// URI for authenticator apps
*
* @param string $secret Base32 secret.
* @param string $user_email User email.
* @return string
*/
public function get_totp_uri( $secret, $user_email ) {
$issuer = get_bloginfo( 'name' );
$issuer = preg_replace( '/[^a-zA-Z0-9 _-]/', '', $issuer );
return sprintf(
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d',
rawurlencode( $issuer ),
rawurlencode( $user_email ),
$secret,
rawurlencode( $issuer ),
self::CODE_LENGTH,
self::TIME_STEP
);
}
// =========================================================================
// User profile section
// =========================================================================
/**
* Render TOTP setup/status section in user profile
*
* @param WP_User $user User being edited.
*/
public function render_user_profile_section( $user ) {
// Only show for users that require 2FA
if ( ! $this->user_requires_2fa( $user ) ) {
return;
}
$totp_data = $this->database->get_totp_data( $user->ID );
$configured = $totp_data && ! empty( $totp_data['is_configured'] );
wp_nonce_field( 'vigilante_totp_profile', 'vigilante_totp_nonce' );
?>
';
printf(
/* translators: %d: Days remaining */
esc_html( _n(
'You have %d day to set up two-factor authentication.',
'You have %d days to set up two-factor authentication.',
$days_left,
'vigilante'
) ),
absint( $days_left )
);
echo '
';
}
}
?>
admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'vigilante_totp_profile' ),
'strings' => array(
'verifying' => __( 'Verifying...', 'vigilante' ),
'generating' => __( 'Generating...', 'vigilante' ),
'success' => __( 'Success!', 'vigilante' ),
'error' => __( 'An error occurred.', 'vigilante' ),
'confirmRegen' => __( 'This will invalidate all existing backup codes. Continue?', 'vigilante' ),
'confirmReconfig' => __( 'This will reset your current TOTP setup. You will need to scan a new QR code. Continue?', 'vigilante' ),
'saveBackupCodes' => __( 'Save these backup codes now. They will not be shown again.', 'vigilante' ),
'codesRemaining' => __( 'backup codes remaining', 'vigilante' ),
),
) );
}
/**
* Enqueue login page assets
*/
public function enqueue_login_assets() {
wp_enqueue_style(
'vigilante-2fa-login',
VIGILANTE_ASSETS_URL . 'css/two-factor-login.css',
array(),
VIGILANTE_VERSION
);
// Dashicons for the smartphone icon
wp_enqueue_style( 'dashicons' );
}
// =========================================================================
// AJAX handlers
// =========================================================================
/**
* AJAX: Verify TOTP setup code and activate
*/
public function ajax_verify_setup() {
check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
$user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
if ( 0 === $user_id ) {
$user_id = get_current_user_id();
}
$code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
$secret = isset( $_POST['secret'] ) ? sanitize_text_field( wp_unslash( $_POST['secret'] ) ) : '';
$reconfig = ! empty( $_POST['reconfigure'] );
// Permission check: user can only set up their own, unless admin
// edit_user, not manage_options: on a network every subsite administrator
// holds manage_options, and map_meta_cap denies edit_user against a user
// they do not administer. On a single site an administrator still passes.
if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
}
if ( empty( $code ) || ! preg_match( '/^[0-9]{6}$/', $code ) ) {
wp_send_json_error( __( 'Enter a valid 6-digit code.', 'vigilante' ) );
}
if ( empty( $secret ) || ! preg_match( '/^[A-Z2-7]+=*$/', $secret ) ) {
wp_send_json_error( __( 'Invalid secret. Please reload and try again.', 'vigilante' ) );
}
// Verify the code against the provided secret
$expected_codes = array();
$now = time();
for ( $i = -self::TIME_WINDOW; $i <= self::TIME_WINDOW; $i++ ) {
$expected_codes[] = $this->generate_code( $secret, $now + ( $i * self::TIME_STEP ) );
}
if ( ! in_array( $code, $expected_codes, true ) ) {
wp_send_json_error( __( 'Invalid code. Make sure your authenticator app is set up correctly and the time is synchronized.', 'vigilante' ) );
}
if ( ! $this->has_encryption_key() ) {
wp_send_json_error( __( 'This site does not define the AUTH_KEY security key, so the authenticator secret cannot be stored securely. Add the WordPress security keys to the site configuration and try again.', 'vigilante' ) );
}
// Encrypt and store secret
$encrypted = $this->encrypt_secret( $secret );
if ( empty( $encrypted ) ) {
wp_send_json_error( __( 'Encryption error. Please try again.', 'vigilante' ) );
}
// Save TOTP data
if ( $reconfig ) {
$this->database->reset_totp_data( $user_id );
}
$this->database->save_totp_data( $user_id, $encrypted );
// Generate backup codes
$backup_codes = $this->generate_backup_codes( $user_id );
$this->log_event( 'totp_configured', $user_id, __( 'TOTP authenticator configured', 'vigilante' ) );
wp_send_json_success( array(
'message' => __( 'Two-factor authentication has been configured successfully.', 'vigilante' ),
'backup_codes' => $backup_codes,
) );
}
/**
* AJAX: Regenerate backup codes
*/
public function ajax_regenerate_backup_codes() {
check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
$user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
if ( 0 === $user_id ) {
$user_id = get_current_user_id();
}
// edit_user, not manage_options: on a network every subsite administrator
// holds manage_options, and map_meta_cap denies edit_user against a user
// they do not administer. On a single site an administrator still passes.
if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
}
$totp_data = $this->database->get_totp_data( $user_id );
if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
wp_send_json_error( __( 'TOTP is not configured for this user.', 'vigilante' ) );
}
$backup_codes = $this->generate_backup_codes( $user_id );
wp_send_json_success( array(
'backup_codes' => $backup_codes,
) );
}
/**
* AJAX: Reconfigure TOTP (reset and allow re-setup from profile)
*/
public function ajax_reconfigure() {
check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
$user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
if ( 0 === $user_id ) {
$user_id = get_current_user_id();
}
// edit_user, not manage_options: on a network every subsite administrator
// holds manage_options, and map_meta_cap denies edit_user against a user
// they do not administer. On a single site an administrator still passes.
if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
}
$this->reset_user_totp( $user_id );
wp_send_json_success( array(
'message' => __( 'TOTP has been reset. You can now set up a new authenticator.', 'vigilante' ),
) );
}
// =========================================================================
// Grace period admin notice and forced redirect
// =========================================================================
/**
* Force redirect to profile page when grace period has expired
* and TOTP is not yet configured.
*
* During grace period: user can browse freely, only a notice is shown.
* After grace period: user is locked to profile page until setup is complete.
*/
public function force_totp_setup_redirect() {
// Don't redirect on AJAX requests
if ( wp_doing_ajax() ) {
return;
}
$user = wp_get_current_user();
if ( ! $user->ID || ! $this->user_requires_2fa( $user ) ) {
return;
}
$totp_data = $this->database->get_totp_data( $user->ID );
// Already configured - no redirect needed
if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
return;
}
// No TOTP data at all - first login just happened, allow freely
if ( ! $totp_data ) {
return;
}
// Check if grace period is still active
if ( ! empty( $totp_data['grace_period_expires'] ) && strtotime( $totp_data['grace_period_expires'] ) > time() ) {
// Grace period active - user can browse freely, just notice shown
return;
}
// Grace period expired - force redirect to profile (unless already there)
global $pagenow;
if ( 'profile.php' === $pagenow || 'user-edit.php' === $pagenow ) {
return;
}
wp_safe_redirect( admin_url( 'profile.php#vigilante-totp-setup' ) );
exit;
}
/**
* Show admin notice for users who need to set up TOTP
*/
public function show_grace_period_notice() {
$user = wp_get_current_user();
if ( ! $this->user_requires_2fa( $user ) ) {
return;
}
$totp_data = $this->database->get_totp_data( $user->ID );
if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
return;
}
$profile_url = admin_url( 'profile.php#vigilante-totp-setup' );
$grace_end = ( $totp_data && ! empty( $totp_data['grace_period_expires'] ) )
? strtotime( $totp_data['grace_period_expires'] )
: 0;
$days_left = ( $grace_end > time() ) ? max( 1, ceil( ( $grace_end - time() ) / DAY_IN_SECONDS ) ) : 0;
?>
—
0 ) : ?>
database->reset_totp_data( $user_id );
// If grace period is configured, set a new one
$grace_days = absint( $this->options['grace_period_days'] ?? 3 );
if ( $grace_days > 0 ) {
$grace_expires = gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) );
$this->database->create_totp_placeholder( $user_id, $grace_expires );
}
// Revoke trusted devices
$this->database->revoke_trusted_devices( $user_id );
$this->log_event( 'totp_reset', $user_id, __( 'TOTP configuration reset by administrator', 'vigilante' ), 'warning' );
return true;
}
/**
* Get TOTP setup data for a new setup (generates secret and QR)
*
* @param int $user_id User ID.
* @return array Setup data with secret, uri, and qr_svg.
*/
public function get_setup_data( $user_id ) {
$user = get_user_by( 'ID', $user_id );
if ( ! $user ) {
return array();
}
$secret = $this->generate_secret();
$uri = $this->get_totp_uri( $secret, $user->user_email );
return array(
'secret' => $secret,
'uri' => $uri,
);
}
// =========================================================================
// HTML styled email for TOTP activation notification
// =========================================================================
/**
* Send TOTP activation notification email
*
* @param WP_User $user User object.
* @param string $site_name Site name.
* @param string $from_name Email from name.
* @return bool
*/
public function send_activation_email( $user, $site_name, $from_name ) {
$profile_url = admin_url( 'profile.php' );
$grace_days = absint( $this->options['grace_period_days'] ?? 3 );
$subject = sprintf(
/* translators: %s: Site name */
__( '[%s] Set up two-factor authentication for your account', 'vigilante' ),
$site_name
);
$body = Vigilante_Email_Template::p(
sprintf(
/* translators: 1: User display name, 2: Site name */
__( 'Hello %1$s, the administrator of %2$s has enabled two-factor authentication using an authenticator app for your account.', 'vigilante' ),
$user->display_name,
$site_name
)
);
$body .= Vigilante_Email_Template::p( __( 'Install an authenticator app on your phone if you do not have one:', 'vigilante' ) );
$body .= Vigilante_Email_Template::ul( array(
'Google Authenticator (Android)',
'Google Authenticator (iOS)',
'Authy (Android / iOS)',
'Microsoft Authenticator',
) );
if ( $grace_days > 0 ) {
$body .= Vigilante_Email_Template::warning_box(
sprintf(
/* translators: %d: Number of days */
__( 'You have %d days to complete the setup. After that, you will not be able to access the admin area without configuring your authenticator app.', 'vigilante' ),
$grace_days
)
);
} else {
$body .= Vigilante_Email_Template::alert_box( __( 'You must configure your authenticator app on your next login.', 'vigilante' ) );
}
$body .= Vigilante_Email_Template::button( $profile_url, __( 'Set up now', 'vigilante' ) );
// Pass from_name via header (avoids filter contamination)
$sent = Vigilante_Email_Template::send(
$user->user_email,
$subject,
__( 'Two-factor authentication enabled', 'vigilante' ),
$body,
false,
$from_name
);
return $sent;
}
// =========================================================================
// Logging helper
// =========================================================================
/**
* Log TOTP event
*
* @param string $action Action name.
* @param int $user_id User ID.
* @param string $message Message.
* @param string $severity Severity level.
*/
private function log_event( $action, $user_id, $message, $severity = 'info' ) {
if ( $this->activity_log ) {
$this->activity_log->log(
'2fa',
$action,
$message,
array( 'user_id' => $user_id ),
$severity
);
}
}
}