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

853 lines 31.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 use Vigilante_Two_Factor_Session;
23
24 /**
25 * Settings instance
26 *
27 * @var Vigilante_Settings
28 */
29 private $settings;
30
31 /**
32 * Database instance
33 *
34 * @var Vigilante_Database
35 */
36 private $database;
37
38 /**
39 * Activity log instance
40 *
41 * @var Vigilante_Activity_Log
42 */
43 private $activity_log;
44
45 /**
46 * Login security instance
47 *
48 * @var Vigilante_Login_Security|null
49 */
50 private $login_security;
51
52 /**
53 * 2FA options
54 *
55 * @var array
56 */
57 private $options;
58
59 /**
60 * Session key for pending verification
61 *
62 * @var string
63 */
64 const SESSION_KEY = 'vigilante_2fa_pending';
65
66 /**
67 * Constructor
68 *
69 * @param Vigilante_Settings $settings Settings instance.
70 * @param Vigilante_Database $database Database instance.
71 * @param Vigilante_Activity_Log $activity_log Activity log instance.
72 * @param Vigilante_Login_Security|null $login_security Login security instance (optional).
73 */
74 public function __construct( $settings, $database, $activity_log, $login_security = null ) {
75 $this->settings = $settings;
76 $this->database = $database;
77 $this->activity_log = $activity_log;
78 $this->login_security = $login_security;
79
80 $login_options = $settings->get_section( 'login_security' );
81 $this->options = $login_options['two_factor'] ?? array();
82
83 if ( $this->is_enabled() ) {
84 $this->init_hooks();
85 }
86 }
87
88 /**
89 * Check if 2FA is enabled
90 *
91 * @return bool
92 */
93 public function is_enabled() {
94 if ( empty( $this->options['enabled'] ) ) {
95 return false;
96 }
97 // Only active when method is email (or not set, for backward compatibility)
98 $method = $this->options['method'] ?? 'email';
99 return 'email' === $method;
100 }
101
102 /**
103 * Initialize hooks
104 */
105 private function init_hooks() {
106 $this->init_session_hooks();
107
108 // Intercept successful authentication
109 add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 );
110
111 // Handle 2FA verification form
112 add_action( 'login_form_vigilante_2fa', array( $this, 'handle_2fa_form' ) );
113
114 // Add 2FA form to login page
115 add_action( 'login_form', array( $this, 'maybe_show_2fa_form' ) );
116
117 // Handle AJAX resend code
118 add_action( 'wp_ajax_nopriv_vigilante_resend_2fa_code', array( $this, 'ajax_resend_code' ) );
119
120 // Enqueue login styles
121 add_action( 'login_enqueue_scripts', array( $this, 'enqueue_login_assets' ) );
122
123 // Filter login error messages to hide default error when 2FA is pending
124 add_filter( 'login_errors', array( $this, 'filter_login_errors' ), 100 );
125 }
126
127 /**
128 * Filter login error messages
129 *
130 * Hide the default "Invalid username or password" while a second-factor
131 * verification is pending for the visitor holding the pending token. Until
132 * 2.11.0 this also looked the visitor up by IP address, which behind a proxy
133 * or a CDN made one user's pending state leak into another's screen (S3).
134 *
135 * @param string $errors Error messages HTML.
136 * @return string Filtered error messages
137 */
138 public function filter_login_errors( $errors ) {
139 if ( $this->get_pending_user_id() ) {
140 return '';
141 }
142
143 return $errors;
144 }
145
146 /**
147 * Check if user requires 2FA after successful password authentication
148 *
149 * @param WP_User|WP_Error $user User object or error.
150 * @param string $username Username.
151 * @param string $password Password.
152 * @return WP_User|WP_Error
153 */
154 public function check_2fa_requirement( $user, $username, $password ) {
155 // Only process successful authentications
156 if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
157 return $user;
158 }
159
160 // An application password is a second factor of its own. The core
161 // action that flags it only fires when those were the credentials (S16).
162 if ( $this->authenticated_with_app_password( $user ) ) {
163 return $user;
164 }
165
166 /*
167 * There is deliberately no "already verifying, let it through" shortcut
168 * here any more. Until 2.11.0 a request carrying action=vigilante_2fa,
169 * the form nonce and a pending token returned $user at this point, and
170 * all three are in the hands of whoever knows the password: the nonce
171 * is printed on the form served to the pending visitor, and the token is
172 * issued to that same visitor. wp-login.php never reached this filter
173 * with that action, because login_form_vigilante_2fa ends the request,
174 * but any other login form that calls wp_signon(), the WooCommerce one
175 * for instance, does reach it and completed the login without a second
176 * factor (S19, found in the 2.11.0 cross review and reproduced). The
177 * verification form authenticates on its own path, handle_2fa_form(),
178 * which never passes through wp_authenticate(): nothing legitimate
179 * needed the shortcut.
180 */
181
182 // Check if 2FA is required for this user
183 if ( ! $this->user_requires_2fa( $user ) ) {
184 return $user;
185 }
186
187 // Check if device is trusted
188 if ( $this->is_device_trusted( $user->ID ) ) {
189 return $user;
190 }
191
192 // REST and XML-RPC have no verification form to show. The account still
193 // needs its second factor, so the login is refused, but without creating
194 // a pending session or sending a code: a connector retrying with the
195 // main password used to trigger one email per attempt (S16).
196 if ( $this->is_api_request() ) {
197 return $this->api_requires_2fa_error();
198 }
199
200 // Check if there's a very recent code (less than 60 seconds old) to avoid duplicate emails on rapid retries
201 $existing_code = $this->database->get_2fa_code( $user->ID );
202 $code_is_recent = $existing_code
203 && strtotime( $existing_code['expires_at'] ) > time()
204 && empty( $existing_code['used'] )
205 && ( time() - strtotime( $existing_code['created_at'] ) ) < 60;
206
207 if ( $code_is_recent ) {
208 // Code was just sent, don't send another email
209 $this->set_pending_verification( $user->ID );
210
211 return new WP_Error(
212 'vigilante_2fa_required',
213 __( 'Please enter the verification code sent to your email.', 'vigilante' )
214 );
215 }
216
217 // Delete any old codes for this user
218 $this->database->delete_2fa_code( $user->ID );
219
220 // Generate and send new verification code
221 $code = $this->generate_code( $user->ID );
222 $this->send_verification_email( $user, $code );
223
224 // Store pending state
225 $this->set_pending_verification( $user->ID );
226
227 // Log code sent
228 $this->log_event( '2fa_code_sent', $user->ID, __( 'Verification code sent via email', 'vigilante' ) );
229
230 // Return error to stop login and show 2FA form
231 return new WP_Error(
232 'vigilante_2fa_required',
233 __( 'Please enter the verification code sent to your email.', 'vigilante' )
234 );
235 }
236
237 /**
238 * Check if user requires 2FA
239 *
240 * @param WP_User $user User object.
241 * @return bool
242 */
243 public function user_requires_2fa( $user ) {
244 // Check if user is explicitly excluded
245 $excluded_users = $this->options['excluded_users'] ?? array();
246 if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
247 return false;
248 }
249
250 // Check if user has an enforced role
251 $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
252
253 foreach ( $user->roles as $role ) {
254 if ( in_array( $role, $enforced_roles, true ) ) {
255 return true;
256 }
257 }
258
259 return false;
260 }
261
262 /**
263 * Generate verification code
264 *
265 * @param int $user_id User ID.
266 * @return string 6-digit code
267 */
268 private function generate_code( $user_id ) {
269 // Generate secure 6-digit code
270 $code = sprintf( '%06d', wp_rand( 0, 999999 ) );
271
272 // Calculate expiry
273 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
274 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $expiry_minutes * 60 ) );
275
276 // Only the hash is stored. The code itself travels in the email and
277 // nowhere else, and verify_code() compares with hash_equals() (S11).
278 $this->database->store_2fa_code( $user_id, wp_hash( $code ), $expires_at );
279
280 return $code;
281 }
282
283 /**
284 * Send verification email
285 *
286 * @param WP_User $user User object.
287 * @param string $code Verification code.
288 * @return bool
289 */
290 private function send_verification_email( $user, $code ) {
291 $site_name = get_bloginfo( 'name' );
292 $from_name = $this->options['email_from_name'] ?? '';
293
294 if ( empty( $from_name ) ) {
295 $from_name = $site_name;
296 }
297
298 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
299
300 $subject = sprintf(
301 /* translators: 1: Site name, 2: Verification code */
302 __( '[%1$s] Your verification code: %2$s', 'vigilante' ),
303 $site_name,
304 $code
305 );
306
307 $body = Vigilante_Email_Template::p( __( 'Your verification code is:', 'vigilante' ) );
308 $body .= Vigilante_Email_Template::code_box( $code );
309 $body .= Vigilante_Email_Template::small(
310 sprintf(
311 /* translators: %d: Minutes until code expires */
312 __( 'This code is valid for %d minutes.', 'vigilante' ),
313 $expiry_minutes
314 )
315 );
316 $body .= Vigilante_Email_Template::small( __( 'If you did not attempt to log in, please ignore this message and consider changing your password.', 'vigilante' ) );
317
318 // Use from_name via header (avoids filter contamination)
319 $sent = Vigilante_Email_Template::send( $user->user_email, $subject, '', $body, false, $from_name );
320
321 return $sent;
322 }
323
324 /**
325 * Handle 2FA verification form submission
326 */
327 public function handle_2fa_form() {
328 // The pending user is resolved first so that a failed nonce can be
329 // explained on the form and recorded (S15). Both failure paths end the
330 // request: a bare return would let wp-login.php fall through to its
331 // default case and call wp_signon(), completing the login without the
332 // second factor.
333 $user_id = $this->get_pending_user_id();
334
335 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
336 $this->handle_invalid_nonce( $user_id );
337 }
338
339 if ( ! $user_id ) {
340 wp_safe_redirect( wp_login_url() );
341 exit;
342 }
343
344 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
345 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
346
347 // Verify code
348 $result = $this->verify_code( $user_id, $code );
349
350 if ( is_wp_error( $result ) ) {
351 // Store error for display
352 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
353
354 // Redirect back to login
355 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
356 exit;
357 }
358
359 // Verification successful
360 $this->clear_pending_verification();
361 $this->database->mark_2fa_code_used( $user_id );
362
363 // Trust device if requested (and if the option allows it, see trust_device)
364 if ( $remember_device && $this->trust_device( $user_id ) ) {
365 $this->log_event( '2fa_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
366 }
367
368 // Log success
369 $this->log_event( '2fa_verification_success', $user_id, __( 'Two-factor verification successful', 'vigilante' ) );
370
371 // Complete login
372 $user = get_user_by( 'ID', $user_id );
373 wp_set_current_user( $user_id, $user->user_login );
374 wp_set_auth_cookie( $user_id, false );
375 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook that must be fired on login.
376 do_action( 'wp_login', $user->user_login, $user );
377
378 // Redirect to admin dashboard (always use admin_url to avoid issues with popups,
379 // malformed URLs, or query parameters that could cause problems)
380 wp_safe_redirect( admin_url() );
381 exit;
382 }
383
384 /**
385 * Verify the submitted code
386 *
387 * @param int $user_id User ID.
388 * @param string $code Submitted code.
389 * @return true|WP_Error
390 */
391 private function verify_code( $user_id, $code ) {
392 $stored = $this->database->get_2fa_code( $user_id );
393 $user = get_user_by( 'ID', $user_id );
394
395 if ( ! $stored ) {
396 return new WP_Error( 'no_code', __( 'No verification code found. Please log in again.', 'vigilante' ) );
397 }
398
399 // Check if expired
400 if ( strtotime( $stored['expires_at'] ) < time() ) {
401 $this->database->delete_2fa_code( $user_id );
402 return new WP_Error( 'code_expired', __( 'Verification code has expired. Please log in again.', 'vigilante' ) );
403 }
404
405 // Check if already used
406 if ( ! empty( $stored['used'] ) ) {
407 return new WP_Error( 'code_used', __( 'Verification code has already been used. Please log in again.', 'vigilante' ) );
408 }
409
410 // Check max attempts for this specific code
411 $max_code_attempts = absint( $this->options['max_attempts'] ?? 3 );
412
413 if ( absint( $stored['attempts'] ) >= $max_code_attempts ) {
414 $this->log_event( '2fa_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
415 $this->database->delete_2fa_code( $user_id );
416 $this->clear_pending_verification();
417
418 return new WP_Error(
419 'max_attempts',
420 __( 'Too many failed attempts. Please contact the site administrator or try again later.', 'vigilante' )
421 );
422 }
423
424 // Check code
425 // Stored hashed since 2.11.0 (S11); a code that predates the update was purged by the migration.
426 if ( ! hash_equals( (string) $stored['code'], wp_hash( $code ) ) ) {
427 // Increment code-specific attempts
428 $this->database->increment_2fa_attempts( $user_id );
429
430 // Also record as failed login attempt for general lockout system
431 if ( $this->login_security && $user ) {
432 $this->login_security->record_failed_attempt( $user->user_login, '2fa' );
433 }
434
435 $attempts_left = $max_code_attempts - ( absint( $stored['attempts'] ) + 1 );
436
437 $this->log_event(
438 '2fa_verification_failed',
439 $user_id,
440 sprintf(
441 /* translators: %d: Attempts remaining */
442 __( 'Invalid verification code. %d attempts remaining.', 'vigilante' ),
443 $attempts_left
444 ),
445 'warning'
446 );
447
448 if ( $attempts_left > 0 ) {
449 return new WP_Error(
450 'invalid_code',
451 sprintf(
452 /* translators: %d: Attempts remaining */
453 __( 'Invalid verification code. %d attempts remaining.', 'vigilante' ),
454 $attempts_left
455 )
456 );
457 } else {
458 return new WP_Error(
459 'max_attempts',
460 __( 'Too many failed attempts. Please contact the site administrator or try again later.', 'vigilante' )
461 );
462 }
463 }
464
465 return true;
466 }
467
468 /**
469 * Maybe show 2FA verification form on login page
470 */
471 public function maybe_show_2fa_form() {
472 // Only the visitor presenting the pending token gets the form. There is
473 // no fallback by IP address and no lookup of the token by user (S3).
474 $session = $this->get_pending_session();
475
476 if ( ! $session ) {
477 return;
478 }
479
480 $user_id = $session['user_id'];
481 $token = $session['token'];
482
483 // Get any error message
484 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
485 delete_transient( 'vigilante_2fa_error_' . $user_id );
486
487 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
488 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
489
490 // Hide the normal login form and disable required fields
491 ?>
492 <style>
493 /* Hide WordPress default error box in 2FA mode */
494 #login_error {
495 display: none !important;
496 }
497 #loginform > p:not(.vigilante-2fa-field),
498 #loginform > .user-pass-wrap,
499 #loginform > .forgetmenot,
500 #loginform > p.submit:not(.vigilante-2fa-submit) {
501 display: none !important;
502 }
503 /* Also hide by ID in case structure varies */
504 #user_login, #user_pass, #loginform > p > label[for="user_login"],
505 #loginform > p > label[for="user_pass"], .login-remember {
506 display: none !important;
507 }
508 </style>
509 <script>
510 (function() {
511 // Disable required attribute on hidden original form fields
512 var userLogin = document.getElementById('user_login');
513 var userPass = document.getElementById('user_pass');
514 var originalSubmit = document.querySelector('#loginform > p.submit:not(.vigilante-2fa-submit) input[type="submit"]');
515
516 if (userLogin) {
517 userLogin.removeAttribute('required');
518 userLogin.disabled = true;
519 }
520 if (userPass) {
521 userPass.removeAttribute('required');
522 userPass.disabled = true;
523 }
524 if (originalSubmit) {
525 originalSubmit.disabled = true;
526 }
527 })();
528 </script>
529
530 <div class="vigilante-2fa-container">
531 <?php if ( $error ) : ?>
532 <div class="vigilante-2fa-error">
533 <?php echo esc_html( $error ); ?>
534 </div>
535 <?php endif; ?>
536
537 <div class="vigilante-2fa-message">
538 <p><?php esc_html_e( 'A verification code has been sent to your email.', 'vigilante' ); ?></p>
539 <p class="vigilante-2fa-expiry">
540 <?php
541 printf(
542 /* translators: %d: Minutes until code expires */
543 esc_html__( 'The code is valid for %d minutes.', 'vigilante' ),
544 absint( $expiry_minutes )
545 );
546 ?>
547 </p>
548 </div>
549
550 <p class="vigilante-2fa-field">
551 <label for="vigilante_2fa_code"><?php esc_html_e( 'Verification Code', 'vigilante' ); ?></label>
552 <input type="text"
553 name="vigilante_2fa_code"
554 id="vigilante_2fa_code"
555 class="input"
556 size="6"
557 maxlength="6"
558 pattern="[0-9]{6}"
559 inputmode="numeric"
560 autocomplete="one-time-code"
561 autofocus
562 required>
563 </p>
564
565 <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
566 <p class="vigilante-2fa-field vigilante-2fa-remember">
567 <label>
568 <input type="checkbox" name="vigilante_2fa_remember" value="1">
569 <?php
570 printf(
571 /* translators: %d: Number of days to remember device */
572 esc_html__( 'Remember this device for %d days', 'vigilante' ),
573 absint( $remember_days )
574 );
575 ?>
576 </label>
577 </p>
578 <?php endif; ?>
579
580 <p class="vigilante-2fa-field vigilante-2fa-submit submit">
581 <input type="hidden" name="action" value="vigilante_2fa">
582 <input type="hidden" name="vigilante_2fa_token" value="<?php echo esc_attr( $token ); ?>">
583 <?php wp_nonce_field( 'vigilante_2fa_verify' ); ?>
584 <input type="submit" name="vigilante-2fa-submit" id="vigilante-2fa-submit" class="button button-primary button-large" value="<?php esc_attr_e( 'Verify', 'vigilante' ); ?>">
585 </p>
586
587 <p class="vigilante-2fa-resend">
588 <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 ); ?>">
589 <?php esc_html_e( 'Resend code', 'vigilante' ); ?>
590 </a>
591 <span class="vigilante-2fa-resend-status"></span>
592 </p>
593 </div>
594
595 <script>
596 document.getElementById('vigilante-resend-code').addEventListener('click', function(e) {
597 e.preventDefault();
598 var link = this;
599 var status = document.querySelector('.vigilante-2fa-resend-status');
600
601 link.style.pointerEvents = 'none';
602 status.textContent = '<?php echo esc_js( __( 'Sending...', 'vigilante' ) ); ?>';
603
604 var xhr = new XMLHttpRequest();
605 xhr.open('POST', '<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>');
606 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
607 xhr.onload = function() {
608 link.style.pointerEvents = 'auto';
609 if (xhr.status === 200) {
610 var response = JSON.parse(xhr.responseText);
611 if (response.success) {
612 status.textContent = '<?php echo esc_js( __( 'Code sent!', 'vigilante' ) ); ?>';
613 status.className = 'vigilante-2fa-resend-status success';
614 } else {
615 status.textContent = response.data || '<?php echo esc_js( __( 'Error sending code', 'vigilante' ) ); ?>';
616 status.className = 'vigilante-2fa-resend-status error';
617 }
618 } else {
619 status.textContent = '<?php echo esc_js( __( 'Error sending code', 'vigilante' ) ); ?>';
620 status.className = 'vigilante-2fa-resend-status error';
621 }
622 setTimeout(function() { status.textContent = ''; }, 3000);
623 };
624 xhr.send('action=vigilante_resend_2fa_code&nonce=' + link.dataset.nonce + '&vigilante_2fa_token=' + link.dataset.token);
625 });
626 </script>
627 <?php
628 }
629
630 /**
631 * AJAX handler for resending verification code
632 */
633 public function ajax_resend_code() {
634 // Verify nonce
635 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'vigilante_resend_2fa' ) ) {
636 wp_send_json_error( __( 'Security check failed.', 'vigilante' ) );
637 }
638
639 $user_id = $this->get_pending_user_id();
640
641 if ( ! $user_id ) {
642 wp_send_json_error( __( 'Session expired. Please log in again.', 'vigilante' ) );
643 }
644
645 $user = get_user_by( 'ID', $user_id );
646
647 if ( ! $user ) {
648 wp_send_json_error( __( 'User not found.', 'vigilante' ) );
649 }
650
651 // Same 60 second margin that check_2fa_requirement() already applies. The
652 // hook is wp_ajax_nopriv_, so without this anyone holding a pending token
653 // can make the site send one email per request.
654 $existing = $this->database->get_2fa_code( $user_id );
655
656 if ( $existing && ! empty( $existing['created_at'] )
657 && ( time() - strtotime( $existing['created_at'] ) ) < 60 ) {
658 wp_send_json_error( __( 'A code was just sent. Please wait a minute before asking for another one.', 'vigilante' ) );
659 }
660
661 // Delete old code
662 $this->database->delete_2fa_code( $user_id );
663
664 // Generate and send new code
665 $code = $this->generate_code( $user_id );
666 $sent = $this->send_verification_email( $user, $code );
667
668 if ( $sent ) {
669 $this->log_event( '2fa_code_resent', $user_id, __( 'Verification code resent', 'vigilante' ) );
670 wp_send_json_success( __( 'New code sent to your email.', 'vigilante' ) );
671 } else {
672 wp_send_json_error( __( 'Failed to send email. Please try again.', 'vigilante' ) );
673 }
674 }
675
676 /**
677 * Enqueue login page assets
678 */
679 public function enqueue_login_assets() {
680 wp_enqueue_style(
681 'vigilante-2fa-login',
682 VIGILANTE_ASSETS_URL . 'css/two-factor-login.css',
683 array(),
684 VIGILANTE_VERSION
685 );
686 }
687
688 /**
689 * Send activation notification to affected users
690 *
691 * @param bool $only_new Only send to users not previously notified.
692 * @return array Result with count of sent emails
693 */
694 public function send_activation_notifications( $only_new = false ) {
695 $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
696 $excluded_users = $this->options['excluded_users'] ?? array();
697 $excluded_users = array_map( 'absint', $excluded_users );
698
699 // Get users with enforced roles
700 $users = get_users( array(
701 'role__in' => $enforced_roles,
702 'exclude' => $excluded_users,
703 ) );
704
705 if ( empty( $users ) ) {
706 return array(
707 'sent' => 0,
708 'skipped' => 0,
709 'failed' => 0,
710 );
711 }
712
713 $site_name = get_bloginfo( 'name' );
714 $from_name = $this->options['email_from_name'] ?? '';
715 $admin_email = get_option( 'admin_email' );
716
717 if ( empty( $from_name ) ) {
718 $from_name = $site_name;
719 }
720
721 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
722
723 $subject = sprintf(
724 /* translators: %s: Site name */
725 __( '[%s] Two-factor authentication enabled for your account', 'vigilante' ),
726 $site_name
727 );
728
729 $body = Vigilante_Email_Template::p(
730 sprintf(
731 /* translators: %s: Site name */
732 __( 'The administrator of %s has enabled two-factor authentication via email for your account.', 'vigilante' ),
733 $site_name
734 )
735 );
736 $body .= Vigilante_Email_Template::info_box(
737 ! empty( $this->options['allow_remember_device'] )
738 ? sprintf(
739 /* translators: %d: Remember days */
740 __( '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' ),
741 $remember_days
742 )
743 : __( 'After entering your password, you will receive a 6-digit code via email that you must enter to complete the login.', 'vigilante' )
744 );
745 $body .= Vigilante_Email_Template::small(
746 sprintf(
747 /* translators: %s: Admin email */
748 __( 'Add %s to your contacts to ensure verification codes do not go to spam.', 'vigilante' ),
749 $admin_email
750 )
751 );
752
753 $sent = 0;
754 $skipped = 0;
755 $failed = 0;
756
757 foreach ( $users as $user ) {
758 // Check if already notified
759 if ( $only_new && $this->database->user_was_2fa_notified( $user->ID ) ) {
760 $skipped++;
761 continue;
762 }
763
764 // Pass from_name via header (avoids filter contamination between sends)
765 $result = Vigilante_Email_Template::send(
766 $user->user_email,
767 $subject,
768 __( 'Two-factor authentication enabled', 'vigilante' ),
769 $body,
770 false,
771 $from_name
772 );
773
774 if ( $result ) {
775 $this->database->mark_2fa_notified( $user->ID );
776 $sent++;
777 } else {
778 $failed++;
779 }
780 }
781
782 // Log event
783 $this->log_event(
784 '2fa_notification_sent',
785 0,
786 sprintf(
787 /* translators: 1: Sent count, 2: Skipped count, 3: Failed count */
788 __( 'Activation notifications sent: %1$d sent, %2$d skipped, %3$d failed', 'vigilante' ),
789 $sent,
790 $skipped,
791 $failed
792 )
793 );
794
795 return array(
796 'sent' => $sent,
797 'skipped' => $skipped,
798 'failed' => $failed,
799 );
800 }
801
802 /**
803 * Log 2FA event
804 *
805 * @param string $action Event action.
806 * @param int $user_id User ID.
807 * @param string $message Event message.
808 * @param string $severity Severity level.
809 */
810 private function log_event( $action, $user_id, $message, $severity = 'info' ) {
811 if ( $this->activity_log ) {
812 $this->activity_log->log(
813 '2fa',
814 $action,
815 $message,
816 array( 'user_id' => $user_id ),
817 $severity
818 );
819 }
820 }
821
822 /**
823 * Get all trusted devices for a user
824 *
825 * @param int $user_id User ID.
826 * @return array
827 */
828 public function get_user_trusted_devices( $user_id ) {
829 return $this->database->get_trusted_devices( $user_id );
830 }
831
832 /**
833 * Revoke all trusted devices for a user
834 *
835 * @param int $user_id User ID.
836 * @return bool
837 */
838 public function revoke_all_trusted_devices( $user_id ) {
839 return $this->database->revoke_trusted_devices( $user_id );
840 }
841
842 /**
843 * Clear expired codes and devices (for maintenance)
844 *
845 * @return array Counts of deleted items
846 */
847 public function cleanup_expired() {
848 return array(
849 'codes' => $this->database->cleanup_expired_2fa_codes(),
850 'devices' => $this->database->cleanup_expired_trusted_devices(),
851 );
852 }
853 }