| @@ -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' ), |