PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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 2.9.4 All 87 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.11.8, at includes/class-two-factor-totp.php

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