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
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
← All changes | includes/trait-two-factor-session.php +9 -217 2.11.122.11.8 View file →
@@ -7,9 +7,9 @@
7 7 * which is how the trusted-device check kept identifying a browser by its
8 8 * User-Agent in two places at once (S1 of the 28 Aug 2026 audit).
9 9 *
10 10 * The using class must provide $this->database (Vigilante_Database),
11 - * $this->policy() (the two_factor settings array) and log_event().
11 + * $this->options (the two_factor settings array) and log_event().
12 12 *
13 13 * @package Vigilante
14 14 * @since 2.11.0
15 15 */
@@ -24,87 +24,8 @@
24 24 */
25 25 trait Vigilante_Two_Factor_Session {
26 26
27 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 28 * User ID authenticated through an application password in this request, or 0.
108 29 *
109 30 * Set by the core action application_password_did_authenticate, which only
110 31 * fires when the credentials were an application password. That is a second
@@ -146,76 +67,11 @@
146 67 * Called from the module's init_hooks().
147 68 */
148 69 protected function init_session_hooks() {
149 70 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 71 }
156 72
157 73 /**
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 74 * Remember which user authenticated with an application password.
219 75 *
220 76 * @param WP_User $user Authenticated user.
221 77 */
@@ -262,12 +118,8 @@
262 118 *
263 119 * @return WP_Error
264 120 */
265 121 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 122 return new WP_Error(
271 123 'vigilante_2fa_required',
272 124 __( 'This account requires two-factor authentication. Log in from a browser, or use an application password for API access.', 'vigilante' )
273 125 );
@@ -300,26 +152,14 @@
300 152 // The attempt counter survives a fresh password login within the hour,
301 153 // so re-authenticating does not reset it (S2).
302 154 $attempts = ( is_array( $data ) && isset( $data['attempts'] ) ) ? absint( $data['attempts'] ) : 0;
303 155
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 156 set_transient(
316 157 'vigilante_2fa_pending_' . $token,
317 158 array(
318 - 'user_id' => $user_id,
319 - 'created_at' => time(),
320 - 'attempts' => $attempts,
321 - 'redirect_to' => $redirect_to,
159 + 'user_id' => $user_id,
160 + 'created_at' => time(),
161 + 'attempts' => $attempts,
322 162 ),
323 163 HOUR_IN_SECONDS
324 164 );
325 165
@@ -412,28 +252,8 @@
412 252 return $session ? $session['user_id'] : false;
413 253 }
414 254
415 255 /**
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 256 * Failed attempts recorded on the pending session presented by this request.
437 257 *
438 258 * @return int
439 259 */
@@ -529,9 +349,9 @@
529 349 * @param int $user_id User ID.
530 350 * @return bool
531 351 */
532 352 private function is_device_trusted( $user_id ) {
533 - if ( empty( $this->policy()['allow_remember_device'] ) ) {
353 + if ( empty( $this->options['allow_remember_device'] ) ) {
534 354 return false;
535 355 }
536 356
537 357 $token = $this->present_device_token();
@@ -552,9 +372,9 @@
552 372 * @param int $user_id User ID.
553 373 * @return bool True if a device row was written.
554 374 */
555 375 private function trust_device( $user_id ) {
556 - if ( empty( $this->policy()['allow_remember_device'] ) ) {
376 + if ( empty( $this->options['allow_remember_device'] ) ) {
557 377 return false;
558 378 }
559 379
560 380 try {
@@ -563,9 +383,9 @@
563 383 return false;
564 384 }
565 385
566 386 $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 );
387 + $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
568 388
569 389 if ( $remember_days < 1 ) {
570 390 $remember_days = 30;
571 391 }
@@ -623,43 +443,15 @@
623 443 if ( headers_sent() ) {
624 444 return;
625 445 }
626 446
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 447 setcookie(
656 448 $name,
657 449 $value,
658 450 array(
659 451 'expires' => $expires,
660 - 'path' => $path,
661 - 'domain' => $domain,
452 + 'path' => COOKIEPATH,
453 + 'domain' => COOKIE_DOMAIN,
662 454 'secure' => is_ssl(),
663 455 'httponly' => true,
664 456 'samesite' => $samesite,
665 457 )