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 +65 -275 2.10.42.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) for this same user
171 - if ( $this->is_2fa_verification_request( $user ) ) {
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,40 +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( $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 238 * Check if user requires 2FA
256 239 *
257 240 * @param WP_User $user User object.
258 241 * @return bool
@@ -289,10 +272,11 @@
289 272 // Calculate expiry
290 273 $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
291 274 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $expiry_minutes * 60 ) );
292 275
293 - // Store in database
294 - $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 );
295 279
296 280 return $code;
297 281 }
298 282
@@ -337,169 +321,22 @@
337 321 return $sent;
338 322 }
339 323
340 324 /**
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 325 * Handle 2FA verification form submission
490 326 */
491 327 public function handle_2fa_form() {
492 - // Verify nonce. A bare return would let wp-login.php fall through to its
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
493 331 // default case and call wp_signon(), completing the login without the
494 - // second factor, so this path must end the request.
332 + // second factor.
333 + $user_id = $this->get_pending_user_id();
334 +
495 335 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;
336 + $this->handle_invalid_nonce( $user_id );
498 337 }
499 338
500 - $user_id = $this->get_pending_user_id();
501 -
502 339 if ( ! $user_id ) {
503 340 wp_safe_redirect( wp_login_url() );
504 341 exit;
505 342 }
@@ -512,9 +349,9 @@
512 349
513 350 if ( is_wp_error( $result ) ) {
514 351 // Store error for display
515 352 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
516 -
353 +
517 354 // Redirect back to login
518 355 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
519 356 exit;
520 357 }
@@ -522,11 +359,10 @@
522 359 // Verification successful
523 360 $this->clear_pending_verification();
524 361 $this->database->mark_2fa_code_used( $user_id );
525 362
526 - // Trust device if requested
527 - if ( $remember_device ) {
528 - $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 ) ) {
529 365 $this->log_event( '2fa_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
530 366 }
531 367
532 368 // Log success
@@ -538,9 +374,9 @@
538 374 wp_set_auth_cookie( $user_id, false );
539 375 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook that must be fired on login.
540 376 do_action( 'wp_login', $user->user_login, $user );
541 377
542 - // 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,
543 379 // malformed URLs, or query parameters that could cause problems)
544 380 wp_safe_redirect( admin_url() );
545 381 exit;
546 382 }
@@ -585,9 +421,10 @@
585 421 );
586 422 }
587 423
588 424 // Check code
589 - 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 ) ) ) {
590 427 // Increment code-specific attempts
591 428 $this->database->increment_2fa_attempts( $user_id );
592 429
593 430 // Also record as failed login attempt for general lockout system
@@ -631,25 +468,18 @@
631 468 /**
632 469 * Maybe show 2FA verification form on login page
633 470 */
634 471 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 ) {
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 ) {
644 477 return;
645 478 }
646 479
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 - }
480 + $user_id = $session['user_id'];
481 + $token = $session['token'];
652 482
653 483 // Get any error message
654 484 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
655 485 delete_transient( 'vigilante_2fa_error_' . $user_id );
@@ -840,48 +670,8 @@
840 670 wp_send_json_success( __( 'New code sent to your email.', 'vigilante' ) );
841 671 } else {
842 672 wp_send_json_error( __( 'Failed to send email. Please try again.', 'vigilante' ) );
843 673 }
844 - }
845 -
846 - /**
847 - * Check if device is trusted
848 - *
849 - * @param int $user_id User ID.
850 - * @return bool
851 - */
852 - private function is_device_trusted( $user_id ) {
853 - $device_hash = $this->generate_device_hash( $user_id );
854 - return $this->database->is_device_trusted( $user_id, $device_hash );
855 - }
856 -
857 - /**
858 - * Trust the current device
859 - *
860 - * @param int $user_id User ID.
861 - */
862 - private function trust_device( $user_id ) {
863 - $device_hash = $this->generate_device_hash( $user_id );
864 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
865 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
866 - $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $remember_days * DAY_IN_SECONDS ) );
867 -
868 - $this->database->trust_device( $user_id, $device_hash, $user_agent, $expires_at );
869 - }
870 -
871 - /**
872 - * Generate device hash
873 - *
874 - * No IP address included for GDPR compliance
875 - *
876 - * @param int $user_id User ID.
877 - * @return string
878 - */
879 - private function generate_device_hash( $user_id ) {
880 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
881 - $salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'vigilante_fallback_salt';
882 -
883 - return hash( 'sha256', $user_id . $user_agent . $salt );
884 674 }
885 675
886 676 /**
887 677 * Enqueue login page assets