PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.7
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-two-factor-email.php

class-two-factor-email.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.9.7, at includes/class-two-factor-email.php

1,029 lines 36.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Two-Factor Email Authentication Class
4 *
5 * Handles email-based two-factor authentication for WordPress login
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Two_Factor_Email
17 *
18 * Email OTP verification for login security
19 */
20 class Vigilante_Two_Factor_Email {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Database instance
31 *
32 * @var Vigilante_Database
33 */
34 private $database;
35
36 /**
37 * Activity log instance
38 *
39 * @var Vigilante_Activity_Log
40 */
41 private $activity_log;
42
43 /**
44 * Login security instance
45 *
46 * @var Vigilante_Login_Security|null
47 */
48 private $login_security;
49
50 /**
51 * 2FA options
52 *
53 * @var array
54 */
55 private $options;
56
57 /**
58 * Session key for pending verification
59 *
60 * @var string
61 */
62 const SESSION_KEY = 'vigilante_2fa_pending';
63
64 /**
65 * Constructor
66 *
67 * @param Vigilante_Settings $settings Settings instance.
68 * @param Vigilante_Database $database Database instance.
69 * @param Vigilante_Activity_Log $activity_log Activity log instance.
70 * @param Vigilante_Login_Security|null $login_security Login security instance (optional).
71 */
72 public function __construct( $settings, $database, $activity_log, $login_security = null ) {
73 $this->settings = $settings;
74 $this->database = $database;
75 $this->activity_log = $activity_log;
76 $this->login_security = $login_security;
77
78 $login_options = $settings->get_section( 'login_security' );
79 $this->options = $login_options['two_factor'] ?? array();
80
81 if ( $this->is_enabled() ) {
82 $this->init_hooks();
83 }
84 }
85
86 /**
87 * Check if 2FA is enabled
88 *
89 * @return bool
90 */
91 public function is_enabled() {
92 if ( empty( $this->options['enabled'] ) ) {
93 return false;
94 }
95 // Only active when method is email (or not set, for backward compatibility)
96 $method = $this->options['method'] ?? 'email';
97 return 'email' === $method;
98 }
99
100 /**
101 * Initialize hooks
102 */
103 private function init_hooks() {
104 // Intercept successful authentication
105 add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 );
106
107 // Handle 2FA verification form
108 add_action( 'login_form_vigilante_2fa', array( $this, 'handle_2fa_form' ) );
109
110 // Add 2FA form to login page
111 add_action( 'login_form', array( $this, 'maybe_show_2fa_form' ) );
112
113 // Handle AJAX resend code
114 add_action( 'wp_ajax_nopriv_vigilante_resend_2fa_code', array( $this, 'ajax_resend_code' ) );
115
116 // Enqueue login styles
117 add_action( 'login_enqueue_scripts', array( $this, 'enqueue_login_assets' ) );
118
119 // Filter login error messages to hide default error when 2FA is pending
120 add_filter( 'login_errors', array( $this, 'filter_login_errors' ), 100 );
121 }
122
123 /**
124 * Filter login error messages
125 *
126 * Hide the default "Invalid username or password" when 2FA verification is pending
127 *
128 * @param string $errors Error messages HTML.
129 * @return string Filtered error messages
130 */
131 public function filter_login_errors( $errors ) {
132 // Check if we have a pending 2FA session (try multiple methods)
133 $user_id = $this->get_pending_user_id();
134
135 if ( $user_id ) {
136 // We're in 2FA mode, hide the default WordPress error
137 // Clean up the trigger transient since cookie is now working
138 $ip = $this->database->get_client_ip();
139 delete_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
140 return '';
141 }
142
143 // Also check if we just triggered 2FA (cookie might not be available yet)
144 $ip = $this->database->get_client_ip();
145 $just_triggered = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
146
147 if ( $just_triggered ) {
148 // Don't delete yet - might need it for the form display
149 // It will expire in 60 seconds anyway
150 return '';
151 }
152
153 return $errors;
154 }
155
156 /**
157 * Check if user requires 2FA after successful password authentication
158 *
159 * @param WP_User|WP_Error $user User object or error.
160 * @param string $username Username.
161 * @param string $password Password.
162 * @return WP_User|WP_Error
163 */
164 public function check_2fa_requirement( $user, $username, $password ) {
165 // Only process successful authentications
166 if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
167 return $user;
168 }
169
170 // Check if already verifying 2FA (form submission)
171 if ( $this->is_2fa_verification_request() ) {
172 return $user;
173 }
174
175 // Check if 2FA is required for this user
176 if ( ! $this->user_requires_2fa( $user ) ) {
177 return $user;
178 }
179
180 // Check if device is trusted
181 if ( $this->is_device_trusted( $user->ID ) ) {
182 return $user;
183 }
184
185 // Check if there's a very recent code (less than 60 seconds old) to avoid duplicate emails on rapid retries
186 $existing_code = $this->database->get_2fa_code( $user->ID );
187 $code_is_recent = $existing_code
188 && strtotime( $existing_code['expires_at'] ) > time()
189 && empty( $existing_code['used'] )
190 && ( time() - strtotime( $existing_code['created_at'] ) ) < 60;
191
192 if ( $code_is_recent ) {
193 // Code was just sent, don't send another email
194 $this->set_pending_verification( $user->ID );
195
196 return new WP_Error(
197 'vigilante_2fa_required',
198 __( 'Please enter the verification code sent to your email.', 'vigilante' )
199 );
200 }
201
202 // Delete any old codes for this user
203 $this->database->delete_2fa_code( $user->ID );
204
205 // Generate and send new verification code
206 $code = $this->generate_code( $user->ID );
207 $this->send_verification_email( $user, $code );
208
209 // Store pending state
210 $this->set_pending_verification( $user->ID );
211
212 // Log code sent
213 $this->log_event( '2fa_code_sent', $user->ID, __( 'Verification code sent via email', 'vigilante' ) );
214
215 // Return error to stop login and show 2FA form
216 return new WP_Error(
217 'vigilante_2fa_required',
218 __( 'Please enter the verification code sent to your email.', 'vigilante' )
219 );
220 }
221
222 /**
223 * Check if this is a 2FA verification request
224 *
225 * @return bool
226 */
227 private function is_2fa_verification_request() {
228 // Check for our custom action
229 $action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
230 return 'vigilante_2fa' === $action;
231 }
232
233 /**
234 * Check if user requires 2FA
235 *
236 * @param WP_User $user User object.
237 * @return bool
238 */
239 public function user_requires_2fa( $user ) {
240 // Check if user is explicitly excluded
241 $excluded_users = $this->options['excluded_users'] ?? array();
242 if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
243 return false;
244 }
245
246 // Check if user has an enforced role
247 $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
248
249 foreach ( $user->roles as $role ) {
250 if ( in_array( $role, $enforced_roles, true ) ) {
251 return true;
252 }
253 }
254
255 return false;
256 }
257
258 /**
259 * Generate verification code
260 *
261 * @param int $user_id User ID.
262 * @return string 6-digit code
263 */
264 private function generate_code( $user_id ) {
265 // Generate secure 6-digit code
266 $code = sprintf( '%06d', wp_rand( 0, 999999 ) );
267
268 // Calculate expiry
269 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
270 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $expiry_minutes * 60 ) );
271
272 // Store in database
273 $this->database->store_2fa_code( $user_id, $code, $expires_at );
274
275 return $code;
276 }
277
278 /**
279 * Send verification email
280 *
281 * @param WP_User $user User object.
282 * @param string $code Verification code.
283 * @return bool
284 */
285 private function send_verification_email( $user, $code ) {
286 $site_name = get_bloginfo( 'name' );
287 $from_name = $this->options['email_from_name'] ?? '';
288
289 if ( empty( $from_name ) ) {
290 $from_name = $site_name;
291 }
292
293 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
294
295 $subject = sprintf(
296 /* translators: 1: Site name, 2: Verification code */
297 __( '[%1$s] Your verification code: %2$s', 'vigilante' ),
298 $site_name,
299 $code
300 );
301
302 $body = Vigilante_Email_Template::p( __( 'Your verification code is:', 'vigilante' ) );
303 $body .= Vigilante_Email_Template::code_box( $code );
304 $body .= Vigilante_Email_Template::small(
305 sprintf(
306 /* translators: %d: Minutes until code expires */
307 __( 'This code is valid for %d minutes.', 'vigilante' ),
308 $expiry_minutes
309 )
310 );
311 $body .= Vigilante_Email_Template::small( __( 'If you did not attempt to log in, please ignore this message and consider changing your password.', 'vigilante' ) );
312
313 // Use from_name via header (avoids filter contamination)
314 $sent = Vigilante_Email_Template::send( $user->user_email, $subject, '', $body, false, $from_name );
315
316 return $sent;
317 }
318
319 /**
320 * Set pending verification state
321 *
322 * @param int $user_id User ID.
323 * @return string Token for the pending session
324 */
325 private function set_pending_verification( $user_id ) {
326 // Check if there's already a valid token for this user
327 $existing_token = $this->get_existing_token_for_user( $user_id );
328
329 if ( $existing_token ) {
330 $token = $existing_token;
331 } else {
332 $token = wp_generate_password( 32, false );
333 }
334
335 set_transient(
336 'vigilante_2fa_pending_' . $token,
337 array(
338 'user_id' => $user_id,
339 'created_at' => time(),
340 ),
341 HOUR_IN_SECONDS
342 );
343
344 // Also store reverse lookup (user_id -> token)
345 set_transient(
346 'vigilante_2fa_user_token_' . $user_id,
347 $token,
348 HOUR_IN_SECONDS
349 );
350
351 // Set a short-lived transient to indicate 2FA was just triggered
352 // This helps filter_login_errors() detect 2FA mode before cookie is available
353 $ip = $this->database->get_client_ip();
354 set_transient( 'vigilante_2fa_triggered_' . md5( $ip ), $user_id, 60 );
355
356 // Store token in cookie for form submission
357 if ( ! headers_sent() ) {
358 setcookie(
359 'vigilante_2fa_token',
360 $token,
361 array(
362 'expires' => time() + HOUR_IN_SECONDS,
363 'path' => COOKIEPATH,
364 'domain' => COOKIE_DOMAIN,
365 'secure' => is_ssl(),
366 'httponly' => true,
367 'samesite' => 'Strict',
368 )
369 );
370 // Make token available in current request
371 $_COOKIE['vigilante_2fa_token'] = $token;
372 }
373
374 return $token;
375 }
376
377 /**
378 * Get existing token for a user if still valid
379 *
380 * @param int $user_id User ID.
381 * @return string|false Token or false if not found
382 */
383 private function get_existing_token_for_user( $user_id ) {
384 $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
385
386 if ( ! $token ) {
387 return false;
388 }
389
390 // Verify the token is still valid
391 $data = get_transient( 'vigilante_2fa_pending_' . $token );
392
393 if ( ! $data || empty( $data['user_id'] ) || absint( $data['user_id'] ) !== $user_id ) {
394 return false;
395 }
396
397 return $token;
398 }
399
400 /**
401 * Get pending verification user ID
402 *
403 * @return int|false User ID or false if not pending
404 */
405 private function get_pending_user_id() {
406 // First try cookie
407 $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
408
409 // Also check POST (for when cookie wasn't set in time)
410 if ( empty( $token ) && isset( $_POST['vigilante_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
411 $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
412 }
413
414 if ( empty( $token ) ) {
415 return false;
416 }
417
418 $data = get_transient( 'vigilante_2fa_pending_' . $token );
419
420 if ( ! $data || empty( $data['user_id'] ) ) {
421 return false;
422 }
423
424 return absint( $data['user_id'] );
425 }
426
427 /**
428 * Clear pending verification
429 */
430 private function clear_pending_verification() {
431 $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
432
433 // Also check POST
434 if ( empty( $token ) && isset( $_POST['vigilante_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
435 $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
436 }
437
438 if ( ! empty( $token ) ) {
439 // Get user ID to clear reverse lookup
440 $data = get_transient( 'vigilante_2fa_pending_' . $token );
441 if ( $data && ! empty( $data['user_id'] ) ) {
442 delete_transient( 'vigilante_2fa_user_token_' . $data['user_id'] );
443 }
444
445 delete_transient( 'vigilante_2fa_pending_' . $token );
446 }
447
448 // Clear cookie
449 if ( ! headers_sent() ) {
450 setcookie(
451 'vigilante_2fa_token',
452 '',
453 array(
454 'expires' => time() - YEAR_IN_SECONDS,
455 'path' => COOKIEPATH,
456 'domain' => COOKIE_DOMAIN,
457 'secure' => is_ssl(),
458 'httponly' => true,
459 'samesite' => 'Strict',
460 )
461 );
462 }
463
464 unset( $_COOKIE['vigilante_2fa_token'] );
465 }
466
467 /**
468 * Handle 2FA verification form submission
469 */
470 public function handle_2fa_form() {
471 // Verify nonce
472 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
473 return;
474 }
475
476 $user_id = $this->get_pending_user_id();
477
478 if ( ! $user_id ) {
479 wp_safe_redirect( wp_login_url() );
480 exit;
481 }
482
483 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
484 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
485
486 // Verify code
487 $result = $this->verify_code( $user_id, $code );
488
489 if ( is_wp_error( $result ) ) {
490 // Store error for display
491 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
492
493 // Redirect back to login
494 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
495 exit;
496 }
497
498 // Verification successful
499 $this->clear_pending_verification();
500 $this->database->mark_2fa_code_used( $user_id );
501
502 // Trust device if requested
503 if ( $remember_device ) {
504 $this->trust_device( $user_id );
505 $this->log_event( '2fa_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
506 }
507
508 // Log success
509 $this->log_event( '2fa_verification_success', $user_id, __( 'Two-factor verification successful', 'vigilante' ) );
510
511 // Complete login
512 $user = get_user_by( 'ID', $user_id );
513 wp_set_current_user( $user_id, $user->user_login );
514 wp_set_auth_cookie( $user_id, false );
515 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook that must be fired on login.
516 do_action( 'wp_login', $user->user_login, $user );
517
518 // Redirect to admin dashboard (always use admin_url to avoid issues with popups,
519 // malformed URLs, or query parameters that could cause problems)
520 wp_safe_redirect( admin_url() );
521 exit;
522 }
523
524 /**
525 * Verify the submitted code
526 *
527 * @param int $user_id User ID.
528 * @param string $code Submitted code.
529 * @return true|WP_Error
530 */
531 private function verify_code( $user_id, $code ) {
532 $stored = $this->database->get_2fa_code( $user_id );
533 $user = get_user_by( 'ID', $user_id );
534
535 if ( ! $stored ) {
536 return new WP_Error( 'no_code', __( 'No verification code found. Please log in again.', 'vigilante' ) );
537 }
538
539 // Check if expired
540 if ( strtotime( $stored['expires_at'] ) < time() ) {
541 $this->database->delete_2fa_code( $user_id );
542 return new WP_Error( 'code_expired', __( 'Verification code has expired. Please log in again.', 'vigilante' ) );
543 }
544
545 // Check if already used
546 if ( ! empty( $stored['used'] ) ) {
547 return new WP_Error( 'code_used', __( 'Verification code has already been used. Please log in again.', 'vigilante' ) );
548 }
549
550 // Check max attempts for this specific code
551 $max_code_attempts = absint( $this->options['max_attempts'] ?? 3 );
552
553 if ( absint( $stored['attempts'] ) >= $max_code_attempts ) {
554 $this->log_event( '2fa_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
555 $this->database->delete_2fa_code( $user_id );
556 $this->clear_pending_verification();
557
558 return new WP_Error(
559 'max_attempts',
560 __( 'Too many failed attempts. Please contact the site administrator or try again later.', 'vigilante' )
561 );
562 }
563
564 // Check code
565 if ( $code !== $stored['code'] ) {
566 // Increment code-specific attempts
567 $this->database->increment_2fa_attempts( $user_id );
568
569 // Also record as failed login attempt for general lockout system
570 if ( $this->login_security && $user ) {
571 $this->login_security->record_failed_attempt( $user->user_login, '2fa' );
572 }
573
574 $attempts_left = $max_code_attempts - ( absint( $stored['attempts'] ) + 1 );
575
576 $this->log_event(
577 '2fa_verification_failed',
578 $user_id,
579 sprintf(
580 /* translators: %d: Attempts remaining */
581 __( 'Invalid verification code. %d attempts remaining.', 'vigilante' ),
582 $attempts_left
583 ),
584 'warning'
585 );
586
587 if ( $attempts_left > 0 ) {
588 return new WP_Error(
589 'invalid_code',
590 sprintf(
591 /* translators: %d: Attempts remaining */
592 __( 'Invalid verification code. %d attempts remaining.', 'vigilante' ),
593 $attempts_left
594 )
595 );
596 } else {
597 return new WP_Error(
598 'max_attempts',
599 __( 'Too many failed attempts. Please contact the site administrator or try again later.', 'vigilante' )
600 );
601 }
602 }
603
604 return true;
605 }
606
607 /**
608 * Maybe show 2FA verification form on login page
609 */
610 public function maybe_show_2fa_form() {
611 $user_id = $this->get_pending_user_id();
612
613 // If no user_id from cookie/POST, try the trigger transient
614 if ( ! $user_id ) {
615 $ip = $this->database->get_client_ip();
616 $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
617 }
618
619 if ( ! $user_id ) {
620 return;
621 }
622
623 // Get the token for hidden field
624 $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
625 if ( empty( $token ) ) {
626 $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
627 }
628
629 // Get any error message
630 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
631 delete_transient( 'vigilante_2fa_error_' . $user_id );
632
633 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
634 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
635
636 // Hide the normal login form and disable required fields
637 ?>
638 <style>
639 /* Hide WordPress default error box in 2FA mode */
640 #login_error {
641 display: none !important;
642 }
643 #loginform > p:not(.vigilante-2fa-field),
644 #loginform > .user-pass-wrap,
645 #loginform > .forgetmenot,
646 #loginform > p.submit:not(.vigilante-2fa-submit) {
647 display: none !important;
648 }
649 /* Also hide by ID in case structure varies */
650 #user_login, #user_pass, #loginform > p > label[for="user_login"],
651 #loginform > p > label[for="user_pass"], .login-remember {
652 display: none !important;
653 }
654 </style>
655 <script>
656 (function() {
657 // Disable required attribute on hidden original form fields
658 var userLogin = document.getElementById('user_login');
659 var userPass = document.getElementById('user_pass');
660 var originalSubmit = document.querySelector('#loginform > p.submit:not(.vigilante-2fa-submit) input[type="submit"]');
661
662 if (userLogin) {
663 userLogin.removeAttribute('required');
664 userLogin.disabled = true;
665 }
666 if (userPass) {
667 userPass.removeAttribute('required');
668 userPass.disabled = true;
669 }
670 if (originalSubmit) {
671 originalSubmit.disabled = true;
672 }
673 })();
674 </script>
675
676 <div class="vigilante-2fa-container">
677 <?php if ( $error ) : ?>
678 <div class="vigilante-2fa-error">
679 <?php echo esc_html( $error ); ?>
680 </div>
681 <?php endif; ?>
682
683 <div class="vigilante-2fa-message">
684 <p><?php esc_html_e( 'A verification code has been sent to your email.', 'vigilante' ); ?></p>
685 <p class="vigilante-2fa-expiry">
686 <?php
687 printf(
688 /* translators: %d: Minutes until code expires */
689 esc_html__( 'The code is valid for %d minutes.', 'vigilante' ),
690 absint( $expiry_minutes )
691 );
692 ?>
693 </p>
694 </div>
695
696 <p class="vigilante-2fa-field">
697 <label for="vigilante_2fa_code"><?php esc_html_e( 'Verification Code', 'vigilante' ); ?></label>
698 <input type="text"
699 name="vigilante_2fa_code"
700 id="vigilante_2fa_code"
701 class="input"
702 size="6"
703 maxlength="6"
704 pattern="[0-9]{6}"
705 inputmode="numeric"
706 autocomplete="one-time-code"
707 autofocus
708 required>
709 </p>
710
711 <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
712 <p class="vigilante-2fa-field vigilante-2fa-remember">
713 <label>
714 <input type="checkbox" name="vigilante_2fa_remember" value="1">
715 <?php
716 printf(
717 /* translators: %d: Number of days to remember device */
718 esc_html__( 'Remember this device for %d days', 'vigilante' ),
719 absint( $remember_days )
720 );
721 ?>
722 </label>
723 </p>
724 <?php endif; ?>
725
726 <p class="vigilante-2fa-field vigilante-2fa-submit submit">
727 <input type="hidden" name="action" value="vigilante_2fa">
728 <input type="hidden" name="vigilante_2fa_token" value="<?php echo esc_attr( $token ); ?>">
729 <?php wp_nonce_field( 'vigilante_2fa_verify' ); ?>
730 <input type="submit" name="vigilante-2fa-submit" id="vigilante-2fa-submit" class="button button-primary button-large" value="<?php esc_attr_e( 'Verify', 'vigilante' ); ?>">
731 </p>
732
733 <p class="vigilante-2fa-resend">
734 <a href="#" id="vigilante-resend-code" data-nonce="<?php echo esc_attr( wp_create_nonce( 'vigilante_resend_2fa' ) ); ?>" data-token="<?php echo esc_attr( $token ); ?>">
735 <?php esc_html_e( 'Resend code', 'vigilante' ); ?>
736 </a>
737 <span class="vigilante-2fa-resend-status"></span>
738 </p>
739 </div>
740
741 <script>
742 document.getElementById('vigilante-resend-code').addEventListener('click', function(e) {
743 e.preventDefault();
744 var link = this;
745 var status = document.querySelector('.vigilante-2fa-resend-status');
746
747 link.style.pointerEvents = 'none';
748 status.textContent = '<?php echo esc_js( __( 'Sending...', 'vigilante' ) ); ?>';
749
750 var xhr = new XMLHttpRequest();
751 xhr.open('POST', '<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>');
752 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
753 xhr.onload = function() {
754 link.style.pointerEvents = 'auto';
755 if (xhr.status === 200) {
756 var response = JSON.parse(xhr.responseText);
757 if (response.success) {
758 status.textContent = '<?php echo esc_js( __( 'Code sent!', 'vigilante' ) ); ?>';
759 status.className = 'vigilante-2fa-resend-status success';
760 } else {
761 status.textContent = response.data || '<?php echo esc_js( __( 'Error sending code', 'vigilante' ) ); ?>';
762 status.className = 'vigilante-2fa-resend-status error';
763 }
764 } else {
765 status.textContent = '<?php echo esc_js( __( 'Error sending code', 'vigilante' ) ); ?>';
766 status.className = 'vigilante-2fa-resend-status error';
767 }
768 setTimeout(function() { status.textContent = ''; }, 3000);
769 };
770 xhr.send('action=vigilante_resend_2fa_code&nonce=' + link.dataset.nonce + '&vigilante_2fa_token=' + link.dataset.token);
771 });
772 </script>
773 <?php
774 }
775
776 /**
777 * AJAX handler for resending verification code
778 */
779 public function ajax_resend_code() {
780 // Verify nonce
781 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'vigilante_resend_2fa' ) ) {
782 wp_send_json_error( __( 'Security check failed.', 'vigilante' ) );
783 }
784
785 $user_id = $this->get_pending_user_id();
786
787 if ( ! $user_id ) {
788 wp_send_json_error( __( 'Session expired. Please log in again.', 'vigilante' ) );
789 }
790
791 $user = get_user_by( 'ID', $user_id );
792
793 if ( ! $user ) {
794 wp_send_json_error( __( 'User not found.', 'vigilante' ) );
795 }
796
797 // Delete old code
798 $this->database->delete_2fa_code( $user_id );
799
800 // Generate and send new code
801 $code = $this->generate_code( $user_id );
802 $sent = $this->send_verification_email( $user, $code );
803
804 if ( $sent ) {
805 $this->log_event( '2fa_code_resent', $user_id, __( 'Verification code resent', 'vigilante' ) );
806 wp_send_json_success( __( 'New code sent to your email.', 'vigilante' ) );
807 } else {
808 wp_send_json_error( __( 'Failed to send email. Please try again.', 'vigilante' ) );
809 }
810 }
811
812 /**
813 * Check if device is trusted
814 *
815 * @param int $user_id User ID.
816 * @return bool
817 */
818 private function is_device_trusted( $user_id ) {
819 $device_hash = $this->generate_device_hash( $user_id );
820 return $this->database->is_device_trusted( $user_id, $device_hash );
821 }
822
823 /**
824 * Trust the current device
825 *
826 * @param int $user_id User ID.
827 */
828 private function trust_device( $user_id ) {
829 $device_hash = $this->generate_device_hash( $user_id );
830 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
831 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
832 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $remember_days * DAY_IN_SECONDS ) );
833
834 $this->database->trust_device( $user_id, $device_hash, $user_agent, $expires_at );
835 }
836
837 /**
838 * Generate device hash
839 *
840 * No IP address included for GDPR compliance
841 *
842 * @param int $user_id User ID.
843 * @return string
844 */
845 private function generate_device_hash( $user_id ) {
846 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
847 $salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'vigilante_fallback_salt';
848
849 return hash( 'sha256', $user_id . $user_agent . $salt );
850 }
851
852 /**
853 * Enqueue login page assets
854 */
855 public function enqueue_login_assets() {
856 wp_enqueue_style(
857 'vigilante-2fa-login',
858 VIGILANTE_ASSETS_URL . 'css/two-factor-login.css',
859 array(),
860 VIGILANTE_VERSION
861 );
862 }
863
864 /**
865 * Send activation notification to affected users
866 *
867 * @param bool $only_new Only send to users not previously notified.
868 * @return array Result with count of sent emails
869 */
870 public function send_activation_notifications( $only_new = false ) {
871 $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
872 $excluded_users = $this->options['excluded_users'] ?? array();
873 $excluded_users = array_map( 'absint', $excluded_users );
874
875 // Get users with enforced roles
876 $users = get_users( array(
877 'role__in' => $enforced_roles,
878 'exclude' => $excluded_users,
879 ) );
880
881 if ( empty( $users ) ) {
882 return array(
883 'sent' => 0,
884 'skipped' => 0,
885 'failed' => 0,
886 );
887 }
888
889 $site_name = get_bloginfo( 'name' );
890 $from_name = $this->options['email_from_name'] ?? '';
891 $admin_email = get_option( 'admin_email' );
892
893 if ( empty( $from_name ) ) {
894 $from_name = $site_name;
895 }
896
897 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
898
899 $subject = sprintf(
900 /* translators: %s: Site name */
901 __( '[%s] Two-factor authentication enabled for your account', 'vigilante' ),
902 $site_name
903 );
904
905 $body = Vigilante_Email_Template::p(
906 sprintf(
907 /* translators: %s: Site name */
908 __( 'The administrator of %s has enabled two-factor authentication via email for your account.', 'vigilante' ),
909 $site_name
910 )
911 );
912 $body .= Vigilante_Email_Template::info_box(
913 ! empty( $this->options['allow_remember_device'] )
914 ? sprintf(
915 /* translators: %d: Remember days */
916 __( 'After entering your password, you will receive a 6-digit code via email. You can check "Remember this device" to skip verification for %d days.', 'vigilante' ),
917 $remember_days
918 )
919 : __( 'After entering your password, you will receive a 6-digit code via email that you must enter to complete the login.', 'vigilante' )
920 );
921 $body .= Vigilante_Email_Template::small(
922 sprintf(
923 /* translators: %s: Admin email */
924 __( 'Add %s to your contacts to ensure verification codes do not go to spam.', 'vigilante' ),
925 $admin_email
926 )
927 );
928
929 $sent = 0;
930 $skipped = 0;
931 $failed = 0;
932
933 foreach ( $users as $user ) {
934 // Check if already notified
935 if ( $only_new && $this->database->user_was_2fa_notified( $user->ID ) ) {
936 $skipped++;
937 continue;
938 }
939
940 // Pass from_name via header (avoids filter contamination between sends)
941 $result = Vigilante_Email_Template::send(
942 $user->user_email,
943 $subject,
944 __( 'Two-factor authentication enabled', 'vigilante' ),
945 $body,
946 false,
947 $from_name
948 );
949
950 if ( $result ) {
951 $this->database->mark_2fa_notified( $user->ID );
952 $sent++;
953 } else {
954 $failed++;
955 }
956 }
957
958 // Log event
959 $this->log_event(
960 '2fa_notification_sent',
961 0,
962 sprintf(
963 /* translators: 1: Sent count, 2: Skipped count, 3: Failed count */
964 __( 'Activation notifications sent: %1$d sent, %2$d skipped, %3$d failed', 'vigilante' ),
965 $sent,
966 $skipped,
967 $failed
968 )
969 );
970
971 return array(
972 'sent' => $sent,
973 'skipped' => $skipped,
974 'failed' => $failed,
975 );
976 }
977
978 /**
979 * Log 2FA event
980 *
981 * @param string $action Event action.
982 * @param int $user_id User ID.
983 * @param string $message Event message.
984 * @param string $severity Severity level.
985 */
986 private function log_event( $action, $user_id, $message, $severity = 'info' ) {
987 if ( $this->activity_log ) {
988 $this->activity_log->log(
989 '2fa',
990 $action,
991 $message,
992 array( 'user_id' => $user_id ),
993 $severity
994 );
995 }
996 }
997
998 /**
999 * Get all trusted devices for a user
1000 *
1001 * @param int $user_id User ID.
1002 * @return array
1003 */
1004 public function get_user_trusted_devices( $user_id ) {
1005 return $this->database->get_trusted_devices( $user_id );
1006 }
1007
1008 /**
1009 * Revoke all trusted devices for a user
1010 *
1011 * @param int $user_id User ID.
1012 * @return bool
1013 */
1014 public function revoke_all_trusted_devices( $user_id ) {
1015 return $this->database->revoke_trusted_devices( $user_id );
1016 }
1017
1018 /**
1019 * Clear expired codes and devices (for maintenance)
1020 *
1021 * @return array Counts of deleted items
1022 */
1023 public function cleanup_expired() {
1024 return array(
1025 'codes' => $this->database->cleanup_expired_2fa_codes(),
1026 'devices' => $this->database->cleanup_expired_trusted_devices(),
1027 );
1028 }
1029 }