PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
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
← All changes | includes/class-two-factor-email.php +66 -38 2.11.13.0.0 View file →
@@ -76,12 +76,15 @@
76 76 $this->database = $database;
77 77 $this->activity_log = $activity_log;
78 78 $this->login_security = $login_security;
79 79
80 - $login_options = $settings->get_section( 'login_security' );
81 - $this->options = $login_options['two_factor'] ?? array();
80 + // Resolved on demand, not here: see the note in the TOTP constructor.
81 + $this->options = null;
82 82
83 - if ( $this->is_enabled() ) {
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'] ) ) {
84 87 $this->init_hooks();
85 88 }
86 89 }
87 90
@@ -90,13 +93,13 @@
90 93 *
91 94 * @return bool
92 95 */
93 96 public function is_enabled() {
94 - if ( empty( $this->options['enabled'] ) ) {
97 + if ( empty( $this->policy()['enabled'] ) ) {
95 98 return false;
96 99 }
97 100 // Only active when method is email (or not set, for backward compatibility)
98 - $method = $this->options['method'] ?? 'email';
101 + $method = $this->policy()['method'] ?? 'email';
99 102 return 'email' === $method;
100 103 }
101 104
102 105 /**
@@ -183,8 +186,14 @@
183 186 if ( ! $this->user_requires_2fa( $user ) ) {
184 187 return $user;
185 188 }
186 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 +
187 196 // Check if device is trusted
188 197 if ( $this->is_device_trusted( $user->ID ) ) {
189 198 return $user;
190 199 }
@@ -240,24 +249,11 @@
240 249 * @param WP_User $user User object.
241 250 * @return bool
242 251 */
243 252 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;
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 );
260 256 }
261 257
262 258 /**
263 259 * Generate verification code
@@ -269,9 +265,9 @@
269 265 // Generate secure 6-digit code
270 266 $code = sprintf( '%06d', wp_rand( 0, 999999 ) );
271 267
272 268 // Calculate expiry
273 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
269 + $expiry_minutes = absint( $this->policy()['code_expiry_minutes'] ?? 10 );
274 270 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $expiry_minutes * 60 ) );
275 271
276 272 // Only the hash is stored. The code itself travels in the email and
277 273 // nowhere else, and verify_code() compares with hash_equals() (S11).
@@ -288,15 +284,15 @@
288 284 * @return bool
289 285 */
290 286 private function send_verification_email( $user, $code ) {
291 287 $site_name = get_bloginfo( 'name' );
292 - $from_name = $this->options['email_from_name'] ?? '';
288 + $from_name = $this->policy()['email_from_name'] ?? '';
293 289
294 290 if ( empty( $from_name ) ) {
295 291 $from_name = $site_name;
296 292 }
297 293
298 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
294 + $expiry_minutes = absint( $this->policy()['code_expiry_minutes'] ?? 10 );
299 295
300 296 $subject = sprintf(
301 297 /* translators: 1: Site name, 2: Verification code */
302 298 __( '[%1$s] Your verification code: %2$s', 'vigilante' ),
@@ -324,8 +320,18 @@
324 320 /**
325 321 * Handle 2FA verification form submission
326 322 */
327 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 +
328 334 // The pending user is resolved first so that a failed nonce can be
329 335 // explained on the form and recorded (S15). Both failure paths end the
330 336 // request: a bare return would let wp-login.php fall through to its
331 337 // default case and call wp_signon(), completing the login without the
@@ -343,12 +349,24 @@
343 349
344 350 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
345 351 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
346 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 +
347 358 // Verify code
348 359 $result = $this->verify_code( $user_id, $code );
349 360
350 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 +
351 369 // Store error for display
352 370 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
353 371
354 372 // Redirect back to login
@@ -374,11 +392,11 @@
374 392 wp_set_auth_cookie( $user_id, false );
375 393 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook that must be fired on login.
376 394 do_action( 'wp_login', $user->user_login, $user );
377 395
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() );
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 );
381 399 exit;
382 400 }
383 401
384 402 /**
@@ -388,8 +406,13 @@
388 406 * @param string $code Submitted code.
389 407 * @return true|WP_Error
390 408 */
391 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 +
392 415 $stored = $this->database->get_2fa_code( $user_id );
393 416 $user = get_user_by( 'ID', $user_id );
394 417
395 418 if ( ! $stored ) {
@@ -407,9 +430,9 @@
407 430 return new WP_Error( 'code_used', __( 'Verification code has already been used. Please log in again.', 'vigilante' ) );
408 431 }
409 432
410 433 // Check max attempts for this specific code
411 - $max_code_attempts = absint( $this->options['max_attempts'] ?? 3 );
434 + $max_code_attempts = absint( $this->policy()['max_attempts'] ?? 3 );
412 435
413 436 if ( absint( $stored['attempts'] ) >= $max_code_attempts ) {
414 437 $this->log_event( '2fa_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
415 438 $this->database->delete_2fa_code( $user_id );
@@ -468,8 +491,13 @@
468 491 /**
469 492 * Maybe show 2FA verification form on login page
470 493 */
471 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 +
472 500 // Only the visitor presenting the pending token gets the form. There is
473 501 // no fallback by IP address and no lookup of the token by user (S3).
474 502 $session = $this->get_pending_session();
475 503
@@ -483,10 +511,10 @@
483 511 // Get any error message
484 512 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
485 513 delete_transient( 'vigilante_2fa_error_' . $user_id );
486 514
487 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
488 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
515 + $expiry_minutes = absint( $this->policy()['code_expiry_minutes'] ?? 10 );
516 + $remember_days = absint( $this->policy()['remember_device_days'] ?? 30 );
489 517
490 518 // Hide the normal login form and disable required fields
491 519 ?>
492 520 <style>
@@ -553,10 +581,10 @@
553 581 name="vigilante_2fa_code"
554 582 id="vigilante_2fa_code"
555 583 class="input"
556 584 size="6"
557 - maxlength="6"
558 - pattern="[0-9]{6}"
585 + maxlength="20"
586 + pattern="[0-9 -]{6,20}"
559 587 inputmode="numeric"
560 588 autocomplete="one-time-code"
561 589 autofocus
562 590 required>
@@ -561,9 +589,9 @@
561 589 autofocus
562 590 required>
563 591 </p>
564 592
565 - <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
593 + <?php if ( ! empty( $this->policy()['allow_remember_device'] ) ) : ?>
566 594 <p class="vigilante-2fa-field vigilante-2fa-remember">
567 595 <label>
568 596 <input type="checkbox" name="vigilante_2fa_remember" value="1">
569 597 <?php
@@ -691,10 +719,10 @@
691 719 * @param bool $only_new Only send to users not previously notified.
692 720 * @return array Result with count of sent emails
693 721 */
694 722 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();
723 + $enforced_roles = $this->policy()['enforced_roles'] ?? array( 'administrator', 'editor' );
724 + $excluded_users = $this->policy()['excluded_users'] ?? array();
697 725 $excluded_users = array_map( 'absint', $excluded_users );
698 726
699 727 // Get users with enforced roles
700 728 $users = get_users( array(
@@ -710,9 +738,9 @@
710 738 );
711 739 }
712 740
713 741 $site_name = get_bloginfo( 'name' );
714 - $from_name = $this->options['email_from_name'] ?? '';
742 + $from_name = $this->policy()['email_from_name'] ?? '';
715 743 $admin_email = get_option( 'admin_email' );
716 744
717 745 if ( empty( $from_name ) ) {
718 746 $from_name = $site_name;
@@ -717,9 +745,9 @@
717 745 if ( empty( $from_name ) ) {
718 746 $from_name = $site_name;
719 747 }
720 748
721 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
749 + $remember_days = absint( $this->policy()['remember_device_days'] ?? 30 );
722 750
723 751 $subject = sprintf(
724 752 /* translators: %s: Site name */
725 753 __( '[%s] Two-factor authentication enabled for your account', 'vigilante' ),
@@ -733,9 +761,9 @@
733 761 $site_name
734 762 )
735 763 );
736 764 $body .= Vigilante_Email_Template::info_box(
737 - ! empty( $this->options['allow_remember_device'] )
765 + ! empty( $this->policy()['allow_remember_device'] )
738 766 ? sprintf(
739 767 /* translators: %d: Remember days */
740 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' ),
741 769 $remember_days