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.1 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 All 89 releases
← All changes | includes/class-two-factor-totp.php +122 -194 2.10.1 → 2.11.8 View file →
@@ -19,8 +19,10 @@
19 19 * Authenticator app OTP verification for login security
20 20 */
21 21 class Vigilante_Two_Factor_TOTP {
22 22
23 + use Vigilante_Two_Factor_Session;
24 +
23 25 /**
24 26 * Settings instance
25 27 *
26 28 * @var Vigilante_Settings
@@ -80,11 +82,12 @@
80 82 */
81 83 const SECRET_LENGTH = 20;
82 84
83 85 /**
84 - * Time window tolerance (allows +-2 time steps for clock skew)
86 + * Time window tolerance: +-1 time step (30 seconds) for clock skew.
87 + * Was 2 until 2.11.0, which accepted five codes at any moment (S2).
85 88 */
86 - const TIME_WINDOW = 2;
89 + const TIME_WINDOW = 1;
87 90
88 91 /**
89 92 * Constructor
90 93 *
@@ -120,8 +123,10 @@
120 123 /**
121 124 * Initialize hooks
122 125 */
123 126 private function init_hooks() {
127 + $this->init_session_hooks();
128 +
124 129 // Intercept authentication
125 130 add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 );
126 131
127 132 // Handle TOTP verification form
@@ -157,14 +162,16 @@
157 162
158 163 /**
159 164 * Filter login errors to hide default messages during 2FA
160 165 *
166 + * Resolved from the pending token only; the lookup by IP address that used
167 + * to live here leaked one user's pending state to another behind a proxy (S3).
168 + *
161 169 * @param string $errors Login error messages.
162 170 * @return string
163 171 */
164 172 public function filter_login_errors( $errors ) {
165 - $ip = $this->database->get_client_ip();
166 - $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
173 + $user_id = $this->get_pending_user_id();
167 174
168 175 if ( ! $user_id ) {
169 176 return $errors;
170 177 }
@@ -191,13 +198,30 @@
191 198 if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
192 199 return $user;
193 200 }
194 201
195 - // Skip if this is a 2FA verification request
196 - if ( $this->is_2fa_verification_request() ) {
202 + // An application password is a second factor of its own. The core
203 + // action that flags it only fires when those were the credentials (S16).
204 + if ( $this->authenticated_with_app_password( $user ) ) {
197 205 return $user;
198 206 }
199 207
208 + /*
209 + * There is deliberately no "already verifying, let it through" shortcut
210 + * here any more. Until 2.11.0 a request carrying action=vigilante_2fa,
211 + * the form nonce and a pending token returned $user at this point, and
212 + * all three are in the hands of whoever knows the password: the nonce
213 + * is printed on the form served to the pending visitor, and the token is
214 + * issued to that same visitor. wp-login.php never reached this filter
215 + * with that action, because login_form_vigilante_2fa ends the request,
216 + * but any other login form that calls wp_signon(), the WooCommerce one
217 + * for instance, does reach it and completed the login without a second
218 + * factor (S19, found in the 2.11.0 cross review and reproduced). The
219 + * verification form authenticates on its own path, handle_2fa_form(),
220 + * which never passes through wp_authenticate(): nothing legitimate
221 + * needed the shortcut.
222 + */
223 +
200 224 // Check if user requires 2FA
201 225 if ( ! $this->user_requires_2fa( $user ) ) {
202 226 return $user;
203 227 }
@@ -225,9 +249,14 @@
225 249
226 250 return $user;
227 251 }
228 252
229 - // TOTP is configured - require verification
253 + // TOTP is configured - require verification. REST and XML-RPC have no
254 + // form to show, so the login is refused without a pending session (S16).
255 + if ( $this->is_api_request() ) {
256 + return $this->api_requires_2fa_error();
257 + }
258 +
230 259 $this->set_pending_verification( $user->ID );
231 260
232 261 $this->log_event( 'totp_verification_requested', $user->ID, __( 'TOTP verification requested at login', 'vigilante' ) );
233 262
@@ -290,29 +319,41 @@
290 319 return false;
291 320 }
292 321
293 322 /**
294 - * Check if this is a 2FA verification form submission
295 - *
296 - * @return bool
297 - */
298 - private function is_2fa_verification_request() {
299 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking action, nonce verified in handler
300 - $action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
301 - return 'vigilante_2fa' === $action;
302 - }
303 -
304 - /**
305 323 * Handle 2FA verification form submission
306 324 */
307 325 public function handle_2fa_form() {
326 + // The pending user is resolved first so that a failed nonce can be
327 + // explained on the form and recorded (S15). Both failure paths end the
328 + // request: a bare return would let wp-login.php fall through to its
329 + // default case and call wp_signon(), completing the login without the
330 + // second factor.
331 + $user_id = $this->get_pending_user_id();
332 +
308 333 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
309 - return;
334 + $this->handle_invalid_nonce( $user_id );
310 335 }
311 336
312 - $user_id = $this->get_pending_user_id();
337 + if ( ! $user_id ) {
338 + wp_safe_redirect( wp_login_url() );
339 + exit;
340 + }
313 341
314 - if ( ! $user_id ) {
342 + // Attempt limit per pending session (S2). Until 2.11.0 nothing counted
343 + // here: the lockout only runs on the authenticate filter, which this
344 + // form never passes through. The limit is checked before any code is
345 + // verified so that a session past it costs nothing, since a backup
346 + // code check alone is up to ten wp_check_password() calls.
347 + $max_attempts = absint( $this->options['max_attempts'] ?? 3 );
348 +
349 + if ( $max_attempts < 1 ) {
350 + $max_attempts = 3;
351 + }
352 +
353 + if ( $this->get_pending_attempts() >= $max_attempts ) {
354 + $this->log_event( 'totp_max_attempts_exceeded', $user_id, __( 'Maximum verification attempts exceeded', 'vigilante' ), 'warning' );
355 + $this->clear_pending_verification();
315 356 wp_safe_redirect( wp_login_url() );
316 357 exit;
317 358 }
318 359
@@ -327,8 +368,9 @@
327 368 $backup_result = $this->verify_backup_code( $user_id, $code );
328 369
329 370 if ( is_wp_error( $backup_result ) ) {
330 371 // Both failed
372 + $this->increment_pending_attempts();
331 373 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
332 374 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
333 375 exit;
334 376 }
@@ -339,10 +381,10 @@
339 381
340 382 // Verification successful
341 383 $this->clear_pending_verification();
342 384
343 - if ( $remember_device ) {
344 - $this->trust_device( $user_id );
385 + // Trust device if requested (and if the option allows it, see trust_device)
386 + if ( $remember_device && $this->trust_device( $user_id ) ) {
345 387 $this->log_event( 'totp_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
346 388 }
347 389
348 390 $this->log_event( 'totp_verification_success', $user_id, __( 'TOTP verification successful', 'vigilante' ) );
@@ -370,18 +412,18 @@
370 412 return;
371 413 }
372 414 }
373 415
374 - $user_id = $this->get_pending_user_id();
416 + // Only the visitor presenting the pending token gets the form. There is
417 + // no fallback by IP address and no lookup of the token by user (S3).
418 + $session = $this->get_pending_session();
375 419
376 - if ( ! $user_id ) {
377 - $ip = $this->database->get_client_ip();
378 - $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
420 + if ( ! $session ) {
421 + return;
379 422 }
380 423
381 - if ( ! $user_id ) {
382 - return;
383 - }
424 + $user_id = $session['user_id'];
425 + $token = $session['token'];
384 426
385 427 // Only show if user has TOTP configured
386 428 $totp_data = $this->database->get_totp_data( $user_id );
387 429 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
@@ -387,13 +429,8 @@
387 429 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
388 430 return;
389 431 }
390 432
391 - $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
392 - if ( empty( $token ) ) {
393 - $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
394 - }
395 -
396 433 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
397 434 delete_transient( 'vigilante_2fa_error_' . $user_id );
398 435
399 436 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
@@ -713,14 +750,19 @@
713 750 /**
714 751 * Encrypt TOTP secret for database storage
715 752 *
716 753 * @param string $secret Plain Base32 secret.
717 - * @return string Encrypted string (base64).
754 + * @return string Encrypted string (base64), or empty string without a key.
718 755 */
719 756 public function encrypt_secret( $secret ) {
720 757 $key = $this->get_encryption_key();
721 - $iv = openssl_random_pseudo_bytes( 16 );
722 758
759 + if ( '' === $key ) {
760 + return '';
761 + }
762 +
763 + $iv = openssl_random_pseudo_bytes( 16 );
764 +
723 765 $encrypted = openssl_encrypt( $secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
724 766
725 767 if ( false === $encrypted ) {
726 768 return '';
@@ -738,8 +780,12 @@
738 780 */
739 781 public function decrypt_secret( $encrypted ) {
740 782 $key = $this->get_encryption_key();
741 783
784 + if ( '' === $key ) {
785 + return false;
786 + }
787 +
742 788 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required for binary data retrieval
743 789 $data = base64_decode( $encrypted, true );
744 790
745 791 if ( false === $data || strlen( $data ) < 17 ) {
@@ -754,21 +800,37 @@
754 800 return ( false !== $decrypted ) ? $decrypted : false;
755 801 }
756 802
757 803 /**
804 + * Whether the site defines the key the authenticator secret is encrypted with
805 + *
806 + * @return bool
807 + */
808 + public function has_encryption_key() {
809 + return defined( 'AUTH_KEY' )
810 + && is_string( AUTH_KEY )
811 + && '' !== AUTH_KEY
812 + && 'put your unique phrase here' !== AUTH_KEY;
813 + }
814 +
815 + /**
758 816 * Get encryption key derived from WordPress salts
759 817 *
760 - * @return string 32-byte key.
818 + * Until 2.11.0 a site without AUTH_KEY fell back to a literal written in
819 + * this file, which gave every such site the same key and made the
820 + * encryption cosmetic (S13). Without AUTH_KEY there is no key: setup
821 + * refuses and says why, and nothing is encrypted with a known value.
822 + *
823 + * @return string 32-byte key, or empty string when the site has none.
761 824 */
762 825 private function get_encryption_key() {
763 - $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'vigilante_fallback_key';
764 - return hash( 'sha256', $salt . 'vigilante_totp', true );
826 + if ( ! $this->has_encryption_key() ) {
827 + return '';
828 + }
829 +
830 + return hash( 'sha256', AUTH_KEY . 'vigilante_totp', true );
765 831 }
766 832
767 - // =========================================================================
768 - // Base32 encoding/decoding
769 - // =========================================================================
770 -
771 833 /**
772 834 * Base32 encode
773 835 *
774 836 * @param string $data Raw binary data.
@@ -1080,9 +1142,12 @@
1080 1142 $secret = isset( $_POST['secret'] ) ? sanitize_text_field( wp_unslash( $_POST['secret'] ) ) : '';
1081 1143 $reconfig = ! empty( $_POST['reconfigure'] );
1082 1144
1083 1145 // Permission check: user can only set up their own, unless admin
1084 - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1146 + // edit_user, not manage_options: on a network every subsite administrator
1147 + // holds manage_options, and map_meta_cap denies edit_user against a user
1148 + // they do not administer. On a single site an administrator still passes.
1149 + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1085 1150 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1086 1151 }
1087 1152
1088 1153 if ( empty( $code ) || ! preg_match( '/^[0-9]{6}$/', $code ) ) {
@@ -1103,8 +1168,12 @@
1103 1168 if ( ! in_array( $code, $expected_codes, true ) ) {
1104 1169 wp_send_json_error( __( 'Invalid code. Make sure your authenticator app is set up correctly and the time is synchronized.', 'vigilante' ) );
1105 1170 }
1106 1171
1172 + if ( ! $this->has_encryption_key() ) {
1173 + wp_send_json_error( __( 'This site does not define the AUTH_KEY security key, so the authenticator secret cannot be stored securely. Add the WordPress security keys to the site configuration and try again.', 'vigilante' ) );
1174 + }
1175 +
1107 1176 // Encrypt and store secret
1108 1177 $encrypted = $this->encrypt_secret( $secret );
1109 1178
1110 1179 if ( empty( $encrypted ) ) {
@@ -1139,9 +1208,12 @@
1139 1208 if ( 0 === $user_id ) {
1140 1209 $user_id = get_current_user_id();
1141 1210 }
1142 1211
1143 - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1212 + // edit_user, not manage_options: on a network every subsite administrator
1213 + // holds manage_options, and map_meta_cap denies edit_user against a user
1214 + // they do not administer. On a single site an administrator still passes.
1215 + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1144 1216 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1145 1217 }
1146 1218
1147 1219 $totp_data = $this->database->get_totp_data( $user_id );
@@ -1167,9 +1239,12 @@
1167 1239 if ( 0 === $user_id ) {
1168 1240 $user_id = get_current_user_id();
1169 1241 }
1170 1242
1171 - if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1243 + // edit_user, not manage_options: on a network every subsite administrator
1244 + // holds manage_options, and map_meta_cap denies edit_user against a user
1245 + // they do not administer. On a single site an administrator still passes.
1246 + if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1172 1247 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1173 1248 }
1174 1249
1175 1250 $this->reset_user_totp( $user_id );
@@ -1178,155 +1253,8 @@
1178 1253 'message' => __( 'TOTP has been reset. You can now set up a new authenticator.', 'vigilante' ),
1179 1254 ) );
1180 1255 }
1181 1256
1182 - /**
1183 - * Set pending verification state
1184 - *
1185 - * @param int $user_id User ID.
1186 - * @return string Token.
1187 - */
1188 - private function set_pending_verification( $user_id ) {
1189 - $existing_token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
1190 -
1191 - $token = $existing_token ? $existing_token : wp_generate_password( 32, false );
1192 -
1193 - set_transient(
1194 - 'vigilante_2fa_pending_' . $token,
1195 - array(
1196 - 'user_id' => $user_id,
1197 - 'created_at' => time(),
1198 - ),
1199 - HOUR_IN_SECONDS
1200 - );
1201 -
1202 - set_transient( 'vigilante_2fa_user_token_' . $user_id, $token, HOUR_IN_SECONDS );
1203 -
1204 - $ip = $this->database->get_client_ip();
1205 - set_transient( 'vigilante_2fa_triggered_' . md5( $ip ), $user_id, 60 );
1206 -
1207 - if ( ! headers_sent() ) {
1208 - setcookie(
1209 - 'vigilante_2fa_token',
1210 - $token,
1211 - array(
1212 - 'expires' => time() + HOUR_IN_SECONDS,
1213 - 'path' => COOKIEPATH,
1214 - 'domain' => COOKIE_DOMAIN,
1215 - 'secure' => is_ssl(),
1216 - 'httponly' => true,
1217 - 'samesite' => 'Strict',
1218 - )
1219 - );
1220 - $_COOKIE['vigilante_2fa_token'] = $token;
1221 - }
1222 -
1223 - return $token;
1224 - }
1225 -
1226 - /**
1227 - * Get pending user ID from token
1228 - *
1229 - * @return int|false User ID or false.
1230 - */
1231 - private function get_pending_user_id() {
1232 - $token = '';
1233 -
1234 - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token is for session identification
1235 - if ( isset( $_POST['vigilante_2fa_token'] ) ) {
1236 - $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
1237 - } elseif ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
1238 - $token = sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) );
1239 - }
1240 -
1241 - if ( empty( $token ) ) {
1242 - return false;
1243 - }
1244 -
1245 - $pending = get_transient( 'vigilante_2fa_pending_' . $token );
1246 -
1247 - if ( ! $pending || ! isset( $pending['user_id'] ) ) {
1248 - return false;
1249 - }
1250 -
1251 - return absint( $pending['user_id'] );
1252 - }
1253 -
1254 - /**
1255 - * Clear pending verification
1256 - */
1257 - private function clear_pending_verification() {
1258 - $token = '';
1259 -
1260 - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token is for session identification
1261 - if ( isset( $_POST['vigilante_2fa_token'] ) ) {
1262 - $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
1263 - } elseif ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
1264 - $token = sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) );
1265 - }
1266 -
1267 - if ( ! empty( $token ) ) {
1268 - delete_transient( 'vigilante_2fa_pending_' . $token );
1269 - }
1270 -
1271 - if ( ! headers_sent() ) {
1272 - setcookie(
1273 - 'vigilante_2fa_token',
1274 - '',
1275 - array(
1276 - 'expires' => time() - HOUR_IN_SECONDS,
1277 - 'path' => COOKIEPATH,
1278 - 'domain' => COOKIE_DOMAIN,
1279 - 'secure' => is_ssl(),
1280 - 'httponly' => true,
1281 - 'samesite' => 'Strict',
1282 - )
1283 - );
1284 - }
1285 - }
1286 -
1287 - // =========================================================================
1288 - // Trusted devices (reuses database methods from email 2FA)
1289 - // =========================================================================
1290 -
1291 - /**
1292 - * Check if current device is trusted
1293 - *
1294 - * @param int $user_id User ID.
1295 - * @return bool
1296 - */
1297 - private function is_device_trusted( $user_id ) {
1298 - $device_hash = $this->generate_device_hash( $user_id );
1299 - return $this->database->is_device_trusted( $user_id, $device_hash );
1300 - }
1301 -
1302 - /**
1303 - * Trust the current device
1304 - *
1305 - * @param int $user_id User ID.
1306 - */
1307 - private function trust_device( $user_id ) {
1308 - $device_hash = $this->generate_device_hash( $user_id );
1309 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1310 - $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
1311 - $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $remember_days * DAY_IN_SECONDS ) );
1312 -
1313 - $this->database->trust_device( $user_id, $device_hash, $user_agent, $expires_at );
1314 - }
1315 -
1316 - /**
1317 - * Generate device hash (no IP for GDPR)
1318 - *
1319 - * @param int $user_id User ID.
1320 - * @return string
1321 - */
1322 - private function generate_device_hash( $user_id ) {
1323 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1324 - $salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'vigilante_fallback_salt';
1325 - return hash( 'sha256', $user_id . $user_agent . $salt );
1326 - }
1327 -
1328 - // =========================================================================
1329 1257 // =========================================================================
1330 1258 // Grace period admin notice and forced redirect
1331 1259 // =========================================================================
1332 1260