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-totp.php +242 -43 2.11.73.0.0 View file →
@@ -88,8 +88,19 @@
88 88 */
89 89 const TIME_WINDOW = 1;
90 90
91 91 /**
92 + * Time steps scanned, on a failure only, to recognise a clock that drifted
93 + *
94 + * Ten minutes either way. Nothing outside TIME_WINDOW is ever accepted:
95 + * these steps only tell a wrong code apart from a right one that arrived
96 + * with the wrong time on it.
97 + *
98 + * @var int
99 + */
100 + const SKEW_SCAN_STEPS = 20;
101 +
102 + /**
92 103 * Constructor
93 104 *
94 105 * @param Vigilante_Settings $settings Settings instance.
95 106 * @param Vigilante_Database $database Database instance.
@@ -101,12 +112,40 @@
101 112 $this->database = $database;
102 113 $this->activity_log = $activity_log;
103 114 $this->login_security = $login_security;
104 115
105 - $login_options = $settings->get_section( 'login_security' );
106 - $this->options = $login_options['two_factor'] ?? array();
116 + // The mechanics (method, expiry, grace period) come from the main site on
117 + // a network, so they are the same wherever the login arrives. Whether an
118 + // account NEEDS a second factor is a separate question with its own
119 + // answer, see Vigilante_Settings::two_factor_required_for().
120 + $this->options = null;
107 121
108 - if ( $this->is_active() ) {
122 + /*
123 + * Gating here on this site's own setting was the fourth leg of the
124 + * bypass the first cross review found: with two factor on in one subsite
125 + * and off in another, the login sent to the permissive one registered
126 + * nothing at all, and the cookie it issued was valid across the whole
127 + * network. So on a network the hooks go up wherever the login lands.
128 + *
129 + * Which of the two classes actually handles a given login is NOT decided
130 + * here any more. The second cross review found that registering both was
131 + * a downgrade (a network set to use an authenticator app also mailed
132 + * codes), and the third found that picking one here by the main site's
133 + * method was a hole (with the main site on totp and a subsite asking for
134 + * email, the account had no enrolment and the login went through). Both
135 + * come from the same mistake: the method is a property of the account, not
136 + * of the site the login arrives at. So both classes register and each one
137 + * asks Vigilante_Settings::two_factor_handler_for() whether this login is
138 + * theirs. Registering a filter costs nothing; the election does not run
139 + * until a login is known to need a second factor.
140 + *
141 + * Nothing is read from the options here on a network, and that is on
142 + * purpose too: this constructor runs on init on EVERY request of every
143 + * site, and reading the main site's policy here meant a switch_to_blog()
144 + * plus the whole autoloaded option set of the main site on every front
145 + * page view of every subsite (measured: 318 rows, 89 KB).
146 + */
147 + if ( is_multisite() || ! empty( $this->policy()['enabled'] ) ) {
109 148 $this->init_hooks();
110 149 }
111 150 }
112 151
@@ -115,10 +154,10 @@
115 154 *
116 155 * @return bool
117 156 */
118 157 public function is_active() {
119 - return ! empty( $this->options['enabled'] )
120 - && 'totp' === ( $this->options['method'] ?? 'email' );
158 + return ! empty( $this->policy()['enabled'] )
159 + && 'totp' === ( $this->policy()['method'] ?? 'email' );
121 160 }
122 161
123 162 /**
124 163 * Initialize hooks
@@ -225,8 +264,14 @@
225 264 if ( ! $this->user_requires_2fa( $user ) ) {
226 265 return $user;
227 266 }
228 267
268 + // And whether this class is the one that must ask. Both are registered on
269 + // a network; the election is per account (see two_factor_handler_for()).
270 + if ( ! $this->handles_second_factor( $user, 'totp' ) ) {
271 + return $user;
272 + }
273 +
229 274 // Check if device is trusted
230 275 if ( $this->is_device_trusted( $user->ID ) ) {
231 276 return $user;
232 277 }
@@ -239,9 +284,9 @@
239 284 // Enforcement happens inside admin via force_totp_setup_redirect()
240 285
241 286 if ( ! $totp_data ) {
242 287 // First time - create grace period placeholder
243 - $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
288 + $grace_days = absint( $this->policy()['grace_period_days'] ?? 3 );
244 289 $grace_expires = ( $grace_days > 0 )
245 290 ? gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) )
246 291 : gmdate( 'Y-m-d H:i:s', time() );
247 292 $this->database->create_totp_placeholder( $user->ID, $grace_expires );
@@ -259,8 +304,22 @@
259 304 $this->set_pending_verification( $user->ID );
260 305
261 306 $this->log_event( 'totp_verification_requested', $user->ID, __( 'TOTP verification requested at login', 'vigilante' ) );
262 307
308 + /*
309 + * This rejection is ours, not a wrong password: the credentials were
310 + * right and the account is being asked for its second factor. Until
311 + * 2.11.12 nothing marked it, and wp_authenticate() fires wp_login_failed
312 + * for every WP_Error that is not empty_username or empty_password
313 + * (wp-includes/pluggable.php, wp_authenticate()), so Login Security
314 + * counted one failed attempt for every correct password. With the
315 + * defaults (5 per address and hour, 3 codes per verification session)
316 + * two real tries were enough to lock the address out for 30 minutes,
317 + * and the activity log filled with failed logins that never happened.
318 + * The rejection is recognised by its error code, which
319 + * Vigilante_Login_Security::CONTROLLED_REJECTIONS lists along with the
320 + * six other refusals the plugin issues itself.
321 + */
263 322 return new WP_Error(
264 323 'vigilante_2fa_required',
265 324 __( 'Please enter the verification code from your authenticator app.', 'vigilante' )
266 325 );
@@ -272,22 +331,12 @@
272 331 * @param WP_User $user User object.
273 332 * @return bool
274 333 */
275 334 public function user_requires_2fa( $user ) {
276 - $excluded_users = $this->options['excluded_users'] ?? array();
277 - if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
278 - return false;
279 - }
280 -
281 - $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
282 -
283 - foreach ( $user->roles as $role ) {
284 - if ( in_array( $role, $enforced_roles, true ) ) {
285 - return true;
286 - }
287 - }
288 -
289 - return false;
335 + // One answer for the whole network: two factor is required if any site
336 + // the account belongs to asks for it, with that site's own enforced roles
337 + // and exclusions. See Vigilante_Settings::two_factor_required_for().
338 + return Vigilante_Settings::two_factor_required_for( $user );
290 339 }
291 340
292 341 /**
293 342 * Check if user is within the grace period
@@ -295,9 +344,9 @@
295 344 * @param int $user_id User ID.
296 345 * @return bool
297 346 */
298 347 private function is_within_grace_period( $user_id ) {
299 - $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
348 + $grace_days = absint( $this->policy()['grace_period_days'] ?? 3 );
300 349
301 350 if ( 0 === $grace_days ) {
302 351 return false;
303 352 }
@@ -322,8 +371,18 @@
322 371 /**
323 372 * Handle 2FA verification form submission
324 373 */
325 374 public function handle_2fa_form() {
375 + /*
376 + * Y solo ella la verifica. Volver aqui no deja pasar nada: la otra clase
377 + * esta enganchada a la misma accion y termina la peticion por su cuenta,
378 + * que es lo que evita el fallthrough a wp_signon() que avisa el comentario
379 + * de abajo.
380 + */
381 + if ( ! $this->pending_belongs_to( 'totp' ) ) {
382 + return;
383 + }
384 +
326 385 // The pending user is resolved first so that a failed nonce can be
327 386 // explained on the form and recorded (S15). Both failure paths end the
328 387 // request: a bare return would let wp-login.php fall through to its
329 388 // default case and call wp_signon(), completing the login without the
@@ -343,9 +402,9 @@
343 402 // here: the lockout only runs on the authenticate filter, which this
344 403 // form never passes through. The limit is checked before any code is
345 404 // verified so that a session past it costs nothing, since a backup
346 405 // code check alone is up to ten wp_check_password() calls.
347 - $max_attempts = absint( $this->options['max_attempts'] ?? 3 );
406 + $max_attempts = absint( $this->policy()['max_attempts'] ?? 3 );
348 407
349 408 if ( $max_attempts < 1 ) {
350 409 $max_attempts = 3;
351 410 }
@@ -352,10 +411,14 @@
352 411
353 412 if ( $this->get_pending_attempts() >= $max_attempts ) {
354 413 $this->log_event( 'totp_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
355 414 $this->clear_pending_verification();
356 - wp_safe_redirect( wp_login_url() );
357 - exit;
415 +
416 + // Until 2.11.12 this redirect carried no message: the visitor landed
417 + // on the password form with no idea the verification session had
418 + // been closed, typed the password again, and that correct password
419 + // counted as one more failed login.
420 + $this->redirect_to_login_with_notice( 'attempts' );
358 421 }
359 422
360 423 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
361 424 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
@@ -378,9 +441,12 @@
378 441 // Backup code succeeded
379 442 $this->log_event( 'totp_backup_code_used', $user_id, __( 'Backup code used for authentication', 'vigilante' ), 'warning' );
380 443 }
381 444
382 - // Verification successful
445 + // Verification successful. Read before the session is cleared: that is
446 + // where the redirect_to of the original login is kept.
447 + $redirect_to = $this->pending_login_redirect();
448 +
383 449 $this->clear_pending_verification();
384 450
385 451 // Trust device if requested (and if the option allows it, see trust_device)
386 452 if ( $remember_device && $this->trust_device( $user_id ) ) {
@@ -395,9 +461,9 @@
395 461 wp_set_auth_cookie( $user_id, false );
396 462 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook
397 463 do_action( 'wp_login', $user->user_login, $user );
398 464
399 - wp_safe_redirect( admin_url() );
465 + wp_safe_redirect( $redirect_to );
400 466 exit;
401 467 }
402 468
403 469 /**
@@ -403,8 +469,13 @@
403 469 /**
404 470 * Show 2FA form on login page
405 471 */
406 472 public function maybe_show_2fa_form() {
473 + // Solo la clase que atiende esta verificacion pinta su formulario.
474 + if ( ! $this->pending_belongs_to( 'totp' ) ) {
475 + return;
476 + }
477 +
407 478 // Don't show 2FA form on logout or other non-auth actions
408 479 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking URL params for display logic
409 480 if ( isset( $_GET['loggedout'] ) || isset( $_GET['action'] ) ) {
410 481 $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@@ -432,9 +503,9 @@
432 503
433 504 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
434 505 delete_transient( 'vigilante_2fa_error_' . $user_id );
435 506
436 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
507 + $remember_days = absint( $this->policy()['remember_device_days'] ?? 30 );
437 508
438 509 // Check remaining backup codes
439 510 $backup_remaining = $this->count_remaining_backup_codes( $user_id );
440 511 ?>
@@ -489,10 +560,10 @@
489 560 name="vigilante_2fa_code"
490 561 id="vigilante_2fa_code"
491 562 class="input"
492 563 size="8"
493 - maxlength="8"
494 - pattern="[a-zA-Z0-9]{6,8}"
564 + maxlength="20"
565 + pattern="[a-zA-Z0-9 -]{6,20}"
495 566 inputmode="numeric"
496 567 autocomplete="one-time-code"
497 568 placeholder="000000"
498 569 autofocus
@@ -498,9 +569,9 @@
498 569 autofocus
499 570 required>
500 571 </p>
501 572
502 - <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
573 + <?php if ( ! empty( $this->policy()['allow_remember_device'] ) ) : ?>
503 574 <p class="vigilante-2fa-field vigilante-2fa-remember">
504 575 <label>
505 576 <input type="checkbox" name="vigilante_2fa_remember" value="1">
506 577 <?php
@@ -533,10 +604,9 @@
533 604 *
534 605 * @return string Base32-encoded secret.
535 606 */
536 607 public function generate_secret() {
537 - $random = wp_generate_password( self::SECRET_LENGTH, false, false );
538 - // Use truly random bytes
608 + // Truly random bytes, Base32 encoded.
539 609 $bytes = '';
540 610 for ( $i = 0; $i < self::SECRET_LENGTH; $i++ ) {
541 611 $bytes .= chr( wp_rand( 0, 255 ) );
542 612 }
@@ -585,8 +655,16 @@
585 655 * @param string $code Submitted code.
586 656 * @return true|WP_Error
587 657 */
588 658 public function verify_totp_code( $user_id, $code ) {
659 + // Password managers show the code in two groups of three and paste it
660 + // that way ("123 456"). sanitize_text_field() does not touch inner
661 + // spaces, so until 2.11.12 a correct code pasted from 1Password was
662 + // answered "Invalid code format" with no hint of why. Separators are
663 + // presentation, never part of the code: drop everything that is not a
664 + // digit before validating.
665 + $code = preg_replace( '/\D/', '', (string) $code );
666 +
589 667 // Validate code format (6 digits for TOTP)
590 668 if ( ! preg_match( '/^[0-9]{6}$/', $code ) ) {
591 669 return new WP_Error( 'invalid_format', __( 'Invalid code format. Enter the 6-digit code from your authenticator app.', 'vigilante' ) );
592 670 }
@@ -621,8 +699,26 @@
621 699 return true;
622 700 }
623 701 }
624 702
703 + // A correct code from a device whose clock disagrees with the server's
704 + // matches a time step outside the window. It is never accepted here: the
705 + // window stays at what the RFC recommends. It is only recognised, so the
706 + // answer says "the clocks disagree" instead of the same "invalid code"
707 + // someone gets for a typo, which is what turns this into a support
708 + // thread. A random guess matching any of these steps is 1 in 24.000.
709 + $skew_seconds = 0;
710 + for ( $i = -self::SKEW_SCAN_STEPS; $i <= self::SKEW_SCAN_STEPS; $i++ ) {
711 + if ( abs( $i ) <= self::TIME_WINDOW ) {
712 + continue;
713 + }
714 +
715 + if ( hash_equals( $this->generate_code( $secret, $now + ( $i * self::TIME_STEP ) ), $code ) ) {
716 + $skew_seconds = $i * self::TIME_STEP;
717 + break;
718 + }
719 + }
720 +
625 721 // Track failed attempts
626 722 $remaining = -1;
627 723 if ( $this->login_security ) {
628 724 $user = get_user_by( 'ID', $user_id );
@@ -631,8 +727,32 @@
631 727 $remaining = $this->login_security->get_remaining_attempts();
632 728 }
633 729 }
634 730
731 + if ( 0 !== $skew_seconds ) {
732 + $minutes = max( 1, (int) round( abs( $skew_seconds ) / MINUTE_IN_SECONDS ) );
733 +
734 + $this->log_event(
735 + 'totp_clock_skew',
736 + $user_id,
737 + sprintf(
738 + /* translators: %d: Minutes of difference between the server clock and the authenticator app. */
739 + __( 'A valid TOTP code was rejected: the server clock and the authenticator app differ by about %d minutes', 'vigilante' ),
740 + $minutes
741 + ),
742 + 'warning'
743 + );
744 +
745 + return new WP_Error(
746 + 'clock_skew',
747 + sprintf(
748 + /* translators: %d: Minutes of difference between the server clock and the authenticator app. */
749 + __( 'That code is correct, but the server clock and your authenticator app differ by about %d minutes, so it cannot be accepted. Ask your host to fix the server time, or check the time settings of your app.', 'vigilante' ),
750 + $minutes
751 + )
752 + );
753 + }
754 +
635 755 $this->log_event( 'totp_verification_failed', $user_id, __( 'Invalid TOTP code entered', 'vigilante' ), 'warning' );
636 756
637 757 if ( $remaining > 0 ) {
638 758 return new WP_Error(
@@ -690,10 +810,12 @@
690 810 * @param string $code Submitted backup code.
691 811 * @return true|WP_Error
692 812 */
693 813 private function verify_backup_code( $user_id, $code ) {
694 - // Backup codes are 8 chars, lowercase alphanumeric
695 - $code = strtolower( trim( $code ) );
814 + // Backup codes are 8 chars, lowercase alphanumeric. Whatever separators
815 + // the holder pasted in (spaces, dashes) are presentation, same as in a
816 + // TOTP code, and go before the length is measured.
817 + $code = strtolower( preg_replace( '/[^A-Za-z0-9]/', '', (string) $code ) );
696 818
697 819 if ( strlen( $code ) !== self::BACKUP_CODE_LENGTH ) {
698 820 return new WP_Error( 'invalid_backup', __( 'Invalid backup code.', 'vigilante' ) );
699 821 }
@@ -924,8 +1046,32 @@
924 1046
925 1047 $totp_data = $this->database->get_totp_data( $user->ID );
926 1048 $configured = $totp_data && ! empty( $totp_data['is_configured'] );
927 1049
1050 + /*
1051 + * And only for accounts this class actually asks. Since 2.11.10 the hooks
1052 + * of both second factor classes go up whenever the feature is on, because
1053 + * which one asks is decided per account and not per site, so without this
1054 + * an install configured for a code by email would show an authenticator
1055 + * app section to everybody. An account already enrolled keeps seeing it
1056 + * while a second factor is required of it, whatever method the site asks
1057 + * for, or it would have no way to manage or remove an enrolment it
1058 + * already has.
1059 + *
1060 + * Since 2.11.11 an enrolment is not used while no site asks that account
1061 + * for an app (see Vigilante_Settings::two_factor_handler_for()), and the
1062 + * section says so instead of "Configured and active": the enrolment is
1063 + * kept, comes back into use as soon as an app is asked for, and its owner
1064 + * can still remove it or renew the backup codes from here. Removing it
1065 + * there does not lead to a new QR code, because nothing asks for one, so
1066 + * that button says what it does instead of "Set up new authenticator".
1067 + */
1068 + $in_use = $this->handles_second_factor( $user, 'totp', $configured );
1069 +
1070 + if ( ! $configured && ! $in_use ) {
1071 + return;
1072 + }
1073 +
928 1074 wp_nonce_field( 'vigilante_totp_profile', 'vigilante_totp_nonce' );
929 1075 ?>
930 1076 <input type="hidden" class="vigilante-totp-user-id" value="<?php echo esc_attr( $user->ID ); ?>">
931 1077 <h2><?php esc_html_e( 'Two-Factor Authentication (TOTP)', 'vigilante' ); ?></h2>
@@ -933,12 +1079,20 @@
933 1079 <?php if ( $configured ) : ?>
934 1080 <tr>
935 1081 <th scope="row"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
936 1082 <td>
937 - <span class="vigilante-totp-status vigilante-totp-active">
938 - <span class="dashicons dashicons-yes-alt"></span>
939 - <?php esc_html_e( 'Configured and active', 'vigilante' ); ?>
940 - </span>
1083 + <?php if ( $in_use ) : ?>
1084 + <span class="vigilante-totp-status vigilante-totp-active">
1085 + <span class="dashicons dashicons-yes-alt"></span>
1086 + <?php esc_html_e( 'Configured and active', 'vigilante' ); ?>
1087 + </span>
1088 + <?php else : ?>
1089 + <span class="vigilante-totp-status vigilante-totp-inactive">
1090 + <span class="dashicons dashicons-info-outline"></span>
1091 + <?php esc_html_e( 'Configured, not in use', 'vigilante' ); ?>
1092 + </span>
1093 + <p class="description"><?php esc_html_e( 'Login for this account is verified with a code sent by email for now. This authenticator setup is kept and will be asked for again if an authenticator app becomes required for this account.', 'vigilante' ); ?></p>
1094 + <?php endif; ?>
941 1095 <?php if ( ! empty( $totp_data['configured_at'] ) ) : ?>
942 1096 <p class="description">
943 1097 <?php
944 1098 printf(
@@ -976,8 +1130,9 @@
976 1130 <div class="vigilante-totp-backup-codes-display" style="display:none;"></div>
977 1131 </td>
978 1132 </tr>
979 1133 <?php if ( current_user_can( 'manage_options' ) || get_current_user_id() === $user->ID ) : ?>
1134 + <?php if ( $in_use ) : ?>
980 1135 <tr>
981 1136 <th scope="row"><?php esc_html_e( 'Reconfigure', 'vigilante' ); ?></th>
982 1137 <td>
983 1138 <button type="button" class="button vigilante-totp-reconfigure" data-user="<?php echo esc_attr( $user->ID ); ?>">
@@ -985,8 +1140,19 @@
985 1140 </button>
986 1141 <p class="description"><?php esc_html_e( 'This will reset your current TOTP setup and require scanning a new QR code.', 'vigilante' ); ?></p>
987 1142 </td>
988 1143 </tr>
1144 + <?php else : ?>
1145 + <tr>
1146 + <th scope="row"><?php esc_html_e( 'Remove', 'vigilante' ); ?></th>
1147 + <td>
1148 + <button type="button" class="button vigilante-totp-reconfigure vigilante-totp-remove" data-user="<?php echo esc_attr( $user->ID ); ?>" data-confirm="<?php esc_attr_e( 'This will remove the authenticator setup of this account and forget its trusted devices. Login keeps using the code sent by email. Continue?', 'vigilante' ); ?>">
1149 + <?php esc_html_e( 'Remove authenticator setup', 'vigilante' ); ?>
1150 + </button>
1151 + <p class="description"><?php esc_html_e( 'Removes this authenticator setup and forgets the trusted devices of this account. If an authenticator app becomes required later, a new one can be set up then.', 'vigilante' ); ?></p>
1152 + </td>
1153 + </tr>
1154 + <?php endif; ?>
989 1155 <?php endif; ?>
990 1156 <?php else : ?>
991 1157 <tr>
992 1158 <th scope="row"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
@@ -1036,13 +1202,22 @@
1036 1202 <code class="vigilante-totp-secret-display"></code>
1037 1203 </div>
1038 1204 <div class="vigilante-totp-verify-setup">
1039 1205 <label for="vigilante_totp_verify_code"><?php esc_html_e( 'Enter code to verify:', 'vigilante' ); ?></label>
1040 - <input type="text" id="vigilante_totp_verify_code" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="off">
1206 + <input type="text" id="vigilante_totp_verify_code" maxlength="20" pattern="[0-9 -]{6,20}" inputmode="numeric" autocomplete="off">
1041 1207 <button type="button" class="button button-primary vigilante-totp-confirm-setup">
1042 1208 <?php esc_html_e( 'Verify and activate', 'vigilante' ); ?>
1043 1209 </button>
1044 1210 <span class="vigilante-totp-setup-status"></span>
1211 + <p class="description vigilante-totp-server-time">
1212 + <?php
1213 + printf(
1214 + /* translators: %s: Server time in UTC, as YYYY-MM-DD HH:MM:SS. */
1215 + esc_html__( 'Codes are tied to the clock. This server reads %s UTC right now; if that is more than half a minute away from the clock of the device running your app, no code will ever be accepted.', 'vigilante' ),
1216 + esc_html( gmdate( 'Y-m-d H:i:s' ) )
1217 + );
1218 + ?>
1219 + </p>
1045 1220 </div>
1046 1221 </div>
1047 1222 <div class="vigilante-totp-setup-success" style="display:none;">
1048 1223 <div class="vigilante-totp-success-msg">
@@ -1149,8 +1324,11 @@
1149 1324 if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1150 1325 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1151 1326 }
1152 1327
1328 + // Same normalisation as verify_totp_code(): separators are presentation.
1329 + $code = preg_replace( '/\D/', '', (string) $code );
1330 +
1153 1331 if ( empty( $code ) || ! preg_match( '/^[0-9]{6}$/', $code ) ) {
1154 1332 wp_send_json_error( __( 'Enter a valid 6-digit code.', 'vigilante' ) );
1155 1333 }
1156 1334
@@ -1272,9 +1450,27 @@
1272 1450 }
1273 1451
1274 1452 $user = wp_get_current_user();
1275 1453
1276 - if ( ! $user->ID || ! $this->user_requires_2fa( $user ) ) {
1454 + /*
1455 + * Only an account this class asks for its app: the same election as the
1456 + * login and the profile section. Asking only whether some second factor
1457 + * is required was enough while this class registered only on sites set to
1458 + * an app; since 2.11.10 it registers wherever two factor is on, and an
1459 + * account verified by email with a leftover row from a grace period was
1460 + * sent to profile.php from every screen of the dashboard, where the setup
1461 + * section is not shown to it, so nothing let it out.
1462 + *
1463 + * Asked first, and as if the account had no enrolment, because the row
1464 + * read below is the expensive part: on a network, for an account with no
1465 + * enrolment, it searches the table of every site the account can reach,
1466 + * and most accounts verified by email have none. Assuming no enrolment
1467 + * changes nothing here: with one, the answer can only move to the app,
1468 + * and an enrolled account leaves at "already configured" anyway. This
1469 + * also answers no when nothing is required, so it replaces
1470 + * user_requires_2fa().
1471 + */
1472 + if ( ! $user->ID || ! $this->handles_second_factor( $user, 'totp', false ) ) {
1277 1473 return;
1278 1474 }
1279 1475
1280 1476 $totp_data = $this->database->get_totp_data( $user->ID );
@@ -1310,9 +1506,12 @@
1310 1506 */
1311 1507 public function show_grace_period_notice() {
1312 1508 $user = wp_get_current_user();
1313 1509
1314 - if ( ! $this->user_requires_2fa( $user ) ) {
1510 + // Same election, in the same order and for the same reasons, as
1511 + // force_totp_setup_redirect(): since 2.11.10 an account verified by email
1512 + // was told on every screen to set up an app.
1513 + if ( ! $user->ID || ! $this->handles_second_factor( $user, 'totp', false ) ) {
1315 1514 return;
1316 1515 }
1317 1516
1318 1517 $totp_data = $this->database->get_totp_data( $user->ID );
@@ -1367,9 +1566,9 @@
1367 1566 public function reset_user_totp( $user_id ) {
1368 1567 $this->database->reset_totp_data( $user_id );
1369 1568
1370 1569 // If grace period is configured, set a new one
1371 - $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
1570 + $grace_days = absint( $this->policy()['grace_period_days'] ?? 3 );
1372 1571 if ( $grace_days > 0 ) {
1373 1572 $grace_expires = gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) );
1374 1573 $this->database->create_totp_placeholder( $user_id, $grace_expires );
1375 1574 }
@@ -1417,9 +1616,9 @@
1417 1616 * @return bool
1418 1617 */
1419 1618 public function send_activation_email( $user, $site_name, $from_name ) {
1420 1619 $profile_url = admin_url( 'profile.php' );
1421 - $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
1620 + $grace_days = absint( $this->policy()['grace_period_days'] ?? 3 );
1422 1621
1423 1622 $subject = sprintf(
1424 1623 /* translators: %s: Site name */
1425 1624 __( '[%s] Set up two-factor authentication for your account', 'vigilante' ),