PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.10.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.10.3
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.10.3, at includes/class-two-factor-email.php

1,053 lines 36.9 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) for this same user
171 if ( $this->is_2fa_verification_request( $user ) ) {
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( $user = null ) {
228 // The action alone proves nothing: it travels in the request and the
229 // attacker sets it. A genuine verification also carries the form nonce
230 // and a pending token issued to this very user.
231 $action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
232
233 if ( 'vigilante_2fa' !== $action ) {
234 return false;
235 }
236
237 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
238 return false;
239 }
240
241 $pending_user_id = $this->get_pending_user_id();
242
243 if ( ! $pending_user_id ) {
244 return false;
245 }
246
247 if ( $user instanceof WP_User ) {
248 return $pending_user_id === (int) $user->ID;
249 }
250
251 return true;
252 }
253
254 /**
255 * Check if user requires 2FA
256 *
257 * @param WP_User $user User object.
258 * @return bool
259 */
260 public function user_requires_2fa( $user ) {
261 // Check if user is explicitly excluded
262 $excluded_users = $this->options['excluded_users'] ?? array();
263 if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
264 return false;
265 }
266
267 // Check if user has an enforced role
268 $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
269
270 foreach ( $user->roles as $role ) {
271 if ( in_array( $role, $enforced_roles, true ) ) {
272 return true;
273 }
274 }
275
276 return false;
277 }
278
279 /**
280 * Generate verification code
281 *
282 * @param int $user_id User ID.
283 * @return string 6-digit code
284 */
285 private function generate_code( $user_id ) {
286 // Generate secure 6-digit code
287 $code = sprintf( '%06d', wp_rand( 0, 999999 ) );
288
289 // Calculate expiry
290 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
291 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $expiry_minutes * 60 ) );
292
293 // Store in database
294 $this->database->store_2fa_code( $user_id, $code, $expires_at );
295
296 return $code;
297 }
298
299 /**
300 * Send verification email
301 *
302 * @param WP_User $user User object.
303 * @param string $code Verification code.
304 * @return bool
305 */
306 private function send_verification_email( $user, $code ) {
307 $site_name = get_bloginfo( 'name' );
308 $from_name = $this->options['email_from_name'] ?? '';
309
310 if ( empty( $from_name ) ) {
311 $from_name = $site_name;
312 }
313
314 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
315
316 $subject = sprintf(
317 /* translators: 1: Site name, 2: Verification code */
318 __( '[%1$s] Your verification code: %2$s', 'vigilante' ),
319 $site_name,
320 $code
321 );
322
323 $body = Vigilante_Email_Template::p( __( 'Your verification code is:', 'vigilante' ) );
324 $body .= Vigilante_Email_Template::code_box( $code );
325 $body .= Vigilante_Email_Template::small(
326 sprintf(
327 /* translators: %d: Minutes until code expires */
328 __( 'This code is valid for %d minutes.', 'vigilante' ),
329 $expiry_minutes
330 )
331 );
332 $body .= Vigilante_Email_Template::small( __( 'If you did not attempt to log in, please ignore this message and consider changing your password.', 'vigilante' ) );
333
334 // Use from_name via header (avoids filter contamination)
335 $sent = Vigilante_Email_Template::send( $user->user_email, $subject, '', $body, false, $from_name );
336
337 return $sent;
338 }
339
340 /**
341 * Set pending verification state
342 *
343 * @param int $user_id User ID.
344 * @return string Token for the pending session
345 */
346 private function set_pending_verification( $user_id ) {
347 // Check if there's already a valid token for this user
348 $existing_token = $this->get_existing_token_for_user( $user_id );
349
350 if ( $existing_token ) {
351 $token = $existing_token;
352 } else {
353 $token = wp_generate_password( 32, false );
354 }
355
356 set_transient(
357 'vigilante_2fa_pending_' . $token,
358 array(
359 'user_id' => $user_id,
360 'created_at' => time(),
361 ),
362 HOUR_IN_SECONDS
363 );
364
365 // Also store reverse lookup (user_id -> token)
366 set_transient(
367 'vigilante_2fa_user_token_' . $user_id,
368 $token,
369 HOUR_IN_SECONDS
370 );
371
372 // Set a short-lived transient to indicate 2FA was just triggered
373 // This helps filter_login_errors() detect 2FA mode before cookie is available
374 $ip = $this->database->get_client_ip();
375 set_transient( 'vigilante_2fa_triggered_' . md5( $ip ), $user_id, 60 );
376
377 // Store token in cookie for form submission
378 if ( ! headers_sent() ) {
379 setcookie(
380 'vigilante_2fa_token',
381 $token,
382 array(
383 'expires' => time() + HOUR_IN_SECONDS,
384 'path' => COOKIEPATH,
385 'domain' => COOKIE_DOMAIN,
386 'secure' => is_ssl(),
387 'httponly' => true,
388 'samesite' => 'Strict',
389 )
390 );
391 // Make token available in current request
392 $_COOKIE['vigilante_2fa_token'] = $token;
393 }
394
395 return $token;
396 }
397
398 /**
399 * Get existing token for a user if still valid
400 *
401 * @param int $user_id User ID.
402 * @return string|false Token or false if not found
403 */
404 private function get_existing_token_for_user( $user_id ) {
405 $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
406
407 if ( ! $token ) {
408 return false;
409 }
410
411 // Verify the token is still valid
412 $data = get_transient( 'vigilante_2fa_pending_' . $token );
413
414 if ( ! $data || empty( $data['user_id'] ) || absint( $data['user_id'] ) !== $user_id ) {
415 return false;
416 }
417
418 return $token;
419 }
420
421 /**
422 * Get pending verification user ID
423 *
424 * @return int|false User ID or false if not pending
425 */
426 private function get_pending_user_id() {
427 // First try cookie
428 $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
429
430 // Also check POST (for when cookie wasn't set in time)
431 if ( empty( $token ) && isset( $_POST['vigilante_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
432 $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
433 }
434
435 if ( empty( $token ) ) {
436 return false;
437 }
438
439 $data = get_transient( 'vigilante_2fa_pending_' . $token );
440
441 if ( ! $data || empty( $data['user_id'] ) ) {
442 return false;
443 }
444
445 return absint( $data['user_id'] );
446 }
447
448 /**
449 * Clear pending verification
450 */
451 private function clear_pending_verification() {
452 $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
453
454 // Also check POST
455 if ( empty( $token ) && isset( $_POST['vigilante_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
456 $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
457 }
458
459 if ( ! empty( $token ) ) {
460 // Get user ID to clear reverse lookup
461 $data = get_transient( 'vigilante_2fa_pending_' . $token );
462 if ( $data && ! empty( $data['user_id'] ) ) {
463 delete_transient( 'vigilante_2fa_user_token_' . $data['user_id'] );
464 }
465
466 delete_transient( 'vigilante_2fa_pending_' . $token );
467 }
468
469 // Clear cookie
470 if ( ! headers_sent() ) {
471 setcookie(
472 'vigilante_2fa_token',
473 '',
474 array(
475 'expires' => time() - YEAR_IN_SECONDS,
476 'path' => COOKIEPATH,
477 'domain' => COOKIE_DOMAIN,
478 'secure' => is_ssl(),
479 'httponly' => true,
480 'samesite' => 'Strict',
481 )
482 );
483 }
484
485 unset( $_COOKIE['vigilante_2fa_token'] );
486 }
487
488 /**
489 * Handle 2FA verification form submission
490 */
491 public function handle_2fa_form() {
492 // Verify nonce. A bare return would let wp-login.php fall through to its
493 // default case and call wp_signon(), completing the login without the
494 // second factor, so this path must end the request.
495 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
496 wp_safe_redirect( wp_login_url() );
497 exit;
498 }
499
500 $user_id = $this->get_pending_user_id();
501
502 if ( ! $user_id ) {
503 wp_safe_redirect( wp_login_url() );
504 exit;
505 }
506
507 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
508 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
509
510 // Verify code
511 $result = $this->verify_code( $user_id, $code );
512
513 if ( is_wp_error( $result ) ) {
514 // Store error for display
515 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
516
517 // Redirect back to login
518 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
519 exit;
520 }
521
522 // Verification successful
523 $this->clear_pending_verification();
524 $this->database->mark_2fa_code_used( $user_id );
525
526 // Trust device if requested
527 if ( $remember_device ) {
528 $this->trust_device( $user_id );
529 $this->log_event( '2fa_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
530 }
531
532 // Log success
533 $this->log_event( '2fa_verification_success', $user_id, __( 'Two-factor verification successful', 'vigilante' ) );
534
535 // Complete login
536 $user = get_user_by( 'ID', $user_id );
537 wp_set_current_user( $user_id, $user->user_login );
538 wp_set_auth_cookie( $user_id, false );
539 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook that must be fired on login.
540 do_action( 'wp_login', $user->user_login, $user );
541
542 // Redirect to admin dashboard (always use admin_url to avoid issues with popups,
543 // malformed URLs, or query parameters that could cause problems)
544 wp_safe_redirect( admin_url() );
545 exit;
546 }
547
548 /**
549 * Verify the submitted code
550 *
551 * @param int $user_id User ID.
552 * @param string $code Submitted code.
553 * @return true|WP_Error
554 */
555 private function verify_code( $user_id, $code ) {
556 $stored = $this->database->get_2fa_code( $user_id );
557 $user = get_user_by( 'ID', $user_id );
558
559 if ( ! $stored ) {
560 return new WP_Error( 'no_code', __( 'No verification code found. Please log in again.', 'vigilante' ) );
561 }
562
563 // Check if expired
564 if ( strtotime( $stored['expires_at'] ) < time() ) {
565 $this->database->delete_2fa_code( $user_id );
566 return new WP_Error( 'code_expired', __( 'Verification code has expired. Please log in again.', 'vigilante' ) );
567 }
568
569 // Check if already used
570 if ( ! empty( $stored['used'] ) ) {
571 return new WP_Error( 'code_used', __( 'Verification code has already been used. Please log in again.', 'vigilante' ) );
572 }
573
574 // Check max attempts for this specific code
575 $max_code_attempts = absint( $this->options['max_attempts'] ?? 3 );
576
577 if ( absint( $stored['attempts'] ) >= $max_code_attempts ) {
578 $this->log_event( '2fa_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
579 $this->database->delete_2fa_code( $user_id );
580 $this->clear_pending_verification();
581
582 return new WP_Error(
583 'max_attempts',
584 __( 'Too many failed attempts. Please contact the site administrator or try again later.', 'vigilante' )
585 );
586 }
587
588 // Check code
589 if ( $code !== $stored['code'] ) {
590 // Increment code-specific attempts
591 $this->database->increment_2fa_attempts( $user_id );
592
593 // Also record as failed login attempt for general lockout system
594 if ( $this->login_security && $user ) {
595 $this->login_security->record_failed_attempt( $user->user_login, '2fa' );
596 }
597
598 $attempts_left = $max_code_attempts - ( absint( $stored['attempts'] ) + 1 );
599
600 $this->log_event(
601 '2fa_verification_failed',
602 $user_id,
603 sprintf(
604 /* translators: %d: Attempts remaining */
605 __( 'Invalid verification code. %d attempts remaining.', 'vigilante' ),
606 $attempts_left
607 ),
608 'warning'
609 );
610
611 if ( $attempts_left > 0 ) {
612 return new WP_Error(
613 'invalid_code',
614 sprintf(
615 /* translators: %d: Attempts remaining */
616 __( 'Invalid verification code. %d attempts remaining.', 'vigilante' ),
617 $attempts_left
618 )
619 );
620 } else {
621 return new WP_Error(
622 'max_attempts',
623 __( 'Too many failed attempts. Please contact the site administrator or try again later.', 'vigilante' )
624 );
625 }
626 }
627
628 return true;
629 }
630
631 /**
632 * Maybe show 2FA verification form on login page
633 */
634 public function maybe_show_2fa_form() {
635 $user_id = $this->get_pending_user_id();
636
637 // If no user_id from cookie/POST, try the trigger transient
638 if ( ! $user_id ) {
639 $ip = $this->database->get_client_ip();
640 $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
641 }
642
643 if ( ! $user_id ) {
644 return;
645 }
646
647 // Get the token for hidden field
648 $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
649 if ( empty( $token ) ) {
650 $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
651 }
652
653 // Get any error message
654 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
655 delete_transient( 'vigilante_2fa_error_' . $user_id );
656
657 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
658 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
659
660 // Hide the normal login form and disable required fields
661 ?>
662 <style>
663 /* Hide WordPress default error box in 2FA mode */
664 #login_error {
665 display: none !important;
666 }
667 #loginform > p:not(.vigilante-2fa-field),
668 #loginform > .user-pass-wrap,
669 #loginform > .forgetmenot,
670 #loginform > p.submit:not(.vigilante-2fa-submit) {
671 display: none !important;
672 }
673 /* Also hide by ID in case structure varies */
674 #user_login, #user_pass, #loginform > p > label[for="user_login"],
675 #loginform > p > label[for="user_pass"], .login-remember {
676 display: none !important;
677 }
678 </style>
679 <script>
680 (function() {
681 // Disable required attribute on hidden original form fields
682 var userLogin = document.getElementById('user_login');
683 var userPass = document.getElementById('user_pass');
684 var originalSubmit = document.querySelector('#loginform > p.submit:not(.vigilante-2fa-submit) input[type="submit"]');
685
686 if (userLogin) {
687 userLogin.removeAttribute('required');
688 userLogin.disabled = true;
689 }
690 if (userPass) {
691 userPass.removeAttribute('required');
692 userPass.disabled = true;
693 }
694 if (originalSubmit) {
695 originalSubmit.disabled = true;
696 }
697 })();
698 </script>
699
700 <div class="vigilante-2fa-container">
701 <?php if ( $error ) : ?>
702 <div class="vigilante-2fa-error">
703 <?php echo esc_html( $error ); ?>
704 </div>
705 <?php endif; ?>
706
707 <div class="vigilante-2fa-message">
708 <p><?php esc_html_e( 'A verification code has been sent to your email.', 'vigilante' ); ?></p>
709 <p class="vigilante-2fa-expiry">
710 <?php
711 printf(
712 /* translators: %d: Minutes until code expires */
713 esc_html__( 'The code is valid for %d minutes.', 'vigilante' ),
714 absint( $expiry_minutes )
715 );
716 ?>
717 </p>
718 </div>
719
720 <p class="vigilante-2fa-field">
721 <label for="vigilante_2fa_code"><?php esc_html_e( 'Verification Code', 'vigilante' ); ?></label>
722 <input type="text"
723 name="vigilante_2fa_code"
724 id="vigilante_2fa_code"
725 class="input"
726 size="6"
727 maxlength="6"
728 pattern="[0-9]{6}"
729 inputmode="numeric"
730 autocomplete="one-time-code"
731 autofocus
732 required>
733 </p>
734
735 <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
736 <p class="vigilante-2fa-field vigilante-2fa-remember">
737 <label>
738 <input type="checkbox" name="vigilante_2fa_remember" value="1">
739 <?php
740 printf(
741 /* translators: %d: Number of days to remember device */
742 esc_html__( 'Remember this device for %d days', 'vigilante' ),
743 absint( $remember_days )
744 );
745 ?>
746 </label>
747 </p>
748 <?php endif; ?>
749
750 <p class="vigilante-2fa-field vigilante-2fa-submit submit">
751 <input type="hidden" name="action" value="vigilante_2fa">
752 <input type="hidden" name="vigilante_2fa_token" value="<?php echo esc_attr( $token ); ?>">
753 <?php wp_nonce_field( 'vigilante_2fa_verify' ); ?>
754 <input type="submit" name="vigilante-2fa-submit" id="vigilante-2fa-submit" class="button button-primary button-large" value="<?php esc_attr_e( 'Verify', 'vigilante' ); ?>">
755 </p>
756
757 <p class="vigilante-2fa-resend">
758 <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 ); ?>">
759 <?php esc_html_e( 'Resend code', 'vigilante' ); ?>
760 </a>
761 <span class="vigilante-2fa-resend-status"></span>
762 </p>
763 </div>
764
765 <script>
766 document.getElementById('vigilante-resend-code').addEventListener('click', function(e) {
767 e.preventDefault();
768 var link = this;
769 var status = document.querySelector('.vigilante-2fa-resend-status');
770
771 link.style.pointerEvents = 'none';
772 status.textContent = '<?php echo esc_js( __( 'Sending...', 'vigilante' ) ); ?>';
773
774 var xhr = new XMLHttpRequest();
775 xhr.open('POST', '<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>');
776 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
777 xhr.onload = function() {
778 link.style.pointerEvents = 'auto';
779 if (xhr.status === 200) {
780 var response = JSON.parse(xhr.responseText);
781 if (response.success) {
782 status.textContent = '<?php echo esc_js( __( 'Code sent!', 'vigilante' ) ); ?>';
783 status.className = 'vigilante-2fa-resend-status success';
784 } else {
785 status.textContent = response.data || '<?php echo esc_js( __( 'Error sending code', 'vigilante' ) ); ?>';
786 status.className = 'vigilante-2fa-resend-status error';
787 }
788 } else {
789 status.textContent = '<?php echo esc_js( __( 'Error sending code', 'vigilante' ) ); ?>';
790 status.className = 'vigilante-2fa-resend-status error';
791 }
792 setTimeout(function() { status.textContent = ''; }, 3000);
793 };
794 xhr.send('action=vigilante_resend_2fa_code&nonce=' + link.dataset.nonce + '&vigilante_2fa_token=' + link.dataset.token);
795 });
796 </script>
797 <?php
798 }
799
800 /**
801 * AJAX handler for resending verification code
802 */
803 public function ajax_resend_code() {
804 // Verify nonce
805 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'vigilante_resend_2fa' ) ) {
806 wp_send_json_error( __( 'Security check failed.', 'vigilante' ) );
807 }
808
809 $user_id = $this->get_pending_user_id();
810
811 if ( ! $user_id ) {
812 wp_send_json_error( __( 'Session expired. Please log in again.', 'vigilante' ) );
813 }
814
815 $user = get_user_by( 'ID', $user_id );
816
817 if ( ! $user ) {
818 wp_send_json_error( __( 'User not found.', 'vigilante' ) );
819 }
820
821 // Delete old code
822 $this->database->delete_2fa_code( $user_id );
823
824 // Generate and send new code
825 $code = $this->generate_code( $user_id );
826 $sent = $this->send_verification_email( $user, $code );
827
828 if ( $sent ) {
829 $this->log_event( '2fa_code_resent', $user_id, __( 'Verification code resent', 'vigilante' ) );
830 wp_send_json_success( __( 'New code sent to your email.', 'vigilante' ) );
831 } else {
832 wp_send_json_error( __( 'Failed to send email. Please try again.', 'vigilante' ) );
833 }
834 }
835
836 /**
837 * Check if device is trusted
838 *
839 * @param int $user_id User ID.
840 * @return bool
841 */
842 private function is_device_trusted( $user_id ) {
843 $device_hash = $this->generate_device_hash( $user_id );
844 return $this->database->is_device_trusted( $user_id, $device_hash );
845 }
846
847 /**
848 * Trust the current device
849 *
850 * @param int $user_id User ID.
851 */
852 private function trust_device( $user_id ) {
853 $device_hash = $this->generate_device_hash( $user_id );
854 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
855 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
856 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $remember_days * DAY_IN_SECONDS ) );
857
858 $this->database->trust_device( $user_id, $device_hash, $user_agent, $expires_at );
859 }
860
861 /**
862 * Generate device hash
863 *
864 * No IP address included for GDPR compliance
865 *
866 * @param int $user_id User ID.
867 * @return string
868 */
869 private function generate_device_hash( $user_id ) {
870 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
871 $salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'vigilante_fallback_salt';
872
873 return hash( 'sha256', $user_id . $user_agent . $salt );
874 }
875
876 /**
877 * Enqueue login page assets
878 */
879 public function enqueue_login_assets() {
880 wp_enqueue_style(
881 'vigilante-2fa-login',
882 VIGILANTE_ASSETS_URL . 'css/two-factor-login.css',
883 array(),
884 VIGILANTE_VERSION
885 );
886 }
887
888 /**
889 * Send activation notification to affected users
890 *
891 * @param bool $only_new Only send to users not previously notified.
892 * @return array Result with count of sent emails
893 */
894 public function send_activation_notifications( $only_new = false ) {
895 $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
896 $excluded_users = $this->options['excluded_users'] ?? array();
897 $excluded_users = array_map( 'absint', $excluded_users );
898
899 // Get users with enforced roles
900 $users = get_users( array(
901 'role__in' => $enforced_roles,
902 'exclude' => $excluded_users,
903 ) );
904
905 if ( empty( $users ) ) {
906 return array(
907 'sent' => 0,
908 'skipped' => 0,
909 'failed' => 0,
910 );
911 }
912
913 $site_name = get_bloginfo( 'name' );
914 $from_name = $this->options['email_from_name'] ?? '';
915 $admin_email = get_option( 'admin_email' );
916
917 if ( empty( $from_name ) ) {
918 $from_name = $site_name;
919 }
920
921 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
922
923 $subject = sprintf(
924 /* translators: %s: Site name */
925 __( '[%s] Two-factor authentication enabled for your account', 'vigilante' ),
926 $site_name
927 );
928
929 $body = Vigilante_Email_Template::p(
930 sprintf(
931 /* translators: %s: Site name */
932 __( 'The administrator of %s has enabled two-factor authentication via email for your account.', 'vigilante' ),
933 $site_name
934 )
935 );
936 $body .= Vigilante_Email_Template::info_box(
937 ! empty( $this->options['allow_remember_device'] )
938 ? sprintf(
939 /* translators: %d: Remember days */
940 __( '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' ),
941 $remember_days
942 )
943 : __( 'After entering your password, you will receive a 6-digit code via email that you must enter to complete the login.', 'vigilante' )
944 );
945 $body .= Vigilante_Email_Template::small(
946 sprintf(
947 /* translators: %s: Admin email */
948 __( 'Add %s to your contacts to ensure verification codes do not go to spam.', 'vigilante' ),
949 $admin_email
950 )
951 );
952
953 $sent = 0;
954 $skipped = 0;
955 $failed = 0;
956
957 foreach ( $users as $user ) {
958 // Check if already notified
959 if ( $only_new && $this->database->user_was_2fa_notified( $user->ID ) ) {
960 $skipped++;
961 continue;
962 }
963
964 // Pass from_name via header (avoids filter contamination between sends)
965 $result = Vigilante_Email_Template::send(
966 $user->user_email,
967 $subject,
968 __( 'Two-factor authentication enabled', 'vigilante' ),
969 $body,
970 false,
971 $from_name
972 );
973
974 if ( $result ) {
975 $this->database->mark_2fa_notified( $user->ID );
976 $sent++;
977 } else {
978 $failed++;
979 }
980 }
981
982 // Log event
983 $this->log_event(
984 '2fa_notification_sent',
985 0,
986 sprintf(
987 /* translators: 1: Sent count, 2: Skipped count, 3: Failed count */
988 __( 'Activation notifications sent: %1$d sent, %2$d skipped, %3$d failed', 'vigilante' ),
989 $sent,
990 $skipped,
991 $failed
992 )
993 );
994
995 return array(
996 'sent' => $sent,
997 'skipped' => $skipped,
998 'failed' => $failed,
999 );
1000 }
1001
1002 /**
1003 * Log 2FA event
1004 *
1005 * @param string $action Event action.
1006 * @param int $user_id User ID.
1007 * @param string $message Event message.
1008 * @param string $severity Severity level.
1009 */
1010 private function log_event( $action, $user_id, $message, $severity = 'info' ) {
1011 if ( $this->activity_log ) {
1012 $this->activity_log->log(
1013 '2fa',
1014 $action,
1015 $message,
1016 array( 'user_id' => $user_id ),
1017 $severity
1018 );
1019 }
1020 }
1021
1022 /**
1023 * Get all trusted devices for a user
1024 *
1025 * @param int $user_id User ID.
1026 * @return array
1027 */
1028 public function get_user_trusted_devices( $user_id ) {
1029 return $this->database->get_trusted_devices( $user_id );
1030 }
1031
1032 /**
1033 * Revoke all trusted devices for a user
1034 *
1035 * @param int $user_id User ID.
1036 * @return bool
1037 */
1038 public function revoke_all_trusted_devices( $user_id ) {
1039 return $this->database->revoke_trusted_devices( $user_id );
1040 }
1041
1042 /**
1043 * Clear expired codes and devices (for maintenance)
1044 *
1045 * @return array Counts of deleted items
1046 */
1047 public function cleanup_expired() {
1048 return array(
1049 'codes' => $this->database->cleanup_expired_2fa_codes(),
1050 'devices' => $this->database->cleanup_expired_trusted_devices(),
1051 );
1052 }
1053 }