PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 All 87 releases
vigilante / includes / trait-two-factor-session.php

trait-two-factor-session.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.8, at includes/trait-two-factor-session.php

461 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared two-factor session and trusted-device logic
4 *
5 * Used by Vigilante_Two_Factor_Email and Vigilante_Two_Factor_TOTP. Until
6 * 2.11.0 both classes carried their own copy of this code, byte for byte,
7 * which is how the trusted-device check kept identifying a browser by its
8 * User-Agent in two places at once (S1 of the 28 Aug 2026 audit).
9 *
10 * The using class must provide $this->database (Vigilante_Database),
11 * $this->options (the two_factor settings array) and log_event().
12 *
13 * @package Vigilante
14 * @since 2.11.0
15 */
16
17 // Prevent direct access
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Trait Vigilante_Two_Factor_Session
24 */
25 trait Vigilante_Two_Factor_Session {
26
27 /**
28 * User ID authenticated through an application password in this request, or 0.
29 *
30 * Set by the core action application_password_did_authenticate, which only
31 * fires when the credentials were an application password. That is a second
32 * factor of its own, so the interactive verification does not apply (S16).
33 *
34 * @var int
35 */
36 private $app_password_user_id = 0;
37
38 // =========================================================================
39 // Names
40 // =========================================================================
41
42 /**
43 * Cookie carrying the pending-verification token.
44 *
45 * @return string
46 */
47 private function pending_cookie_name() {
48 return 'vigilante_2fa_token';
49 }
50
51 /**
52 * Cookie carrying the trusted-device secret.
53 *
54 * @return string
55 */
56 private function device_cookie_name() {
57 return 'vigilante_2fa_device';
58 }
59
60 // =========================================================================
61 // Request context (S16)
62 // =========================================================================
63
64 /**
65 * Register the hook that flags application-password logins.
66 *
67 * Called from the module's init_hooks().
68 */
69 protected function init_session_hooks() {
70 add_action( 'application_password_did_authenticate', array( $this, 'remember_app_password_user' ) );
71 }
72
73 /**
74 * Remember which user authenticated with an application password.
75 *
76 * @param WP_User $user Authenticated user.
77 */
78 public function remember_app_password_user( $user ) {
79 if ( $user instanceof WP_User ) {
80 $this->app_password_user_id = (int) $user->ID;
81 }
82 }
83
84 /**
85 * Whether this user was authenticated with an application password in this request.
86 *
87 * @param WP_User|mixed $user User being authenticated.
88 * @return bool
89 */
90 private function authenticated_with_app_password( $user ) {
91 return $user instanceof WP_User
92 && $this->app_password_user_id > 0
93 && (int) $user->ID === $this->app_password_user_id;
94 }
95
96 /**
97 * Whether the request comes through REST or XML-RPC, where no form can be shown.
98 *
99 * @return bool
100 */
101 private function is_api_request() {
102 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
103 return true;
104 }
105
106 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
107 return true;
108 }
109
110 return false;
111 }
112
113 /**
114 * The error returned to an API login that still needs its second factor.
115 *
116 * No pending session is created and no code is sent: a connector that
117 * retries with the main password used to trigger one email per attempt.
118 *
119 * @return WP_Error
120 */
121 private function api_requires_2fa_error() {
122 return new WP_Error(
123 'vigilante_2fa_required',
124 __( 'This account requires two-factor authentication. Log in from a browser, or use an application password for API access.', 'vigilante' )
125 );
126 }
127
128 // =========================================================================
129 // Pending verification session (S3, S15)
130 // =========================================================================
131
132 /**
133 * Set pending verification state
134 *
135 * The token travels only in an HttpOnly cookie (and in the hidden field of
136 * the form the cookie holder is shown). There is no lookup by IP address:
137 * behind a proxy or a CDN that used to hand one user's pending session to
138 * whoever shared the apparent address (S3).
139 *
140 * @param int $user_id User ID.
141 * @return string Token for the pending session
142 */
143 private function set_pending_verification( $user_id ) {
144 $user_id = absint( $user_id );
145 $token = $this->get_existing_token_for_user( $user_id );
146 $data = $token ? get_transient( 'vigilante_2fa_pending_' . $token ) : false;
147
148 if ( ! $token ) {
149 $token = wp_generate_password( 32, false );
150 }
151
152 // The attempt counter survives a fresh password login within the hour,
153 // so re-authenticating does not reset it (S2).
154 $attempts = ( is_array( $data ) && isset( $data['attempts'] ) ) ? absint( $data['attempts'] ) : 0;
155
156 set_transient(
157 'vigilante_2fa_pending_' . $token,
158 array(
159 'user_id' => $user_id,
160 'created_at' => time(),
161 'attempts' => $attempts,
162 ),
163 HOUR_IN_SECONDS
164 );
165
166 // Reverse lookup (user_id -> token), used only to reuse the token on a
167 // repeated password login. It is never handed to a visitor.
168 set_transient( 'vigilante_2fa_user_token_' . $user_id, $token, HOUR_IN_SECONDS );
169
170 $this->set_cookie( $this->pending_cookie_name(), $token, time() + HOUR_IN_SECONDS, 'Strict' );
171
172 // Make the token available in the current request.
173 $_COOKIE[ $this->pending_cookie_name() ] = $token;
174
175 return $token;
176 }
177
178 /**
179 * Get the existing pending token for a user if still valid
180 *
181 * @param int $user_id User ID.
182 * @return string|false Token or false if not found
183 */
184 private function get_existing_token_for_user( $user_id ) {
185 $token = get_transient( 'vigilante_2fa_user_token_' . absint( $user_id ) );
186
187 if ( ! $token ) {
188 return false;
189 }
190
191 $data = get_transient( 'vigilante_2fa_pending_' . $token );
192
193 if ( ! is_array( $data ) || empty( $data['user_id'] ) || absint( $data['user_id'] ) !== absint( $user_id ) ) {
194 return false;
195 }
196
197 return $token;
198 }
199
200 /**
201 * The pending token presented by this request, from the form or the cookie.
202 *
203 * @return string Token or empty string.
204 */
205 private function get_pending_token() {
206 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Session token, not form data: the verification form nonce is checked in handle_2fa_form() before anything acts on it.
207 if ( isset( $_POST['vigilante_2fa_token'] ) ) {
208 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Same token as the line above.
209 return sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) );
210 }
211
212 if ( isset( $_COOKIE[ $this->pending_cookie_name() ] ) ) {
213 return sanitize_text_field( wp_unslash( $_COOKIE[ $this->pending_cookie_name() ] ) );
214 }
215
216 return '';
217 }
218
219 /**
220 * The pending session presented by this request.
221 *
222 * @return array|false Session data (user_id, created_at, attempts, token) or false.
223 */
224 private function get_pending_session() {
225 $token = $this->get_pending_token();
226
227 if ( '' === $token ) {
228 return false;
229 }
230
231 $data = get_transient( 'vigilante_2fa_pending_' . $token );
232
233 if ( ! is_array( $data ) || empty( $data['user_id'] ) ) {
234 return false;
235 }
236
237 $data['user_id'] = absint( $data['user_id'] );
238 $data['attempts'] = isset( $data['attempts'] ) ? absint( $data['attempts'] ) : 0;
239 $data['token'] = $token;
240
241 return $data;
242 }
243
244 /**
245 * Get pending verification user ID
246 *
247 * @return int|false User ID or false if not pending
248 */
249 private function get_pending_user_id() {
250 $session = $this->get_pending_session();
251
252 return $session ? $session['user_id'] : false;
253 }
254
255 /**
256 * Failed attempts recorded on the pending session presented by this request.
257 *
258 * @return int
259 */
260 private function get_pending_attempts() {
261 $session = $this->get_pending_session();
262
263 return $session ? $session['attempts'] : 0;
264 }
265
266 /**
267 * Record one more failed attempt on the pending session.
268 *
269 * @return int Attempts after the increment, or 0 if there is no session.
270 */
271 private function increment_pending_attempts() {
272 $session = $this->get_pending_session();
273
274 if ( ! $session ) {
275 return 0;
276 }
277
278 $token = $session['token'];
279 unset( $session['token'] );
280 $session['attempts']++;
281
282 set_transient( 'vigilante_2fa_pending_' . $token, $session, HOUR_IN_SECONDS );
283
284 return $session['attempts'];
285 }
286
287 /**
288 * Clear pending verification
289 */
290 private function clear_pending_verification() {
291 $token = $this->get_pending_token();
292
293 if ( '' !== $token ) {
294 $data = get_transient( 'vigilante_2fa_pending_' . $token );
295
296 if ( is_array( $data ) && ! empty( $data['user_id'] ) ) {
297 delete_transient( 'vigilante_2fa_user_token_' . absint( $data['user_id'] ) );
298 }
299
300 delete_transient( 'vigilante_2fa_pending_' . $token );
301 }
302
303 $this->set_cookie( $this->pending_cookie_name(), '', time() - YEAR_IN_SECONDS, 'Strict' );
304 unset( $_COOKIE[ $this->pending_cookie_name() ] );
305 }
306
307 /**
308 * End a form submission whose nonce did not verify (S15).
309 *
310 * Until 2.11.0 this redirected to the login screen with no message and no
311 * record; the pending cookie was still set, so the form came back with no
312 * explanation. Now the holder of a pending session sees why and the
313 * attempt is logged. Either way the request ends here: a bare return would
314 * let wp-login.php fall through to wp_signon() without the second factor.
315 *
316 * @param int|false $user_id Pending user, if any.
317 */
318 private function handle_invalid_nonce( $user_id ) {
319 if ( $user_id ) {
320 set_transient(
321 'vigilante_2fa_error_' . $user_id,
322 __( 'The verification form expired. Please try again.', 'vigilante' ),
323 60
324 );
325
326 $this->log_event( '2fa_nonce_failed', $user_id, __( 'Verification form submitted with an invalid or expired nonce', 'vigilante' ), 'warning' );
327
328 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
329 exit;
330 }
331
332 wp_safe_redirect( wp_login_url() );
333 exit;
334 }
335
336 // =========================================================================
337 // Trusted devices (S1, S4)
338 // =========================================================================
339
340 /**
341 * Check if the current device is trusted
342 *
343 * The device presents a random secret from an HttpOnly cookie and only its
344 * SHA-256 is stored. Until 2.11.0 the identity was a hash of the User-Agent,
345 * so anyone with the password who reproduced the browser string skipped the
346 * second factor (S1). The option is enforced here as well: with it off no
347 * stored row is honoured, whatever the form sent (S4).
348 *
349 * @param int $user_id User ID.
350 * @return bool
351 */
352 private function is_device_trusted( $user_id ) {
353 if ( empty( $this->options['allow_remember_device'] ) ) {
354 return false;
355 }
356
357 $token = $this->present_device_token();
358
359 if ( '' === $token ) {
360 return false;
361 }
362
363 return $this->database->is_device_trusted( absint( $user_id ), hash( 'sha256', $token ) );
364 }
365
366 /**
367 * Trust the current device
368 *
369 * Ignored silently when the option is off: a cached form may still send
370 * the checkbox, and that is no reason to refuse the login (S4).
371 *
372 * @param int $user_id User ID.
373 * @return bool True if a device row was written.
374 */
375 private function trust_device( $user_id ) {
376 if ( empty( $this->options['allow_remember_device'] ) ) {
377 return false;
378 }
379
380 try {
381 $token = bin2hex( random_bytes( 32 ) );
382 } catch ( Exception $e ) {
383 return false;
384 }
385
386 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
387 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
388
389 if ( $remember_days < 1 ) {
390 $remember_days = 30;
391 }
392
393 $expires = time() + ( $remember_days * DAY_IN_SECONDS );
394
395 // The User-Agent is kept as a label for the device list only; it plays
396 // no part in recognising the device.
397 $written = $this->database->trust_device(
398 absint( $user_id ),
399 hash( 'sha256', $token ),
400 $user_agent,
401 gmdate( 'Y-m-d H:i:s', $expires )
402 );
403
404 if ( ! $written ) {
405 return false;
406 }
407
408 // Lax, not Strict: the cookie has to travel on the GET that brings the
409 // user back to wp-login.php from another site.
410 $this->set_cookie( $this->device_cookie_name(), $token, $expires, 'Lax' );
411
412 return true;
413 }
414
415 /**
416 * The device secret presented by this request, if well formed.
417 *
418 * @return string 64 hex characters or empty string.
419 */
420 private function present_device_token() {
421 if ( ! isset( $_COOKIE[ $this->device_cookie_name() ] ) ) {
422 return '';
423 }
424
425 $token = sanitize_text_field( wp_unslash( $_COOKIE[ $this->device_cookie_name() ] ) );
426
427 return preg_match( '/^[0-9a-f]{64}$/', $token ) ? $token : '';
428 }
429
430 // =========================================================================
431 // Cookies
432 // =========================================================================
433
434 /**
435 * Set a plugin cookie with the attributes every 2FA cookie shares.
436 *
437 * @param string $name Cookie name.
438 * @param string $value Value (empty to clear).
439 * @param int $expires Expiry timestamp.
440 * @param string $samesite Lax or Strict.
441 */
442 private function set_cookie( $name, $value, $expires, $samesite ) {
443 if ( headers_sent() ) {
444 return;
445 }
446
447 setcookie(
448 $name,
449 $value,
450 array(
451 'expires' => $expires,
452 'path' => COOKIEPATH,
453 'domain' => COOKIE_DOMAIN,
454 'secure' => is_ssl(),
455 'httponly' => true,
456 'samesite' => $samesite,
457 )
458 );
459 }
460 }
461