assets
1 week ago
format
1 week ago
helpers
1 week ago
class-ajax-passkeys.php
1 week ago
class-api-register.php
1 week ago
class-api-signin.php
1 week ago
class-attestation-object.php
1 week ago
class-authenticate-server.php
1 week ago
class-authenticator-data.php
1 week ago
class-byte-buffer.php
1 week ago
class-chor-decoder.php
1 week ago
class-passkeys-endpoints.php
1 week ago
class-passkeys-user-profile.php
1 week ago
class-passkeys-wizard-steps.php
1 week ago
class-passkeys.php
1 week ago
class-pending-2fa-helper.php
1 week ago
class-source-repository.php
1 week ago
class-web-authn-exception.php
1 week ago
class-web-authn.php
1 week ago
index.php
1 week ago
class-pending-2fa-helper.php
225 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Helper for managing pending 2FA state after primary auth. |
| 4 | * |
| 5 | * @package wp-2fa |
| 6 | * @since 3.1.0 |
| 7 | * @copyright 2026 Melapress |
| 8 | * @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 |
| 9 | */ |
| 10 | |
| 11 | declare(strict_types=1); |
| 12 | |
| 13 | namespace WP2FA\Passkeys; |
| 14 | |
| 15 | use WP2FA\WP2FA; |
| 16 | use WP2FA\Authenticator\Login; |
| 17 | use WP2FA\Utils\Settings_Utils; |
| 18 | |
| 19 | // Exit if accessed directly. |
| 20 | defined( 'ABSPATH' ) || exit; |
| 21 | |
| 22 | if ( ! class_exists( '\\WP2FA\\Passkeys\\Pending_2FA_Helper' ) ) { |
| 23 | /** |
| 24 | * Pending 2FA state helper. |
| 25 | * |
| 26 | * Provides utilities to mark, query, clear, and optionally enforce |
| 27 | * a pending second factor step for a logged-in user. |
| 28 | * |
| 29 | * @since 3.1.0 |
| 30 | */ |
| 31 | class Pending_2FA_Helper { |
| 32 | /** |
| 33 | * Transient key prefix used to store pending 2FA state. |
| 34 | */ |
| 35 | public const TRANSIENT_PREFIX = 'wp_2fa_signin_pending_'; |
| 36 | |
| 37 | /** |
| 38 | * Get the transient key for a user. |
| 39 | * |
| 40 | * @param int $user_id User ID. |
| 41 | * |
| 42 | * @return string Transient key. |
| 43 | * |
| 44 | * @since 3.1.0 |
| 45 | */ |
| 46 | public static function key_for( int $user_id ): string { |
| 47 | return self::TRANSIENT_PREFIX . (string) $user_id; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Mark a user as pending 2FA. |
| 52 | * |
| 53 | * @param int $user_id User ID. |
| 54 | * @param array|null $payload Optional payload to persist (merged with defaults). |
| 55 | * @param int|null $ttl Optional TTL (seconds). Defaults to filtered value. |
| 56 | * |
| 57 | * @return bool True on success. |
| 58 | * |
| 59 | * @since 3.1.0 |
| 60 | */ |
| 61 | public static function mark_pending( int $user_id, ?array $payload = null, ?int $ttl = null ): bool { |
| 62 | if ( $user_id <= 0 ) { |
| 63 | return false; |
| 64 | } |
| 65 | |
| 66 | $ttl = (int) ( $ttl ?? \apply_filters( 'wp_2fa_signin_pending_ttl', 600, $user_id ) ); // default 10 minutes. |
| 67 | $ttl = max( 60, $ttl ); |
| 68 | |
| 69 | $client_ip = isset( $_SERVER['REMOTE_ADDR'] ) ? \sanitize_text_field( \wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : ''; |
| 70 | $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( \sanitize_text_field( \wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ), 0, 255 ) : ''; |
| 71 | |
| 72 | $defaults = array( |
| 73 | 'uid' => $user_id, |
| 74 | 'iat' => time(), |
| 75 | 'source' => 'unknown', |
| 76 | 'ip' => $client_ip, |
| 77 | 'ua' => $user_agent, |
| 78 | ); |
| 79 | $data = is_array( $payload ) ? array_merge( $defaults, $payload ) : $defaults; |
| 80 | |
| 81 | return (bool) \set_site_transient( self::key_for( $user_id ), $data, $ttl ); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Get pending payload for a user. |
| 86 | * |
| 87 | * @param int $user_id User ID. |
| 88 | * |
| 89 | * @return array|null Pending payload array or null if none. |
| 90 | * |
| 91 | * @since 3.1.0 |
| 92 | */ |
| 93 | public static function get_pending( int $user_id ): ?array { |
| 94 | if ( $user_id <= 0 ) { |
| 95 | return null; |
| 96 | } |
| 97 | $value = \get_site_transient( self::key_for( $user_id ) ); |
| 98 | return is_array( $value ) ? $value : null; |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Check if a user has pending 2FA. |
| 103 | * |
| 104 | * @param int $user_id User ID. |
| 105 | * |
| 106 | * @return bool True if pending 2FA exists. |
| 107 | * |
| 108 | * @since 3.1.0 |
| 109 | */ |
| 110 | public static function has_pending( int $user_id ): bool { |
| 111 | return null !== self::get_pending( $user_id ); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Clear pending 2FA for a user. |
| 116 | * |
| 117 | * @param int $user_id User ID. |
| 118 | * |
| 119 | * @since 3.1.0 |
| 120 | */ |
| 121 | public static function clear_pending( int $user_id ): void { |
| 122 | if ( $user_id > 0 ) { |
| 123 | \delete_site_transient( self::key_for( $user_id ) ); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Enforce pending 2FA on normal page requests. |
| 129 | * |
| 130 | * - Skips REST and AJAX requests by default. |
| 131 | * - Skips if a filter reports current request is the challenge view. |
| 132 | * - Fires an action for custom enforcement or optionally redirects to a URL provided by filter. |
| 133 | * |
| 134 | * Filters: |
| 135 | * - `wp_2fa_is_challenge_request( bool $default )` -> bool |
| 136 | * - `wp_2fa_pending_redirect_url( string $default, int $user_id, array $payload )` -> string URL or empty for no redirect. |
| 137 | * |
| 138 | * @since 3.1.0 |
| 139 | */ |
| 140 | public static function enforce_on_request(): void { |
| 141 | // Skip during Cron, REST, or AJAX. |
| 142 | if ( defined( 'DOING_CRON' ) && DOING_CRON ) { |
| 143 | return; |
| 144 | } |
| 145 | if ( function_exists( 'wp_doing_ajax' ) && \wp_doing_ajax() ) { |
| 146 | return; |
| 147 | } |
| 148 | if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { |
| 149 | return; |
| 150 | } |
| 151 | // Extra REST guards for safety. |
| 152 | $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? \sanitize_text_field( \wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ) : ''; |
| 153 | if ( $request_uri && 0 === strpos( $request_uri, '/wp-json/' ) ) { |
| 154 | return; |
| 155 | } |
| 156 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Not processing form data; only routing context check. |
| 157 | $rest_route = isset( $_GET['rest_route'] ) ? \sanitize_text_field( \wp_unslash( (string) $_GET['rest_route'] ) ) : ''; |
| 158 | if ( $rest_route && 0 === strpos( $rest_route, '/' ) ) { |
| 159 | return; |
| 160 | } |
| 161 | |
| 162 | $user_id = (int) \get_current_user_id(); |
| 163 | if ( $user_id <= 0 ) { |
| 164 | return; |
| 165 | } |
| 166 | |
| 167 | $payload = self::get_pending( $user_id ); |
| 168 | if ( null === $payload ) { |
| 169 | return; |
| 170 | } |
| 171 | |
| 172 | $is_challenge = (bool) \apply_filters( 'wp_2fa_is_challenge_request', false ); |
| 173 | if ( $is_challenge ) { |
| 174 | return; |
| 175 | } |
| 176 | |
| 177 | $user = \get_user_by( 'id', $user_id ); |
| 178 | if ( ! $user ) { |
| 179 | return; |
| 180 | } |
| 181 | |
| 182 | // Allow implementers to handle enforcement (e.g., show challenge UI). |
| 183 | // Prefer redirect if a URL is provided; otherwise simulate login event and fire a custom action. |
| 184 | $redirect = (string) \apply_filters( 'wp_2fa_pending_redirect_url', '', $user_id, $payload ); |
| 185 | if ( ! empty( $redirect ) ) { |
| 186 | // Clear the pending flag before redirecting to avoid loops. |
| 187 | self::clear_pending( $user_id ); |
| 188 | \wp_safe_redirect( $redirect ); |
| 189 | exit; |
| 190 | } |
| 191 | |
| 192 | // @free:start |
| 193 | $skip_for_passkeys = 1; |
| 194 | // @free:end |
| 195 | |
| 196 | |
| 197 | // Clear the pending flag to prevent repeated triggers. |
| 198 | self::clear_pending( $user_id ); |
| 199 | |
| 200 | // If not skipping, call the plugin login handler to trigger the 2FA flow. |
| 201 | if ( ! $skip_for_passkeys ) { |
| 202 | $_REQUEST['redirect_to'] = $_SERVER['REQUEST_URI'] ?? ''; |
| 203 | $_REQUEST['redirect_to'] = \esc_url( $_REQUEST['redirect_to'] ); |
| 204 | Login::wp_login( $user->user_login, $user ); |
| 205 | } |
| 206 | |
| 207 | // Also fire a custom action for themes/plugins that prefer not to rely on wp_login here. |
| 208 | \do_action( 'wp_2fa_enforce_pending', $user_id, $payload ); |
| 209 | // Do not call core login hooks here; they are meant for actual login events |
| 210 | // and can interfere with REST and other flows when fired out of context. |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Add necessary hooks. |
| 215 | * |
| 216 | * @return void |
| 217 | * |
| 218 | * @since 3.1.0 |
| 219 | */ |
| 220 | public static function add_hooks(): void { |
| 221 | \add_action( 'wp_loaded', array( self::class, 'enforce_on_request' ), 9 ); |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 |