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

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