PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
3.0.1 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 All 89 releases
← All changes | includes/class-two-factor-totp.php +359 -232 2.10.1 → 2.11.12 View file →
@@ -19,8 +19,10 @@
19 19 * Authenticator app OTP verification for login security
20 20 */
21 21 class Vigilante_Two_Factor_TOTP {
22 22
23 + use Vigilante_Two_Factor_Session;
24 +
23 25 /**
24 26 * Settings instance
25 27 *
26 28 * @var Vigilante_Settings
@@ -80,13 +82,25 @@
80 82 */
81 83 const SECRET_LENGTH = 20;
82 84
83 85 /**
84 - * Time window tolerance (allows +-2 time steps for clock skew)
86 + * Time window tolerance: +-1 time step (30 seconds) for clock skew.
87 + * Was 2 until 2.11.0, which accepted five codes at any moment (S2).
85 88 */
86 - const TIME_WINDOW = 2;
89 + const TIME_WINDOW = 1;
87 90
88 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 + /**
89 103 * Constructor
90 104 *
91 105 * @param Vigilante_Settings $settings Settings instance.
92 106 * @param Vigilante_Database $database Database instance.
@@ -98,12 +112,40 @@
98 112 $this->database = $database;
99 113 $this->activity_log = $activity_log;
100 114 $this->login_security = $login_security;
101 115
102 - $login_options = $settings->get_section( 'login_security' );
103 - $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;
104 121
105 - 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'] ) ) {
106 148 $this->init_hooks();
107 149 }
108 150 }
109 151
@@ -112,10 +154,10 @@
112 154 *
113 155 * @return bool
114 156 */
115 157 public function is_active() {
116 - return ! empty( $this->options['enabled'] )
117 - && 'totp' === ( $this->options['method'] ?? 'email' );
158 + return ! empty( $this->policy()['enabled'] )
159 + && 'totp' === ( $this->policy()['method'] ?? 'email' );
118 160 }
119 161
120 162 /**
121 163 * Initialize hooks
@@ -120,8 +162,10 @@
120 162 /**
121 163 * Initialize hooks
122 164 */
123 165 private function init_hooks() {
166 + $this->init_session_hooks();
167 +
124 168 // Intercept authentication
125 169 add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 );
126 170
127 171 // Handle TOTP verification form
@@ -157,14 +201,16 @@
157 201
158 202 /**
159 203 * Filter login errors to hide default messages during 2FA
160 204 *
205 + * Resolved from the pending token only; the lookup by IP address that used
206 + * to live here leaked one user's pending state to another behind a proxy (S3).
207 + *
161 208 * @param string $errors Login error messages.
162 209 * @return string
163 210 */
164 211 public function filter_login_errors( $errors ) {
165 - $ip = $this->database->get_client_ip();
166 - $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
212 + $user_id = $this->get_pending_user_id();
167 213
168 214 if ( ! $user_id ) {
169 215 return $errors;
170 216 }
@@ -191,18 +237,41 @@
191 237 if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
192 238 return $user;
193 239 }
194 240
195 - // Skip if this is a 2FA verification request
196 - if ( $this->is_2fa_verification_request() ) {
241 + // An application password is a second factor of its own. The core
242 + // action that flags it only fires when those were the credentials (S16).
243 + if ( $this->authenticated_with_app_password( $user ) ) {
197 244 return $user;
198 245 }
199 246
247 + /*
248 + * There is deliberately no "already verifying, let it through" shortcut
249 + * here any more. Until 2.11.0 a request carrying action=vigilante_2fa,
250 + * the form nonce and a pending token returned $user at this point, and
251 + * all three are in the hands of whoever knows the password: the nonce
252 + * is printed on the form served to the pending visitor, and the token is
253 + * issued to that same visitor. wp-login.php never reached this filter
254 + * with that action, because login_form_vigilante_2fa ends the request,
255 + * but any other login form that calls wp_signon(), the WooCommerce one
256 + * for instance, does reach it and completed the login without a second
257 + * factor (S19, found in the 2.11.0 cross review and reproduced). The
258 + * verification form authenticates on its own path, handle_2fa_form(),
259 + * which never passes through wp_authenticate(): nothing legitimate
260 + * needed the shortcut.
261 + */
262 +
200 263 // Check if user requires 2FA
201 264 if ( ! $this->user_requires_2fa( $user ) ) {
202 265 return $user;
203 266 }
204 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 +
205 274 // Check if device is trusted
206 275 if ( $this->is_device_trusted( $user->ID ) ) {
207 276 return $user;
208 277 }
@@ -215,9 +284,9 @@
215 284 // Enforcement happens inside admin via force_totp_setup_redirect()
216 285
217 286 if ( ! $totp_data ) {
218 287 // First time - create grace period placeholder
219 - $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
288 + $grace_days = absint( $this->policy()['grace_period_days'] ?? 3 );
220 289 $grace_expires = ( $grace_days > 0 )
221 290 ? gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) )
222 291 : gmdate( 'Y-m-d H:i:s', time() );
223 292 $this->database->create_totp_placeholder( $user->ID, $grace_expires );
@@ -225,13 +294,32 @@
225 294
226 295 return $user;
227 296 }
228 297
229 - // TOTP is configured - require verification
298 + // TOTP is configured - require verification. REST and XML-RPC have no
299 + // form to show, so the login is refused without a pending session (S16).
300 + if ( $this->is_api_request() ) {
301 + return $this->api_requires_2fa_error();
302 + }
303 +
230 304 $this->set_pending_verification( $user->ID );
231 305
232 306 $this->log_event( 'totp_verification_requested', $user->ID, __( 'TOTP verification requested at login', 'vigilante' ) );
233 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 + */
234 322 return new WP_Error(
235 323 'vigilante_2fa_required',
236 324 __( 'Please enter the verification code from your authenticator app.', 'vigilante' )
237 325 );
@@ -243,22 +331,12 @@
243 331 * @param WP_User $user User object.
244 332 * @return bool
245 333 */
246 334 public function user_requires_2fa( $user ) {
247 - $excluded_users = $this->options['excluded_users'] ?? array();
248 - if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
249 - return false;
250 - }
251 -
252 - $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
253 -
254 - foreach ( $user->roles as $role ) {
255 - if ( in_array( $role, $enforced_roles, true ) ) {
256 - return true;
257 - }
258 - }
259 -
260 - 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 );
261 339 }
262 340
263 341 /**
264 342 * Check if user is within the grace period
@@ -266,9 +344,9 @@
266 344 * @param int $user_id User ID.
267 345 * @return bool
268 346 */
269 347 private function is_within_grace_period( $user_id ) {
270 - $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
348 + $grace_days = absint( $this->policy()['grace_period_days'] ?? 3 );
271 349
272 350 if ( 0 === $grace_days ) {
273 351 return false;
274 352 }
@@ -290,33 +368,59 @@
290 368 return false;
291 369 }
292 370
293 371 /**
294 - * Check if this is a 2FA verification form submission
295 - *
296 - * @return bool
297 - */
298 - private function is_2fa_verification_request() {
299 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking action, nonce verified in handler
300 - $action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
301 - return 'vigilante_2fa' === $action;
302 - }
303 -
304 - /**
305 372 * Handle 2FA verification form submission
306 373 */
307 374 public function handle_2fa_form() {
308 - if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
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' ) ) {
309 382 return;
310 383 }
311 384
385 + // The pending user is resolved first so that a failed nonce can be
386 + // explained on the form and recorded (S15). Both failure paths end the
387 + // request: a bare return would let wp-login.php fall through to its
388 + // default case and call wp_signon(), completing the login without the
389 + // second factor.
312 390 $user_id = $this->get_pending_user_id();
313 391
392 + if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
393 + $this->handle_invalid_nonce( $user_id );
394 + }
395 +
314 396 if ( ! $user_id ) {
315 397 wp_safe_redirect( wp_login_url() );
316 398 exit;
317 399 }
318 400
401 + // Attempt limit per pending session (S2). Until 2.11.0 nothing counted
402 + // here: the lockout only runs on the authenticate filter, which this
403 + // form never passes through. The limit is checked before any code is
404 + // verified so that a session past it costs nothing, since a backup
405 + // code check alone is up to ten wp_check_password() calls.
406 + $max_attempts = absint( $this->policy()['max_attempts'] ?? 3 );
407 +
408 + if ( $max_attempts < 1 ) {
409 + $max_attempts = 3;
410 + }
411 +
412 + if ( $this->get_pending_attempts() >= $max_attempts ) {
413 + $this->log_event( 'totp_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
414 + $this->clear_pending_verification();
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' );
421 + }
422 +
319 423 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
320 424 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
321 425
322 426 // Try TOTP code first, then backup code
@@ -327,8 +431,9 @@
327 431 $backup_result = $this->verify_backup_code( $user_id, $code );
328 432
329 433 if ( is_wp_error( $backup_result ) ) {
330 434 // Both failed
435 + $this->increment_pending_attempts();
331 436 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
332 437 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
333 438 exit;
334 439 }
@@ -336,13 +441,16 @@
336 441 // Backup code succeeded
337 442 $this->log_event( 'totp_backup_code_used', $user_id, __( 'Backup code used for authentication', 'vigilante' ), 'warning' );
338 443 }
339 444
340 - // 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 +
341 449 $this->clear_pending_verification();
342 450
343 - if ( $remember_device ) {
344 - $this->trust_device( $user_id );
451 + // Trust device if requested (and if the option allows it, see trust_device)
452 + if ( $remember_device && $this->trust_device( $user_id ) ) {
345 453 $this->log_event( 'totp_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
346 454 }
347 455
348 456 $this->log_event( 'totp_verification_success', $user_id, __( 'TOTP verification successful', 'vigilante' ) );
@@ -353,9 +461,9 @@
353 461 wp_set_auth_cookie( $user_id, false );
354 462 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook
355 463 do_action( 'wp_login', $user->user_login, $user );
356 464
357 - wp_safe_redirect( admin_url() );
465 + wp_safe_redirect( $redirect_to );
358 466 exit;
359 467 }
360 468
361 469 /**
@@ -361,8 +469,13 @@
361 469 /**
362 470 * Show 2FA form on login page
363 471 */
364 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 +
365 478 // Don't show 2FA form on logout or other non-auth actions
366 479 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking URL params for display logic
367 480 if ( isset( $_GET['loggedout'] ) || isset( $_GET['action'] ) ) {
368 481 $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@@ -370,18 +483,18 @@
370 483 return;
371 484 }
372 485 }
373 486
374 - $user_id = $this->get_pending_user_id();
487 + // Only the visitor presenting the pending token gets the form. There is
488 + // no fallback by IP address and no lookup of the token by user (S3).
489 + $session = $this->get_pending_session();
375 490
376 - if ( ! $user_id ) {
377 - $ip = $this->database->get_client_ip();
378 - $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
491 + if ( ! $session ) {
492 + return;
379 493 }
380 494
381 - if ( ! $user_id ) {
382 - return;
383 - }
495 + $user_id = $session['user_id'];
496 + $token = $session['token'];
384 497
385 498 // Only show if user has TOTP configured
386 499 $totp_data = $this->database->get_totp_data( $user_id );
387 500 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
@@ -387,17 +500,12 @@
387 500 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
388 501 return;
389 502 }
390 503
391 - $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
392 - if ( empty( $token ) ) {
393 - $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
394 - }
395 -
396 504 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
397 505 delete_transient( 'vigilante_2fa_error_' . $user_id );
398 506
399 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
507 + $remember_days = absint( $this->policy()['remember_device_days'] ?? 30 );
400 508
401 509 // Check remaining backup codes
402 510 $backup_remaining = $this->count_remaining_backup_codes( $user_id );
403 511 ?>
@@ -452,10 +560,10 @@
452 560 name="vigilante_2fa_code"
453 561 id="vigilante_2fa_code"
454 562 class="input"
455 563 size="8"
456 - maxlength="8"
457 - pattern="[a-zA-Z0-9]{6,8}"
564 + maxlength="20"
565 + pattern="[a-zA-Z0-9 -]{6,20}"
458 566 inputmode="numeric"
459 567 autocomplete="one-time-code"
460 568 placeholder="000000"
461 569 autofocus
@@ -461,9 +569,9 @@
461 569 autofocus
462 570 required>
463 571 </p>
464 572
465 - <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
573 + <?php if ( ! empty( $this->policy()['allow_remember_device'] ) ) : ?>
466 574 <p class="vigilante-2fa-field vigilante-2fa-remember">
467 575 <label>
468 576 <input type="checkbox" name="vigilante_2fa_remember" value="1">
469 577 <?php
@@ -496,10 +604,9 @@
496 604 *
497 605 * @return string Base32-encoded secret.
498 606 */
499 607 public function generate_secret() {
500 - $random = wp_generate_password( self::SECRET_LENGTH, false, false );
501 - // Use truly random bytes
608 + // Truly random bytes, Base32 encoded.
502 609 $bytes = '';
503 610 for ( $i = 0; $i < self::SECRET_LENGTH; $i++ ) {
504 611 $bytes .= chr( wp_rand( 0, 255 ) );
505 612 }
@@ -548,8 +655,16 @@
548 655 * @param string $code Submitted code.
549 656 * @return true|WP_Error
550 657 */
551 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 +
552 667 // Validate code format (6 digits for TOTP)
553 668 if ( ! preg_match( '/^[0-9]{6}$/', $code ) ) {
554 669 return new WP_Error( 'invalid_format', __( 'Invalid code format. Enter the 6-digit code from your authenticator app.', 'vigilante' ) );
555 670 }
@@ -584,8 +699,26 @@
584 699 return true;
585 700 }
586 701 }
587 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 +
588 721 // Track failed attempts
589 722 $remaining = -1;
590 723 if ( $this->login_security ) {
591 724 $user = get_user_by( 'ID', $user_id );
@@ -594,8 +727,32 @@
594 727 $remaining = $this->login_security->get_remaining_attempts();
595 728 }
596 729 }
597 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 +
598 755 $this->log_event( 'totp_verification_failed', $user_id, __( 'Invalid TOTP code entered', 'vigilante' ), 'warning' );
599 756
600 757 if ( $remaining > 0 ) {
601 758 return new WP_Error(
@@ -653,10 +810,12 @@
653 810 * @param string $code Submitted backup code.
654 811 * @return true|WP_Error
655 812 */
656 813 private function verify_backup_code( $user_id, $code ) {
657 - // Backup codes are 8 chars, lowercase alphanumeric
658 - $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 ) );
659 818
660 819 if ( strlen( $code ) !== self::BACKUP_CODE_LENGTH ) {
661 820 return new WP_Error( 'invalid_backup', __( 'Invalid backup code.', 'vigilante' ) );
662 821 }
@@ -713,14 +872,19 @@
713 872 /**
714 873 * Encrypt TOTP secret for database storage
715 874 *
716 875 * @param string $secret Plain Base32 secret.
717 - * @return string Encrypted string (base64).
876 + * @return string Encrypted string (base64), or empty string without a key.
718 877 */
719 878 public function encrypt_secret( $secret ) {
720 879 $key = $this->get_encryption_key();
721 - $iv = openssl_random_pseudo_bytes( 16 );
722 880
881 + if ( '' === $key ) {
882 + return '';
883 + }
884 +
885 + $iv = openssl_random_pseudo_bytes( 16 );
886 +
723 887 $encrypted = openssl_encrypt( $secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
724 888
725 889 if ( false === $encrypted ) {
726 890 return '';
@@ -738,8 +902,12 @@
738 902 */
739 903 public function decrypt_secret( $encrypted ) {
740 904 $key = $this->get_encryption_key();
741 905
906 + if ( '' === $key ) {
907 + return false;
908 + }
909 +
742 910 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required for binary data retrieval
743 911 $data = base64_decode( $encrypted, true );
744 912
745 913 if ( false === $data || strlen( $data ) < 17 ) {
@@ -754,21 +922,37 @@
754 922 return ( false !== $decrypted ) ? $decrypted : false;
755 923 }
756 924
757 925 /**
926 + * Whether the site defines the key the authenticator secret is encrypted with
927 + *
928 + * @return bool
929 + */
930 + public function has_encryption_key() {
931 + return defined( 'AUTH_KEY' )
932 + && is_string( AUTH_KEY )
933 + && '' !== AUTH_KEY
934 + && 'put your unique phrase here' !== AUTH_KEY;
935 + }
936 +
937 + /**
758 938 * Get encryption key derived from WordPress salts
759 939 *
760 - * @return string 32-byte key.
940 + * Until 2.11.0 a site without AUTH_KEY fell back to a literal written in
941 + * this file, which gave every such site the same key and made the
942 + * encryption cosmetic (S13). Without AUTH_KEY there is no key: setup
943 + * refuses and says why, and nothing is encrypted with a known value.
944 + *
945 + * @return string 32-byte key, or empty string when the site has none.
761 946 */
762 947 private function get_encryption_key() {
763 - $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'vigilante_fallback_key';
764 - return hash( 'sha256', $salt . 'vigilante_totp', true );
948 + if ( ! $this->has_encryption_key() ) {
949 + return '';
950 + }
951 +
952 + return hash( 'sha256', AUTH_KEY . 'vigilante_totp', true );
765 953 }
766 954
767 - // =========================================================================
768 - // Base32 encoding/decoding
769 - // =========================================================================
770 -
771 955 /**
772 956 * Base32 encode
773 957 *
774 958 * @param string $data Raw binary data.
@@ -862,8 +1046,32 @@
862 1046
863 1047 $totp_data = $this->database->get_totp_data( $user->ID );
864 1048 $configured = $totp_data && ! empty( $totp_data['is_configured'] );
865 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 +
866 1074 wp_nonce_field( 'vigilante_totp_profile', 'vigilante_totp_nonce' );
867 1075 ?>
868 1076 <input type="hidden" class="vigilante-totp-user-id" value="<?php echo esc_attr( $user->ID ); ?>">
869 1077 <h2><?php esc_html_e( 'Two-Factor Authentication (TOTP)', 'vigilante' ); ?></h2>
@@ -871,12 +1079,20 @@
871 1079 <?php if ( $configured ) : ?>
872 1080 <tr>
873 1081 <th scope="row"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
874 1082 <td>
875 - <span class="vigilante-totp-status vigilante-totp-active">
876 - <span class="dashicons dashicons-yes-alt"></span>
877 - <?php esc_html_e( 'Configured and active', 'vigilante' ); ?>
878 - </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; ?>
879 1095 <?php if ( ! empty( $totp_data['configured_at'] ) ) : ?>
880 1096 <p class="description">
881 1097 <?php
882 1098 printf(
@@ -914,8 +1130,9 @@
914 1130 <div class="vigilante-totp-backup-codes-display" style="display:none;"></div>
915 1131 </td>
916 1132 </tr>
917 1133 <?php if ( current_user_can( 'manage_options' ) || get_current_user_id() === $user->ID ) : ?>
1134 + <?php if ( $in_use ) : ?>
918 1135 <tr>
919 1136 <th scope="row"><?php esc_html_e( 'Reconfigure', 'vigilante' ); ?></th>
920 1137 <td>
921 1138 <button type="button" class="button vigilante-totp-reconfigure" data-user="<?php echo esc_attr( $user->ID ); ?>">
@@ -923,8 +1140,19 @@
923 1140 </button>
924 1141 <p class="description"><?php esc_html_e( 'This will reset your current TOTP setup and require scanning a new QR code.', 'vigilante' ); ?></p>
925 1142 </td>
926 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; ?>
927 1155 <?php endif; ?>
928 1156 <?php else : ?>
929 1157 <tr>
930 1158 <th scope="row"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
@@ -974,13 +1202,22 @@
974 1202 <code class="vigilante-totp-secret-display"></code>
975 1203 </div>
976 1204 <div class="vigilante-totp-verify-setup">
977 1205 <label for="vigilante_totp_verify_code"><?php esc_html_e( 'Enter code to verify:', 'vigilante' ); ?></label>
978 - <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">
979 1207 <button type="button" class="button button-primary vigilante-totp-confirm-setup">
980 1208 <?php esc_html_e( 'Verify and activate', 'vigilante' ); ?>
981 1209 </button>
982 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>
983 1220 </div>
984 1221 </div>
985 1222 <div class="vigilante-totp-setup-success" style="display:none;">
986 1223 <div class="vigilante-totp-success-msg">
@@ -1080,12 +1317,18 @@
1080 1317 $secret = isset( $_POST['secret'] ) ? sanitize_text_field( wp_unslash( $_POST['secret'] ) ) : '';
1081 1318 $reconfig = ! empty( $_POST['reconfigure'] );
1082 1319
1083 1320 // Permission check: user can only set up their own, unless admin
1084 - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1321 + // edit_user, not manage_options: on a network every subsite administrator
1322 + // holds manage_options, and map_meta_cap denies edit_user against a user
1323 + // they do not administer. On a single site an administrator still passes.
1324 + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1085 1325 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1086 1326 }
1087 1327
1328 + // Same normalisation as verify_totp_code(): separators are presentation.
1329 + $code = preg_replace( '/\D/', '', (string) $code );
1330 +
1088 1331 if ( empty( $code ) || ! preg_match( '/^[0-9]{6}$/', $code ) ) {
1089 1332 wp_send_json_error( __( 'Enter a valid 6-digit code.', 'vigilante' ) );
1090 1333 }
1091 1334
@@ -1103,8 +1346,12 @@
1103 1346 if ( ! in_array( $code, $expected_codes, true ) ) {
1104 1347 wp_send_json_error( __( 'Invalid code. Make sure your authenticator app is set up correctly and the time is synchronized.', 'vigilante' ) );
1105 1348 }
1106 1349
1350 + if ( ! $this->has_encryption_key() ) {
1351 + wp_send_json_error( __( 'This site does not define the AUTH_KEY security key, so the authenticator secret cannot be stored securely. Add the WordPress security keys to the site configuration and try again.', 'vigilante' ) );
1352 + }
1353 +
1107 1354 // Encrypt and store secret
1108 1355 $encrypted = $this->encrypt_secret( $secret );
1109 1356
1110 1357 if ( empty( $encrypted ) ) {
@@ -1139,9 +1386,12 @@
1139 1386 if ( 0 === $user_id ) {
1140 1387 $user_id = get_current_user_id();
1141 1388 }
1142 1389
1143 - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1390 + // edit_user, not manage_options: on a network every subsite administrator
1391 + // holds manage_options, and map_meta_cap denies edit_user against a user
1392 + // they do not administer. On a single site an administrator still passes.
1393 + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1144 1394 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1145 1395 }
1146 1396
1147 1397 $totp_data = $this->database->get_totp_data( $user_id );
@@ -1167,9 +1417,12 @@
1167 1417 if ( 0 === $user_id ) {
1168 1418 $user_id = get_current_user_id();
1169 1419 }
1170 1420
1171 - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1421 + // edit_user, not manage_options: on a network every subsite administrator
1422 + // holds manage_options, and map_meta_cap denies edit_user against a user
1423 + // they do not administer. On a single site an administrator still passes.
1424 + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1172 1425 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1173 1426 }
1174 1427
1175 1428 $this->reset_user_totp( $user_id );
@@ -1178,156 +1431,9 @@
1178 1431 'message' => __( 'TOTP has been reset. You can now set up a new authenticator.', 'vigilante' ),
1179 1432 ) );
1180 1433 }
1181 1434
1182 - /**
1183 - * Set pending verification state
1184 - *
1185 - * @param int $user_id User ID.
1186 - * @return string Token.
1187 - */
1188 - private function set_pending_verification( $user_id ) {
1189 - $existing_token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
1190 -
1191 - $token = $existing_token ? $existing_token : wp_generate_password( 32, false );
1192 -
1193 - set_transient(
1194 - 'vigilante_2fa_pending_' . $token,
1195 - array(
1196 - 'user_id' => $user_id,
1197 - 'created_at' => time(),
1198 - ),
1199 - HOUR_IN_SECONDS
1200 - );
1201 -
1202 - set_transient( 'vigilante_2fa_user_token_' . $user_id, $token, HOUR_IN_SECONDS );
1203 -
1204 - $ip = $this->database->get_client_ip();
1205 - set_transient( 'vigilante_2fa_triggered_' . md5( $ip ), $user_id, 60 );
1206 -
1207 - if ( ! headers_sent() ) {
1208 - setcookie(
1209 - 'vigilante_2fa_token',
1210 - $token,
1211 - array(
1212 - 'expires' => time() + HOUR_IN_SECONDS,
1213 - 'path' => COOKIEPATH,
1214 - 'domain' => COOKIE_DOMAIN,
1215 - 'secure' => is_ssl(),
1216 - 'httponly' => true,
1217 - 'samesite' => 'Strict',
1218 - )
1219 - );
1220 - $_COOKIE['vigilante_2fa_token'] = $token;
1221 - }
1222 -
1223 - return $token;
1224 - }
1225 -
1226 - /**
1227 - * Get pending user ID from token
1228 - *
1229 - * @return int|false User ID or false.
1230 - */
1231 - private function get_pending_user_id() {
1232 - $token = '';
1233 -
1234 - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token is for session identification
1235 - if ( isset( $_POST['vigilante_2fa_token'] ) ) {
1236 - $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
1237 - } elseif ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
1238 - $token = sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) );
1239 - }
1240 -
1241 - if ( empty( $token ) ) {
1242 - return false;
1243 - }
1244 -
1245 - $pending = get_transient( 'vigilante_2fa_pending_' . $token );
1246 -
1247 - if ( ! $pending || ! isset( $pending['user_id'] ) ) {
1248 - return false;
1249 - }
1250 -
1251 - return absint( $pending['user_id'] );
1252 - }
1253 -
1254 - /**
1255 - * Clear pending verification
1256 - */
1257 - private function clear_pending_verification() {
1258 - $token = '';
1259 -
1260 - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token is for session identification
1261 - if ( isset( $_POST['vigilante_2fa_token'] ) ) {
1262 - $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
1263 - } elseif ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
1264 - $token = sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) );
1265 - }
1266 -
1267 - if ( ! empty( $token ) ) {
1268 - delete_transient( 'vigilante_2fa_pending_' . $token );
1269 - }
1270 -
1271 - if ( ! headers_sent() ) {
1272 - setcookie(
1273 - 'vigilante_2fa_token',
1274 - '',
1275 - array(
1276 - 'expires' => time() - HOUR_IN_SECONDS,
1277 - 'path' => COOKIEPATH,
1278 - 'domain' => COOKIE_DOMAIN,
1279 - 'secure' => is_ssl(),
1280 - 'httponly' => true,
1281 - 'samesite' => 'Strict',
1282 - )
1283 - );
1284 - }
1285 - }
1286 -
1287 1435 // =========================================================================
1288 - // Trusted devices (reuses database methods from email 2FA)
1289 - // =========================================================================
1290 -
1291 - /**
1292 - * Check if current device is trusted
1293 - *
1294 - * @param int $user_id User ID.
1295 - * @return bool
1296 - */
1297 - private function is_device_trusted( $user_id ) {
1298 - $device_hash = $this->generate_device_hash( $user_id );
1299 - return $this->database->is_device_trusted( $user_id, $device_hash );
1300 - }
1301 -
1302 - /**
1303 - * Trust the current device
1304 - *
1305 - * @param int $user_id User ID.
1306 - */
1307 - private function trust_device( $user_id ) {
1308 - $device_hash = $this->generate_device_hash( $user_id );
1309 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1310 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
1311 - $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $remember_days * DAY_IN_SECONDS ) );
1312 -
1313 - $this->database->trust_device( $user_id, $device_hash, $user_agent, $expires_at );
1314 - }
1315 -
1316 - /**
1317 - * Generate device hash (no IP for GDPR)
1318 - *
1319 - * @param int $user_id User ID.
1320 - * @return string
1321 - */
1322 - private function generate_device_hash( $user_id ) {
1323 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1324 - $salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'vigilante_fallback_salt';
1325 - return hash( 'sha256', $user_id . $user_agent . $salt );
1326 - }
1327 -
1328 - // =========================================================================
1329 - // =========================================================================
1330 1436 // Grace period admin notice and forced redirect
1331 1437 // =========================================================================
1332 1438
1333 1439 /**
@@ -1344,9 +1450,27 @@
1344 1450 }
1345 1451
1346 1452 $user = wp_get_current_user();
1347 1453
1348 - 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 ) ) {
1349 1473 return;
1350 1474 }
1351 1475
1352 1476 $totp_data = $this->database->get_totp_data( $user->ID );
@@ -1382,9 +1506,12 @@
1382 1506 */
1383 1507 public function show_grace_period_notice() {
1384 1508 $user = wp_get_current_user();
1385 1509
1386 - 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 ) ) {
1387 1514 return;
1388 1515 }
1389 1516
1390 1517 $totp_data = $this->database->get_totp_data( $user->ID );
@@ -1439,9 +1566,9 @@
1439 1566 public function reset_user_totp( $user_id ) {
1440 1567 $this->database->reset_totp_data( $user_id );
1441 1568
1442 1569 // If grace period is configured, set a new one
1443 - $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
1570 + $grace_days = absint( $this->policy()['grace_period_days'] ?? 3 );
1444 1571 if ( $grace_days > 0 ) {
1445 1572 $grace_expires = gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) );
1446 1573 $this->database->create_totp_placeholder( $user_id, $grace_expires );
1447 1574 }
@@ -1489,9 +1616,9 @@
1489 1616 * @return bool
1490 1617 */
1491 1618 public function send_activation_email( $user, $site_name, $from_name ) {
1492 1619 $profile_url = admin_url( 'profile.php' );
1493 - $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
1620 + $grace_days = absint( $this->policy()['grace_period_days'] ?? 3 );
1494 1621
1495 1622 $subject = sprintf(
1496 1623 /* translators: %s: Site name */
1497 1624 __( '[%s] Set up two-factor authentication for your account', 'vigilante' ),