PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.10.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.10.4
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 / class-two-factor-totp.php

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

1,603 lines 60.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Two-Factor TOTP Authentication Class
4 *
5 * Handles authenticator app (TOTP) based two-factor authentication.
6 * RFC 6238 compliant, pure PHP implementation.
7 *
8 * @package Vigilante
9 */
10
11 // Prevent direct access
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Class Vigilante_Two_Factor_TOTP
18 *
19 * Authenticator app OTP verification for login security
20 */
21 class Vigilante_Two_Factor_TOTP {
22
23 /**
24 * Settings instance
25 *
26 * @var Vigilante_Settings
27 */
28 private $settings;
29
30 /**
31 * Database instance
32 *
33 * @var Vigilante_Database
34 */
35 private $database;
36
37 /**
38 * Activity log instance
39 *
40 * @var Vigilante_Activity_Log
41 */
42 private $activity_log;
43
44 /**
45 * Login security instance
46 *
47 * @var Vigilante_Login_Security|null
48 */
49 private $login_security;
50
51 /**
52 * 2FA options
53 *
54 * @var array
55 */
56 private $options;
57
58 /**
59 * TOTP time step in seconds
60 */
61 const TIME_STEP = 30;
62
63 /**
64 * TOTP code length
65 */
66 const CODE_LENGTH = 6;
67
68 /**
69 * Number of backup codes to generate
70 */
71 const BACKUP_CODE_COUNT = 10;
72
73 /**
74 * Backup code length
75 */
76 const BACKUP_CODE_LENGTH = 8;
77
78 /**
79 * Secret key length in bytes (160 bits = 20 bytes, standard)
80 */
81 const SECRET_LENGTH = 20;
82
83 /**
84 * Time window tolerance (allows +-2 time steps for clock skew)
85 */
86 const TIME_WINDOW = 2;
87
88 /**
89 * Constructor
90 *
91 * @param Vigilante_Settings $settings Settings instance.
92 * @param Vigilante_Database $database Database instance.
93 * @param Vigilante_Activity_Log $activity_log Activity log instance.
94 * @param Vigilante_Login_Security|null $login_security Login security instance.
95 */
96 public function __construct( $settings, $database, $activity_log, $login_security = null ) {
97 $this->settings = $settings;
98 $this->database = $database;
99 $this->activity_log = $activity_log;
100 $this->login_security = $login_security;
101
102 $login_options = $settings->get_section( 'login_security' );
103 $this->options = $login_options['two_factor'] ?? array();
104
105 if ( $this->is_active() ) {
106 $this->init_hooks();
107 }
108 }
109
110 /**
111 * Check if TOTP method is active
112 *
113 * @return bool
114 */
115 public function is_active() {
116 return ! empty( $this->options['enabled'] )
117 && 'totp' === ( $this->options['method'] ?? 'email' );
118 }
119
120 /**
121 * Initialize hooks
122 */
123 private function init_hooks() {
124 // Intercept authentication
125 add_filter( 'authenticate', array( $this, 'check_2fa_requirement' ), 100, 3 );
126
127 // Handle TOTP verification form
128 add_action( 'login_form_vigilante_2fa', array( $this, 'handle_2fa_form' ) );
129
130 // Show TOTP form on login page
131 add_action( 'login_form', array( $this, 'maybe_show_2fa_form' ) );
132
133 // Enqueue login assets
134 add_action( 'login_enqueue_scripts', array( $this, 'enqueue_login_assets' ) );
135
136 // Filter login errors
137 add_filter( 'login_errors', array( $this, 'filter_login_errors' ), 100 );
138
139 // User profile section (TOTP setup)
140 add_action( 'show_user_profile', array( $this, 'render_user_profile_section' ) );
141 add_action( 'edit_user_profile', array( $this, 'render_user_profile_section' ) );
142
143 // AJAX handlers for TOTP setup
144 add_action( 'wp_ajax_vigilante_totp_verify_setup', array( $this, 'ajax_verify_setup' ) );
145 add_action( 'wp_ajax_vigilante_totp_regenerate_backup', array( $this, 'ajax_regenerate_backup_codes' ) );
146 add_action( 'wp_ajax_vigilante_totp_reconfigure', array( $this, 'ajax_reconfigure' ) );
147
148 // Admin profile scripts
149 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_profile_assets' ) );
150
151 // Grace period admin notice
152 add_action( 'admin_notices', array( $this, 'show_grace_period_notice' ) );
153
154 // Force redirect to profile when grace period is expired and TOTP not configured
155 add_action( 'admin_init', array( $this, 'force_totp_setup_redirect' ) );
156 }
157
158 /**
159 * Filter login errors to hide default messages during 2FA
160 *
161 * @param string $errors Login error messages.
162 * @return string
163 */
164 public function filter_login_errors( $errors ) {
165 $ip = $this->database->get_client_ip();
166 $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
167
168 if ( ! $user_id ) {
169 return $errors;
170 }
171
172 // Only filter errors if user has TOTP configured (verification form will be shown)
173 // Don't filter if user needs to set up TOTP (they need to see the setup message)
174 $totp_data = $this->database->get_totp_data( $user_id );
175 if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
176 return '';
177 }
178
179 return $errors;
180 }
181
182 /**
183 * Check if user requires 2FA and if TOTP is configured
184 *
185 * @param WP_User|WP_Error $user User object or error.
186 * @param string $username Username.
187 * @param string $password Password.
188 * @return WP_User|WP_Error
189 */
190 public function check_2fa_requirement( $user, $username, $password ) {
191 if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
192 return $user;
193 }
194
195 // Skip if this is a 2FA verification request for this same user
196 if ( $this->is_2fa_verification_request( $user ) ) {
197 return $user;
198 }
199
200 // Check if user requires 2FA
201 if ( ! $this->user_requires_2fa( $user ) ) {
202 return $user;
203 }
204
205 // Check if device is trusted
206 if ( $this->is_device_trusted( $user->ID ) ) {
207 return $user;
208 }
209
210 // Check if TOTP is configured for this user
211 $totp_data = $this->database->get_totp_data( $user->ID );
212
213 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
214 // TOTP not yet set up - always allow login
215 // Enforcement happens inside admin via force_totp_setup_redirect()
216
217 if ( ! $totp_data ) {
218 // First time - create grace period placeholder
219 $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
220 $grace_expires = ( $grace_days > 0 )
221 ? gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) )
222 : gmdate( 'Y-m-d H:i:s', time() );
223 $this->database->create_totp_placeholder( $user->ID, $grace_expires );
224 }
225
226 return $user;
227 }
228
229 // TOTP is configured - require verification
230 $this->set_pending_verification( $user->ID );
231
232 $this->log_event( 'totp_verification_requested', $user->ID, __( 'TOTP verification requested at login', 'vigilante' ) );
233
234 return new WP_Error(
235 'vigilante_2fa_required',
236 __( 'Please enter the verification code from your authenticator app.', 'vigilante' )
237 );
238 }
239
240 /**
241 * Check if user requires 2FA
242 *
243 * @param WP_User $user User object.
244 * @return bool
245 */
246 public function user_requires_2fa( $user ) {
247 $excluded_users = $this->options['excluded_users'] ?? array();
248 if ( in_array( $user->ID, array_map( 'absint', $excluded_users ), true ) ) {
249 return false;
250 }
251
252 $enforced_roles = $this->options['enforced_roles'] ?? array( 'administrator', 'editor' );
253
254 foreach ( $user->roles as $role ) {
255 if ( in_array( $role, $enforced_roles, true ) ) {
256 return true;
257 }
258 }
259
260 return false;
261 }
262
263 /**
264 * Check if user is within the grace period
265 *
266 * @param int $user_id User ID.
267 * @return bool
268 */
269 private function is_within_grace_period( $user_id ) {
270 $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
271
272 if ( 0 === $grace_days ) {
273 return false;
274 }
275
276 $totp_data = $this->database->get_totp_data( $user_id );
277
278 // If no TOTP row exists, create one with grace period start
279 if ( ! $totp_data ) {
280 $grace_expires = gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) );
281 $this->database->create_totp_placeholder( $user_id, $grace_expires );
282 return true;
283 }
284
285 // Check grace period expiration
286 if ( ! empty( $totp_data['grace_period_expires'] ) ) {
287 return strtotime( $totp_data['grace_period_expires'] ) > time();
288 }
289
290 return false;
291 }
292
293 /**
294 * Check if this is a 2FA verification form submission
295 *
296 * @return bool
297 */
298 private function is_2fa_verification_request( $user = null ) {
299 // The action alone proves nothing: it travels in the request and the
300 // attacker sets it. A genuine verification also carries the form nonce
301 // and a pending token issued to this very user.
302 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce is verified right below.
303 $action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
304
305 if ( 'vigilante_2fa' !== $action ) {
306 return false;
307 }
308
309 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
310 return false;
311 }
312
313 $pending_user_id = $this->get_pending_user_id();
314
315 if ( ! $pending_user_id ) {
316 return false;
317 }
318
319 if ( $user instanceof WP_User ) {
320 return $pending_user_id === (int) $user->ID;
321 }
322
323 return true;
324 }
325
326 /**
327 * Handle 2FA verification form submission
328 */
329 public function handle_2fa_form() {
330 // Verify nonce. A bare return would let wp-login.php fall through to its
331 // default case and call wp_signon(), completing the login without the
332 // second factor, so this path must end the request.
333 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
334 wp_safe_redirect( wp_login_url() );
335 exit;
336 }
337
338 $user_id = $this->get_pending_user_id();
339
340 if ( ! $user_id ) {
341 wp_safe_redirect( wp_login_url() );
342 exit;
343 }
344
345 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
346 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
347
348 // Try TOTP code first, then backup code
349 $result = $this->verify_totp_code( $user_id, $code );
350
351 if ( is_wp_error( $result ) ) {
352 // Try as backup code
353 $backup_result = $this->verify_backup_code( $user_id, $code );
354
355 if ( is_wp_error( $backup_result ) ) {
356 // Both failed
357 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
358 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
359 exit;
360 }
361
362 // Backup code succeeded
363 $this->log_event( 'totp_backup_code_used', $user_id, __( 'Backup code used for authentication', 'vigilante' ), 'warning' );
364 }
365
366 // Verification successful
367 $this->clear_pending_verification();
368
369 if ( $remember_device ) {
370 $this->trust_device( $user_id );
371 $this->log_event( 'totp_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
372 }
373
374 $this->log_event( 'totp_verification_success', $user_id, __( 'TOTP verification successful', 'vigilante' ) );
375
376 // Complete login
377 $user = get_user_by( 'ID', $user_id );
378 wp_set_current_user( $user_id, $user->user_login );
379 wp_set_auth_cookie( $user_id, false );
380 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook
381 do_action( 'wp_login', $user->user_login, $user );
382
383 wp_safe_redirect( admin_url() );
384 exit;
385 }
386
387 /**
388 * Show 2FA form on login page
389 */
390 public function maybe_show_2fa_form() {
391 // Don't show 2FA form on logout or other non-auth actions
392 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking URL params for display logic
393 if ( isset( $_GET['loggedout'] ) || isset( $_GET['action'] ) ) {
394 $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
395 if ( isset( $_GET['loggedout'] ) || in_array( $action, array( 'logout', 'lostpassword', 'register', 'rp', 'resetpass' ), true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
396 return;
397 }
398 }
399
400 $user_id = $this->get_pending_user_id();
401
402 if ( ! $user_id ) {
403 $ip = $this->database->get_client_ip();
404 $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
405 }
406
407 if ( ! $user_id ) {
408 return;
409 }
410
411 // Only show if user has TOTP configured
412 $totp_data = $this->database->get_totp_data( $user_id );
413 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
414 return;
415 }
416
417 $token = isset( $_COOKIE['vigilante_2fa_token'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) ) : '';
418 if ( empty( $token ) ) {
419 $token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
420 }
421
422 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
423 delete_transient( 'vigilante_2fa_error_' . $user_id );
424
425 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
426
427 // Check remaining backup codes
428 $backup_remaining = $this->count_remaining_backup_codes( $user_id );
429 ?>
430 <style>
431 #login_error { display: none !important; }
432 #loginform > p:not(.vigilante-2fa-field),
433 #loginform > .user-pass-wrap,
434 #loginform > .forgetmenot,
435 #loginform > p.submit:not(.vigilante-2fa-submit) { display: none !important; }
436 #user_login, #user_pass, #loginform > p > label[for="user_login"],
437 #loginform > p > label[for="user_pass"], .login-remember { display: none !important; }
438 </style>
439 <script>
440 (function() {
441 var userLogin = document.getElementById('user_login');
442 var userPass = document.getElementById('user_pass');
443 var originalSubmit = document.querySelector('#loginform > p.submit:not(.vigilante-2fa-submit) input[type="submit"]');
444 if (userLogin) { userLogin.removeAttribute('required'); userLogin.disabled = true; }
445 if (userPass) { userPass.removeAttribute('required'); userPass.disabled = true; }
446 if (originalSubmit) { originalSubmit.disabled = true; }
447 })();
448 </script>
449
450 <div class="vigilante-2fa-container vigilante-2fa-totp">
451 <?php if ( $error ) : ?>
452 <div class="vigilante-2fa-error">
453 <?php echo esc_html( $error ); ?>
454 </div>
455 <?php endif; ?>
456
457 <div class="vigilante-2fa-message">
458 <p class="vigilante-2fa-totp-icon">
459 <span class="dashicons dashicons-smartphone"></span>
460 </p>
461 <p><?php esc_html_e( 'Enter the 6-digit code from your authenticator app.', 'vigilante' ); ?></p>
462 <?php if ( $backup_remaining > 0 ) : ?>
463 <p class="vigilante-2fa-backup-hint">
464 <?php
465 printf(
466 /* translators: %d: Number of backup codes remaining */
467 esc_html__( 'Lost your phone? You can use a backup code instead (%d remaining).', 'vigilante' ),
468 absint( $backup_remaining )
469 );
470 ?>
471 </p>
472 <?php endif; ?>
473 </div>
474
475 <p class="vigilante-2fa-field">
476 <label for="vigilante_2fa_code"><?php esc_html_e( 'Authentication code or backup code', 'vigilante' ); ?></label>
477 <input type="text"
478 name="vigilante_2fa_code"
479 id="vigilante_2fa_code"
480 class="input"
481 size="8"
482 maxlength="8"
483 pattern="[a-zA-Z0-9]{6,8}"
484 inputmode="numeric"
485 autocomplete="one-time-code"
486 placeholder="000000"
487 autofocus
488 required>
489 </p>
490
491 <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
492 <p class="vigilante-2fa-field vigilante-2fa-remember">
493 <label>
494 <input type="checkbox" name="vigilante_2fa_remember" value="1">
495 <?php
496 printf(
497 /* translators: %d: Number of days to remember device */
498 esc_html__( 'Remember this device for %d days', 'vigilante' ),
499 absint( $remember_days )
500 );
501 ?>
502 </label>
503 </p>
504 <?php endif; ?>
505
506 <p class="vigilante-2fa-field vigilante-2fa-submit submit">
507 <input type="hidden" name="action" value="vigilante_2fa">
508 <input type="hidden" name="vigilante_2fa_token" value="<?php echo esc_attr( $token ); ?>">
509 <?php wp_nonce_field( 'vigilante_2fa_verify' ); ?>
510 <input type="submit" name="vigilante-2fa-submit" id="vigilante-2fa-submit" class="button button-primary button-large" value="<?php esc_attr_e( 'Verify', 'vigilante' ); ?>">
511 </p>
512 </div>
513 <?php
514 }
515
516 // =========================================================================
517 // TOTP Algorithm (RFC 6238)
518 // =========================================================================
519
520 /**
521 * Generate a random TOTP secret
522 *
523 * @return string Base32-encoded secret.
524 */
525 public function generate_secret() {
526 $random = wp_generate_password( self::SECRET_LENGTH, false, false );
527 // Use truly random bytes
528 $bytes = '';
529 for ( $i = 0; $i < self::SECRET_LENGTH; $i++ ) {
530 $bytes .= chr( wp_rand( 0, 255 ) );
531 }
532 return $this->base32_encode( $bytes );
533 }
534
535 /**
536 * Generate TOTP code for a given time
537 *
538 * @param string $secret Base32-encoded secret.
539 * @param int|null $time Unix timestamp (null = current time).
540 * @return string 6-digit code.
541 */
542 public function generate_code( $secret, $time = null ) {
543 if ( null === $time ) {
544 $time = time();
545 }
546
547 $counter = intval( floor( $time / self::TIME_STEP ) );
548
549 // Pack counter as 8-byte big-endian
550 $counter_bytes = pack( 'N*', 0, $counter );
551
552 // Decode secret from Base32
553 $key = $this->base32_decode( $secret );
554
555 // HMAC-SHA1
556 $hash = hash_hmac( 'sha1', $counter_bytes, $key, true );
557
558 // Dynamic truncation
559 $offset = ord( $hash[19] ) & 0x0f;
560 $code = (
561 ( ( ord( $hash[ $offset ] ) & 0x7f ) << 24 ) |
562 ( ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 ) |
563 ( ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 ) |
564 ( ( ord( $hash[ $offset + 3 ] ) & 0xff ) )
565 ) % pow( 10, self::CODE_LENGTH );
566
567 return str_pad( (string) $code, self::CODE_LENGTH, '0', STR_PAD_LEFT );
568 }
569
570 /**
571 * Verify a TOTP code against stored secret
572 *
573 * @param int $user_id User ID.
574 * @param string $code Submitted code.
575 * @return true|WP_Error
576 */
577 public function verify_totp_code( $user_id, $code ) {
578 // Validate code format (6 digits for TOTP)
579 if ( ! preg_match( '/^[0-9]{6}$/', $code ) ) {
580 return new WP_Error( 'invalid_format', __( 'Invalid code format. Enter the 6-digit code from your authenticator app.', 'vigilante' ) );
581 }
582
583 $totp_data = $this->database->get_totp_data( $user_id );
584
585 if ( ! $totp_data || empty( $totp_data['secret'] ) ) {
586 return new WP_Error( 'not_configured', __( 'TOTP is not configured. Please contact the site administrator.', 'vigilante' ) );
587 }
588
589 $secret = $this->decrypt_secret( $totp_data['secret'] );
590
591 if ( ! $secret ) {
592 return new WP_Error( 'decrypt_failed', __( 'Authentication error. Please contact the site administrator.', 'vigilante' ) );
593 }
594
595 // Check code against current and adjacent time steps (clock skew tolerance)
596 $now = time();
597 for ( $i = -self::TIME_WINDOW; $i <= self::TIME_WINDOW; $i++ ) {
598 $expected = $this->generate_code( $secret, $now + ( $i * self::TIME_STEP ) );
599 if ( hash_equals( $expected, $code ) ) {
600 // Prevent replay: check if this code was already used
601 $last_used = get_transient( 'vigilante_totp_last_' . $user_id );
602 if ( $last_used === $code ) {
603 return new WP_Error( 'code_reused', __( 'This code has already been used. Wait for a new code.', 'vigilante' ) );
604 }
605 set_transient( 'vigilante_totp_last_' . $user_id, $code, self::TIME_STEP * 2 );
606
607 // Update last used timestamp
608 $this->database->update_totp_last_used( $user_id );
609
610 return true;
611 }
612 }
613
614 // Track failed attempts
615 $remaining = -1;
616 if ( $this->login_security ) {
617 $user = get_user_by( 'ID', $user_id );
618 if ( $user ) {
619 $this->login_security->record_failed_attempt( $user->user_login, '2fa' );
620 $remaining = $this->login_security->get_remaining_attempts();
621 }
622 }
623
624 $this->log_event( 'totp_verification_failed', $user_id, __( 'Invalid TOTP code entered', 'vigilante' ), 'warning' );
625
626 if ( $remaining > 0 ) {
627 return new WP_Error(
628 'invalid_code',
629 sprintf(
630 /* translators: %d: Number of attempts remaining */
631 __( 'Invalid verification code. %d attempts remaining before lockout.', 'vigilante' ),
632 $remaining
633 )
634 );
635 }
636
637 return new WP_Error(
638 'invalid_code',
639 __( 'Invalid verification code. If you have lost access to your authenticator app, contact the site administrator.', 'vigilante' )
640 );
641 }
642
643 // =========================================================================
644 // Backup codes
645 // =========================================================================
646
647 /**
648 * Generate backup codes for a user
649 *
650 * @param int $user_id User ID.
651 * @return array Plain text backup codes (show to user once).
652 */
653 public function generate_backup_codes( $user_id ) {
654 $codes = array();
655 $hashed = array();
656 $charset = 'abcdefghjkmnpqrstuvwxyz23456789'; // Avoid confusable chars
657
658 for ( $i = 0; $i < self::BACKUP_CODE_COUNT; $i++ ) {
659 $code = '';
660 for ( $j = 0; $j < self::BACKUP_CODE_LENGTH; $j++ ) {
661 $code .= $charset[ wp_rand( 0, strlen( $charset ) - 1 ) ];
662 }
663 $codes[] = $code;
664 $hashed[] = wp_hash_password( $code );
665 }
666
667 // Store hashed codes
668 $this->database->store_totp_backup_codes( $user_id, wp_json_encode( $hashed ) );
669
670 $this->log_event( 'totp_backup_codes_generated', $user_id, __( 'Backup codes generated', 'vigilante' ) );
671
672 return $codes;
673 }
674
675 /**
676 * Verify a backup code
677 *
678 * @param int $user_id User ID.
679 * @param string $code Submitted backup code.
680 * @return true|WP_Error
681 */
682 private function verify_backup_code( $user_id, $code ) {
683 // Backup codes are 8 chars, lowercase alphanumeric
684 $code = strtolower( trim( $code ) );
685
686 if ( strlen( $code ) !== self::BACKUP_CODE_LENGTH ) {
687 return new WP_Error( 'invalid_backup', __( 'Invalid backup code.', 'vigilante' ) );
688 }
689
690 $totp_data = $this->database->get_totp_data( $user_id );
691
692 if ( ! $totp_data || empty( $totp_data['backup_codes'] ) ) {
693 return new WP_Error( 'no_backup_codes', __( 'No backup codes available.', 'vigilante' ) );
694 }
695
696 $stored_hashes = json_decode( $totp_data['backup_codes'], true );
697
698 if ( ! is_array( $stored_hashes ) ) {
699 return new WP_Error( 'corrupt_data', __( 'Backup codes data is corrupt.', 'vigilante' ) );
700 }
701
702 // Check each stored hash
703 foreach ( $stored_hashes as $idx => $hash ) {
704 if ( wp_check_password( $code, $hash ) ) {
705 // Remove used code
706 unset( $stored_hashes[ $idx ] );
707 $stored_hashes = array_values( $stored_hashes );
708 $this->database->store_totp_backup_codes( $user_id, wp_json_encode( $stored_hashes ) );
709
710 return true;
711 }
712 }
713
714 return new WP_Error( 'invalid_backup', __( 'Invalid backup code.', 'vigilante' ) );
715 }
716
717 /**
718 * Count remaining backup codes for a user
719 *
720 * @param int $user_id User ID.
721 * @return int
722 */
723 public function count_remaining_backup_codes( $user_id ) {
724 $totp_data = $this->database->get_totp_data( $user_id );
725
726 if ( ! $totp_data || empty( $totp_data['backup_codes'] ) ) {
727 return 0;
728 }
729
730 $codes = json_decode( $totp_data['backup_codes'], true );
731
732 return is_array( $codes ) ? count( $codes ) : 0;
733 }
734
735 // =========================================================================
736 // Secret encryption
737 // =========================================================================
738
739 /**
740 * Encrypt TOTP secret for database storage
741 *
742 * @param string $secret Plain Base32 secret.
743 * @return string Encrypted string (base64).
744 */
745 public function encrypt_secret( $secret ) {
746 $key = $this->get_encryption_key();
747 $iv = openssl_random_pseudo_bytes( 16 );
748
749 $encrypted = openssl_encrypt( $secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
750
751 if ( false === $encrypted ) {
752 return '';
753 }
754
755 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for safe binary storage
756 return base64_encode( $iv . $encrypted );
757 }
758
759 /**
760 * Decrypt TOTP secret from database
761 *
762 * @param string $encrypted Encrypted string (base64).
763 * @return string|false Plain Base32 secret or false.
764 */
765 public function decrypt_secret( $encrypted ) {
766 $key = $this->get_encryption_key();
767
768 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required for binary data retrieval
769 $data = base64_decode( $encrypted, true );
770
771 if ( false === $data || strlen( $data ) < 17 ) {
772 return false;
773 }
774
775 $iv = substr( $data, 0, 16 );
776 $text = substr( $data, 16 );
777
778 $decrypted = openssl_decrypt( $text, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
779
780 return ( false !== $decrypted ) ? $decrypted : false;
781 }
782
783 /**
784 * Get encryption key derived from WordPress salts
785 *
786 * @return string 32-byte key.
787 */
788 private function get_encryption_key() {
789 $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'vigilante_fallback_key';
790 return hash( 'sha256', $salt . 'vigilante_totp', true );
791 }
792
793 // =========================================================================
794 // Base32 encoding/decoding
795 // =========================================================================
796
797 /**
798 * Base32 encode
799 *
800 * @param string $data Raw binary data.
801 * @return string Base32-encoded string.
802 */
803 private function base32_encode( $data ) {
804 $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
805 $binary = '';
806
807 for ( $i = 0; $i < strlen( $data ); $i++ ) {
808 $binary .= str_pad( decbin( ord( $data[ $i ] ) ), 8, '0', STR_PAD_LEFT );
809 }
810
811 $result = '';
812 for ( $i = 0; $i < strlen( $binary ); $i += 5 ) {
813 $chunk = substr( $binary, $i, 5 );
814 $chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT );
815 $result .= $alphabet[ intval( $chunk, 2 ) ];
816 }
817
818 return $result;
819 }
820
821 /**
822 * Base32 decode
823 *
824 * @param string $data Base32-encoded string.
825 * @return string Raw binary data.
826 */
827 private function base32_decode( $data ) {
828 $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
829 $data = strtoupper( rtrim( $data, '=' ) );
830 $binary = '';
831
832 for ( $i = 0; $i < strlen( $data ); $i++ ) {
833 $pos = strpos( $alphabet, $data[ $i ] );
834 if ( false === $pos ) {
835 continue;
836 }
837 $binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT );
838 }
839
840 $result = '';
841 for ( $i = 0; $i + 8 <= strlen( $binary ); $i += 8 ) {
842 $result .= chr( intval( substr( $binary, $i, 8 ), 2 ) );
843 }
844
845 return $result;
846 }
847
848 // =========================================================================
849 // TOTP URI and QR code
850 // =========================================================================
851
852 /**
853 * Generate otpauth:// URI for authenticator apps
854 *
855 * @param string $secret Base32 secret.
856 * @param string $user_email User email.
857 * @return string
858 */
859 public function get_totp_uri( $secret, $user_email ) {
860 $issuer = get_bloginfo( 'name' );
861 $issuer = preg_replace( '/[^a-zA-Z0-9 _-]/', '', $issuer );
862
863 return sprintf(
864 'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d',
865 rawurlencode( $issuer ),
866 rawurlencode( $user_email ),
867 $secret,
868 rawurlencode( $issuer ),
869 self::CODE_LENGTH,
870 self::TIME_STEP
871 );
872 }
873
874 // =========================================================================
875 // User profile section
876 // =========================================================================
877
878 /**
879 * Render TOTP setup/status section in user profile
880 *
881 * @param WP_User $user User being edited.
882 */
883 public function render_user_profile_section( $user ) {
884 // Only show for users that require 2FA
885 if ( ! $this->user_requires_2fa( $user ) ) {
886 return;
887 }
888
889 $totp_data = $this->database->get_totp_data( $user->ID );
890 $configured = $totp_data && ! empty( $totp_data['is_configured'] );
891
892 wp_nonce_field( 'vigilante_totp_profile', 'vigilante_totp_nonce' );
893 ?>
894 <input type="hidden" class="vigilante-totp-user-id" value="<?php echo esc_attr( $user->ID ); ?>">
895 <h2><?php esc_html_e( 'Two-Factor Authentication (TOTP)', 'vigilante' ); ?></h2>
896 <table class="form-table vigilante-totp-profile" role="presentation">
897 <?php if ( $configured ) : ?>
898 <tr>
899 <th scope="row"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
900 <td>
901 <span class="vigilante-totp-status vigilante-totp-active">
902 <span class="dashicons dashicons-yes-alt"></span>
903 <?php esc_html_e( 'Configured and active', 'vigilante' ); ?>
904 </span>
905 <?php if ( ! empty( $totp_data['configured_at'] ) ) : ?>
906 <p class="description">
907 <?php
908 printf(
909 /* translators: %s: Date and time */
910 esc_html__( 'Set up on: %s', 'vigilante' ),
911 esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $totp_data['configured_at'] ) ) )
912 );
913 ?>
914 </p>
915 <?php endif; ?>
916 </td>
917 </tr>
918 <tr>
919 <th scope="row"><?php esc_html_e( 'Backup codes', 'vigilante' ); ?></th>
920 <td>
921 <?php
922 $remaining = $this->count_remaining_backup_codes( $user->ID );
923 $warning = $remaining <= 3;
924 ?>
925 <span class="vigilante-totp-backup-count <?php echo $warning ? 'warning' : ''; ?>">
926 <?php
927 printf(
928 /* translators: 1: Remaining codes, 2: Total codes */
929 esc_html__( '%1$d of %2$d remaining', 'vigilante' ),
930 absint( $remaining ),
931 absint( self::BACKUP_CODE_COUNT )
932 );
933 ?>
934 </span>
935 <p>
936 <button type="button" class="button vigilante-totp-regenerate-backup" data-user="<?php echo esc_attr( $user->ID ); ?>">
937 <?php esc_html_e( 'Generate new backup codes', 'vigilante' ); ?>
938 </button>
939 </p>
940 <div class="vigilante-totp-backup-codes-display" style="display:none;"></div>
941 </td>
942 </tr>
943 <?php if ( current_user_can( 'manage_options' ) || get_current_user_id() === $user->ID ) : ?>
944 <tr>
945 <th scope="row"><?php esc_html_e( 'Reconfigure', 'vigilante' ); ?></th>
946 <td>
947 <button type="button" class="button vigilante-totp-reconfigure" data-user="<?php echo esc_attr( $user->ID ); ?>">
948 <?php esc_html_e( 'Set up new authenticator', 'vigilante' ); ?>
949 </button>
950 <p class="description"><?php esc_html_e( 'This will reset your current TOTP setup and require scanning a new QR code.', 'vigilante' ); ?></p>
951 </td>
952 </tr>
953 <?php endif; ?>
954 <?php else : ?>
955 <tr>
956 <th scope="row"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
957 <td>
958 <span class="vigilante-totp-status vigilante-totp-pending">
959 <span class="dashicons dashicons-warning"></span>
960 <?php esc_html_e( 'Not configured', 'vigilante' ); ?>
961 </span>
962 <?php
963 if ( $totp_data && ! empty( $totp_data['grace_period_expires'] ) ) {
964 $grace_end = strtotime( $totp_data['grace_period_expires'] );
965 if ( $grace_end > time() ) {
966 $days_left = max( 1, ceil( ( $grace_end - time() ) / DAY_IN_SECONDS ) );
967 echo '<p class="description vigilante-totp-grace-notice">';
968 printf(
969 /* translators: %d: Days remaining */
970 esc_html( _n(
971 'You have %d day to set up two-factor authentication.',
972 'You have %d days to set up two-factor authentication.',
973 $days_left,
974 'vigilante'
975 ) ),
976 absint( $days_left )
977 );
978 echo '</p>';
979 }
980 }
981 ?>
982 </td>
983 </tr>
984 <tr>
985 <th scope="row"><?php esc_html_e( 'Setup', 'vigilante' ); ?></th>
986 <td>
987 <div class="vigilante-totp-setup" id="vigilante-totp-setup">
988 <div class="vigilante-totp-setup-loading">
989 <button type="button" class="button button-primary vigilante-totp-start-setup">
990 <?php esc_html_e( 'Start setup', 'vigilante' ); ?>
991 </button>
992 </div>
993 <div class="vigilante-totp-setup-qr" style="display:none;">
994 <p class="description">
995 <?php esc_html_e( 'Scan this QR code with your authenticator app (Google Authenticator, Authy, Microsoft Authenticator, etc.)', 'vigilante' ); ?>
996 </p>
997 <div class="vigilante-totp-qr-container"></div>
998 <div class="vigilante-totp-manual-key">
999 <p><?php esc_html_e( 'Or enter this key manually:', 'vigilante' ); ?></p>
1000 <code class="vigilante-totp-secret-display"></code>
1001 </div>
1002 <div class="vigilante-totp-verify-setup">
1003 <label for="vigilante_totp_verify_code"><?php esc_html_e( 'Enter code to verify:', 'vigilante' ); ?></label>
1004 <input type="text" id="vigilante_totp_verify_code" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="off">
1005 <button type="button" class="button button-primary vigilante-totp-confirm-setup">
1006 <?php esc_html_e( 'Verify and activate', 'vigilante' ); ?>
1007 </button>
1008 <span class="vigilante-totp-setup-status"></span>
1009 </div>
1010 </div>
1011 <div class="vigilante-totp-setup-success" style="display:none;">
1012 <div class="vigilante-totp-success-msg">
1013 <span class="dashicons dashicons-yes-alt"></span>
1014 <?php esc_html_e( 'Two-factor authentication configured successfully!', 'vigilante' ); ?>
1015 </div>
1016 <div class="vigilante-totp-backup-codes-display"></div>
1017 </div>
1018 </div>
1019 </td>
1020 </tr>
1021 <?php endif; ?>
1022 </table>
1023 <?php
1024 }
1025
1026 /**
1027 * Enqueue profile page assets (only on user profile pages)
1028 *
1029 * @param string $hook Current admin page hook.
1030 */
1031 public function enqueue_profile_assets( $hook ) {
1032 if ( 'profile.php' !== $hook && 'user-edit.php' !== $hook ) {
1033 return;
1034 }
1035
1036 wp_enqueue_style(
1037 'vigilante-totp-profile',
1038 VIGILANTE_ASSETS_URL . 'css/two-factor-admin.css',
1039 array(),
1040 VIGILANTE_VERSION
1041 );
1042
1043 // QR code generator library (bundled locally for WordPress.org compliance)
1044 wp_enqueue_script(
1045 'qrcode-js',
1046 VIGILANTE_ASSETS_URL . 'js/qrcode.min.js',
1047 array(),
1048 '1.0.0',
1049 true
1050 );
1051
1052 wp_enqueue_script(
1053 'vigilante-totp-profile',
1054 VIGILANTE_ASSETS_URL . 'js/two-factor-admin.js',
1055 array( 'jquery', 'qrcode-js' ),
1056 VIGILANTE_VERSION,
1057 true
1058 );
1059
1060 wp_localize_script( 'vigilante-totp-profile', 'vigilanteTOTP', array(
1061 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1062 'nonce' => wp_create_nonce( 'vigilante_totp_profile' ),
1063 'strings' => array(
1064 'verifying' => __( 'Verifying...', 'vigilante' ),
1065 'generating' => __( 'Generating...', 'vigilante' ),
1066 'success' => __( 'Success!', 'vigilante' ),
1067 'error' => __( 'An error occurred.', 'vigilante' ),
1068 'confirmRegen' => __( 'This will invalidate all existing backup codes. Continue?', 'vigilante' ),
1069 'confirmReconfig' => __( 'This will reset your current TOTP setup. You will need to scan a new QR code. Continue?', 'vigilante' ),
1070 'saveBackupCodes' => __( 'Save these backup codes now. They will not be shown again.', 'vigilante' ),
1071 'codesRemaining' => __( 'backup codes remaining', 'vigilante' ),
1072 ),
1073 ) );
1074 }
1075
1076 /**
1077 * Enqueue login page assets
1078 */
1079 public function enqueue_login_assets() {
1080 wp_enqueue_style(
1081 'vigilante-2fa-login',
1082 VIGILANTE_ASSETS_URL . 'css/two-factor-login.css',
1083 array(),
1084 VIGILANTE_VERSION
1085 );
1086
1087 // Dashicons for the smartphone icon
1088 wp_enqueue_style( 'dashicons' );
1089 }
1090
1091 // =========================================================================
1092 // AJAX handlers
1093 // =========================================================================
1094
1095 /**
1096 * AJAX: Verify TOTP setup code and activate
1097 */
1098 public function ajax_verify_setup() {
1099 check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
1100
1101 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1102 if ( 0 === $user_id ) {
1103 $user_id = get_current_user_id();
1104 }
1105 $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
1106 $secret = isset( $_POST['secret'] ) ? sanitize_text_field( wp_unslash( $_POST['secret'] ) ) : '';
1107 $reconfig = ! empty( $_POST['reconfigure'] );
1108
1109 // Permission check: user can only set up their own, unless admin
1110 // edit_user, not manage_options: on a network every subsite administrator
1111 // holds manage_options, and map_meta_cap denies edit_user against a user
1112 // they do not administer. On a single site an administrator still passes.
1113 if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1114 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1115 }
1116
1117 if ( empty( $code ) || ! preg_match( '/^[0-9]{6}$/', $code ) ) {
1118 wp_send_json_error( __( 'Enter a valid 6-digit code.', 'vigilante' ) );
1119 }
1120
1121 if ( empty( $secret ) || ! preg_match( '/^[A-Z2-7]+=*$/', $secret ) ) {
1122 wp_send_json_error( __( 'Invalid secret. Please reload and try again.', 'vigilante' ) );
1123 }
1124
1125 // Verify the code against the provided secret
1126 $expected_codes = array();
1127 $now = time();
1128 for ( $i = -self::TIME_WINDOW; $i <= self::TIME_WINDOW; $i++ ) {
1129 $expected_codes[] = $this->generate_code( $secret, $now + ( $i * self::TIME_STEP ) );
1130 }
1131
1132 if ( ! in_array( $code, $expected_codes, true ) ) {
1133 wp_send_json_error( __( 'Invalid code. Make sure your authenticator app is set up correctly and the time is synchronized.', 'vigilante' ) );
1134 }
1135
1136 // Encrypt and store secret
1137 $encrypted = $this->encrypt_secret( $secret );
1138
1139 if ( empty( $encrypted ) ) {
1140 wp_send_json_error( __( 'Encryption error. Please try again.', 'vigilante' ) );
1141 }
1142
1143 // Save TOTP data
1144 if ( $reconfig ) {
1145 $this->database->reset_totp_data( $user_id );
1146 }
1147
1148 $this->database->save_totp_data( $user_id, $encrypted );
1149
1150 // Generate backup codes
1151 $backup_codes = $this->generate_backup_codes( $user_id );
1152
1153 $this->log_event( 'totp_configured', $user_id, __( 'TOTP authenticator configured', 'vigilante' ) );
1154
1155 wp_send_json_success( array(
1156 'message' => __( 'Two-factor authentication has been configured successfully.', 'vigilante' ),
1157 'backup_codes' => $backup_codes,
1158 ) );
1159 }
1160
1161 /**
1162 * AJAX: Regenerate backup codes
1163 */
1164 public function ajax_regenerate_backup_codes() {
1165 check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
1166
1167 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1168 if ( 0 === $user_id ) {
1169 $user_id = get_current_user_id();
1170 }
1171
1172 // edit_user, not manage_options: on a network every subsite administrator
1173 // holds manage_options, and map_meta_cap denies edit_user against a user
1174 // they do not administer. On a single site an administrator still passes.
1175 if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1176 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1177 }
1178
1179 $totp_data = $this->database->get_totp_data( $user_id );
1180
1181 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
1182 wp_send_json_error( __( 'TOTP is not configured for this user.', 'vigilante' ) );
1183 }
1184
1185 $backup_codes = $this->generate_backup_codes( $user_id );
1186
1187 wp_send_json_success( array(
1188 'backup_codes' => $backup_codes,
1189 ) );
1190 }
1191
1192 /**
1193 * AJAX: Reconfigure TOTP (reset and allow re-setup from profile)
1194 */
1195 public function ajax_reconfigure() {
1196 check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
1197
1198 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1199 if ( 0 === $user_id ) {
1200 $user_id = get_current_user_id();
1201 }
1202
1203 // edit_user, not manage_options: on a network every subsite administrator
1204 // holds manage_options, and map_meta_cap denies edit_user against a user
1205 // they do not administer. On a single site an administrator still passes.
1206 if ( get_current_user_id() !== $user_id && ! current_user_can( 'edit_user', $user_id ) ) {
1207 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1208 }
1209
1210 $this->reset_user_totp( $user_id );
1211
1212 wp_send_json_success( array(
1213 'message' => __( 'TOTP has been reset. You can now set up a new authenticator.', 'vigilante' ),
1214 ) );
1215 }
1216
1217 /**
1218 * Set pending verification state
1219 *
1220 * @param int $user_id User ID.
1221 * @return string Token.
1222 */
1223 private function set_pending_verification( $user_id ) {
1224 $existing_token = get_transient( 'vigilante_2fa_user_token_' . $user_id );
1225
1226 $token = $existing_token ? $existing_token : wp_generate_password( 32, false );
1227
1228 set_transient(
1229 'vigilante_2fa_pending_' . $token,
1230 array(
1231 'user_id' => $user_id,
1232 'created_at' => time(),
1233 ),
1234 HOUR_IN_SECONDS
1235 );
1236
1237 set_transient( 'vigilante_2fa_user_token_' . $user_id, $token, HOUR_IN_SECONDS );
1238
1239 $ip = $this->database->get_client_ip();
1240 set_transient( 'vigilante_2fa_triggered_' . md5( $ip ), $user_id, 60 );
1241
1242 if ( ! headers_sent() ) {
1243 setcookie(
1244 'vigilante_2fa_token',
1245 $token,
1246 array(
1247 'expires' => time() + HOUR_IN_SECONDS,
1248 'path' => COOKIEPATH,
1249 'domain' => COOKIE_DOMAIN,
1250 'secure' => is_ssl(),
1251 'httponly' => true,
1252 'samesite' => 'Strict',
1253 )
1254 );
1255 $_COOKIE['vigilante_2fa_token'] = $token;
1256 }
1257
1258 return $token;
1259 }
1260
1261 /**
1262 * Get pending user ID from token
1263 *
1264 * @return int|false User ID or false.
1265 */
1266 private function get_pending_user_id() {
1267 $token = '';
1268
1269 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token is for session identification
1270 if ( isset( $_POST['vigilante_2fa_token'] ) ) {
1271 $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
1272 } elseif ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
1273 $token = sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) );
1274 }
1275
1276 if ( empty( $token ) ) {
1277 return false;
1278 }
1279
1280 $pending = get_transient( 'vigilante_2fa_pending_' . $token );
1281
1282 if ( ! $pending || ! isset( $pending['user_id'] ) ) {
1283 return false;
1284 }
1285
1286 return absint( $pending['user_id'] );
1287 }
1288
1289 /**
1290 * Clear pending verification
1291 */
1292 private function clear_pending_verification() {
1293 $token = '';
1294
1295 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Token is for session identification
1296 if ( isset( $_POST['vigilante_2fa_token'] ) ) {
1297 $token = sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
1298 } elseif ( isset( $_COOKIE['vigilante_2fa_token'] ) ) {
1299 $token = sanitize_text_field( wp_unslash( $_COOKIE['vigilante_2fa_token'] ) );
1300 }
1301
1302 if ( ! empty( $token ) ) {
1303 delete_transient( 'vigilante_2fa_pending_' . $token );
1304 }
1305
1306 if ( ! headers_sent() ) {
1307 setcookie(
1308 'vigilante_2fa_token',
1309 '',
1310 array(
1311 'expires' => time() - HOUR_IN_SECONDS,
1312 'path' => COOKIEPATH,
1313 'domain' => COOKIE_DOMAIN,
1314 'secure' => is_ssl(),
1315 'httponly' => true,
1316 'samesite' => 'Strict',
1317 )
1318 );
1319 }
1320 }
1321
1322 // =========================================================================
1323 // Trusted devices (reuses database methods from email 2FA)
1324 // =========================================================================
1325
1326 /**
1327 * Check if current device is trusted
1328 *
1329 * @param int $user_id User ID.
1330 * @return bool
1331 */
1332 private function is_device_trusted( $user_id ) {
1333 $device_hash = $this->generate_device_hash( $user_id );
1334 return $this->database->is_device_trusted( $user_id, $device_hash );
1335 }
1336
1337 /**
1338 * Trust the current device
1339 *
1340 * @param int $user_id User ID.
1341 */
1342 private function trust_device( $user_id ) {
1343 $device_hash = $this->generate_device_hash( $user_id );
1344 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1345 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
1346 $expires_at = gmdate( 'Y-m-d H:i:s', time() + ( $remember_days * DAY_IN_SECONDS ) );
1347
1348 $this->database->trust_device( $user_id, $device_hash, $user_agent, $expires_at );
1349 }
1350
1351 /**
1352 * Generate device hash (no IP for GDPR)
1353 *
1354 * @param int $user_id User ID.
1355 * @return string
1356 */
1357 private function generate_device_hash( $user_id ) {
1358 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
1359 $salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'vigilante_fallback_salt';
1360 return hash( 'sha256', $user_id . $user_agent . $salt );
1361 }
1362
1363 // =========================================================================
1364 // =========================================================================
1365 // Grace period admin notice and forced redirect
1366 // =========================================================================
1367
1368 /**
1369 * Force redirect to profile page when grace period has expired
1370 * and TOTP is not yet configured.
1371 *
1372 * During grace period: user can browse freely, only a notice is shown.
1373 * After grace period: user is locked to profile page until setup is complete.
1374 */
1375 public function force_totp_setup_redirect() {
1376 // Don't redirect on AJAX requests
1377 if ( wp_doing_ajax() ) {
1378 return;
1379 }
1380
1381 $user = wp_get_current_user();
1382
1383 if ( ! $user->ID || ! $this->user_requires_2fa( $user ) ) {
1384 return;
1385 }
1386
1387 $totp_data = $this->database->get_totp_data( $user->ID );
1388
1389 // Already configured - no redirect needed
1390 if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
1391 return;
1392 }
1393
1394 // No TOTP data at all - first login just happened, allow freely
1395 if ( ! $totp_data ) {
1396 return;
1397 }
1398
1399 // Check if grace period is still active
1400 if ( ! empty( $totp_data['grace_period_expires'] ) && strtotime( $totp_data['grace_period_expires'] ) > time() ) {
1401 // Grace period active - user can browse freely, just notice shown
1402 return;
1403 }
1404
1405 // Grace period expired - force redirect to profile (unless already there)
1406 global $pagenow;
1407 if ( 'profile.php' === $pagenow || 'user-edit.php' === $pagenow ) {
1408 return;
1409 }
1410
1411 wp_safe_redirect( admin_url( 'profile.php#vigilante-totp-setup' ) );
1412 exit;
1413 }
1414
1415 /**
1416 * Show admin notice for users who need to set up TOTP
1417 */
1418 public function show_grace_period_notice() {
1419 $user = wp_get_current_user();
1420
1421 if ( ! $this->user_requires_2fa( $user ) ) {
1422 return;
1423 }
1424
1425 $totp_data = $this->database->get_totp_data( $user->ID );
1426
1427 if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
1428 return;
1429 }
1430
1431 $profile_url = admin_url( 'profile.php#vigilante-totp-setup' );
1432 $grace_end = ( $totp_data && ! empty( $totp_data['grace_period_expires'] ) )
1433 ? strtotime( $totp_data['grace_period_expires'] )
1434 : 0;
1435 $days_left = ( $grace_end > time() ) ? max( 1, ceil( ( $grace_end - time() ) / DAY_IN_SECONDS ) ) : 0;
1436 ?>
1437 <div class="notice notice-warning vigilante-totp-grace-notice">
1438 <p>
1439 <span class="dashicons dashicons-shield"></span>
1440 <strong><?php esc_html_e( 'Two-factor authentication setup required', 'vigilante' ); ?></strong>
1441 &mdash;
1442 <?php if ( $days_left > 0 ) : ?>
1443 <?php
1444 printf(
1445 /* translators: %d: Days remaining */
1446 esc_html( _n(
1447 'You have %d day to set up your authenticator app.',
1448 'You have %d days to set up your authenticator app.',
1449 $days_left,
1450 'vigilante'
1451 ) ),
1452 absint( $days_left )
1453 );
1454 ?>
1455 <?php else : ?>
1456 <?php esc_html_e( 'Please set up your authenticator app now.', 'vigilante' ); ?>
1457 <?php endif; ?>
1458 <a href="<?php echo esc_url( $profile_url ); ?>"><?php esc_html_e( 'Set up now', 'vigilante' ); ?></a>
1459 </p>
1460 </div>
1461 <?php
1462 }
1463
1464 // =========================================================================
1465 // Admin TOTP reset (called from admin-ajax)
1466 // =========================================================================
1467
1468 /**
1469 * Reset TOTP for a user (admin action)
1470 *
1471 * @param int $user_id User ID to reset.
1472 * @return bool
1473 */
1474 public function reset_user_totp( $user_id ) {
1475 $this->database->reset_totp_data( $user_id );
1476
1477 // If grace period is configured, set a new one
1478 $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
1479 if ( $grace_days > 0 ) {
1480 $grace_expires = gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) );
1481 $this->database->create_totp_placeholder( $user_id, $grace_expires );
1482 }
1483
1484 // Revoke trusted devices
1485 $this->database->revoke_trusted_devices( $user_id );
1486
1487 $this->log_event( 'totp_reset', $user_id, __( 'TOTP configuration reset by administrator', 'vigilante' ), 'warning' );
1488
1489 return true;
1490 }
1491
1492 /**
1493 * Get TOTP setup data for a new setup (generates secret and QR)
1494 *
1495 * @param int $user_id User ID.
1496 * @return array Setup data with secret, uri, and qr_svg.
1497 */
1498 public function get_setup_data( $user_id ) {
1499 $user = get_user_by( 'ID', $user_id );
1500
1501 if ( ! $user ) {
1502 return array();
1503 }
1504
1505 $secret = $this->generate_secret();
1506 $uri = $this->get_totp_uri( $secret, $user->user_email );
1507
1508 return array(
1509 'secret' => $secret,
1510 'uri' => $uri,
1511 );
1512 }
1513
1514 // =========================================================================
1515 // HTML styled email for TOTP activation notification
1516 // =========================================================================
1517
1518 /**
1519 * Send TOTP activation notification email
1520 *
1521 * @param WP_User $user User object.
1522 * @param string $site_name Site name.
1523 * @param string $from_name Email from name.
1524 * @return bool
1525 */
1526 public function send_activation_email( $user, $site_name, $from_name ) {
1527 $profile_url = admin_url( 'profile.php' );
1528 $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
1529
1530 $subject = sprintf(
1531 /* translators: %s: Site name */
1532 __( '[%s] Set up two-factor authentication for your account', 'vigilante' ),
1533 $site_name
1534 );
1535
1536 $body = Vigilante_Email_Template::p(
1537 sprintf(
1538 /* translators: 1: User display name, 2: Site name */
1539 __( 'Hello %1$s, the administrator of %2$s has enabled two-factor authentication using an authenticator app for your account.', 'vigilante' ),
1540 $user->display_name,
1541 $site_name
1542 )
1543 );
1544 $body .= Vigilante_Email_Template::p( __( 'Install an authenticator app on your phone if you do not have one:', 'vigilante' ) );
1545 $body .= Vigilante_Email_Template::ul( array(
1546 '<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator (Android)</a>',
1547 '<a href="https://apps.apple.com/app/google-authenticator/id388497605">Google Authenticator (iOS)</a>',
1548 '<a href="https://authy.com/download/">Authy (Android / iOS)</a>',
1549 '<a href="https://www.microsoft.com/en-us/security/mobile-authenticator-app">Microsoft Authenticator</a>',
1550 ) );
1551
1552 if ( $grace_days > 0 ) {
1553 $body .= Vigilante_Email_Template::warning_box(
1554 sprintf(
1555 /* translators: %d: Number of days */
1556 __( 'You have %d days to complete the setup. After that, you will not be able to access the admin area without configuring your authenticator app.', 'vigilante' ),
1557 $grace_days
1558 )
1559 );
1560 } else {
1561 $body .= Vigilante_Email_Template::alert_box( __( 'You must configure your authenticator app on your next login.', 'vigilante' ) );
1562 }
1563
1564 $body .= Vigilante_Email_Template::button( $profile_url, __( 'Set up now', 'vigilante' ) );
1565
1566 // Pass from_name via header (avoids filter contamination)
1567 $sent = Vigilante_Email_Template::send(
1568 $user->user_email,
1569 $subject,
1570 __( 'Two-factor authentication enabled', 'vigilante' ),
1571 $body,
1572 false,
1573 $from_name
1574 );
1575
1576 return $sent;
1577 }
1578
1579 // =========================================================================
1580 // Logging helper
1581 // =========================================================================
1582
1583 /**
1584 * Log TOTP event
1585 *
1586 * @param string $action Action name.
1587 * @param int $user_id User ID.
1588 * @param string $message Message.
1589 * @param string $severity Severity level.
1590 */
1591 private function log_event( $action, $user_id, $message, $severity = 'info' ) {
1592 if ( $this->activity_log ) {
1593 $this->activity_log->log(
1594 '2fa',
1595 $action,
1596 $message,
1597 array( 'user_id' => $user_id ),
1598 $severity
1599 );
1600 }
1601 }
1602 }
1603