| @@ -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,11 +82,12 @@ | ||
| 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 | /** |
| 89 | 92 | * Constructor |
| 90 | 93 | * |
| @@ -120,8 +123,10 @@ | ||
| 120 | 123 | /** |
| 121 | 124 | * Initialize hooks |
| 122 | 125 | */ |
| 123 | 126 | private function init_hooks() { |
| 127 | + $this->init_session_hooks(); | |
| 128 | + | |
| 124 | 129 | // Intercept authentication |
| 125 | 130 | add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 ); |
| 126 | 131 | |
| 127 | 132 | // Handle TOTP verification form |
| @@ -157,14 +162,16 @@ | ||
| 157 | 162 | |
| 158 | 163 | /** |
| 159 | 164 | * Filter login errors to hide default messages during 2FA |
| 160 | 165 | * |
| 166 | + * Resolved from the pending token only; the lookup by IP address that used | |
| 167 | + * to live here leaked one user's pending state to another behind a proxy (S3). | |
| 168 | + * | |
| 161 | 169 | * @param string $errors Login error messages. |
| 162 | 170 | * @return string |
| 163 | 171 | */ |
| 164 | 172 | public function filter_login_errors( $errors ) { |
| 165 | - $ip = $this->database->get_client_ip(); | |
| 166 | - $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) ); | |
| 173 | + $user_id = $this->get_pending_user_id(); | |
| 167 | 174 | |
| 168 | 175 | if ( ! $user_id ) { |
| 169 | 176 | return $errors; |
| 170 | 177 | } |
| @@ -191,13 +198,30 @@ | ||
| 191 | 198 | if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) { |
| 192 | 199 | return $user; |
| 193 | 200 | } |
| 194 | 201 | |
| 195 | - // Skip if this is a 2FA verification request | |
| 196 | - if ( $this->is_2fa_verification_request() ) { | |
| 202 | + // An application password is a second factor of its own. The core | |
| 203 | + // action that flags it only fires when those were the credentials (S16). | |
| 204 | + if ( $this->authenticated_with_app_password( $user ) ) { | |
| 197 | 205 | return $user; |
| 198 | 206 | } |
| 199 | 207 | |
| 208 | + /* | |
| 209 | + * There is deliberately no "already verifying, let it through" shortcut | |
| 210 | + * here any more. Until 2.11.0 a request carrying action=vigilante_2fa, | |
| 211 | + * the form nonce and a pending token returned $user at this point, and | |
| 212 | + * all three are in the hands of whoever knows the password: the nonce | |
| 213 | + * is printed on the form served to the pending visitor, and the token is | |
| 214 | + * issued to that same visitor. wp-login.php never reached this filter | |
| 215 | + * with that action, because login_form_vigilante_2fa ends the request, | |
| 216 | + * but any other login form that calls wp_signon(), the WooCommerce one | |
| 217 | + * for instance, does reach it and completed the login without a second | |
| 218 | + * factor (S19, found in the 2.11.0 cross review and reproduced). The | |
| 219 | + * verification form authenticates on its own path, handle_2fa_form(), | |
| 220 | + * which never passes through wp_authenticate(): nothing legitimate | |
| 221 | + * needed the shortcut. | |
| 222 | + */ | |
| 223 | + | |
| 200 | 224 | // Check if user requires 2FA |
| 201 | 225 | if ( ! $this->user_requires_2fa( $user ) ) { |
| 202 | 226 | return $user; |
| 203 | 227 | } |
| @@ -225,9 +249,14 @@ | ||
| 225 | 249 | |
| 226 | 250 | return $user; |
| 227 | 251 | } |
| 228 | 252 | |
| 229 | - // TOTP is configured - require verification | |
| 253 | + // TOTP is configured - require verification. REST and XML-RPC have no | |
| 254 | + // form to show, so the login is refused without a pending session (S16). | |
| 255 | + if ( $this->is_api_request() ) { | |
| 256 | + return $this->api_requires_2fa_error(); | |
| 257 | + } | |
| 258 | + | |
| 230 | 259 | $this->set_pending_verification( $user->ID ); |
| 231 | 260 | |
| 232 | 261 | $this->log_event( 'totp_verification_requested', $user->ID, __( 'TOTP verification requested at login', 'vigilante' ) ); |
| 233 | 262 | |
| @@ -290,29 +319,41 @@ | ||
| 290 | 319 | return false; |
| 291 | 320 | } |
| 292 | 321 | |
| 293 | 322 | /** |
| 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 | 323 | * Handle 2FA verification form submission |
| 306 | 324 | */ |
| 307 | 325 | public function handle_2fa_form() { |
| 326 | + // The pending user is resolved first so that a failed nonce can be | |
| 327 | + // explained on the form and recorded (S15). Both failure paths end the | |
| 328 | + // request: a bare return would let wp-login.php fall through to its | |
| 329 | + // default case and call wp_signon(), completing the login without the | |
| 330 | + // second factor. | |
| 331 | + $user_id = $this->get_pending_user_id(); | |
| 332 | + | |
| 308 | 333 | if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) { |
| 309 | - return; | |
| 334 | + $this->handle_invalid_nonce( $user_id ); | |
| 310 | 335 | } |
| 311 | 336 | |
| 312 | - $user_id = $this->get_pending_user_id(); | |
| 337 | + if ( ! $user_id ) { | |
| 338 | + wp_safe_redirect( wp_login_url() ); | |
| 339 | + exit; | |
| 340 | + } | |
| 313 | 341 | |
| 314 | - if ( ! $user_id ) { | |
| 342 | + // Attempt limit per pending session (S2). Until 2.11.0 nothing counted | |
| 343 | + // here: the lockout only runs on the authenticate filter, which this | |
| 344 | + // form never passes through. The limit is checked before any code is | |
| 345 | + // verified so that a session past it costs nothing, since a backup | |
| 346 | + // code check alone is up to ten wp_check_password() calls. | |
| 347 | + $max_attempts = absint( $this->options['max_attempts'] ?? 3 ); | |
| 348 | + | |
| 349 | + if ( $max_attempts < 1 ) { | |
| 350 | + $max_attempts = 3; | |
| 351 | + } | |
| 352 | + | |
| 353 | + if ( $this->get_pending_attempts() >= $max_attempts ) { | |
| 354 | + $this->log_event( 'totp_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' ); | |
| 355 | + $this->clear_pending_verification(); | |
| 315 | 356 | wp_safe_redirect( wp_login_url() ); |
| 316 | 357 | exit; |
| 317 | 358 | } |
| 318 | 359 | |
| @@ -327,8 +368,9 @@ | ||
| 327 | 368 | $backup_result = $this->verify_backup_code( $user_id, $code ); |
| 328 | 369 | |
| 329 | 370 | if ( is_wp_error( $backup_result ) ) { |
| 330 | 371 | // Both failed |
| 372 | + $this->increment_pending_attempts(); | |
| 331 | 373 | set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 ); |
| 332 | 374 | wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) ); |
| 333 | 375 | exit; |
| 334 | 376 | } |
| @@ -339,10 +381,10 @@ | ||
| 339 | 381 | |
| 340 | 382 | // Verification successful |
| 341 | 383 | $this->clear_pending_verification(); |
| 342 | 384 | |
| 343 | - if ( $remember_device ) { | |
| 344 | - $this->trust_device( $user_id ); | |
| 385 | + // Trust device if requested (and if the option allows it, see trust_device) | |
| 386 | + if ( $remember_device && $this->trust_device( $user_id ) ) { | |
| 345 | 387 | $this->log_event( 'totp_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) ); |
| 346 | 388 | } |
| 347 | 389 | |
| 348 | 390 | $this->log_event( 'totp_verification_success', $user_id, __( 'TOTP verification successful', 'vigilante' ) ); |
| @@ -370,18 +412,18 @@ | ||
| 370 | 412 | return; |
| 371 | 413 | } |
| 372 | 414 | } |
| 373 | 415 | |
| 374 | - $user_id = $this->get_pending_user_id(); | |
| 416 | + // Only the visitor presenting the pending token gets the form. There is | |
| 417 | + // no fallback by IP address and no lookup of the token by user (S3). | |
| 418 | + $session = $this->get_pending_session(); | |
| 375 | 419 | |
| 376 | - if ( ! $user_id ) { | |
| 377 | - $ip = $this->database->get_client_ip(); | |
| 378 | - $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) ); | |
| 420 | + if ( ! $session ) { | |
| 421 | + return; | |
| 379 | 422 | } |
| 380 | 423 | |
| 381 | - if ( ! $user_id ) { | |
| 382 | - return; | |
| 383 | - } | |
| 424 | + $user_id = $session['user_id']; | |
| 425 | + $token = $session['token']; | |
| 384 | 426 | |
| 385 | 427 | // Only show if user has TOTP configured |
| 386 | 428 | $totp_data = $this->database->get_totp_data( $user_id ); |
| 387 | 429 | if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) { |
| @@ -387,13 +429,8 @@ | ||
| 387 | 429 | if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) { |
| 388 | 430 | return; |
| 389 | 431 | } |
| 390 | 432 | |
| 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 | 433 | $error = get_transient( 'vigilante_2fa_error_' . $user_id ); |
| 397 | 434 | delete_transient( 'vigilante_2fa_error_' . $user_id ); |
| 398 | 435 | |
| 399 | 436 | $remember_days = absint( $this->options['remember_device_days'] ?? 30 ); |
| @@ -713,14 +750,19 @@ | ||
| 713 | 750 | /** |
| 714 | 751 | * Encrypt TOTP secret for database storage |
| 715 | 752 | * |
| 716 | 753 | * @param string $secret Plain Base32 secret. |
| 717 | - * @return string Encrypted string (base64). | |
| 754 | + * @return string Encrypted string (base64), or empty string without a key. | |
| 718 | 755 | */ |
| 719 | 756 | public function encrypt_secret( $secret ) { |
| 720 | 757 | $key = $this->get_encryption_key(); |
| 721 | - $iv = openssl_random_pseudo_bytes( 16 ); | |
| 722 | 758 | |
| 759 | + if ( '' === $key ) { | |
| 760 | + return ''; | |
| 761 | + } | |
| 762 | + | |
| 763 | + $iv = openssl_random_pseudo_bytes( 16 ); | |
| 764 | + | |
| 723 | 765 | $encrypted = openssl_encrypt( $secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); |
| 724 | 766 | |
| 725 | 767 | if ( false === $encrypted ) { |
| 726 | 768 | return ''; |
| @@ -738,8 +780,12 @@ | ||
| 738 | 780 | */ |
| 739 | 781 | public function decrypt_secret( $encrypted ) { |
| 740 | 782 | $key = $this->get_encryption_key(); |
| 741 | 783 | |
| 784 | + if ( '' === $key ) { | |
| 785 | + return false; | |
| 786 | + } | |
| 787 | + | |
| 742 | 788 | // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required for binary data retrieval |
| 743 | 789 | $data = base64_decode( $encrypted, true ); |
| 744 | 790 | |
| 745 | 791 | if ( false === $data || strlen( $data ) < 17 ) { |
| @@ -754,21 +800,37 @@ | ||
| 754 | 800 | return ( false !== $decrypted ) ? $decrypted : false; |
| 755 | 801 | } |
| 756 | 802 | |
| 757 | 803 | /** |
| 804 | + * Whether the site defines the key the authenticator secret is encrypted with | |
| 805 | + * | |
| 806 | + * @return bool | |
| 807 | + */ | |
| 808 | + public function has_encryption_key() { | |
| 809 | + return defined( 'AUTH_KEY' ) | |
| 810 | + && is_string( AUTH_KEY ) | |
| 811 | + && '' !== AUTH_KEY | |
| 812 | + && 'put your unique phrase here' !== AUTH_KEY; | |
| 813 | + } | |
| 814 | + | |
| 815 | + /** | |
| 758 | 816 | * Get encryption key derived from WordPress salts |
| 759 | 817 | * |
| 760 | - * @return string 32-byte key. | |
| 818 | + * Until 2.11.0 a site without AUTH_KEY fell back to a literal written in | |
| 819 | + * this file, which gave every such site the same key and made the | |
| 820 | + * encryption cosmetic (S13). Without AUTH_KEY there is no key: setup | |
| 821 | + * refuses and says why, and nothing is encrypted with a known value. | |
| 822 | + * | |
| 823 | + * @return string 32-byte key, or empty string when the site has none. | |
| 761 | 824 | */ |
| 762 | 825 | private function get_encryption_key() { |
| 763 | - $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'vigilante_fallback_key'; | |
| 764 | - return hash( 'sha256', $salt . 'vigilante_totp', true ); | |
| 826 | + if ( ! $this->has_encryption_key() ) { | |
| 827 | + return ''; | |
| 828 | + } | |
| 829 | + | |
| 830 | + return hash( 'sha256', AUTH_KEY . 'vigilante_totp', true ); | |
| 765 | 831 | } |
| 766 | 832 | |
| 767 | - // ========================================================================= | |
| 768 | - // Base32 encoding/decoding | |
| 769 | - // ========================================================================= | |
| 770 | - | |
| 771 | 833 | /** |
| 772 | 834 | * Base32 encode |
| 773 | 835 | * |
| 774 | 836 | * @param string $data Raw binary data. |
| @@ -1080,9 +1142,12 @@ | ||
| 1080 | 1142 | $secret = isset( $_POST['secret'] ) ? sanitize_text_field( wp_unslash( $_POST['secret'] ) ) : ''; |
| 1081 | 1143 | $reconfig = ! empty( $_POST['reconfigure'] ); |
| 1082 | 1144 | |
| 1083 | 1145 | // Permission check: user can only set up their own, unless admin |
| 1084 | - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) { | |
| 1146 | + // edit_user, not manage_options: on a network every subsite administrator | |
| 1147 | + // holds manage_options, and map_meta_cap denies edit_user against a user | |
| 1148 | + // they do not administer. On a single site an administrator still passes. | |
| 1149 | + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) { | |
| 1085 | 1150 | wp_send_json_error( __( 'Permission denied.', 'vigilante' ) ); |
| 1086 | 1151 | } |
| 1087 | 1152 | |
| 1088 | 1153 | if ( empty( $code ) || ! preg_match( '/^[0-9]{6}$/', $code ) ) { |
| @@ -1103,8 +1168,12 @@ | ||
| 1103 | 1168 | if ( ! in_array( $code, $expected_codes, true ) ) { |
| 1104 | 1169 | wp_send_json_error( __( 'Invalid code. Make sure your authenticator app is set up correctly and the time is synchronized.', 'vigilante' ) ); |
| 1105 | 1170 | } |
| 1106 | 1171 | |
| 1172 | + if ( ! $this->has_encryption_key() ) { | |
| 1173 | + 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' ) ); | |
| 1174 | + } | |
| 1175 | + | |
| 1107 | 1176 | // Encrypt and store secret |
| 1108 | 1177 | $encrypted = $this->encrypt_secret( $secret ); |
| 1109 | 1178 | |
| 1110 | 1179 | if ( empty( $encrypted ) ) { |
| @@ -1139,9 +1208,12 @@ | ||
| 1139 | 1208 | if ( 0 === $user_id ) { |
| 1140 | 1209 | $user_id = get_current_user_id(); |
| 1141 | 1210 | } |
| 1142 | 1211 | |
| 1143 | - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) { | |
| 1212 | + // edit_user, not manage_options: on a network every subsite administrator | |
| 1213 | + // holds manage_options, and map_meta_cap denies edit_user against a user | |
| 1214 | + // they do not administer. On a single site an administrator still passes. | |
| 1215 | + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) { | |
| 1144 | 1216 | wp_send_json_error( __( 'Permission denied.', 'vigilante' ) ); |
| 1145 | 1217 | } |
| 1146 | 1218 | |
| 1147 | 1219 | $totp_data = $this->database->get_totp_data( $user_id ); |
| @@ -1167,9 +1239,12 @@ | ||
| 1167 | 1239 | if ( 0 === $user_id ) { |
| 1168 | 1240 | $user_id = get_current_user_id(); |
| 1169 | 1241 | } |
| 1170 | 1242 | |
| 1171 | - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) { | |
| 1243 | + // edit_user, not manage_options: on a network every subsite administrator | |
| 1244 | + // holds manage_options, and map_meta_cap denies edit_user against a user | |
| 1245 | + // they do not administer. On a single site an administrator still passes. | |
| 1246 | + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) { | |
| 1172 | 1247 | wp_send_json_error( __( 'Permission denied.', 'vigilante' ) ); |
| 1173 | 1248 | } |
| 1174 | 1249 | |
| 1175 | 1250 | $this->reset_user_totp( $user_id ); |
| @@ -1178,155 +1253,8 @@ | ||
| 1178 | 1253 | 'message' => __( 'TOTP has been reset. You can now set up a new authenticator.', 'vigilante' ), |
| 1179 | 1254 | ) ); |
| 1180 | 1255 | } |
| 1181 | 1256 | |
| 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 | - // ========================================================================= | |
| 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 | 1257 | // ========================================================================= |
| 1330 | 1258 | // Grace period admin notice and forced redirect |
| 1331 | 1259 | // ========================================================================= |
| 1332 | 1260 | |