PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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 +76 -252 2.10.22.11.8 View file →
@@ -18,8 +18,10 @@
18 18 * Email OTP verification for login security
19 19 */
20 20 class Vigilante_Two_Factor_Email {
21 21
22 + use Vigilante_Two_Factor_Session;
23 +
22 24 /**
23 25 * Settings instance
24 26 *
25 27 * @var Vigilante_Settings
@@ -100,8 +102,10 @@
100 102 /**
101 103 * Initialize hooks
102 104 */
103 105 private function init_hooks() {
106 + $this->init_session_hooks();
107 +
104 108 // Intercept successful authentication
105 109 add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 );
106 110
107 111 // Handle 2FA verification form
@@ -121,36 +125,22 @@
121 125 }
122 126
123 127 /**
124 128 * Filter login error messages
125 - *
126 - * Hide the default "Invalid username or password" when 2FA verification is pending
127 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 + *
128 135 * @param string $errors Error messages HTML.
129 136 * @return string Filtered error messages
130 137 */
131 138 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 ) );
139 + if ( $this->get_pending_user_id() ) {
140 140 return '';
141 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 -
142 +
153 143 return $errors;
154 144 }
155 145
156 146 /**
@@ -166,13 +156,30 @@
166 156 if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
167 157 return $user;
168 158 }
169 159
170 - // Check if already verifying 2FA (form submission)
171 - if ( $this->is_2fa_verification_request() ) {
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 ) ) {
172 163 return $user;
173 164 }
174 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 +
175 182 // Check if 2FA is required for this user
176 183 if ( ! $this->user_requires_2fa( $user ) ) {
177 184 return $user;
178 185 }
@@ -181,19 +188,27 @@
181 188 if ( $this->is_device_trusted( $user->ID ) ) {
182 189 return $user;
183 190 }
184 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 +
185 200 // Check if there's a very recent code (less than 60 seconds old) to avoid duplicate emails on rapid retries
186 201 $existing_code = $this->database->get_2fa_code( $user->ID );
187 - $code_is_recent = $existing_code
188 - && strtotime( $existing_code['expires_at'] ) > time()
202 + $code_is_recent = $existing_code
203 + && strtotime( $existing_code['expires_at'] ) > time()
189 204 && empty( $existing_code['used'] )
190 205 && ( time() - strtotime( $existing_code['created_at'] ) ) < 60;
191 -
206 +
192 207 if ( $code_is_recent ) {
193 208 // Code was just sent, don't send another email
194 209 $this->set_pending_verification( $user->ID );
195 -
210 +
196 211 return new WP_Error(
197 212 'vigilante_2fa_required',
198 213 __( 'Please enter the verification code sent to your email.', 'vigilante' )
199 214 );
@@ -219,19 +234,8 @@
219 234 );
220 235 }
221 236
222 237 /**
223 - * Check if this is a 2FA verification request
224 - *
225 - * @return bool
226 - */
227 - private function is_2fa_verification_request() {
228 - // Check for our custom action
229 - $action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
230 - return 'vigilante_2fa' === $action;
231 - }
232 -
233 - /**
234 238 * Check if user requires 2FA
235 239 *
236 240 * @param WP_User $user User object.
237 241 * @return bool
@@ -268,10 +272,11 @@
268 272 // Calculate expiry
269 273 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
270 274 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $expiry_minutes * 60 ) );
271 275
272 - // Store in database
273 - $this->database->store_2fa_code( $user_id, $code, $expires_at );
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 );
274 279
275 280 return $code;
276 281 }
277 282
@@ -316,166 +321,22 @@
316 321 return $sent;
317 322 }
318 323
319 324 /**
320 - * Set pending verification state
321 - *
322 - * @param int $user_id User ID.
323 - * @return string Token for the pending session
324 - */
325 - private function set_pending_verification( $user_id ) {
326 - // Check if there's already a valid token for this user
327 - $existing_token = $this->get_existing_token_for_user( $user_id );
328 -
329 - if ( $existing_token ) {
330 - $token = $existing_token;
331 - } else {
332 - $token = wp_generate_password( 32, false );
333 - }
334 -
335 - set_transient(
336 - 'vigilante_2fa_pending_' . $token,
337 - array(
338 - 'user_id' => $user_id,
339 - 'created_at' => time(),
340 - ),
341 - HOUR_IN_SECONDS
342 - );
343 -
344 - // Also store reverse lookup (user_id -> token)
345 - set_transient(
346 - 'vigilante_2fa_user_token_' . $user_id,
347 - $token,
348 - HOUR_IN_SECONDS
349 - );
350 -
351 - // Set a short-lived transient to indicate 2FA was just triggered
352 - // This helps filter_login_errors() detect 2FA mode before cookie is available
353 - $ip = $this->database->get_client_ip();
354 - set_transient( 'vigilante_2fa_triggered_' . md5( $ip ), $user_id, 60 );
355 -
356 - // Store token in cookie for form submission
357 - if ( ! headers_sent() ) {
358 - setcookie(
359 - 'vigilante_2fa_token',
360 - $token,
361 - array(
362 - 'expires' => time() + HOUR_IN_SECONDS,
363 - 'path' => COOKIEPATH,
364 - 'domain' => COOKIE_DOMAIN,
365 - 'secure' => is_ssl(),
366 - 'httponly' => true,
367 - 'samesite' => 'Strict',
368 - )
369 - );
370 - // Make token available in current request
371 - $_COOKIE['vigilante_2fa_token'] = $token;
372 - }
373 -
374 - return $token;
375 - }
376 -
377 - /**
378 - * Get existing token for a user if still valid
379 - *
380 - * @param int $user_id User ID.
381 - * @return string|false Token or false if not found
382 - */
383 - private function get_existing_token_for_user( $user_id ) {
384 - $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
385 -
386 - if ( ! $token ) {
387 - return false;
388 - }
389 -
390 - // Verify the token is still valid
391 - $data = get_transient( 'vigilante_2fa_pending_' . $token );
392 -
393 - if ( ! $data || empty( $data['user_id'] ) || absint( $data['user_id'] ) !== $user_id ) {
394 - return false;
395 - }
396 -
397 - return $token;
398 - }
399 -
400 - /**
401 - * Get pending verification user ID
402 - *
403 - * @return int|false User ID or false if not pending
404 - */
405 - private function get_pending_user_id() {
406 - // First try cookie
407 - $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
408 -
409 - // Also check POST (for when cookie wasn't set in time)
410 - if ( empty( $token ) && isset( $_POST['vigilante_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
411 - $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
412 - }
413 -
414 - if ( empty( $token ) ) {
415 - return false;
416 - }
417 -
418 - $data = get_transient( 'vigilante_2fa_pending_' . $token );
419 -
420 - if ( ! $data || empty( $data['user_id'] ) ) {
421 - return false;
422 - }
423 -
424 - return absint( $data['user_id'] );
425 - }
426 -
427 - /**
428 - * Clear pending verification
429 - */
430 - private function clear_pending_verification() {
431 - $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
432 -
433 - // Also check POST
434 - if ( empty( $token ) && isset( $_POST['vigilante_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
435 - $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
436 - }
437 -
438 - if ( ! empty( $token ) ) {
439 - // Get user ID to clear reverse lookup
440 - $data = get_transient( 'vigilante_2fa_pending_' . $token );
441 - if ( $data && ! empty( $data['user_id'] ) ) {
442 - delete_transient( 'vigilante_2fa_user_token_' . $data['user_id'] );
443 - }
444 -
445 - delete_transient( 'vigilante_2fa_pending_' . $token );
446 - }
447 -
448 - // Clear cookie
449 - if ( ! headers_sent() ) {
450 - setcookie(
451 - 'vigilante_2fa_token',
452 - '',
453 - array(
454 - 'expires' => time() - YEAR_IN_SECONDS,
455 - 'path' => COOKIEPATH,
456 - 'domain' => COOKIE_DOMAIN,
457 - 'secure' => is_ssl(),
458 - 'httponly' => true,
459 - 'samesite' => 'Strict',
460 - )
461 - );
462 - }
463 -
464 - unset( $_COOKIE['vigilante_2fa_token'] );
465 - }
466 -
467 - /**
468 325 * Handle 2FA verification form submission
469 326 */
470 327 public function handle_2fa_form() {
471 - // Verify nonce
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 +
472 335 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
473 - return;
336 + $this->handle_invalid_nonce( $user_id );
474 337 }
475 338
476 - $user_id = $this->get_pending_user_id();
477 -
478 339 if ( ! $user_id ) {
479 340 wp_safe_redirect( wp_login_url() );
480 341 exit;
481 342 }
@@ -488,9 +349,9 @@
488 349
489 350 if ( is_wp_error( $result ) ) {
490 351 // Store error for display
491 352 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
492 -
353 +
493 354 // Redirect back to login
494 355 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
495 356 exit;
496 357 }
@@ -498,11 +359,10 @@
498 359 // Verification successful
499 360 $this->clear_pending_verification();
500 361 $this->database->mark_2fa_code_used( $user_id );
501 362
502 - // Trust device if requested
503 - if ( $remember_device ) {
504 - $this->trust_device( $user_id );
363 + // Trust device if requested (and if the option allows it, see trust_device)
364 + if ( $remember_device && $this->trust_device( $user_id ) ) {
505 365 $this->log_event( '2fa_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
506 366 }
507 367
508 368 // Log success
@@ -514,9 +374,9 @@
514 374 wp_set_auth_cookie( $user_id, false );
515 375 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook that must be fired on login.
516 376 do_action( 'wp_login', $user->user_login, $user );
517 377
518 - // Redirect to admin dashboard (always use admin_url to avoid issues with popups,
378 + // Redirect to admin dashboard (always use admin_url to avoid issues with popups,
519 379 // malformed URLs, or query parameters that could cause problems)
520 380 wp_safe_redirect( admin_url() );
521 381 exit;
522 382 }
@@ -561,9 +421,10 @@
561 421 );
562 422 }
563 423
564 424 // Check code
565 - if ( $code !== $stored['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 ) ) ) {
566 427 // Increment code-specific attempts
567 428 $this->database->increment_2fa_attempts( $user_id );
568 429
569 430 // Also record as failed login attempt for general lockout system
@@ -607,25 +468,18 @@
607 468 /**
608 469 * Maybe show 2FA verification form on login page
609 470 */
610 471 public function maybe_show_2fa_form() {
611 - $user_id = $this->get_pending_user_id();
612 -
613 - // If no user_id from cookie/POST, try the trigger transient
614 - if ( ! $user_id ) {
615 - $ip = $this->database->get_client_ip();
616 - $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
617 - }
618 -
619 - if ( ! $user_id ) {
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 ) {
620 477 return;
621 478 }
622 479
623 - // Get the token for hidden field
624 - $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
625 - if ( empty( $token ) ) {
626 - $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
627 - }
480 + $user_id = $session['user_id'];
481 + $token = $session['token'];
628 482
629 483 // Get any error message
630 484 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
631 485 delete_transient( 'vigilante_2fa_error_' . $user_id );
@@ -793,8 +647,18 @@
793 647 if ( ! $user ) {
794 648 wp_send_json_error( __( 'User not found.', 'vigilante' ) );
795 649 }
796 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 +
797 661 // Delete old code
798 662 $this->database->delete_2fa_code( $user_id );
799 663
800 664 // Generate and send new code
@@ -806,48 +670,8 @@
806 670 wp_send_json_success( __( 'New code sent to your email.', 'vigilante' ) );
807 671 } else {
808 672 wp_send_json_error( __( 'Failed to send email. Please try again.', 'vigilante' ) );
809 673 }
810 - }
811 -
812 - /**
813 - * Check if device is trusted
814 - *
815 - * @param int $user_id User ID.
816 - * @return bool
817 - */
818 - private function is_device_trusted( $user_id ) {
819 - $device_hash = $this->generate_device_hash( $user_id );
820 - return $this->database->is_device_trusted( $user_id, $device_hash );
821 - }
822 -
823 - /**
824 - * Trust the current device
825 - *
826 - * @param int $user_id User ID.
827 - */
828 - private function trust_device( $user_id ) {
829 - $device_hash = $this->generate_device_hash( $user_id );
830 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
831 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
832 - $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $remember_days * DAY_IN_SECONDS ) );
833 -
834 - $this->database->trust_device( $user_id, $device_hash, $user_agent, $expires_at );
835 - }
836 -
837 - /**
838 - * Generate device hash
839 - *
840 - * No IP address included for GDPR compliance
841 - *
842 - * @param int $user_id User ID.
843 - * @return string
844 - */
845 - private function generate_device_hash( $user_id ) {
846 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
847 - $salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'vigilante_fallback_salt';
848 -
849 - return hash( 'sha256', $user_id . $user_agent . $salt );
850 674 }
851 675
852 676 /**
853 677 * Enqueue login page assets