PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
3.0.0 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 All 88 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.12, at includes/trait-two-factor-session.php

669 lines 24.0 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->policy() (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 * The second factor mechanics in force, resolved once per request
29 *
30 * The method, the expiry and the grace period come from the main site on a
31 * network, so they are the same wherever the login arrives. Read here and not
32 * in the constructor because the constructors run on init on EVERY request of
33 * every site: reading the main site's settings there meant a switch_to_blog()
34 * and the whole autoloaded option set of the main site on every front page
35 * view of every subsite, measured at 318 rows and 89 KB by the third cross
36 * review of 2.11.10. Nothing outside a login needs this value.
37 *
38 * Whether an account NEEDS a second factor, and which class asks for it, are
39 * separate questions with their own answers: two_factor_required_for() and
40 * two_factor_handler_for().
41 *
42 * @since 2.11.10
43 *
44 * @return array
45 */
46 protected function policy() {
47 if ( null === $this->options ) {
48 $this->options = Vigilante_Settings::two_factor_policy();
49 }
50
51 return $this->options;
52 }
53
54 /**
55 * Whether this class is the one that must ask this account for its factor
56 *
57 * @since 2.11.10
58 * @since 2.11.11 The enrolment can be passed in by a caller that has just read it.
59 *
60 * @param WP_User $user User being authenticated.
61 * @param string $method Method this class implements, 'email' or 'totp'.
62 * @param bool|null $enrolled Whether the account has a TOTP enrolment, when the
63 * caller already read its row. On a network that read
64 * can search every site the account belongs to, and
65 * the dashboard hooks run on every screen.
66 * @return bool
67 */
68 protected function handles_second_factor( $user, $method, $enrolled = null ) {
69 if ( null === $enrolled ) {
70 $enrolled = $this->database && method_exists( $this->database, 'has_totp_enrolment' )
71 ? $this->database->has_totp_enrolment( $user->ID )
72 : false;
73 }
74
75 return ( $method === Vigilante_Settings::two_factor_handler_for( $user, $enrolled ) );
76 }
77
78 /**
79 * Whether the verification pending in this request belongs to this class
80 *
81 * Both second factor classes hang off login_form_vigilante_2fa and login_form
82 * since 2.11.10, so without this the two of them printed a form on the same
83 * page and both tried to verify the same code. Measured as "the second factor
84 * is asked for twice" by the release matrix. The same election as the
85 * authenticate filter, so a given pending session is handled start to finish
86 * by one class.
87 *
88 * @since 2.11.10
89 *
90 * @param string $method Method this class implements, 'email' or 'totp'.
91 * @return bool True also when there is nothing pending, so each class goes on
92 * applying its own rules.
93 */
94 protected function pending_belongs_to( $method ) {
95 $user_id = $this->get_pending_user_id();
96
97 if ( ! $user_id ) {
98 return true;
99 }
100
101 $user = get_userdata( $user_id );
102
103 return $user ? $this->handles_second_factor( $user, $method ) : true;
104 }
105
106 /**
107 * User ID authenticated through an application password in this request, or 0.
108 *
109 * Set by the core action application_password_did_authenticate, which only
110 * fires when the credentials were an application password. That is a second
111 * factor of its own, so the interactive verification does not apply (S16).
112 *
113 * @var int
114 */
115 private $app_password_user_id = 0;
116
117 // =========================================================================
118 // Names
119 // =========================================================================
120
121 /**
122 * Cookie carrying the pending-verification token.
123 *
124 * @return string
125 */
126 private function pending_cookie_name() {
127 return 'vigilante_2fa_token';
128 }
129
130 /**
131 * Cookie carrying the trusted-device secret.
132 *
133 * @return string
134 */
135 private function device_cookie_name() {
136 return 'vigilante_2fa_device';
137 }
138
139 // =========================================================================
140 // Request context (S16)
141 // =========================================================================
142
143 /**
144 * Register the hook that flags application-password logins.
145 *
146 * Called from the module's init_hooks().
147 */
148 protected function init_session_hooks() {
149 add_action( 'application_password_did_authenticate', array( $this, 'remember_app_password_user' ) );
150
151 // Why the verification session ended, explained on the login screen it
152 // sends the visitor back to. Both classes use the trait, so both
153 // register this; the notice itself prints once (see the method).
154 add_filter( 'login_message', array( $this, 'show_2fa_session_notice' ) );
155 }
156
157 /**
158 * Explain on the login screen why a verification session ended
159 *
160 * Until 2.11.12 running out of verification attempts cleared the pending
161 * session and redirected to wp-login.php with no message at all: the visitor
162 * was back at the password form with no idea why, typed the password again,
163 * and that correct password was counted as one more failed login.
164 *
165 * The query argument only picks one of the literal strings below. It decides
166 * nothing and it is not trusted for anything (rule 21): anyone can add it to
167 * a URL, and all it can produce is one of these notices on a login screen.
168 *
169 * @since 2.11.12
170 *
171 * @param string $message Login screen message so far.
172 * @return string
173 */
174 public function show_2fa_session_notice( $message ) {
175 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display of a static notice on the login screen; nothing is decided or written.
176 $notice = isset( $_GET['vigilante_2fa_notice'] ) ? sanitize_key( wp_unslash( $_GET['vigilante_2fa_notice'] ) ) : '';
177
178 if ( '' === $notice ) {
179 return $message;
180 }
181
182 // Both two-factor classes use this trait and both register the filter,
183 // so without this the notice would print twice on a site that has them
184 // both loaded. A trait gives each using class its own statics, hence the
185 // prefixed global rather than a static property.
186 if ( ! empty( $GLOBALS['vigilante_2fa_notice_printed'] ) ) {
187 return $message;
188 }
189
190 $texts = array(
191 'attempts' => __( 'Too many incorrect verification codes. The verification session was closed for security. Log in again to start a new one.', 'vigilante' ),
192 'expired' => __( 'The verification session expired. Log in again to start a new one.', 'vigilante' ),
193 );
194
195 if ( ! isset( $texts[ $notice ] ) ) {
196 return $message;
197 }
198
199 $GLOBALS['vigilante_2fa_notice_printed'] = true;
200
201 return $message . '<div id="login_error" class="notice notice-error"><p>' . esc_html( $texts[ $notice ] ) . '</p></div>';
202 }
203
204 /**
205 * Send the visitor back to the login screen with an explanation
206 *
207 * @since 2.11.12
208 *
209 * @param string $notice One of the keys of show_2fa_session_notice().
210 * @return void
211 */
212 private function redirect_to_login_with_notice( $notice ) {
213 wp_safe_redirect( add_query_arg( 'vigilante_2fa_notice', rawurlencode( $notice ), wp_login_url() ) );
214 exit;
215 }
216
217 /**
218 * Remember which user authenticated with an application password.
219 *
220 * @param WP_User $user Authenticated user.
221 */
222 public function remember_app_password_user( $user ) {
223 if ( $user instanceof WP_User ) {
224 $this->app_password_user_id = (int) $user->ID;
225 }
226 }
227
228 /**
229 * Whether this user was authenticated with an application password in this request.
230 *
231 * @param WP_User|mixed $user User being authenticated.
232 * @return bool
233 */
234 private function authenticated_with_app_password( $user ) {
235 return $user instanceof WP_User
236 && $this->app_password_user_id > 0
237 && (int) $user->ID === $this->app_password_user_id;
238 }
239
240 /**
241 * Whether the request comes through REST or XML-RPC, where no form can be shown.
242 *
243 * @return bool
244 */
245 private function is_api_request() {
246 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
247 return true;
248 }
249
250 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
251 return true;
252 }
253
254 return false;
255 }
256
257 /**
258 * The error returned to an API login that still needs its second factor.
259 *
260 * No pending session is created and no code is sent: a connector that
261 * retries with the main password used to trigger one email per attempt.
262 *
263 * @return WP_Error
264 */
265 private function api_requires_2fa_error() {
266 // A controlled rejection, not a wrong password: the credentials were
267 // right and the account simply needs its second factor. The error code
268 // is what keeps it out of the brute force count; see
269 // Vigilante_Login_Security::CONTROLLED_REJECTIONS.
270 return new WP_Error(
271 'vigilante_2fa_required',
272 __( 'This account requires two-factor authentication. Log in from a browser, or use an application password for API access.', 'vigilante' )
273 );
274 }
275
276 // =========================================================================
277 // Pending verification session (S3, S15)
278 // =========================================================================
279
280 /**
281 * Set pending verification state
282 *
283 * The token travels only in an HttpOnly cookie (and in the hidden field of
284 * the form the cookie holder is shown). There is no lookup by IP address:
285 * behind a proxy or a CDN that used to hand one user's pending session to
286 * whoever shared the apparent address (S3).
287 *
288 * @param int $user_id User ID.
289 * @return string Token for the pending session
290 */
291 private function set_pending_verification( $user_id ) {
292 $user_id = absint( $user_id );
293 $token = $this->get_existing_token_for_user( $user_id );
294 $data = $token ? get_transient( 'vigilante_2fa_pending_' . $token ) : false;
295
296 if ( ! $token ) {
297 $token = wp_generate_password( 32, false );
298 }
299
300 // The attempt counter survives a fresh password login within the hour,
301 // so re-authenticating does not reset it (S2).
302 $attempts = ( is_array( $data ) && isset( $data['attempts'] ) ) ? absint( $data['attempts'] ) : 0;
303
304 // Where the login was headed. The verification form is a second request
305 // with its own POST, so redirect_to has to travel in the session or it
306 // is lost and every login lands on the dashboard (2.11.12). Kept from a
307 // previous pending session when this request does not carry one.
308 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Not a decision: stored as-is and validated against the site by wp_validate_redirect() before use, in pending_login_redirect().
309 $redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';
310
311 if ( '' === $redirect_to && is_array( $data ) && ! empty( $data['redirect_to'] ) ) {
312 $redirect_to = (string) $data['redirect_to'];
313 }
314
315 set_transient(
316 'vigilante_2fa_pending_' . $token,
317 array(
318 'user_id' => $user_id,
319 'created_at' => time(),
320 'attempts' => $attempts,
321 'redirect_to' => $redirect_to,
322 ),
323 HOUR_IN_SECONDS
324 );
325
326 // Reverse lookup (user_id -> token), used only to reuse the token on a
327 // repeated password login. It is never handed to a visitor.
328 set_transient( 'vigilante_2fa_user_token_' . $user_id, $token, HOUR_IN_SECONDS );
329
330 $this->set_cookie( $this->pending_cookie_name(), $token, time() + HOUR_IN_SECONDS, 'Strict' );
331
332 // Make the token available in the current request.
333 $_COOKIE[ $this->pending_cookie_name() ] = $token;
334
335 return $token;
336 }
337
338 /**
339 * Get the existing pending token for a user if still valid
340 *
341 * @param int $user_id User ID.
342 * @return string|false Token or false if not found
343 */
344 private function get_existing_token_for_user( $user_id ) {
345 $token = get_transient( 'vigilante_2fa_user_token_' . absint( $user_id ) );
346
347 if ( ! $token ) {
348 return false;
349 }
350
351 $data = get_transient( 'vigilante_2fa_pending_' . $token );
352
353 if ( ! is_array( $data ) || empty( $data['user_id'] ) || absint( $data['user_id'] ) !== absint( $user_id ) ) {
354 return false;
355 }
356
357 return $token;
358 }
359
360 /**
361 * The pending token presented by this request, from the form or the cookie.
362 *
363 * @return string Token or empty string.
364 */
365 private function get_pending_token() {
366 // 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.
367 if ( isset( $_POST['vigilante_2fa_token'] ) ) {
368 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Same token as the line above.
369 return sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) );
370 }
371
372 if ( isset( $_COOKIE[ $this->pending_cookie_name() ] ) ) {
373 return sanitize_text_field( wp_unslash( $_COOKIE[ $this->pending_cookie_name() ] ) );
374 }
375
376 return '';
377 }
378
379 /**
380 * The pending session presented by this request.
381 *
382 * @return array|false Session data (user_id, created_at, attempts, token) or false.
383 */
384 private function get_pending_session() {
385 $token = $this->get_pending_token();
386
387 if ( '' === $token ) {
388 return false;
389 }
390
391 $data = get_transient( 'vigilante_2fa_pending_' . $token );
392
393 if ( ! is_array( $data ) || empty( $data['user_id'] ) ) {
394 return false;
395 }
396
397 $data['user_id'] = absint( $data['user_id'] );
398 $data['attempts'] = isset( $data['attempts'] ) ? absint( $data['attempts'] ) : 0;
399 $data['token'] = $token;
400
401 return $data;
402 }
403
404 /**
405 * Get pending verification user ID
406 *
407 * @return int|false User ID or false if not pending
408 */
409 private function get_pending_user_id() {
410 $session = $this->get_pending_session();
411
412 return $session ? $session['user_id'] : false;
413 }
414
415 /**
416 * Where to send the visitor once the second factor is verified
417 *
418 * @since 2.11.12
419 *
420 * @return string URL on this site.
421 */
422 private function pending_login_redirect() {
423 $session = $this->get_pending_session();
424 $stored = ( is_array( $session ) && ! empty( $session['redirect_to'] ) ) ? (string) $session['redirect_to'] : '';
425
426 if ( '' === $stored ) {
427 return admin_url();
428 }
429
430 // Same gate core uses: anything off this site falls back to the
431 // dashboard, so a stored value cannot send anyone off-site.
432 return wp_validate_redirect( $stored, admin_url() );
433 }
434
435 /**
436 * Failed attempts recorded on the pending session presented by this request.
437 *
438 * @return int
439 */
440 private function get_pending_attempts() {
441 $session = $this->get_pending_session();
442
443 return $session ? $session['attempts'] : 0;
444 }
445
446 /**
447 * Record one more failed attempt on the pending session.
448 *
449 * @return int Attempts after the increment, or 0 if there is no session.
450 */
451 private function increment_pending_attempts() {
452 $session = $this->get_pending_session();
453
454 if ( ! $session ) {
455 return 0;
456 }
457
458 $token = $session['token'];
459 unset( $session['token'] );
460 $session['attempts']++;
461
462 set_transient( 'vigilante_2fa_pending_' . $token, $session, HOUR_IN_SECONDS );
463
464 return $session['attempts'];
465 }
466
467 /**
468 * Clear pending verification
469 */
470 private function clear_pending_verification() {
471 $token = $this->get_pending_token();
472
473 if ( '' !== $token ) {
474 $data = get_transient( 'vigilante_2fa_pending_' . $token );
475
476 if ( is_array( $data ) && ! empty( $data['user_id'] ) ) {
477 delete_transient( 'vigilante_2fa_user_token_' . absint( $data['user_id'] ) );
478 }
479
480 delete_transient( 'vigilante_2fa_pending_' . $token );
481 }
482
483 $this->set_cookie( $this->pending_cookie_name(), '', time() - YEAR_IN_SECONDS, 'Strict' );
484 unset( $_COOKIE[ $this->pending_cookie_name() ] );
485 }
486
487 /**
488 * End a form submission whose nonce did not verify (S15).
489 *
490 * Until 2.11.0 this redirected to the login screen with no message and no
491 * record; the pending cookie was still set, so the form came back with no
492 * explanation. Now the holder of a pending session sees why and the
493 * attempt is logged. Either way the request ends here: a bare return would
494 * let wp-login.php fall through to wp_signon() without the second factor.
495 *
496 * @param int|false $user_id Pending user, if any.
497 */
498 private function handle_invalid_nonce( $user_id ) {
499 if ( $user_id ) {
500 set_transient(
501 'vigilante_2fa_error_' . $user_id,
502 __( 'The verification form expired. Please try again.', 'vigilante' ),
503 60
504 );
505
506 $this->log_event( '2fa_nonce_failed', $user_id, __( 'Verification form submitted with an invalid or expired nonce', 'vigilante' ), 'warning' );
507
508 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
509 exit;
510 }
511
512 wp_safe_redirect( wp_login_url() );
513 exit;
514 }
515
516 // =========================================================================
517 // Trusted devices (S1, S4)
518 // =========================================================================
519
520 /**
521 * Check if the current device is trusted
522 *
523 * The device presents a random secret from an HttpOnly cookie and only its
524 * SHA-256 is stored. Until 2.11.0 the identity was a hash of the User-Agent,
525 * so anyone with the password who reproduced the browser string skipped the
526 * second factor (S1). The option is enforced here as well: with it off no
527 * stored row is honoured, whatever the form sent (S4).
528 *
529 * @param int $user_id User ID.
530 * @return bool
531 */
532 private function is_device_trusted( $user_id ) {
533 if ( empty( $this->policy()['allow_remember_device'] ) ) {
534 return false;
535 }
536
537 $token = $this->present_device_token();
538
539 if ( '' === $token ) {
540 return false;
541 }
542
543 return $this->database->is_device_trusted( absint( $user_id ), hash( 'sha256', $token ) );
544 }
545
546 /**
547 * Trust the current device
548 *
549 * Ignored silently when the option is off: a cached form may still send
550 * the checkbox, and that is no reason to refuse the login (S4).
551 *
552 * @param int $user_id User ID.
553 * @return bool True if a device row was written.
554 */
555 private function trust_device( $user_id ) {
556 if ( empty( $this->policy()['allow_remember_device'] ) ) {
557 return false;
558 }
559
560 try {
561 $token = bin2hex( random_bytes( 32 ) );
562 } catch ( Exception $e ) {
563 return false;
564 }
565
566 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
567 $remember_days = absint( $this->policy()['remember_device_days'] ?? 30 );
568
569 if ( $remember_days < 1 ) {
570 $remember_days = 30;
571 }
572
573 $expires = time() + ( $remember_days * DAY_IN_SECONDS );
574
575 // The User-Agent is kept as a label for the device list only; it plays
576 // no part in recognising the device.
577 $written = $this->database->trust_device(
578 absint( $user_id ),
579 hash( 'sha256', $token ),
580 $user_agent,
581 gmdate( 'Y-m-d H:i:s', $expires )
582 );
583
584 if ( ! $written ) {
585 return false;
586 }
587
588 // Lax, not Strict: the cookie has to travel on the GET that brings the
589 // user back to wp-login.php from another site.
590 $this->set_cookie( $this->device_cookie_name(), $token, $expires, 'Lax' );
591
592 return true;
593 }
594
595 /**
596 * The device secret presented by this request, if well formed.
597 *
598 * @return string 64 hex characters or empty string.
599 */
600 private function present_device_token() {
601 if ( ! isset( $_COOKIE[ $this->device_cookie_name() ] ) ) {
602 return '';
603 }
604
605 $token = sanitize_text_field( wp_unslash( $_COOKIE[ $this->device_cookie_name() ] ) );
606
607 return preg_match( '/^[0-9a-f]{64}$/', $token ) ? $token : '';
608 }
609
610 // =========================================================================
611 // Cookies
612 // =========================================================================
613
614 /**
615 * Set a plugin cookie with the attributes every 2FA cookie shares.
616 *
617 * @param string $name Cookie name.
618 * @param string $value Value (empty to clear).
619 * @param int $expires Expiry timestamp.
620 * @param string $samesite Lax or Strict.
621 */
622 private function set_cookie( $name, $value, $expires, $samesite ) {
623 if ( headers_sent() ) {
624 return;
625 }
626
627 /*
628 * On a network these secrets used to travel to every site, while the rows
629 * that validate them carry the blog prefix and belong to one: a device
630 * trusted on one site handed its 64 hex secret to every other site,
631 * where a site administrator, or anything running there, could read it
632 * from the request and replay it.
633 *
634 * Both halves of the scope have to move, and the first attempt only moved
635 * one. An empty domain says "this host only", which isolates the sites of
636 * a network by subdomains; but in a network by subdirectories every site
637 * shares the host and the core leaves COOKIE_DOMAIN empty anyway
638 * (wp-includes/ms-default-constants.php sets it only for subdomain
639 * installs), so that change alone did nothing there. What separates those
640 * sites is the path. Found by the cross review of 2.11.10.
641 *
642 * So on a network the cookie is scoped to this site's own host and path,
643 * which is exactly the reach of the table that validates it. On a single
644 * site both come out as the core's own values and nothing changes.
645 */
646 $domain = COOKIE_DOMAIN;
647 $path = COOKIEPATH;
648
649 if ( is_multisite() ) {
650 $domain = '';
651 $site_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
652 $path = ( is_string( $site_path ) && '' !== $site_path ) ? $site_path : '/';
653 }
654
655 setcookie(
656 $name,
657 $value,
658 array(
659 'expires' => $expires,
660 'path' => $path,
661 'domain' => $domain,
662 'secure' => is_ssl(),
663 'httponly' => true,
664 'samesite' => $samesite,
665 )
666 );
667 }
668 }
669