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