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 +139 -287 2.9.73.0.0 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
@@ -74,12 +76,15 @@
74 76 $this->database = $database;
75 77 $this->activity_log = $activity_log;
76 78 $this->login_security = $login_security;
77 79
78 - $login_options = $settings->get_section( 'login_security' );
79 - $this->options = $login_options['two_factor'] ?? array();
80 + // Resolved on demand, not here: see the note in the TOTP constructor.
81 + $this->options = null;
80 82
81 - 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'] ) ) {
82 87 $this->init_hooks();
83 88 }
84 89 }
85 90
@@ -88,13 +93,13 @@
88 93 *
89 94 * @return bool
90 95 */
91 96 public function is_enabled() {
92 - if ( empty( $this->options['enabled'] ) ) {
97 + if ( empty( $this->policy()['enabled'] ) ) {
93 98 return false;
94 99 }
95 100 // Only active when method is email (or not set, for backward compatibility)
96 - $method = $this->options['method'] ?? 'email';
101 + $method = $this->policy()['method'] ?? 'email';
97 102 return 'email' === $method;
98 103 }
99 104
100 105 /**
@@ -100,8 +105,10 @@
100 105 /**
101 106 * Initialize hooks
102 107 */
103 108 private function init_hooks() {
109 + $this->init_session_hooks();
110 +
104 111 // Intercept successful authentication
105 112 add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 );
106 113
107 114 // Handle 2FA verification form
@@ -121,36 +128,22 @@
121 128 }
122 129
123 130 /**
124 131 * Filter login error messages
125 - *
126 - * Hide the default "Invalid username or password" when 2FA verification is pending
127 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 + *
128 138 * @param string $errors Error messages HTML.
129 139 * @return string Filtered error messages
130 140 */
131 141 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 ) );
142 + if ( $this->get_pending_user_id() ) {
140 143 return '';
141 144 }
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 -
145 +
153 146 return $errors;
154 147 }
155 148
156 149 /**
@@ -166,34 +159,65 @@
166 159 if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
167 160 return $user;
168 161 }
169 162
170 - // Check if already verifying 2FA (form submission)
171 - if ( $this->is_2fa_verification_request() ) {
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 ) ) {
172 166 return $user;
173 167 }
174 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 +
175 185 // Check if 2FA is required for this user
176 186 if ( ! $this->user_requires_2fa( $user ) ) {
177 187 return $user;
178 188 }
179 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 +
180 196 // Check if device is trusted
181 197 if ( $this->is_device_trusted( $user->ID ) ) {
182 198 return $user;
183 199 }
184 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 +
185 209 // Check if there's a very recent code (less than 60 seconds old) to avoid duplicate emails on rapid retries
186 210 $existing_code = $this->database->get_2fa_code( $user->ID );
187 - $code_is_recent = $existing_code
188 - && strtotime( $existing_code['expires_at'] ) > time()
211 + $code_is_recent = $existing_code
212 + && strtotime( $existing_code['expires_at'] ) > time()
189 213 && empty( $existing_code['used'] )
190 214 && ( time() - strtotime( $existing_code['created_at'] ) ) < 60;
191 -
215 +
192 216 if ( $code_is_recent ) {
193 217 // Code was just sent, don't send another email
194 218 $this->set_pending_verification( $user->ID );
195 -
219 +
196 220 return new WP_Error(
197 221 'vigilante_2fa_required',
198 222 __( 'Please enter the verification code sent to your email.', 'vigilante' )
199 223 );
@@ -219,19 +243,8 @@
219 243 );
220 244 }
221 245
222 246 /**
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 247 * Check if user requires 2FA
235 248 *
236 249 * @param WP_User $user User object.
237 250 * @return bool
@@ -236,24 +249,11 @@
236 249 * @param WP_User $user User object.
237 250 * @return bool
238 251 */
239 252 public function user_requires_2fa( $user ) {
240 - // Check if user is explicitly excluded
241 - $excluded_users = $this->options['excluded_users'] ?? array();
242 - if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
243 - return false;
244 - }
245 -
246 - // Check if user has an enforced role
247 - $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
248 -
249 - foreach ( $user->roles as $role ) {
250 - if ( in_array( $role, $enforced_roles, true ) ) {
251 - return true;
252 - }
253 - }
254 -
255 - 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 );
256 256 }
257 257
258 258 /**
259 259 * Generate verification code
@@ -265,13 +265,14 @@
265 265 // Generate secure 6-digit code
266 266 $code = sprintf( '%06d', wp_rand( 0, 999999 ) );
267 267
268 268 // Calculate expiry
269 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
269 + $expiry_minutes = absint( $this->policy()['code_expiry_minutes'] ?? 10 );
270 270 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $expiry_minutes * 60 ) );
271 271
272 - // Store in database
273 - $this->database->store_2fa_code( $user_id, $code, $expires_at );
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 );
274 275
275 276 return $code;
276 277 }
277 278
@@ -283,15 +284,15 @@
283 284 * @return bool
284 285 */
285 286 private function send_verification_email( $user, $code ) {
286 287 $site_name = get_bloginfo( 'name' );
287 - $from_name = $this->options['email_from_name'] ?? '';
288 + $from_name = $this->policy()['email_from_name'] ?? '';
288 289
289 290 if ( empty( $from_name ) ) {
290 291 $from_name = $site_name;
291 292 }
292 293
293 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
294 + $expiry_minutes = absint( $this->policy()['code_expiry_minutes'] ?? 10 );
294 295
295 296 $subject = sprintf(
296 297 /* translators: 1: Site name, 2: Verification code */
297 298 __( '[%1$s] Your verification code: %2$s', 'vigilante' ),
@@ -316,166 +317,32 @@
316 317 return $sent;
317 318 }
318 319
319 320 /**
320 - * Set pending verification state
321 - *
322 - * @param int $user_id User ID.
323 - * @return string Token for the pending session
321 + * Handle 2FA verification form submission
324 322 */
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 );
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;
333 332 }
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 333
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 );
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();
355 340
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 - * Handle 2FA verification form submission
469 - */
470 - public function handle_2fa_form() {
471 - // Verify nonce
472 341 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
473 - return;
342 + $this->handle_invalid_nonce( $user_id );
474 343 }
475 344
476 - $user_id = $this->get_pending_user_id();
477 -
478 345 if ( ! $user_id ) {
479 346 wp_safe_redirect( wp_login_url() );
480 347 exit;
481 348 }
@@ -482,15 +349,27 @@
482 349
483 350 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
484 351 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
485 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 +
486 358 // Verify code
487 359 $result = $this->verify_code( $user_id, $code );
488 360
489 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 +
490 369 // Store error for display
491 370 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
492 -
371 +
493 372 // Redirect back to login
494 373 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
495 374 exit;
496 375 }
@@ -498,11 +377,10 @@
498 377 // Verification successful
499 378 $this->clear_pending_verification();
500 379 $this->database->mark_2fa_code_used( $user_id );
501 380
502 - // Trust device if requested
503 - if ( $remember_device ) {
504 - $this->trust_device( $user_id );
381 + // Trust device if requested (and if the option allows it, see trust_device)
382 + if ( $remember_device && $this->trust_device( $user_id ) ) {
505 383 $this->log_event( '2fa_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
506 384 }
507 385
508 386 // Log success
@@ -514,11 +392,11 @@
514 392 wp_set_auth_cookie( $user_id, false );
515 393 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook that must be fired on login.
516 394 do_action( 'wp_login', $user->user_login, $user );
517 395
518 - // Redirect to admin dashboard (always use admin_url to avoid issues with popups,
519 - // malformed URLs, or query parameters that could cause problems)
520 - 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 );
521 399 exit;
522 400 }
523 401
524 402 /**
@@ -528,8 +406,13 @@
528 406 * @param string $code Submitted code.
529 407 * @return true|WP_Error
530 408 */
531 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 +
532 415 $stored = $this->database->get_2fa_code( $user_id );
533 416 $user = get_user_by( 'ID', $user_id );
534 417
535 418 if ( ! $stored ) {
@@ -547,9 +430,9 @@
547 430 return new WP_Error( 'code_used', __( 'Verification code has already been used. Please log in again.', 'vigilante' ) );
548 431 }
549 432
550 433 // Check max attempts for this specific code
551 - $max_code_attempts = absint( $this->options['max_attempts'] ?? 3 );
434 + $max_code_attempts = absint( $this->policy()['max_attempts'] ?? 3 );
552 435
553 436 if ( absint( $stored['attempts'] ) >= $max_code_attempts ) {
554 437 $this->log_event( '2fa_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
555 438 $this->database->delete_2fa_code( $user_id );
@@ -561,9 +444,10 @@
561 444 );
562 445 }
563 446
564 447 // Check code
565 - if ( $code !== $stored['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 ) ) ) {
566 450 // Increment code-specific attempts
567 451 $this->database->increment_2fa_attempts( $user_id );
568 452
569 453 // Also record as failed login attempt for general lockout system
@@ -607,32 +491,30 @@
607 491 /**
608 492 * Maybe show 2FA verification form on login page
609 493 */
610 494 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 ) );
495 + // Solo la clase que atiende esta verificacion pinta su formulario.
496 + if ( ! $this->pending_belongs_to( 'email' ) ) {
497 + return;
617 498 }
618 -
619 - if ( ! $user_id ) {
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 ) {
620 505 return;
621 506 }
622 507
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 - }
508 + $user_id = $session['user_id'];
509 + $token = $session['token'];
628 510
629 511 // Get any error message
630 512 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
631 513 delete_transient( 'vigilante_2fa_error_' . $user_id );
632 514
633 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
634 - $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 );
635 517
636 518 // Hide the normal login form and disable required fields
637 519 ?>
638 520 <style>
@@ -699,10 +581,10 @@
699 581 name="vigilante_2fa_code"
700 582 id="vigilante_2fa_code"
701 583 class="input"
702 584 size="6"
703 - maxlength="6"
704 - pattern="[0-9]{6}"
585 + maxlength="20"
586 + pattern="[0-9 -]{6,20}"
705 587 inputmode="numeric"
706 588 autocomplete="one-time-code"
707 589 autofocus
708 590 required>
@@ -707,9 +589,9 @@
707 589 autofocus
708 590 required>
709 591 </p>
710 592
711 - <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
593 + <?php if ( ! empty( $this->policy()['allow_remember_device'] ) ) : ?>
712 594 <p class="vigilante-2fa-field vigilante-2fa-remember">
713 595 <label>
714 596 <input type="checkbox" name="vigilante_2fa_remember" value="1">
715 597 <?php
@@ -793,8 +675,18 @@
793 675 if ( ! $user ) {
794 676 wp_send_json_error( __( 'User not found.', 'vigilante' ) );
795 677 }
796 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 +
797 689 // Delete old code
798 690 $this->database->delete_2fa_code( $user_id );
799 691
800 692 // Generate and send new code
@@ -809,48 +701,8 @@
809 701 }
810 702 }
811 703
812 704 /**
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 - }
851 -
852 - /**
853 705 * Enqueue login page assets
854 706 */
855 707 public function enqueue_login_assets() {
856 708 wp_enqueue_style(
@@ -867,10 +719,10 @@
867 719 * @param bool $only_new Only send to users not previously notified.
868 720 * @return array Result with count of sent emails
869 721 */
870 722 public function send_activation_notifications( $only_new = false ) {
871 - $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
872 - $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();
873 725 $excluded_users = array_map( 'absint', $excluded_users );
874 726
875 727 // Get users with enforced roles
876 728 $users = get_users( array(
@@ -886,9 +738,9 @@
886 738 );
887 739 }
888 740
889 741 $site_name = get_bloginfo( 'name' );
890 - $from_name = $this->options['email_from_name'] ?? '';
742 + $from_name = $this->policy()['email_from_name'] ?? '';
891 743 $admin_email = get_option( 'admin_email' );
892 744
893 745 if ( empty( $from_name ) ) {
894 746 $from_name = $site_name;
@@ -893,9 +745,9 @@
893 745 if ( empty( $from_name ) ) {
894 746 $from_name = $site_name;
895 747 }
896 748
897 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
749 + $remember_days = absint( $this->policy()['remember_device_days'] ?? 30 );
898 750
899 751 $subject = sprintf(
900 752 /* translators: %s: Site name */
901 753 __( '[%s] Two-factor authentication enabled for your account', 'vigilante' ),
@@ -909,9 +761,9 @@
909 761 $site_name
910 762 )
911 763 );
912 764 $body .= Vigilante_Email_Template::info_box(
913 - ! empty( $this->options['allow_remember_device'] )
765 + ! empty( $this->policy()['allow_remember_device'] )
914 766 ? sprintf(
915 767 /* translators: %d: Remember days */
916 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' ),
917 769 $remember_days