';
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
if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
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' ) );
}
// 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();
}
if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
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();
}
if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
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' ),
) );
}
/**
* Set pending verification state
*
* @param int $user_id User ID.
* @return string Token.
*/
private function set_pending_verification( $user_id ) {
$existing_token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
$token = $existing_token ? $existing_token : wp_generate_password( 32, false );
set_transient(
'vigilante_2fa_pending_' . $token,
array(
'user_id' => $user_id,
'created_at' => time(),
),
HOUR_IN_SECONDS
);
set_transient( 'vigilante_2fa_user_token_' . $user_id, $token, HOUR_IN_SECONDS );
$ip = $this->database->get_client_ip();
set_transient( 'vigilante_2fa_triggered_' . md5( $ip ), $user_id, 60 );
if ( ! headers_sent() ) {
setcookie(
'vigilante_2fa_token',
$token,
array(
'expires' => time() + HOUR_IN_SECONDS,
'path' => COOKIEPATH,
'domain' => COOKIE_DOMAIN,
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Strict',
)
);
$_COOKIE['vigilante_2fa_token'] = $token;
}
return $token;
}
/**
* Get pending user ID from token
*
* @return int|false User ID or false.
*/
private function get_pending_user_id() {
$token = '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token is for session identification
if ( isset( $_POST['vigilante_2fa_token'] ) ) {
$token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
} elseif ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
$token = sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) );
}
if ( empty( $token ) ) {
return false;
}
$pending = get_transient( 'vigilante_2fa_pending_' . $token );
if ( ! $pending || ! isset( $pending['user_id'] ) ) {
return false;
}
return absint( $pending['user_id'] );
}
/**
* Clear pending verification
*/
private function clear_pending_verification() {
$token = '';
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token is for session identification
if ( isset( $_POST['vigilante_2fa_token'] ) ) {
$token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
} elseif ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
$token = sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) );
}
if ( ! empty( $token ) ) {
delete_transient( 'vigilante_2fa_pending_' . $token );
}
if ( ! headers_sent() ) {
setcookie(
'vigilante_2fa_token',
'',
array(
'expires' => time() - HOUR_IN_SECONDS,
'path' => COOKIEPATH,
'domain' => COOKIE_DOMAIN,
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Strict',
)
);
}
}
// =========================================================================
// Trusted devices (reuses database methods from email 2FA)
// =========================================================================
/**
* Check if current device is trusted
*
* @param int $user_id User ID.
* @return bool
*/
private function is_device_trusted( $user_id ) {
$device_hash = $this->generate_device_hash( $user_id );
return $this->database->is_device_trusted( $user_id, $device_hash );
}
/**
* Trust the current device
*
* @param int $user_id User ID.
*/
private function trust_device( $user_id ) {
$device_hash = $this->generate_device_hash( $user_id );
$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
$remember_days = absint( $this->options['remember_device_days'] ?? 30 );
$expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $remember_days * DAY_IN_SECONDS ) );
$this->database->trust_device( $user_id, $device_hash, $user_agent, $expires_at );
}
/**
* Generate device hash (no IP for GDPR)
*
* @param int $user_id User ID.
* @return string
*/
private function generate_device_hash( $user_id ) {
$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
$salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'vigilante_fallback_salt';
return hash( 'sha256', $user_id . $user_agent . $salt );
}
// =========================================================================
// =========================================================================
// 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
);
}
}
}