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

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

1,568 lines 58.4 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
196 if ( $this->is_2fa_verification_request() ) {
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() {
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 * Handle 2FA verification form submission
306 */
307 public function handle_2fa_form() {
308 if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vigilante_2fa_verify' ) ) {
309 return;
310 }
311
312 $user_id = $this->get_pending_user_id();
313
314 if ( ! $user_id ) {
315 wp_safe_redirect( wp_login_url() );
316 exit;
317 }
318
319 $code = isset( $_POST['vigilante_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['vigilante_2fa_code'] ) ) : '';
320 $remember_device = ! empty( $_POST['vigilante_2fa_remember'] );
321
322 // Try TOTP code first, then backup code
323 $result = $this->verify_totp_code( $user_id, $code );
324
325 if ( is_wp_error( $result ) ) {
326 // Try as backup code
327 $backup_result = $this->verify_backup_code( $user_id, $code );
328
329 if ( is_wp_error( $backup_result ) ) {
330 // Both failed
331 set_transient( 'vigilante_2fa_error_' . $user_id, $result->get_error_message(), 60 );
332 wp_safe_redirect( add_query_arg( 'vigilante_2fa', '1', wp_login_url() ) );
333 exit;
334 }
335
336 // Backup code succeeded
337 $this->log_event( 'totp_backup_code_used', $user_id, __( 'Backup code used for authentication', 'vigilante' ), 'warning' );
338 }
339
340 // Verification successful
341 $this->clear_pending_verification();
342
343 if ( $remember_device ) {
344 $this->trust_device( $user_id );
345 $this->log_event( 'totp_device_trusted', $user_id, __( 'Device saved as trusted', 'vigilante' ) );
346 }
347
348 $this->log_event( 'totp_verification_success', $user_id, __( 'TOTP verification successful', 'vigilante' ) );
349
350 // Complete login
351 $user = get_user_by( 'ID', $user_id );
352 wp_set_current_user( $user_id, $user->user_login );
353 wp_set_auth_cookie( $user_id, false );
354 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- wp_login is a WordPress core hook
355 do_action( 'wp_login', $user->user_login, $user );
356
357 wp_safe_redirect( admin_url() );
358 exit;
359 }
360
361 /**
362 * Show 2FA form on login page
363 */
364 public function maybe_show_2fa_form() {
365 // Don't show 2FA form on logout or other non-auth actions
366 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just checking URL params for display logic
367 if ( isset( $_GET['loggedout'] ) || isset( $_GET['action'] ) ) {
368 $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
369 if ( isset( $_GET['loggedout'] ) || in_array( $action, array( 'logout', 'lostpassword', 'register', 'rp', 'resetpass' ), true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
370 return;
371 }
372 }
373
374 $user_id = $this->get_pending_user_id();
375
376 if ( ! $user_id ) {
377 $ip = $this->database->get_client_ip();
378 $user_id = get_transient( 'vigilante_2fa_triggered_' . md5( $ip ) );
379 }
380
381 if ( ! $user_id ) {
382 return;
383 }
384
385 // Only show if user has TOTP configured
386 $totp_data = $this->database->get_totp_data( $user_id );
387 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
388 return;
389 }
390
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 $error = get_transient( 'vigilante_2fa_error_' . $user_id );
397 delete_transient( 'vigilante_2fa_error_' . $user_id );
398
399 $remember_days = absint( $this->options['remember_device_days'] ?? 30 );
400
401 // Check remaining backup codes
402 $backup_remaining = $this->count_remaining_backup_codes( $user_id );
403 ?>
404 <style>
405 #login_error { display: none !important; }
406 #loginform > p:not(.vigilante-2fa-field),
407 #loginform > .user-pass-wrap,
408 #loginform > .forgetmenot,
409 #loginform > p.submit:not(.vigilante-2fa-submit) { display: none !important; }
410 #user_login, #user_pass, #loginform > p > label[for="user_login"],
411 #loginform > p > label[for="user_pass"], .login-remember { display: none !important; }
412 </style>
413 <script>
414 (function() {
415 var userLogin = document.getElementById('user_login');
416 var userPass = document.getElementById('user_pass');
417 var originalSubmit = document.querySelector('#loginform > p.submit:not(.vigilante-2fa-submit) input[type="submit"]');
418 if (userLogin) { userLogin.removeAttribute('required'); userLogin.disabled = true; }
419 if (userPass) { userPass.removeAttribute('required'); userPass.disabled = true; }
420 if (originalSubmit) { originalSubmit.disabled = true; }
421 })();
422 </script>
423
424 <div class="vigilante-2fa-container vigilante-2fa-totp">
425 <?php if ( $error ) : ?>
426 <div class="vigilante-2fa-error">
427 <?php echo esc_html( $error ); ?>
428 </div>
429 <?php endif; ?>
430
431 <div class="vigilante-2fa-message">
432 <p class="vigilante-2fa-totp-icon">
433 <span class="dashicons dashicons-smartphone"></span>
434 </p>
435 <p><?php esc_html_e( 'Enter the 6-digit code from your authenticator app.', 'vigilante' ); ?></p>
436 <?php if ( $backup_remaining > 0 ) : ?>
437 <p class="vigilante-2fa-backup-hint">
438 <?php
439 printf(
440 /* translators: %d: Number of backup codes remaining */
441 esc_html__( 'Lost your phone? You can use a backup code instead (%d remaining).', 'vigilante' ),
442 absint( $backup_remaining )
443 );
444 ?>
445 </p>
446 <?php endif; ?>
447 </div>
448
449 <p class="vigilante-2fa-field">
450 <label for="vigilante_2fa_code"><?php esc_html_e( 'Authentication code or backup code', 'vigilante' ); ?></label>
451 <input type="text"
452 name="vigilante_2fa_code"
453 id="vigilante_2fa_code"
454 class="input"
455 size="8"
456 maxlength="8"
457 pattern="[a-zA-Z0-9]{6,8}"
458 inputmode="numeric"
459 autocomplete="one-time-code"
460 placeholder="000000"
461 autofocus
462 required>
463 </p>
464
465 <?php if ( ! empty( $this->options['allow_remember_device'] ) ) : ?>
466 <p class="vigilante-2fa-field vigilante-2fa-remember">
467 <label>
468 <input type="checkbox" name="vigilante_2fa_remember" value="1">
469 <?php
470 printf(
471 /* translators: %d: Number of days to remember device */
472 esc_html__( 'Remember this device for %d days', 'vigilante' ),
473 absint( $remember_days )
474 );
475 ?>
476 </label>
477 </p>
478 <?php endif; ?>
479
480 <p class="vigilante-2fa-field vigilante-2fa-submit submit">
481 <input type="hidden" name="action" value="vigilante_2fa">
482 <input type="hidden" name="vigilante_2fa_token" value="<?php echo esc_attr( $token ); ?>">
483 <?php wp_nonce_field( 'vigilante_2fa_verify' ); ?>
484 <input type="submit" name="vigilante-2fa-submit" id="vigilante-2fa-submit" class="button button-primary button-large" value="<?php esc_attr_e( 'Verify', 'vigilante' ); ?>">
485 </p>
486 </div>
487 <?php
488 }
489
490 // =========================================================================
491 // TOTP Algorithm (RFC 6238)
492 // =========================================================================
493
494 /**
495 * Generate a random TOTP secret
496 *
497 * @return string Base32-encoded secret.
498 */
499 public function generate_secret() {
500 $random = wp_generate_password( self::SECRET_LENGTH, false, false );
501 // Use truly random bytes
502 $bytes = '';
503 for ( $i = 0; $i < self::SECRET_LENGTH; $i++ ) {
504 $bytes .= chr( wp_rand( 0, 255 ) );
505 }
506 return $this->base32_encode( $bytes );
507 }
508
509 /**
510 * Generate TOTP code for a given time
511 *
512 * @param string $secret Base32-encoded secret.
513 * @param int|null $time Unix timestamp (null = current time).
514 * @return string 6-digit code.
515 */
516 public function generate_code( $secret, $time = null ) {
517 if ( null === $time ) {
518 $time = time();
519 }
520
521 $counter = intval( floor( $time / self::TIME_STEP ) );
522
523 // Pack counter as 8-byte big-endian
524 $counter_bytes = pack( 'N*', 0, $counter );
525
526 // Decode secret from Base32
527 $key = $this->base32_decode( $secret );
528
529 // HMAC-SHA1
530 $hash = hash_hmac( 'sha1', $counter_bytes, $key, true );
531
532 // Dynamic truncation
533 $offset = ord( $hash[19] ) & 0x0f;
534 $code = (
535 ( ( ord( $hash[ $offset ] ) & 0x7f ) << 24 ) |
536 ( ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 ) |
537 ( ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 ) |
538 ( ( ord( $hash[ $offset + 3 ] ) & 0xff ) )
539 ) % pow( 10, self::CODE_LENGTH );
540
541 return str_pad( (string) $code, self::CODE_LENGTH, '0', STR_PAD_LEFT );
542 }
543
544 /**
545 * Verify a TOTP code against stored secret
546 *
547 * @param int $user_id User ID.
548 * @param string $code Submitted code.
549 * @return true|WP_Error
550 */
551 public function verify_totp_code( $user_id, $code ) {
552 // Validate code format (6 digits for TOTP)
553 if ( ! preg_match( '/^[0-9]{6}$/', $code ) ) {
554 return new WP_Error( 'invalid_format', __( 'Invalid code format. Enter the 6-digit code from your authenticator app.', 'vigilante' ) );
555 }
556
557 $totp_data = $this->database->get_totp_data( $user_id );
558
559 if ( ! $totp_data || empty( $totp_data['secret'] ) ) {
560 return new WP_Error( 'not_configured', __( 'TOTP is not configured. Please contact the site administrator.', 'vigilante' ) );
561 }
562
563 $secret = $this->decrypt_secret( $totp_data['secret'] );
564
565 if ( ! $secret ) {
566 return new WP_Error( 'decrypt_failed', __( 'Authentication error. Please contact the site administrator.', 'vigilante' ) );
567 }
568
569 // Check code against current and adjacent time steps (clock skew tolerance)
570 $now = time();
571 for ( $i = -self::TIME_WINDOW; $i <= self::TIME_WINDOW; $i++ ) {
572 $expected = $this->generate_code( $secret, $now + ( $i * self::TIME_STEP ) );
573 if ( hash_equals( $expected, $code ) ) {
574 // Prevent replay: check if this code was already used
575 $last_used = get_transient( 'vigilante_totp_last_' . $user_id );
576 if ( $last_used === $code ) {
577 return new WP_Error( 'code_reused', __( 'This code has already been used. Wait for a new code.', 'vigilante' ) );
578 }
579 set_transient( 'vigilante_totp_last_' . $user_id, $code, self::TIME_STEP * 2 );
580
581 // Update last used timestamp
582 $this->database->update_totp_last_used( $user_id );
583
584 return true;
585 }
586 }
587
588 // Track failed attempts
589 $remaining = -1;
590 if ( $this->login_security ) {
591 $user = get_user_by( 'ID', $user_id );
592 if ( $user ) {
593 $this->login_security->record_failed_attempt( $user->user_login, '2fa' );
594 $remaining = $this->login_security->get_remaining_attempts();
595 }
596 }
597
598 $this->log_event( 'totp_verification_failed', $user_id, __( 'Invalid TOTP code entered', 'vigilante' ), 'warning' );
599
600 if ( $remaining > 0 ) {
601 return new WP_Error(
602 'invalid_code',
603 sprintf(
604 /* translators: %d: Number of attempts remaining */
605 __( 'Invalid verification code. %d attempts remaining before lockout.', 'vigilante' ),
606 $remaining
607 )
608 );
609 }
610
611 return new WP_Error(
612 'invalid_code',
613 __( 'Invalid verification code. If you have lost access to your authenticator app, contact the site administrator.', 'vigilante' )
614 );
615 }
616
617 // =========================================================================
618 // Backup codes
619 // =========================================================================
620
621 /**
622 * Generate backup codes for a user
623 *
624 * @param int $user_id User ID.
625 * @return array Plain text backup codes (show to user once).
626 */
627 public function generate_backup_codes( $user_id ) {
628 $codes = array();
629 $hashed = array();
630 $charset = 'abcdefghjkmnpqrstuvwxyz23456789'; // Avoid confusable chars
631
632 for ( $i = 0; $i < self::BACKUP_CODE_COUNT; $i++ ) {
633 $code = '';
634 for ( $j = 0; $j < self::BACKUP_CODE_LENGTH; $j++ ) {
635 $code .= $charset[ wp_rand( 0, strlen( $charset ) - 1 ) ];
636 }
637 $codes[] = $code;
638 $hashed[] = wp_hash_password( $code );
639 }
640
641 // Store hashed codes
642 $this->database->store_totp_backup_codes( $user_id, wp_json_encode( $hashed ) );
643
644 $this->log_event( 'totp_backup_codes_generated', $user_id, __( 'Backup codes generated', 'vigilante' ) );
645
646 return $codes;
647 }
648
649 /**
650 * Verify a backup code
651 *
652 * @param int $user_id User ID.
653 * @param string $code Submitted backup code.
654 * @return true|WP_Error
655 */
656 private function verify_backup_code( $user_id, $code ) {
657 // Backup codes are 8 chars, lowercase alphanumeric
658 $code = strtolower( trim( $code ) );
659
660 if ( strlen( $code ) !== self::BACKUP_CODE_LENGTH ) {
661 return new WP_Error( 'invalid_backup', __( 'Invalid backup code.', 'vigilante' ) );
662 }
663
664 $totp_data = $this->database->get_totp_data( $user_id );
665
666 if ( ! $totp_data || empty( $totp_data['backup_codes'] ) ) {
667 return new WP_Error( 'no_backup_codes', __( 'No backup codes available.', 'vigilante' ) );
668 }
669
670 $stored_hashes = json_decode( $totp_data['backup_codes'], true );
671
672 if ( ! is_array( $stored_hashes ) ) {
673 return new WP_Error( 'corrupt_data', __( 'Backup codes data is corrupt.', 'vigilante' ) );
674 }
675
676 // Check each stored hash
677 foreach ( $stored_hashes as $idx => $hash ) {
678 if ( wp_check_password( $code, $hash ) ) {
679 // Remove used code
680 unset( $stored_hashes[ $idx ] );
681 $stored_hashes = array_values( $stored_hashes );
682 $this->database->store_totp_backup_codes( $user_id, wp_json_encode( $stored_hashes ) );
683
684 return true;
685 }
686 }
687
688 return new WP_Error( 'invalid_backup', __( 'Invalid backup code.', 'vigilante' ) );
689 }
690
691 /**
692 * Count remaining backup codes for a user
693 *
694 * @param int $user_id User ID.
695 * @return int
696 */
697 public function count_remaining_backup_codes( $user_id ) {
698 $totp_data = $this->database->get_totp_data( $user_id );
699
700 if ( ! $totp_data || empty( $totp_data['backup_codes'] ) ) {
701 return 0;
702 }
703
704 $codes = json_decode( $totp_data['backup_codes'], true );
705
706 return is_array( $codes ) ? count( $codes ) : 0;
707 }
708
709 // =========================================================================
710 // Secret encryption
711 // =========================================================================
712
713 /**
714 * Encrypt TOTP secret for database storage
715 *
716 * @param string $secret Plain Base32 secret.
717 * @return string Encrypted string (base64).
718 */
719 public function encrypt_secret( $secret ) {
720 $key = $this->get_encryption_key();
721 $iv = openssl_random_pseudo_bytes( 16 );
722
723 $encrypted = openssl_encrypt( $secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
724
725 if ( false === $encrypted ) {
726 return '';
727 }
728
729 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for safe binary storage
730 return base64_encode( $iv . $encrypted );
731 }
732
733 /**
734 * Decrypt TOTP secret from database
735 *
736 * @param string $encrypted Encrypted string (base64).
737 * @return string|false Plain Base32 secret or false.
738 */
739 public function decrypt_secret( $encrypted ) {
740 $key = $this->get_encryption_key();
741
742 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required for binary data retrieval
743 $data = base64_decode( $encrypted, true );
744
745 if ( false === $data || strlen( $data ) < 17 ) {
746 return false;
747 }
748
749 $iv = substr( $data, 0, 16 );
750 $text = substr( $data, 16 );
751
752 $decrypted = openssl_decrypt( $text, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
753
754 return ( false !== $decrypted ) ? $decrypted : false;
755 }
756
757 /**
758 * Get encryption key derived from WordPress salts
759 *
760 * @return string 32-byte key.
761 */
762 private function get_encryption_key() {
763 $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'vigilante_fallback_key';
764 return hash( 'sha256', $salt . 'vigilante_totp', true );
765 }
766
767 // =========================================================================
768 // Base32 encoding/decoding
769 // =========================================================================
770
771 /**
772 * Base32 encode
773 *
774 * @param string $data Raw binary data.
775 * @return string Base32-encoded string.
776 */
777 private function base32_encode( $data ) {
778 $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
779 $binary = '';
780
781 for ( $i = 0; $i < strlen( $data ); $i++ ) {
782 $binary .= str_pad( decbin( ord( $data[ $i ] ) ), 8, '0', STR_PAD_LEFT );
783 }
784
785 $result = '';
786 for ( $i = 0; $i < strlen( $binary ); $i += 5 ) {
787 $chunk = substr( $binary, $i, 5 );
788 $chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT );
789 $result .= $alphabet[ intval( $chunk, 2 ) ];
790 }
791
792 return $result;
793 }
794
795 /**
796 * Base32 decode
797 *
798 * @param string $data Base32-encoded string.
799 * @return string Raw binary data.
800 */
801 private function base32_decode( $data ) {
802 $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
803 $data = strtoupper( rtrim( $data, '=' ) );
804 $binary = '';
805
806 for ( $i = 0; $i < strlen( $data ); $i++ ) {
807 $pos = strpos( $alphabet, $data[ $i ] );
808 if ( false === $pos ) {
809 continue;
810 }
811 $binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT );
812 }
813
814 $result = '';
815 for ( $i = 0; $i + 8 <= strlen( $binary ); $i += 8 ) {
816 $result .= chr( intval( substr( $binary, $i, 8 ), 2 ) );
817 }
818
819 return $result;
820 }
821
822 // =========================================================================
823 // TOTP URI and QR code
824 // =========================================================================
825
826 /**
827 * Generate otpauth:// URI for authenticator apps
828 *
829 * @param string $secret Base32 secret.
830 * @param string $user_email User email.
831 * @return string
832 */
833 public function get_totp_uri( $secret, $user_email ) {
834 $issuer = get_bloginfo( 'name' );
835 $issuer = preg_replace( '/[^a-zA-Z0-9 _-]/', '', $issuer );
836
837 return sprintf(
838 'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d',
839 rawurlencode( $issuer ),
840 rawurlencode( $user_email ),
841 $secret,
842 rawurlencode( $issuer ),
843 self::CODE_LENGTH,
844 self::TIME_STEP
845 );
846 }
847
848 // =========================================================================
849 // User profile section
850 // =========================================================================
851
852 /**
853 * Render TOTP setup/status section in user profile
854 *
855 * @param WP_User $user User being edited.
856 */
857 public function render_user_profile_section( $user ) {
858 // Only show for users that require 2FA
859 if ( ! $this->user_requires_2fa( $user ) ) {
860 return;
861 }
862
863 $totp_data = $this->database->get_totp_data( $user->ID );
864 $configured = $totp_data && ! empty( $totp_data['is_configured'] );
865
866 wp_nonce_field( 'vigilante_totp_profile', 'vigilante_totp_nonce' );
867 ?>
868 <input type="hidden" class="vigilante-totp-user-id" value="<?php echo esc_attr( $user->ID ); ?>">
869 <h2><?php esc_html_e( 'Two-Factor Authentication (TOTP)', 'vigilante' ); ?></h2>
870 <table class="form-table vigilante-totp-profile" role="presentation">
871 <?php if ( $configured ) : ?>
872 <tr>
873 <th scope="row"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
874 <td>
875 <span class="vigilante-totp-status vigilante-totp-active">
876 <span class="dashicons dashicons-yes-alt"></span>
877 <?php esc_html_e( 'Configured and active', 'vigilante' ); ?>
878 </span>
879 <?php if ( ! empty( $totp_data['configured_at'] ) ) : ?>
880 <p class="description">
881 <?php
882 printf(
883 /* translators: %s: Date and time */
884 esc_html__( 'Set up on: %s', 'vigilante' ),
885 esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $totp_data['configured_at'] ) ) )
886 );
887 ?>
888 </p>
889 <?php endif; ?>
890 </td>
891 </tr>
892 <tr>
893 <th scope="row"><?php esc_html_e( 'Backup codes', 'vigilante' ); ?></th>
894 <td>
895 <?php
896 $remaining = $this->count_remaining_backup_codes( $user->ID );
897 $warning = $remaining <= 3;
898 ?>
899 <span class="vigilante-totp-backup-count <?php echo $warning ? 'warning' : ''; ?>">
900 <?php
901 printf(
902 /* translators: 1: Remaining codes, 2: Total codes */
903 esc_html__( '%1$d of %2$d remaining', 'vigilante' ),
904 absint( $remaining ),
905 absint( self::BACKUP_CODE_COUNT )
906 );
907 ?>
908 </span>
909 <p>
910 <button type="button" class="button vigilante-totp-regenerate-backup" data-user="<?php echo esc_attr( $user->ID ); ?>">
911 <?php esc_html_e( 'Generate new backup codes', 'vigilante' ); ?>
912 </button>
913 </p>
914 <div class="vigilante-totp-backup-codes-display" style="display:none;"></div>
915 </td>
916 </tr>
917 <?php if ( current_user_can( 'manage_options' ) || get_current_user_id() === $user->ID ) : ?>
918 <tr>
919 <th scope="row"><?php esc_html_e( 'Reconfigure', 'vigilante' ); ?></th>
920 <td>
921 <button type="button" class="button vigilante-totp-reconfigure" data-user="<?php echo esc_attr( $user->ID ); ?>">
922 <?php esc_html_e( 'Set up new authenticator', 'vigilante' ); ?>
923 </button>
924 <p class="description"><?php esc_html_e( 'This will reset your current TOTP setup and require scanning a new QR code.', 'vigilante' ); ?></p>
925 </td>
926 </tr>
927 <?php endif; ?>
928 <?php else : ?>
929 <tr>
930 <th scope="row"><?php esc_html_e( 'Status', 'vigilante' ); ?></th>
931 <td>
932 <span class="vigilante-totp-status vigilante-totp-pending">
933 <span class="dashicons dashicons-warning"></span>
934 <?php esc_html_e( 'Not configured', 'vigilante' ); ?>
935 </span>
936 <?php
937 if ( $totp_data && ! empty( $totp_data['grace_period_expires'] ) ) {
938 $grace_end = strtotime( $totp_data['grace_period_expires'] );
939 if ( $grace_end > time() ) {
940 $days_left = max( 1, ceil( ( $grace_end - time() ) / DAY_IN_SECONDS ) );
941 echo '<p class="description vigilante-totp-grace-notice">';
942 printf(
943 /* translators: %d: Days remaining */
944 esc_html( _n(
945 'You have %d day to set up two-factor authentication.',
946 'You have %d days to set up two-factor authentication.',
947 $days_left,
948 'vigilante'
949 ) ),
950 absint( $days_left )
951 );
952 echo '</p>';
953 }
954 }
955 ?>
956 </td>
957 </tr>
958 <tr>
959 <th scope="row"><?php esc_html_e( 'Setup', 'vigilante' ); ?></th>
960 <td>
961 <div class="vigilante-totp-setup" id="vigilante-totp-setup">
962 <div class="vigilante-totp-setup-loading">
963 <button type="button" class="button button-primary vigilante-totp-start-setup">
964 <?php esc_html_e( 'Start setup', 'vigilante' ); ?>
965 </button>
966 </div>
967 <div class="vigilante-totp-setup-qr" style="display:none;">
968 <p class="description">
969 <?php esc_html_e( 'Scan this QR code with your authenticator app (Google Authenticator, Authy, Microsoft Authenticator, etc.)', 'vigilante' ); ?>
970 </p>
971 <div class="vigilante-totp-qr-container"></div>
972 <div class="vigilante-totp-manual-key">
973 <p><?php esc_html_e( 'Or enter this key manually:', 'vigilante' ); ?></p>
974 <code class="vigilante-totp-secret-display"></code>
975 </div>
976 <div class="vigilante-totp-verify-setup">
977 <label for="vigilante_totp_verify_code"><?php esc_html_e( 'Enter code to verify:', 'vigilante' ); ?></label>
978 <input type="text" id="vigilante_totp_verify_code" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="off">
979 <button type="button" class="button button-primary vigilante-totp-confirm-setup">
980 <?php esc_html_e( 'Verify and activate', 'vigilante' ); ?>
981 </button>
982 <span class="vigilante-totp-setup-status"></span>
983 </div>
984 </div>
985 <div class="vigilante-totp-setup-success" style="display:none;">
986 <div class="vigilante-totp-success-msg">
987 <span class="dashicons dashicons-yes-alt"></span>
988 <?php esc_html_e( 'Two-factor authentication configured successfully!', 'vigilante' ); ?>
989 </div>
990 <div class="vigilante-totp-backup-codes-display"></div>
991 </div>
992 </div>
993 </td>
994 </tr>
995 <?php endif; ?>
996 </table>
997 <?php
998 }
999
1000 /**
1001 * Enqueue profile page assets (only on user profile pages)
1002 *
1003 * @param string $hook Current admin page hook.
1004 */
1005 public function enqueue_profile_assets( $hook ) {
1006 if ( 'profile.php' !== $hook && 'user-edit.php' !== $hook ) {
1007 return;
1008 }
1009
1010 wp_enqueue_style(
1011 'vigilante-totp-profile',
1012 VIGILANTE_ASSETS_URL . 'css/two-factor-admin.css',
1013 array(),
1014 VIGILANTE_VERSION
1015 );
1016
1017 // QR code generator library (bundled locally for WordPress.org compliance)
1018 wp_enqueue_script(
1019 'qrcode-js',
1020 VIGILANTE_ASSETS_URL . 'js/qrcode.min.js',
1021 array(),
1022 '1.0.0',
1023 true
1024 );
1025
1026 wp_enqueue_script(
1027 'vigilante-totp-profile',
1028 VIGILANTE_ASSETS_URL . 'js/two-factor-admin.js',
1029 array( 'jquery', 'qrcode-js' ),
1030 VIGILANTE_VERSION,
1031 true
1032 );
1033
1034 wp_localize_script( 'vigilante-totp-profile', 'vigilanteTOTP', array(
1035 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1036 'nonce' => wp_create_nonce( 'vigilante_totp_profile' ),
1037 'strings' => array(
1038 'verifying' => __( 'Verifying...', 'vigilante' ),
1039 'generating' => __( 'Generating...', 'vigilante' ),
1040 'success' => __( 'Success!', 'vigilante' ),
1041 'error' => __( 'An error occurred.', 'vigilante' ),
1042 'confirmRegen' => __( 'This will invalidate all existing backup codes. Continue?', 'vigilante' ),
1043 'confirmReconfig' => __( 'This will reset your current TOTP setup. You will need to scan a new QR code. Continue?', 'vigilante' ),
1044 'saveBackupCodes' => __( 'Save these backup codes now. They will not be shown again.', 'vigilante' ),
1045 'codesRemaining' => __( 'backup codes remaining', 'vigilante' ),
1046 ),
1047 ) );
1048 }
1049
1050 /**
1051 * Enqueue login page assets
1052 */
1053 public function enqueue_login_assets() {
1054 wp_enqueue_style(
1055 'vigilante-2fa-login',
1056 VIGILANTE_ASSETS_URL . 'css/two-factor-login.css',
1057 array(),
1058 VIGILANTE_VERSION
1059 );
1060
1061 // Dashicons for the smartphone icon
1062 wp_enqueue_style( 'dashicons' );
1063 }
1064
1065 // =========================================================================
1066 // AJAX handlers
1067 // =========================================================================
1068
1069 /**
1070 * AJAX: Verify TOTP setup code and activate
1071 */
1072 public function ajax_verify_setup() {
1073 check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
1074
1075 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1076 if ( 0 === $user_id ) {
1077 $user_id = get_current_user_id();
1078 }
1079 $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
1080 $secret = isset( $_POST['secret'] ) ? sanitize_text_field( wp_unslash( $_POST['secret'] ) ) : '';
1081 $reconfig = ! empty( $_POST['reconfigure'] );
1082
1083 // Permission check: user can only set up their own, unless admin
1084 if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1085 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1086 }
1087
1088 if ( empty( $code ) || ! preg_match( '/^[0-9]{6}$/', $code ) ) {
1089 wp_send_json_error( __( 'Enter a valid 6-digit code.', 'vigilante' ) );
1090 }
1091
1092 if ( empty( $secret ) || ! preg_match( '/^[A-Z2-7]+=*$/', $secret ) ) {
1093 wp_send_json_error( __( 'Invalid secret. Please reload and try again.', 'vigilante' ) );
1094 }
1095
1096 // Verify the code against the provided secret
1097 $expected_codes = array();
1098 $now = time();
1099 for ( $i = -self::TIME_WINDOW; $i <= self::TIME_WINDOW; $i++ ) {
1100 $expected_codes[] = $this->generate_code( $secret, $now + ( $i * self::TIME_STEP ) );
1101 }
1102
1103 if ( ! in_array( $code, $expected_codes, true ) ) {
1104 wp_send_json_error( __( 'Invalid code. Make sure your authenticator app is set up correctly and the time is synchronized.', 'vigilante' ) );
1105 }
1106
1107 // Encrypt and store secret
1108 $encrypted = $this->encrypt_secret( $secret );
1109
1110 if ( empty( $encrypted ) ) {
1111 wp_send_json_error( __( 'Encryption error. Please try again.', 'vigilante' ) );
1112 }
1113
1114 // Save TOTP data
1115 if ( $reconfig ) {
1116 $this->database->reset_totp_data( $user_id );
1117 }
1118
1119 $this->database->save_totp_data( $user_id, $encrypted );
1120
1121 // Generate backup codes
1122 $backup_codes = $this->generate_backup_codes( $user_id );
1123
1124 $this->log_event( 'totp_configured', $user_id, __( 'TOTP authenticator configured', 'vigilante' ) );
1125
1126 wp_send_json_success( array(
1127 'message' => __( 'Two-factor authentication has been configured successfully.', 'vigilante' ),
1128 'backup_codes' => $backup_codes,
1129 ) );
1130 }
1131
1132 /**
1133 * AJAX: Regenerate backup codes
1134 */
1135 public function ajax_regenerate_backup_codes() {
1136 check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
1137
1138 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1139 if ( 0 === $user_id ) {
1140 $user_id = get_current_user_id();
1141 }
1142
1143 if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1144 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1145 }
1146
1147 $totp_data = $this->database->get_totp_data( $user_id );
1148
1149 if ( ! $totp_data || empty( $totp_data['is_configured'] ) ) {
1150 wp_send_json_error( __( 'TOTP is not configured for this user.', 'vigilante' ) );
1151 }
1152
1153 $backup_codes = $this->generate_backup_codes( $user_id );
1154
1155 wp_send_json_success( array(
1156 'backup_codes' => $backup_codes,
1157 ) );
1158 }
1159
1160 /**
1161 * AJAX: Reconfigure TOTP (reset and allow re-setup from profile)
1162 */
1163 public function ajax_reconfigure() {
1164 check_ajax_referer( 'vigilante_totp_profile', 'nonce' );
1165
1166 $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0;
1167 if ( 0 === $user_id ) {
1168 $user_id = get_current_user_id();
1169 }
1170
1171 if ( get_current_user_id() !== $user_id && ! current_user_can( 'manage_options' ) ) {
1172 wp_send_json_error( __( 'Permission denied.', 'vigilante' ) );
1173 }
1174
1175 $this->reset_user_totp( $user_id );
1176
1177 wp_send_json_success( array(
1178 'message' => __( 'TOTP has been reset. You can now set up a new authenticator.', 'vigilante' ),
1179 ) );
1180 }
1181
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 // =========================================================================
1330 // Grace period admin notice and forced redirect
1331 // =========================================================================
1332
1333 /**
1334 * Force redirect to profile page when grace period has expired
1335 * and TOTP is not yet configured.
1336 *
1337 * During grace period: user can browse freely, only a notice is shown.
1338 * After grace period: user is locked to profile page until setup is complete.
1339 */
1340 public function force_totp_setup_redirect() {
1341 // Don't redirect on AJAX requests
1342 if ( wp_doing_ajax() ) {
1343 return;
1344 }
1345
1346 $user = wp_get_current_user();
1347
1348 if ( ! $user->ID || ! $this->user_requires_2fa( $user ) ) {
1349 return;
1350 }
1351
1352 $totp_data = $this->database->get_totp_data( $user->ID );
1353
1354 // Already configured - no redirect needed
1355 if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
1356 return;
1357 }
1358
1359 // No TOTP data at all - first login just happened, allow freely
1360 if ( ! $totp_data ) {
1361 return;
1362 }
1363
1364 // Check if grace period is still active
1365 if ( ! empty( $totp_data['grace_period_expires'] ) && strtotime( $totp_data['grace_period_expires'] ) > time() ) {
1366 // Grace period active - user can browse freely, just notice shown
1367 return;
1368 }
1369
1370 // Grace period expired - force redirect to profile (unless already there)
1371 global $pagenow;
1372 if ( 'profile.php' === $pagenow || 'user-edit.php' === $pagenow ) {
1373 return;
1374 }
1375
1376 wp_safe_redirect( admin_url( 'profile.php#vigilante-totp-setup' ) );
1377 exit;
1378 }
1379
1380 /**
1381 * Show admin notice for users who need to set up TOTP
1382 */
1383 public function show_grace_period_notice() {
1384 $user = wp_get_current_user();
1385
1386 if ( ! $this->user_requires_2fa( $user ) ) {
1387 return;
1388 }
1389
1390 $totp_data = $this->database->get_totp_data( $user->ID );
1391
1392 if ( $totp_data && ! empty( $totp_data['is_configured'] ) ) {
1393 return;
1394 }
1395
1396 $profile_url = admin_url( 'profile.php#vigilante-totp-setup' );
1397 $grace_end = ( $totp_data && ! empty( $totp_data['grace_period_expires'] ) )
1398 ? strtotime( $totp_data['grace_period_expires'] )
1399 : 0;
1400 $days_left = ( $grace_end > time() ) ? max( 1, ceil( ( $grace_end - time() ) / DAY_IN_SECONDS ) ) : 0;
1401 ?>
1402 <div class="notice notice-warning vigilante-totp-grace-notice">
1403 <p>
1404 <span class="dashicons dashicons-shield"></span>
1405 <strong><?php esc_html_e( 'Two-factor authentication setup required', 'vigilante' ); ?></strong>
1406 &mdash;
1407 <?php if ( $days_left > 0 ) : ?>
1408 <?php
1409 printf(
1410 /* translators: %d: Days remaining */
1411 esc_html( _n(
1412 'You have %d day to set up your authenticator app.',
1413 'You have %d days to set up your authenticator app.',
1414 $days_left,
1415 'vigilante'
1416 ) ),
1417 absint( $days_left )
1418 );
1419 ?>
1420 <?php else : ?>
1421 <?php esc_html_e( 'Please set up your authenticator app now.', 'vigilante' ); ?>
1422 <?php endif; ?>
1423 <a href="<?php echo esc_url( $profile_url ); ?>"><?php esc_html_e( 'Set up now', 'vigilante' ); ?></a>
1424 </p>
1425 </div>
1426 <?php
1427 }
1428
1429 // =========================================================================
1430 // Admin TOTP reset (called from admin-ajax)
1431 // =========================================================================
1432
1433 /**
1434 * Reset TOTP for a user (admin action)
1435 *
1436 * @param int $user_id User ID to reset.
1437 * @return bool
1438 */
1439 public function reset_user_totp( $user_id ) {
1440 $this->database->reset_totp_data( $user_id );
1441
1442 // If grace period is configured, set a new one
1443 $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
1444 if ( $grace_days > 0 ) {
1445 $grace_expires = gmdate( 'Y-m-d H:i:s', time() + ( $grace_days * DAY_IN_SECONDS ) );
1446 $this->database->create_totp_placeholder( $user_id, $grace_expires );
1447 }
1448
1449 // Revoke trusted devices
1450 $this->database->revoke_trusted_devices( $user_id );
1451
1452 $this->log_event( 'totp_reset', $user_id, __( 'TOTP configuration reset by administrator', 'vigilante' ), 'warning' );
1453
1454 return true;
1455 }
1456
1457 /**
1458 * Get TOTP setup data for a new setup (generates secret and QR)
1459 *
1460 * @param int $user_id User ID.
1461 * @return array Setup data with secret, uri, and qr_svg.
1462 */
1463 public function get_setup_data( $user_id ) {
1464 $user = get_user_by( 'ID', $user_id );
1465
1466 if ( ! $user ) {
1467 return array();
1468 }
1469
1470 $secret = $this->generate_secret();
1471 $uri = $this->get_totp_uri( $secret, $user->user_email );
1472
1473 return array(
1474 'secret' => $secret,
1475 'uri' => $uri,
1476 );
1477 }
1478
1479 // =========================================================================
1480 // HTML styled email for TOTP activation notification
1481 // =========================================================================
1482
1483 /**
1484 * Send TOTP activation notification email
1485 *
1486 * @param WP_User $user User object.
1487 * @param string $site_name Site name.
1488 * @param string $from_name Email from name.
1489 * @return bool
1490 */
1491 public function send_activation_email( $user, $site_name, $from_name ) {
1492 $profile_url = admin_url( 'profile.php' );
1493 $grace_days = absint( $this->options['grace_period_days'] ?? 3 );
1494
1495 $subject = sprintf(
1496 /* translators: %s: Site name */
1497 __( '[%s] Set up two-factor authentication for your account', 'vigilante' ),
1498 $site_name
1499 );
1500
1501 $body = Vigilante_Email_Template::p(
1502 sprintf(
1503 /* translators: 1: User display name, 2: Site name */
1504 __( 'Hello %1$s, the administrator of %2$s has enabled two-factor authentication using an authenticator app for your account.', 'vigilante' ),
1505 $user->display_name,
1506 $site_name
1507 )
1508 );
1509 $body .= Vigilante_Email_Template::p( __( 'Install an authenticator app on your phone if you do not have one:', 'vigilante' ) );
1510 $body .= Vigilante_Email_Template::ul( array(
1511 '<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator (Android)</a>',
1512 '<a href="https://apps.apple.com/app/google-authenticator/id388497605">Google Authenticator (iOS)</a>',
1513 '<a href="https://authy.com/download/">Authy (Android / iOS)</a>',
1514 '<a href="https://www.microsoft.com/en-us/security/mobile-authenticator-app">Microsoft Authenticator</a>',
1515 ) );
1516
1517 if ( $grace_days > 0 ) {
1518 $body .= Vigilante_Email_Template::warning_box(
1519 sprintf(
1520 /* translators: %d: Number of days */
1521 __( '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' ),
1522 $grace_days
1523 )
1524 );
1525 } else {
1526 $body .= Vigilante_Email_Template::alert_box( __( 'You must configure your authenticator app on your next login.', 'vigilante' ) );
1527 }
1528
1529 $body .= Vigilante_Email_Template::button( $profile_url, __( 'Set up now', 'vigilante' ) );
1530
1531 // Pass from_name via header (avoids filter contamination)
1532 $sent = Vigilante_Email_Template::send(
1533 $user->user_email,
1534 $subject,
1535 __( 'Two-factor authentication enabled', 'vigilante' ),
1536 $body,
1537 false,
1538 $from_name
1539 );
1540
1541 return $sent;
1542 }
1543
1544 // =========================================================================
1545 // Logging helper
1546 // =========================================================================
1547
1548 /**
1549 * Log TOTP event
1550 *
1551 * @param string $action Action name.
1552 * @param int $user_id User ID.
1553 * @param string $message Message.
1554 * @param string $severity Severity level.
1555 */
1556 private function log_event( $action, $user_id, $message, $severity = 'info' ) {
1557 if ( $this->activity_log ) {
1558 $this->activity_log->log(
1559 '2fa',
1560 $action,
1561 $message,
1562 array( 'user_id' => $user_id ),
1563 $severity
1564 );
1565 }
1566 }
1567 }
1568