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

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