is_2fa_enabled_for_user( $user->ID ) ) { return $user; } // Generate a token for the 2FA session. $token = wp_generate_password( 32, false ); set_transient( 'wpsp_2fa_' . $token, array( 'user_id' => $user->ID, 'redirect' => isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url(), ), 5 * MINUTE_IN_SECONDS ); // Redirect to 2FA form immediately. wp_safe_redirect( add_query_arg( array( 'action' => 'wpsp_2fa', 'token' => $token, ), wp_login_url() ) ); exit; } /** * Enforce 2FA setup for administrators. */ public function enforce_admin_2fa() { // Skip AJAX requests. if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { return; } $user = wp_get_current_user(); // Only apply to administrators. if ( ! $user || ! user_can( $user, 'manage_options' ) ) { return; } // Skip if already has 2FA enabled. if ( $this->is_2fa_enabled_for_user( $user->ID ) ) { return; } // Allow access to profile page for setup. global $pagenow; if ( 'profile.php' === $pagenow || 'admin-ajax.php' === $pagenow ) { return; } // Redirect to profile page with notice. wp_safe_redirect( add_query_arg( 'wpsp_2fa_required', '1', admin_url( 'profile.php' ) ) ); exit; } /** * Check if we should render the 2FA form (for custom login URLs). */ public function maybe_render_2fa_form() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['action'] ) && 'wpsp_2fa' === $_GET['action'] ) { $this->render_2fa_form(); } } /** * Render 2FA verification form. */ public function render_2fa_form() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; if ( empty( $token ) ) { wp_safe_redirect( wp_login_url() ); exit; } $data = get_transient( 'wpsp_2fa_' . $token ); if ( ! $data ) { wp_safe_redirect( wp_login_url() ); exit; } $error = ''; // Handle form submission. // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( isset( $_POST['wpsp_2fa_code'] ) || isset( $_POST['wpsp_backup_code'] ) ) { // Check TOTP code first, then backup code. $code = ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! empty( $_POST['wpsp_2fa_code'] ) ) { $code = sanitize_text_field( wp_unslash( $_POST['wpsp_2fa_code'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing } elseif ( ! empty( $_POST['wpsp_backup_code'] ) ) { $code = sanitize_text_field( wp_unslash( $_POST['wpsp_backup_code'] ) ); } $user_id = $data['user_id']; if ( ! empty( $code ) && $this->verify_code( $user_id, $code ) ) { // Delete the token. delete_transient( 'wpsp_2fa_' . $token ); // Log the user in. wp_set_auth_cookie( $user_id, false ); wp_set_current_user( $user_id ); // Redirect. wp_safe_redirect( $data['redirect'] ); exit; } $error = __( 'Invalid verification code.', 'wp-security-pack' ); } // Render the form. login_header( __( 'Two-Factor Authentication', 'wp-security-pack' ) ); ?>

is_2fa_enabled_for_user( $user->ID ); // Show notice if 2FA is required. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['wpsp_2fa_required'] ) && ! $is_enabled ) : ?>

__( 'Permission denied.', 'wp-security-pack' ) ) ); } $secret = $this->generate_secret(); // Store temporarily (not enabled yet). update_user_meta( $user_id, self::SECRET_META_KEY . '_pending', $secret ); $user = get_user_by( 'id', $user_id ); $site = wp_parse_url( home_url(), PHP_URL_HOST ); // Generate otpauth URL for QR code. $otpauth = sprintf( 'otpauth://totp/%s:%s?secret=%s&issuer=%s', rawurlencode( $site ), rawurlencode( $user->user_email ), $secret, rawurlencode( $site ) ); wp_send_json_success( array( 'secret' => $secret, 'otpauth' => $otpauth, ) ); } /** * Verify 2FA setup via AJAX. */ public function ajax_verify_setup() { check_ajax_referer( 'wpsp_2fa_setup' ); $user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0; $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; if ( ! current_user_can( 'edit_user', $user_id ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); } $pending_secret = get_user_meta( $user_id, self::SECRET_META_KEY . '_pending', true ); if ( empty( $pending_secret ) ) { wp_send_json_error( array( 'message' => __( 'No pending setup found.', 'wp-security-pack' ) ) ); } // Verify the code. if ( ! $this->verify_totp( $pending_secret, $code ) ) { wp_send_json_error( array( 'message' => __( 'Invalid code. Please try again.', 'wp-security-pack' ) ) ); } // Enable 2FA. update_user_meta( $user_id, self::SECRET_META_KEY, $pending_secret ); update_user_meta( $user_id, self::ENABLED_META_KEY, '1' ); delete_user_meta( $user_id, self::SECRET_META_KEY . '_pending' ); // Generate backup codes - show plain to user, store hashed. $plain_codes = $this->generate_backup_codes(); $hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes ); update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes ); wp_send_json_success( array( 'message' => __( '2FA enabled successfully. Save your backup codes now - they will not be shown again!', 'wp-security-pack' ), 'backup_codes' => $plain_codes, 'show_codes' => true, ) ); } /** * Disable 2FA via AJAX. */ public function ajax_disable_2fa() { check_ajax_referer( 'wpsp_2fa_setup' ); $user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0; if ( ! current_user_can( 'edit_user', $user_id ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); } delete_user_meta( $user_id, self::SECRET_META_KEY ); delete_user_meta( $user_id, self::ENABLED_META_KEY ); delete_user_meta( $user_id, self::BACKUP_CODES_META_KEY ); wp_send_json_success( array( 'message' => __( '2FA disabled.', 'wp-security-pack' ) ) ); } /** * Regenerate backup codes via AJAX. */ public function ajax_regenerate_backup_codes() { check_ajax_referer( 'wpsp_2fa_setup' ); $user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0; if ( ! current_user_can( 'edit_user', $user_id ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'wp-security-pack' ) ) ); } // Generate new codes. $plain_codes = $this->generate_backup_codes(); // Hash them before storing. $hashed_codes = array_map( array( $this, 'hash_backup_code' ), $plain_codes ); update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, $hashed_codes ); // Return plain codes to show user ONCE. wp_send_json_success( array( 'codes' => $plain_codes, 'message' => __( 'Backup codes regenerated. Save these codes now - they will not be shown again.', 'wp-security-pack' ), ) ); } /** * Check if 2FA is enabled for a user. * * @param int $user_id User ID. * @return bool */ public function is_2fa_enabled_for_user( $user_id ) { return '1' === get_user_meta( $user_id, self::ENABLED_META_KEY, true ); } /** * Verify a code (TOTP or backup). * * @param int $user_id User ID. * @param string $code Code to verify. * @return bool */ public function verify_code( $user_id, $code ) { $code = preg_replace( '/\s+/', '', $code ); // Try TOTP first. $secret = get_user_meta( $user_id, self::SECRET_META_KEY, true ); if ( $this->verify_totp( $secret, $code ) ) { return true; } // Try backup code. return $this->verify_backup_code( $user_id, $code ); } /** * Verify TOTP code. * * @param string $secret Secret key. * @param string $code Code to verify. * @param int $window Time window tolerance. * @return bool */ private function verify_totp( $secret, $code, $window = 1 ) { if ( empty( $secret ) || empty( $code ) ) { return false; } $time = floor( time() / self::TIME_STEP ); for ( $i = -$window; $i <= $window; $i++ ) { $calculated = $this->calculate_totp( $secret, $time + $i ); if ( hash_equals( $calculated, $code ) ) { return true; } } return false; } /** * Calculate TOTP code. * * @param string $secret Secret key. * @param int $time Time counter. * @return string */ private function calculate_totp( $secret, $time ) { // Decode base32 secret. $secret_decoded = $this->base32_decode( $secret ); // Pack time. $time_packed = pack( 'N*', 0, $time ); // Calculate HMAC. $hash = hash_hmac( 'sha1', $time_packed, $secret_decoded, true ); // Dynamic truncation. $offset = ord( substr( $hash, -1 ) ) & 0x0F; $code = ( ord( $hash[ $offset ] ) & 0x7F ) << 24; $code |= ( ord( $hash[ $offset + 1 ] ) & 0xFF ) << 16; $code |= ( ord( $hash[ $offset + 2 ] ) & 0xFF ) << 8; $code |= ( ord( $hash[ $offset + 3 ] ) & 0xFF ); $code = $code % pow( 10, self::CODE_LENGTH ); return str_pad( $code, self::CODE_LENGTH, '0', STR_PAD_LEFT ); } /** * Generate secret key. * * @param int $length Length in bytes (16 = 26 base32 chars). * @return string */ private function generate_secret( $length = 16 ) { $random = wp_generate_password( $length, false, false ); return $this->base32_encode( $random ); } /** * Generate backup codes. * * @param int $count Number of codes. * @return array */ private function generate_backup_codes( $count = 10 ) { $codes = array(); for ( $i = 0; $i < $count; $i++ ) { $codes[] = strtoupper( wp_generate_password( 8, false, false ) ); } return $codes; } /** * Get backup codes count for user (hashed codes stored). * * @param int $user_id User ID. * @return int */ public function get_backup_codes_count( $user_id ) { $codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true ); return is_array( $codes ) ? count( $codes ) : 0; } /** * Get backup codes for user (internal use only). * * @param int $user_id User ID. * @return array */ private function get_backup_codes( $user_id ) { $codes = get_user_meta( $user_id, self::BACKUP_CODES_META_KEY, true ); return is_array( $codes ) ? $codes : array(); } /** * Hash a backup code for secure storage. * * @param string $code Plain backup code. * @return string Hashed code. */ private function hash_backup_code( $code ) { return wp_hash( strtoupper( $code ) ); } /** * Verify and consume a backup code. * * @param int $user_id User ID. * @param string $code Backup code. * @return bool */ private function verify_backup_code( $user_id, $code ) { $stored_codes = $this->get_backup_codes( $user_id ); $code = strtoupper( preg_replace( '/[^A-Z0-9]/', '', $code ) ); $hashed_input = $this->hash_backup_code( $code ); // Check against hashed codes. foreach ( $stored_codes as $index => $stored_code ) { // Support both hashed (new) and plain (legacy) codes. if ( hash_equals( $stored_code, $hashed_input ) || hash_equals( $stored_code, $code ) ) { // Remove used code. unset( $stored_codes[ $index ] ); update_user_meta( $user_id, self::BACKUP_CODES_META_KEY, array_values( $stored_codes ) ); return true; } } return false; } /** * Base32 encode. * * @param string $data Data to encode. * @return string */ private function base32_encode( $data ) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $binary = ''; $encoded = ''; foreach ( str_split( $data ) as $char ) { $binary .= str_pad( decbin( ord( $char ) ), 8, '0', STR_PAD_LEFT ); } $chunks = str_split( $binary, 5 ); foreach ( $chunks as $chunk ) { $chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT ); $encoded .= $alphabet[ bindec( $chunk ) ]; } return $encoded; } /** * Base32 decode. * * @param string $data Data to decode. * @return string */ private function base32_decode( $data ) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $data = strtoupper( $data ); $binary = ''; $decoded = ''; foreach ( str_split( $data ) as $char ) { $pos = strpos( $alphabet, $char ); if ( false !== $pos ) { $binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT ); } } $chunks = str_split( $binary, 8 ); foreach ( $chunks as $chunk ) { if ( strlen( $chunk ) === 8 ) { $decoded .= chr( bindec( $chunk ) ); } } return $decoded; } }