PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 4.0.0
WP 2FA – Two-factor authentication for WordPress v4.0.0
4.0.0 1.7.1 2.0.0 2.0.1 2.1.0 2.2.0 2.2.1 2.3.0 2.4.0 2.4.1 2.4.2 2.5.0 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.8.0 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0 3.0.1 3.1.0 3.1.1 3.1.1.2 trunk 1.2.0 1.3.0 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.6.0 1.6.1 1.6.2 1.7.0
wp-2fa / includes / classes / Admin / Methods / passkeys / class-pending-2fa-helper.php
wp-2fa / includes / classes / Admin / Methods / passkeys Last commit date
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