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 +129 -311 2.10.43.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) for this same user
171 - if ( $this->is_2fa_verification_request( $user ) ) {
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,40 +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( $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 247 * Check if user requires 2FA
256 248 *
257 249 * @param WP_User $user User object.
258 250 * @return bool
@@ -257,24 +249,11 @@
257 249 * @param WP_User $user User object.
258 250 * @return bool
259 251 */
260 252 public function user_requires_2fa( $user ) {
261 - // Check if user is explicitly excluded
262 - $excluded_users = $this->options['excluded_users'] ?? array();
263 - if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
264 - return false;
265 - }
266 -
267 - // Check if user has an enforced role
268 - $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
269 -
270 - foreach ( $user->roles as $role ) {
271 - if ( in_array( $role, $enforced_roles, true ) ) {
272 - return true;
273 - }
274 - }
275 -
276 - 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 );
277 256 }
278 257
279 258 /**
280 259 * Generate verification code
@@ -286,13 +265,14 @@
286 265 // Generate secure 6-digit code
287 266 $code = sprintf( '%06d', wp_rand( 0, 999999 ) );
288 267
289 268 // Calculate expiry
290 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
269 + $expiry_minutes = absint( $this->policy()['code_expiry_minutes'] ?? 10 );
291 270 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $expiry_minutes * 60 ) );
292 271
293 - // Store in database
294 - $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 );
295 275
296 276 return $code;
297 277 }
298 278
@@ -304,15 +284,15 @@
304 284 * @return bool
305 285 */
306 286 private function send_verification_email( $user, $code ) {
307 287 $site_name = get_bloginfo( 'name' );
308 - $from_name = $this->options['email_from_name'] ?? '';
288 + $from_name = $this->policy()['email_from_name'] ?? '';
309 289
310 290 if ( empty( $from_name ) ) {
311 291 $from_name = $site_name;
312 292 }
313 293
314 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
294 + $expiry_minutes = absint( $this->policy()['code_expiry_minutes'] ?? 10 );
315 295
316 296 $subject = sprintf(
317 297 /* translators: 1: Site name, 2: Verification code */
318 298 __( '[%1$s] Your verification code: %2$s', 'vigilante' ),
@@ -337,169 +317,32 @@
337 317 return $sent;
338 318 }
339 319
340 320 /**
341 - * Set pending verification state
342 - *
343 - * @param int $user_id User ID.
344 - * @return string Token for the pending session
321 + * Handle 2FA verification form submission
345 322 */
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 );
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;
354 332 }
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 333
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 );
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();
376 340
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 - * Handle 2FA verification form submission
490 - */
491 - public function handle_2fa_form() {
492 - // Verify nonce. A bare return would let wp-login.php fall through to its
493 - // default case and call wp_signon(), completing the login without the
494 - // second factor, so this path must end the request.
495 341 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;
342 + $this->handle_invalid_nonce( $user_id );
498 343 }
499 344
500 - $user_id = $this->get_pending_user_id();
501 -
502 345 if ( ! $user_id ) {
503 346 wp_safe_redirect( wp_login_url() );
504 347 exit;
505 348 }
@@ -506,15 +349,27 @@
506 349
507 350 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
508 351 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
509 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 +
510 358 // Verify code
511 359 $result = $this->verify_code( $user_id, $code );
512 360
513 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 +
514 369 // Store error for display
515 370 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
516 -
371 +
517 372 // Redirect back to login
518 373 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
519 374 exit;
520 375 }
@@ -522,11 +377,10 @@
522 377 // Verification successful
523 378 $this->clear_pending_verification();
524 379 $this->database->mark_2fa_code_used( $user_id );
525 380
526 - // Trust device if requested
527 - if ( $remember_device ) {
528 - $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 ) ) {
529 383 $this->log_event( '2fa_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
530 384 }
531 385
532 386 // Log success
@@ -538,11 +392,11 @@
538 392 wp_set_auth_cookie( $user_id, false );
539 393 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook that must be fired on login.
540 394 do_action( 'wp_login', $user->user_login, $user );
541 395
542 - // Redirect to admin dashboard (always use admin_url to avoid issues with popups,
543 - // malformed URLs, or query parameters that could cause problems)
544 - 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 );
545 399 exit;
546 400 }
547 401
548 402 /**
@@ -552,8 +406,13 @@
552 406 * @param string $code Submitted code.
553 407 * @return true|WP_Error
554 408 */
555 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 +
556 415 $stored = $this->database->get_2fa_code( $user_id );
557 416 $user = get_user_by( 'ID', $user_id );
558 417
559 418 if ( ! $stored ) {
@@ -571,9 +430,9 @@
571 430 return new WP_Error( 'code_used', __( 'Verification code has already been used. Please log in again.', 'vigilante' ) );
572 431 }
573 432
574 433 // Check max attempts for this specific code
575 - $max_code_attempts = absint( $this->options['max_attempts'] ?? 3 );
434 + $max_code_attempts = absint( $this->policy()['max_attempts'] ?? 3 );
576 435
577 436 if ( absint( $stored['attempts'] ) >= $max_code_attempts ) {
578 437 $this->log_event( '2fa_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
579 438 $this->database->delete_2fa_code( $user_id );
@@ -585,9 +444,10 @@
585 444 );
586 445 }
587 446
588 447 // Check code
589 - 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 ) ) ) {
590 450 // Increment code-specific attempts
591 451 $this->database->increment_2fa_attempts( $user_id );
592 452
593 453 // Also record as failed login attempt for general lockout system
@@ -631,32 +491,30 @@
631 491 /**
632 492 * Maybe show 2FA verification form on login page
633 493 */
634 494 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 ) );
495 + // Solo la clase que atiende esta verificacion pinta su formulario.
496 + if ( ! $this->pending_belongs_to( 'email' ) ) {
497 + return;
641 498 }
642 -
643 - 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 ) {
644 505 return;
645 506 }
646 507
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 - }
508 + $user_id = $session['user_id'];
509 + $token = $session['token'];
652 510
653 511 // Get any error message
654 512 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
655 513 delete_transient( 'vigilante_2fa_error_' . $user_id );
656 514
657 - $expiry_minutes = absint( $this->options['code_expiry_minutes'] ?? 10 );
658 - $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 );
659 517
660 518 // Hide the normal login form and disable required fields
661 519 ?>
662 520 <style>
@@ -723,10 +581,10 @@
723 581 name="vigilante_2fa_code"
724 582 id="vigilante_2fa_code"
725 583 class="input"
726 584 size="6"
727 - maxlength="6"
728 - pattern="[0-9]{6}"
585 + maxlength="20"
586 + pattern="[0-9 -]{6,20}"
729 587 inputmode="numeric"
730 588 autocomplete="one-time-code"
731 589 autofocus
732 590 required>
@@ -731,9 +589,9 @@
731 589 autofocus
732 590 required>
733 591 </p>
734 592
735 - <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
593 + <?php if ( ! empty( $this->policy()['allow_remember_device'] ) ) : ?>
736 594 <p class="vigilante-2fa-field vigilante-2fa-remember">
737 595 <label>
738 596 <input type="checkbox" name="vigilante_2fa_remember" value="1">
739 597 <?php
@@ -843,48 +701,8 @@
843 701 }
844 702 }
845 703
846 704 /**
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 - }
885 -
886 - /**
887 705 * Enqueue login page assets
888 706 */
889 707 public function enqueue_login_assets() {
890 708 wp_enqueue_style(
@@ -901,10 +719,10 @@
901 719 * @param bool $only_new Only send to users not previously notified.
902 720 * @return array Result with count of sent emails
903 721 */
904 722 public function send_activation_notifications( $only_new = false ) {
905 - $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
906 - $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();
907 725 $excluded_users = array_map( 'absint', $excluded_users );
908 726
909 727 // Get users with enforced roles
910 728 $users = get_users( array(
@@ -920,9 +738,9 @@
920 738 );
921 739 }
922 740
923 741 $site_name = get_bloginfo( 'name' );
924 - $from_name = $this->options['email_from_name'] ?? '';
742 + $from_name = $this->policy()['email_from_name'] ?? '';
925 743 $admin_email = get_option( 'admin_email' );
926 744
927 745 if ( empty( $from_name ) ) {
928 746 $from_name = $site_name;
@@ -927,9 +745,9 @@
927 745 if ( empty( $from_name ) ) {
928 746 $from_name = $site_name;
929 747 }
930 748
931 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
749 + $remember_days = absint( $this->policy()['remember_device_days'] ?? 30 );
932 750
933 751 $subject = sprintf(
934 752 /* translators: %s: Site name */
935 753 __( '[%s] Two-factor authentication enabled for your account', 'vigilante' ),
@@ -943,9 +761,9 @@
943 761 $site_name
944 762 )
945 763 );
946 764 $body .= Vigilante_Email_Template::info_box(
947 - ! empty( $this->options['allow_remember_device'] )
765 + ! empty( $this->policy()['allow_remember_device'] )
948 766 ? sprintf(
949 767 /* translators: %d: Remember days */
950 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' ),
951 769 $remember_days