PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.1
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.1
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-database.php

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

1,600 lines 58.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Database Class
4 *
5 * Handles database operations for activity log and login attempts
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Database
17 *
18 * Manages custom database tables
19 */
20 class Vigilante_Database {
21
22 /**
23 * Database version for migrations
24 */
25 const DB_VERSION = '1.4.0';
26
27 /**
28 * Option name for storing DB version
29 */
30 const DB_VERSION_OPTION = 'vigilante_db_version';
31
32 /**
33 * Activity log table name (without prefix)
34 *
35 * @var string
36 */
37 private $activity_log_table = 'vigilante_activity_log';
38
39 /**
40 * Login attempts table name (without prefix)
41 *
42 * @var string
43 */
44 private $login_attempts_table = 'vigilante_login_attempts';
45
46 /**
47 * File integrity table name (without prefix)
48 *
49 * @var string
50 */
51 private $file_integrity_table = 'vigilante_file_integrity';
52
53 /**
54 * 2FA codes table name (without prefix)
55 *
56 * @var string
57 */
58 private $two_factor_codes_table = 'vigilante_2fa_codes';
59
60 /**
61 * 2FA trusted devices table name (without prefix)
62 *
63 * @var string
64 */
65 private $two_factor_devices_table = 'vigilante_2fa_trusted_devices';
66
67 /**
68 * 2FA notifications table name (without prefix)
69 *
70 * @var string
71 */
72 private $two_factor_notifications_table = 'vigilante_2fa_notifications';
73
74 /**
75 * 2FA TOTP secrets table name (without prefix)
76 *
77 * @var string
78 */
79 private $two_factor_totp_table = 'vigilante_2fa_totp';
80
81 /**
82 * WordPress database instance
83 *
84 * @var wpdb
85 */
86 private $wpdb;
87
88 /**
89 * Constructor
90 */
91 public function __construct() {
92 global $wpdb;
93 $this->wpdb = $wpdb;
94 }
95
96 /**
97 * Get full table name with prefix
98 *
99 * @param string $table Table name without prefix.
100 * @return string Full table name.
101 */
102 public function get_table_name( $table ) {
103 return $this->wpdb->prefix . $table;
104 }
105
106 /**
107 * Get escaped table name for use in SQL queries
108 *
109 * @param string $table Full table name.
110 * @return string Escaped table name with backticks.
111 */
112 private function esc_table( $table ) {
113 return '`' . esc_sql( $table ) . '`';
114 }
115
116 /**
117 * Get activity log table name
118 *
119 * @return string
120 */
121 public function get_activity_log_table() {
122 return $this->get_table_name( $this->activity_log_table );
123 }
124
125 /**
126 * Get login attempts table name
127 *
128 * @return string
129 */
130 public function get_login_attempts_table() {
131 return $this->get_table_name( $this->login_attempts_table );
132 }
133
134 /**
135 * Get file integrity table name
136 *
137 * @return string
138 */
139 public function get_file_integrity_table() {
140 return $this->get_table_name( $this->file_integrity_table );
141 }
142
143 /**
144 * Get 2FA codes table name
145 *
146 * @return string
147 */
148 public function get_2fa_codes_table() {
149 return $this->get_table_name( $this->two_factor_codes_table );
150 }
151
152 /**
153 * Get 2FA trusted devices table name
154 *
155 * @return string
156 */
157 public function get_2fa_devices_table() {
158 return $this->get_table_name( $this->two_factor_devices_table );
159 }
160
161 /**
162 * Get 2FA notifications table name
163 *
164 * @return string
165 */
166 public function get_2fa_notifications_table() {
167 return $this->get_table_name( $this->two_factor_notifications_table );
168 }
169
170 /**
171 * Get TOTP secrets table name with prefix
172 *
173 * @return string
174 */
175 public function get_totp_table() {
176 return $this->get_table_name( $this->two_factor_totp_table );
177 }
178
179 /**
180 * Create all required database tables
181 *
182 * @return bool True on success.
183 */
184 public function create_tables() {
185 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
186
187 $charset_collate = $this->wpdb->get_charset_collate();
188 $result = true;
189
190 // Activity Log table
191 $activity_log_sql = "CREATE TABLE {$this->get_activity_log_table()} (
192 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
193 event_type varchar(50) NOT NULL,
194 event_action varchar(100) NOT NULL,
195 event_message text NOT NULL,
196 user_id bigint(20) unsigned DEFAULT 0,
197 user_login varchar(60) DEFAULT '',
198 ip_address varchar(45) DEFAULT '',
199 user_agent text,
200 request_method varchar(10) DEFAULT '',
201 object_type varchar(50) DEFAULT '',
202 object_id bigint(20) unsigned DEFAULT 0,
203 object_name varchar(255) DEFAULT '',
204 severity varchar(20) DEFAULT 'info',
205 extra_data longtext,
206 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
207 PRIMARY KEY (id),
208 KEY event_type (event_type),
209 KEY event_action (event_action),
210 KEY user_id (user_id),
211 KEY ip_address (ip_address),
212 KEY severity (severity),
213 KEY request_method (request_method),
214 KEY created_at (created_at)
215 ) $charset_collate;";
216
217 dbDelta( $activity_log_sql );
218
219 // Login Attempts table
220 $login_attempts_sql = "CREATE TABLE {$this->get_login_attempts_table()} (
221 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
222 ip_address varchar(45) NOT NULL,
223 username varchar(60) NOT NULL,
224 attempt_type varchar(20) NOT NULL DEFAULT 'login',
225 status varchar(20) NOT NULL DEFAULT 'failed',
226 user_agent text,
227 lockout_until datetime DEFAULT NULL,
228 attempt_count int(11) unsigned DEFAULT 1,
229 last_attempt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
230 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
231 PRIMARY KEY (id),
232 KEY ip_address (ip_address),
233 KEY username (username),
234 KEY status (status),
235 KEY lockout_until (lockout_until),
236 KEY last_attempt (last_attempt),
237 UNIQUE KEY ip_username (ip_address, username)
238 ) $charset_collate;";
239
240 dbDelta( $login_attempts_sql );
241
242 // File Integrity table
243 $file_integrity_sql = "CREATE TABLE {$this->get_file_integrity_table()} (
244 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
245 file_path varchar(500) NOT NULL,
246 file_hash varchar(64) NOT NULL,
247 file_size bigint(20) unsigned NOT NULL DEFAULT 0,
248 file_type varchar(50) NOT NULL DEFAULT 'core',
249 status varchar(20) NOT NULL DEFAULT 'ok',
250 last_checked datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
251 last_modified datetime DEFAULT NULL,
252 extra_data text,
253 PRIMARY KEY (id),
254 KEY file_type (file_type),
255 KEY status (status),
256 KEY last_checked (last_checked),
257 UNIQUE KEY file_path (file_path(255))
258 ) $charset_collate;";
259
260 dbDelta( $file_integrity_sql );
261
262 // 2FA Codes table
263 $two_factor_codes_sql = "CREATE TABLE {$this->get_2fa_codes_table()} (
264 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
265 user_id bigint(20) unsigned NOT NULL,
266 code varchar(64) NOT NULL,
267 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
268 expires_at datetime NOT NULL,
269 attempts int(11) unsigned DEFAULT 0,
270 used tinyint(1) DEFAULT 0,
271 PRIMARY KEY (id),
272 KEY user_id (user_id),
273 KEY expires_at (expires_at)
274 ) $charset_collate;";
275
276 dbDelta( $two_factor_codes_sql );
277
278 // 2FA Trusted Devices table
279 $two_factor_devices_sql = "CREATE TABLE {$this->get_2fa_devices_table()} (
280 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
281 user_id bigint(20) unsigned NOT NULL,
282 device_hash varchar(64) NOT NULL,
283 user_agent text,
284 created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
285 expires_at datetime NOT NULL,
286 PRIMARY KEY (id),
287 KEY user_id (user_id),
288 KEY device_hash (device_hash),
289 KEY expires_at (expires_at)
290 ) $charset_collate;";
291
292 dbDelta( $two_factor_devices_sql );
293
294 // 2FA Notifications table (tracks which users have been notified)
295 $two_factor_notifications_sql = "CREATE TABLE {$this->get_2fa_notifications_table()} (
296 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
297 user_id bigint(20) unsigned NOT NULL,
298 sent_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
299 PRIMARY KEY (id),
300 UNIQUE KEY user_id (user_id)
301 ) $charset_collate;";
302
303 dbDelta( $two_factor_notifications_sql );
304
305 // 2FA TOTP secrets table
306 $two_factor_totp_sql = "CREATE TABLE {$this->get_totp_table()} (
307 id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
308 user_id bigint(20) unsigned NOT NULL,
309 secret text NOT NULL,
310 backup_codes text,
311 is_configured tinyint(1) DEFAULT 0,
312 configured_at datetime DEFAULT NULL,
313 last_used_at datetime DEFAULT NULL,
314 grace_period_expires datetime DEFAULT NULL,
315 PRIMARY KEY (id),
316 UNIQUE KEY user_id (user_id)
317 ) $charset_collate;";
318
319 dbDelta( $two_factor_totp_sql );
320
321 // Store database version
322 update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
323
324 return $result;
325 }
326
327 /**
328 * Check if tables need to be updated
329 *
330 * @return bool True if update needed.
331 */
332 public function needs_update() {
333 $current_version = get_option( self::DB_VERSION_OPTION, '0' );
334 return version_compare( $current_version, self::DB_VERSION, '<' );
335 }
336
337 /**
338 * Run database migrations
339 *
340 * Handles schema changes between versions.
341 */
342 public function run_migrations() {
343 $current_version = get_option( self::DB_VERSION_OPTION, '0' );
344
345 // v1.3.0: Add request_method column to activity log
346 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside the multiline prepare().
347 if ( version_compare( $current_version, '1.3.0', '<' ) ) {
348 $table = $this->get_activity_log_table();
349
350 // Check if column already exists
351 $column_exists = $this->wpdb->get_results(
352 $this->wpdb->prepare(
353 'SHOW COLUMNS FROM %i LIKE %s',
354 $table,
355 'request_method'
356 )
357 );
358
359 if ( empty( $column_exists ) ) {
360 $this->wpdb->query(
361 $this->wpdb->prepare(
362 'ALTER TABLE %i ADD COLUMN request_method varchar(10) DEFAULT %s AFTER user_agent',
363 $table,
364 ''
365 )
366 );
367
368 // Add index
369 $this->wpdb->query(
370 $this->wpdb->prepare(
371 'ALTER TABLE %i ADD KEY request_method (request_method)',
372 $table
373 )
374 );
375 }
376 }
377 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
378
379 // Update stored version
380 update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
381 }
382
383 /**
384 * Schema changes and purges of the 2.11.0 security release
385 *
386 * Called from the 2.11.0 block of Vigilante_Admin::run_migrations(), not
387 * from needs_update(): the vigilante_db_version option is shared with that
388 * chain and on any updated site it already holds a plugin version (2.9.9 or
389 * later), so a bump of DB_VERSION would never fire. create_tables() widens
390 * the code column on its own through dbDelta; this method does what dbDelta
391 * cannot, which is deleting rows.
392 *
393 * - Trusted devices identified a browser by its User-Agent (S1). If the old
394 * rows survived, the bypass would survive with them.
395 * - Email codes were stored in clear (S11). They are compared against a
396 * hash from now on, so any pending code would fail; they expire in
397 * minutes and a new one is a click away.
398 *
399 * @since 2.11.0
400 */
401 public function purge_for_2_11_0() {
402 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+, and the sniff reports inside prepare(). Plugin tables, no cache to invalidate.
403 $this->wpdb->query(
404 $this->wpdb->prepare( 'DELETE FROM %i', $this->get_2fa_devices_table() )
405 );
406 $this->wpdb->query(
407 $this->wpdb->prepare( 'DELETE FROM %i', $this->get_2fa_codes_table() )
408 );
409 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
410 }
411
412 /**
413 * Drop all plugin tables
414 *
415 * @return bool
416 */
417 public function drop_tables() {
418 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
419 $tables = array(
420 $this->get_activity_log_table(),
421 $this->get_login_attempts_table(),
422 $this->get_file_integrity_table(),
423 $this->get_2fa_codes_table(),
424 $this->get_2fa_devices_table(),
425 $this->get_2fa_notifications_table(),
426 $this->get_totp_table(),
427 );
428
429 foreach ( $tables as $table ) {
430 $this->wpdb->query( $this->wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter
431 }
432 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
433
434 delete_option( self::DB_VERSION_OPTION );
435
436 return true;
437 }
438
439 // =========================================================================
440 // ACTIVITY LOG METHODS
441 // =========================================================================
442
443 /**
444 * Check if activity log table exists
445 *
446 * @return bool
447 */
448 private function activity_log_table_exists() {
449 $table = $this->get_activity_log_table();
450 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
451 $result = $this->wpdb->get_var( $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
452 return $result === $table;
453 }
454
455 /**
456 * Insert activity log entry
457 *
458 * @param array $data Log data.
459 * @return int|false Insert ID or false on failure.
460 */
461 public function insert_activity_log( $data ) {
462 // Verify table exists before inserting (prevents errors in Plugin Check environment)
463 if ( ! $this->activity_log_table_exists() ) {
464 return false;
465 }
466
467 $defaults = array(
468 'event_type' => 'general',
469 'event_action' => '',
470 'event_message' => '',
471 'user_id' => get_current_user_id(),
472 'user_login' => '',
473 'ip_address' => $this->get_client_ip(),
474 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
475 'request_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '',
476 'object_type' => '',
477 'object_id' => 0,
478 'object_name' => '',
479 'severity' => 'info',
480 'extra_data' => '',
481 'created_at' => current_time( 'mysql' ),
482 );
483
484 $data = wp_parse_args( $data, $defaults );
485
486 // Get username if not provided
487 if ( empty( $data['user_login'] ) && $data['user_id'] > 0 ) {
488 $user = get_userdata( $data['user_id'] );
489 if ( $user ) {
490 $data['user_login'] = $user->user_login;
491 }
492 }
493
494 // Serialize extra data if array
495 if ( is_array( $data['extra_data'] ) ) {
496 $data['extra_data'] = wp_json_encode( $data['extra_data'] );
497 }
498
499 // Sanitize data
500 $data = array(
501 'event_type' => sanitize_key( $data['event_type'] ),
502 'event_action' => sanitize_text_field( $data['event_action'] ),
503 'event_message' => sanitize_textarea_field( $data['event_message'] ),
504 'user_id' => absint( $data['user_id'] ),
505 'user_login' => sanitize_user( $data['user_login'] ),
506 'ip_address' => sanitize_text_field( $data['ip_address'] ),
507 'user_agent' => sanitize_textarea_field( substr( $data['user_agent'], 0, 500 ) ),
508 'request_method' => sanitize_text_field( strtoupper( substr( $data['request_method'], 0, 10 ) ) ),
509 'object_type' => sanitize_key( $data['object_type'] ),
510 'object_id' => absint( $data['object_id'] ),
511 'object_name' => sanitize_text_field( $data['object_name'] ),
512 'severity' => sanitize_key( $data['severity'] ),
513 'extra_data' => $data['extra_data'],
514 'created_at' => $data['created_at'],
515 );
516
517 $result = $this->wpdb->insert(
518 $this->get_activity_log_table(),
519 $data,
520 array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s' )
521 );
522
523 return $result ? $this->wpdb->insert_id : false;
524 }
525
526 /**
527 * Get activity log entries
528 *
529 * @param array $args Query arguments.
530 * @return array
531 */
532 public function get_activity_logs( $args = array() ) {
533 $defaults = array(
534 'per_page' => 50,
535 'page' => 1,
536 'event_type' => '',
537 'severity' => '',
538 'request_method' => '',
539 'search' => '',
540 'date_from' => '',
541 'date_to' => '',
542 );
543
544 $args = wp_parse_args( $args, $defaults );
545 $table = $this->get_activity_log_table();
546
547 // Sanitize inputs
548 $event_type = sanitize_key( $args['event_type'] );
549 $severity = sanitize_key( $args['severity'] );
550 $request_method = sanitize_text_field( $args['request_method'] );
551 $search = sanitize_text_field( $args['search'] );
552
553 // Use default dates for empty values (MySQL requires valid DATETIME)
554 $date_from = ! empty( $args['date_from'] ) ? sanitize_text_field( $args['date_from'] ) : '1970-01-01 00:00:00';
555 $date_to = ! empty( $args['date_to'] ) ? sanitize_text_field( $args['date_to'] ) : '9999-12-31 23:59:59';
556
557 // Calculate pagination
558 $per_page = absint( $args['per_page'] );
559 $offset = ( absint( $args['page'] ) - 1 ) * $per_page;
560
561 if ( ! empty( $search ) ) {
562 $like = '%' . $this->wpdb->esc_like( $search ) . '%';
563 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
564 $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM %i WHERE (event_type = %s OR %s = '') AND (severity = %s OR %s = '') AND (request_method = %s OR %s = '') AND created_at >= %s AND created_at <= %s AND (event_message LIKE %s OR user_login LIKE %s OR ip_address LIKE %s OR user_agent LIKE %s OR object_name LIKE %s OR extra_data LIKE %s) ORDER BY created_at DESC LIMIT %d OFFSET %d", $table, $event_type, $event_type, $severity, $severity, $request_method, $request_method, $date_from, $date_to, $like, $like, $like, $like, $like, $like, $per_page, $offset ) );
565 } else {
566 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
567 $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM %i WHERE (event_type = %s OR %s = '') AND (severity = %s OR %s = '') AND (request_method = %s OR %s = '') AND created_at >= %s AND created_at <= %s ORDER BY created_at DESC LIMIT %d OFFSET %d", $table, $event_type, $event_type, $severity, $severity, $request_method, $request_method, $date_from, $date_to, $per_page, $offset ) );
568 }
569
570 return $results ? $results : array();
571 }
572
573 /**
574 * Get total count of activity logs
575 *
576 * @param array $args Query arguments (same as get_activity_logs).
577 * @return int
578 */
579 public function get_activity_logs_count( $args = array() ) {
580 $table = $this->get_activity_log_table();
581
582 // Sanitize inputs
583 $event_type = isset( $args['event_type'] ) ? sanitize_key( $args['event_type'] ) : '';
584 $severity = isset( $args['severity'] ) ? sanitize_key( $args['severity'] ) : '';
585 $request_method = isset( $args['request_method'] ) ? sanitize_text_field( $args['request_method'] ) : '';
586 $search = isset( $args['search'] ) ? sanitize_text_field( $args['search'] ) : '';
587
588 // Use default dates for empty values (MySQL requires valid DATETIME)
589 $date_from = ! empty( $args['date_from'] ) ? sanitize_text_field( $args['date_from'] ) : '1970-01-01 00:00:00';
590 $date_to = ! empty( $args['date_to'] ) ? sanitize_text_field( $args['date_to'] ) : '9999-12-31 23:59:59';
591
592 if ( ! empty( $search ) ) {
593 $like = '%' . $this->wpdb->esc_like( $search ) . '%';
594 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
595 $count = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE (event_type = %s OR %s = '') AND (severity = %s OR %s = '') AND (request_method = %s OR %s = '') AND created_at >= %s AND created_at <= %s AND (event_message LIKE %s OR user_login LIKE %s OR ip_address LIKE %s OR user_agent LIKE %s OR object_name LIKE %s OR extra_data LIKE %s)", $table, $event_type, $event_type, $severity, $severity, $request_method, $request_method, $date_from, $date_to, $like, $like, $like, $like, $like, $like ) );
596 } else {
597 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
598 $count = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE (event_type = %s OR %s = '') AND (severity = %s OR %s = '') AND (request_method = %s OR %s = '') AND created_at >= %s AND created_at <= %s", $table, $event_type, $event_type, $severity, $severity, $request_method, $request_method, $date_from, $date_to ) );
599 }
600
601 return absint( $count );
602 }
603
604 /**
605 * Delete old activity logs
606 *
607 * @param int $days Days to keep.
608 * @return int Number of deleted rows.
609 */
610 public function cleanup_old_activity_logs( $days = 30 ) {
611 $table = $this->get_activity_log_table();
612 $date = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
613
614 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
615 $deleted = $this->wpdb->query( $this->wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s', $table, $date ) );
616
617 return $deleted ? $deleted : 0;
618 }
619
620 /**
621 * Truncate activity log table
622 *
623 * @return bool
624 */
625 public function truncate_activity_log() {
626 $table = $this->get_activity_log_table();
627 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
628 return false !== $this->wpdb->query( $this->wpdb->prepare( 'TRUNCATE TABLE %i', $table ) );
629 }
630
631 // =========================================================================
632 // LOGIN ATTEMPTS METHODS
633 // =========================================================================
634
635 /**
636 * Check if login attempts table exists
637 *
638 * @return bool
639 */
640 private function login_attempts_table_exists() {
641 $table = $this->get_login_attempts_table();
642 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
643 $result = $this->wpdb->get_var( $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
644 return $result === $table;
645 }
646
647 /**
648 * Record a login attempt
649 *
650 * @param string $ip_address IP address.
651 * @param string $username Username attempted.
652 * @param string $status Status: 'failed', 'success', 'lockout'.
653 * @return int|false
654 */
655 public function record_login_attempt( $ip_address, $username, $status = 'failed' ) {
656 // Verify table exists before inserting
657 if ( ! $this->login_attempts_table_exists() ) {
658 return false;
659 }
660
661 $table = $this->get_login_attempts_table();
662 $ip_address = sanitize_text_field( $ip_address );
663 $username = sanitize_user( $username );
664 $status = sanitize_key( $status );
665 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
666
667 /*
668 * UTC, like every other timestamp this table is compared against.
669 * Until 2.11.0 last_attempt was written in the site's local time while
670 * get_failed_attempt_count() compared it against a UTC window and
671 * set_lockout() wrote lockout_until in UTC, so the login lockout only
672 * worked on sites whose timezone is UTC: with a positive offset the
673 * lockout was never seen as active, with a negative one the attempts
674 * were never counted (S18, found on 5 Sep 2026 while testing S8).
675 */
676 $now = current_time( 'mysql', true );
677
678 // Check if record exists for this IP + username
679 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
680 $existing = $this->wpdb->get_row( $this->wpdb->prepare( 'SELECT * FROM %i WHERE ip_address = %s AND username = %s', $table, $ip_address, $username ), ARRAY_A );
681
682 if ( $existing ) {
683 // An active lockout keeps its status: recording a failure on top
684 // of it used to flip the row back to 'failed', so the lockout
685 // vanished from is_locked_out() the moment anyone tried again (S8).
686 $locked = 'lockout' === $existing['status']
687 && ! empty( $existing['lockout_until'] )
688 && $existing['lockout_until'] > $now;
689
690 // Update existing record
691 $data = array(
692 'status' => $locked ? 'lockout' : $status,
693 'attempt_count' => $existing['attempt_count'] + 1,
694 'last_attempt' => $now,
695 'user_agent' => $user_agent,
696 );
697
698 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
699 $this->wpdb->update(
700 $table,
701 $data,
702 array(
703 'ip_address' => $ip_address,
704 'username' => $username,
705 ),
706 array( '%s', '%d', '%s', '%s' ),
707 array( '%s', '%s' )
708 );
709
710 return $existing['id'];
711 } else {
712 // Insert new record
713 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
714 $this->wpdb->insert(
715 $table,
716 array(
717 'ip_address' => $ip_address,
718 'username' => $username,
719 'status' => $status,
720 'user_agent' => $user_agent,
721 'attempt_count' => 1,
722 'last_attempt' => $now,
723 'created_at' => $now,
724 ),
725 array( '%s', '%s', '%s', '%s', '%d', '%s', '%s' )
726 );
727
728 return $this->wpdb->insert_id;
729 }
730 }
731
732 /**
733 * Get login attempts for an IP
734 *
735 * @param string $ip_address IP address.
736 * @param int $minutes Minutes to look back.
737 * @return array
738 */
739 public function get_login_attempts( $ip_address, $minutes = 30 ) {
740 $table = $this->get_login_attempts_table();
741 $since = gmdate( 'Y-m-d H:i:s', strtotime( "-{$minutes} minutes" ) );
742
743 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
744 $results = $this->wpdb->get_results( $this->wpdb->prepare( 'SELECT * FROM %i WHERE ip_address = %s AND last_attempt >= %s ORDER BY last_attempt DESC', $table, $ip_address, $since ), ARRAY_A );
745
746 return $results ? $results : array();
747 }
748
749 /**
750 * Get failed attempt count for an IP
751 *
752 * @param string $ip_address IP address.
753 * @param int $minutes Minutes to look back.
754 * @return int
755 */
756 public function get_failed_attempt_count( $ip_address, $minutes = 30 ) {
757 $table = $this->get_login_attempts_table();
758 $since = gmdate( 'Y-m-d H:i:s', strtotime( "-{$minutes} minutes" ) );
759
760 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
761 $count = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT SUM(attempt_count) FROM %i WHERE ip_address = %s AND status = 'failed' AND last_attempt >= %s", $table, $ip_address, $since ) );
762
763 return absint( $count );
764 }
765
766 /**
767 * Set lockout for an IP
768 *
769 * @param string $ip_address IP address.
770 * @param int $seconds Lockout duration in seconds.
771 * @return bool
772 */
773 public function set_lockout( $ip_address, $seconds ) {
774 $table = $this->get_login_attempts_table();
775 $lockout_until = gmdate( 'Y-m-d H:i:s', time() + $seconds );
776
777 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
778 return false !== $this->wpdb->query( $this->wpdb->prepare( "UPDATE %i SET lockout_until = %s, status = 'lockout' WHERE ip_address = %s", $table, $lockout_until, $ip_address ) );
779 }
780
781 /**
782 * Check if an IP is locked out
783 *
784 * @param string $ip_address IP address.
785 * @return array|false Lockout data or false if not locked.
786 */
787 public function is_locked_out( $ip_address ) {
788 $table = $this->get_login_attempts_table();
789 // UTC: lockout_until is written with gmdate(). See record_login_attempt() (S18).
790 $now = current_time( 'mysql', true );
791
792 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
793 $lockout = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM %i WHERE ip_address = %s AND lockout_until > %s AND status = 'lockout' ORDER BY lockout_until DESC LIMIT 1", $table, $ip_address, $now ), ARRAY_A );
794
795 return $lockout ? $lockout : false;
796 }
797
798 /**
799 * Clear lockout for an IP
800 *
801 * @param string $ip_address IP address.
802 * @return bool
803 */
804 public function clear_lockout( $ip_address ) {
805 $table = $this->get_login_attempts_table();
806
807 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
808 return false !== $this->wpdb->query( $this->wpdb->prepare( "UPDATE %i SET lockout_until = NULL, status = 'cleared', attempt_count = 0 WHERE ip_address = %s", $table, $ip_address ) );
809 }
810
811 /**
812 * Get all active lockouts
813 *
814 * @return array List of locked IPs with their data.
815 */
816 public function get_active_lockouts() {
817 $table = $this->get_login_attempts_table();
818 // UTC: lockout_until is written with gmdate(). See record_login_attempt() (S18).
819 $now = current_time( 'mysql', true );
820
821 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
822 $lockouts = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT ip_address, username, attempt_count as attempts, lockout_until as locked_until, last_attempt FROM %i WHERE lockout_until > %s AND status = 'lockout' ORDER BY lockout_until DESC", $table, $now ) );
823
824 return $lockouts ? $lockouts : array();
825 }
826
827 /**
828 * Clear all lockouts
829 *
830 * @return bool
831 */
832 public function clear_all_lockouts() {
833 $table = $this->get_login_attempts_table();
834
835 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
836 return false !== $this->wpdb->query( $this->wpdb->prepare( "UPDATE %i SET lockout_until = NULL, status = 'cleared', attempt_count = 0 WHERE status = 'lockout'", $table ) );
837 }
838
839 /**
840 * Reset login attempts for an IP
841 *
842 * @param string $ip_address IP address.
843 * @return bool
844 */
845 public function reset_login_attempts( $ip_address ) {
846 $table = $this->get_login_attempts_table();
847
848 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
849 return false !== $this->wpdb->delete(
850 $table,
851 array( 'ip_address' => $ip_address ),
852 array( '%s' )
853 );
854 }
855
856 /**
857 * Clean up old login attempts
858 *
859 * @param int $hours Hours to keep.
860 * @return int Number of deleted rows.
861 */
862 public function cleanup_old_login_attempts( $hours = 24 ) {
863 $table = $this->get_login_attempts_table();
864 $date = gmdate( 'Y-m-d H:i:s', strtotime( "-{$hours} hours" ) );
865
866 // UTC on both sides, like the rest of this table since 2.11.0 (S18).
867 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
868 $deleted = $this->wpdb->query( $this->wpdb->prepare( 'DELETE FROM %i WHERE last_attempt < %s AND (lockout_until IS NULL OR lockout_until < %s)', $table, $date, current_time( 'mysql', true ) ) );
869
870 return $deleted ? $deleted : 0;
871 }
872
873 /**
874 * Get all currently locked out IPs
875 *
876 * @return array
877 */
878 public function get_locked_out_ips() {
879 $table = $this->get_login_attempts_table();
880 // UTC: lockout_until is written with gmdate(). See record_login_attempt() (S18).
881 $now = current_time( 'mysql', true );
882
883 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
884 $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT DISTINCT ip_address, lockout_until, attempt_count, last_attempt FROM %i WHERE lockout_until > %s AND status = 'lockout' ORDER BY lockout_until DESC", $table, $now ), ARRAY_A );
885
886 return $results ? $results : array();
887 }
888
889 // =========================================================================
890 // FILE INTEGRITY METHODS
891 // =========================================================================
892
893 /**
894 * Check if file integrity table exists
895 *
896 * @return bool
897 */
898 private function file_integrity_table_exists() {
899 $table = $this->get_file_integrity_table();
900 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
901 $result = $this->wpdb->get_var( $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
902 return $result === $table;
903 }
904
905 /**
906 * Store file hash
907 *
908 * @param string $file_path File path.
909 * @param string $hash File hash.
910 * @param int $size File size.
911 * @param string $type File type: 'core', 'plugin', 'theme'.
912 * @return int|false
913 */
914 public function store_file_hash( $file_path, $hash, $size = 0, $type = 'core' ) {
915 // Verify table exists before inserting
916 if ( ! $this->file_integrity_table_exists() ) {
917 return false;
918 }
919
920 $table = $this->get_file_integrity_table();
921 $now = current_time( 'mysql' );
922
923 // Check if exists
924 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
925 $existing = $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT id FROM %i WHERE file_path = %s', $table, $file_path ) );
926
927 if ( $existing ) {
928 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
929 $this->wpdb->update(
930 $table,
931 array(
932 'file_hash' => $hash,
933 'file_size' => $size,
934 'file_type' => $type,
935 'status' => 'ok',
936 'last_checked' => $now,
937 ),
938 array( 'id' => $existing ),
939 array( '%s', '%d', '%s', '%s', '%s' ),
940 array( '%d' )
941 );
942 return $existing;
943 }
944
945 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
946 $this->wpdb->insert(
947 $table,
948 array(
949 'file_path' => $file_path,
950 'file_hash' => $hash,
951 'file_size' => $size,
952 'file_type' => $type,
953 'status' => 'ok',
954 'last_checked' => $now,
955 ),
956 array( '%s', '%s', '%d', '%s', '%s', '%s' )
957 );
958
959 return $this->wpdb->insert_id;
960 }
961
962 /**
963 * Get stored file hash
964 *
965 * @param string $file_path File path.
966 * @return array|null
967 */
968 public function get_file_hash( $file_path ) {
969 $table = $this->get_file_integrity_table();
970
971 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
972 $result = $this->wpdb->get_row( $this->wpdb->prepare( 'SELECT * FROM %i WHERE file_path = %s', $table, $file_path ), ARRAY_A );
973
974 return $result;
975 }
976
977 /**
978 * Update file status
979 *
980 * @param string $file_path File path.
981 * @param string $status Status: 'ok', 'modified', 'deleted', 'new'.
982 * @param string $new_hash New hash if modified.
983 * @return bool
984 */
985 public function update_file_status( $file_path, $status, $new_hash = '' ) {
986 $table = $this->get_file_integrity_table();
987
988 $data = array(
989 'status' => $status,
990 'last_checked' => current_time( 'mysql' ),
991 );
992
993 if ( ! empty( $new_hash ) ) {
994 $data['file_hash'] = $new_hash;
995 }
996
997 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
998 return false !== $this->wpdb->update(
999 $table,
1000 $data,
1001 array( 'file_path' => $file_path ),
1002 array_fill( 0, count( $data ), '%s' ),
1003 array( '%s' )
1004 );
1005 }
1006
1007 /**
1008 * Get files by status
1009 *
1010 * @param string $status File status.
1011 * @param string $type File type (optional).
1012 * @return array
1013 */
1014 public function get_files_by_status( $status, $type = '' ) {
1015 $table = $this->get_file_integrity_table();
1016 $status = sanitize_key( $status );
1017 $type = sanitize_key( $type );
1018
1019 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1020 $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM %i WHERE status = %s AND (file_type = %s OR %s = '') ORDER BY file_path ASC", $table, $status, $type, $type ), ARRAY_A );
1021
1022 return $results ? $results : array();
1023 }
1024
1025 /**
1026 * Clear all file hashes
1027 *
1028 * @param string $type Optional file type to clear.
1029 * @return bool
1030 */
1031 public function clear_file_hashes( $type = '' ) {
1032 $table = $this->get_file_integrity_table();
1033
1034 if ( ! empty( $type ) ) {
1035 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
1036 return false !== $this->wpdb->delete(
1037 $table,
1038 array( 'file_type' => $type ),
1039 array( '%s' )
1040 );
1041 }
1042
1043 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
1044 return false !== $this->wpdb->query( $this->wpdb->prepare( 'TRUNCATE TABLE %i', $table ) );
1045 }
1046
1047 // =========================================================================
1048 // UTILITY METHODS
1049 // =========================================================================
1050
1051 /**
1052 * Get client IP address
1053 *
1054 * Delegates to the shared resolver, which only trusts REMOTE_ADDR unless a
1055 * proxy header has been explicitly declared in settings.
1056 *
1057 * @return string
1058 */
1059 public function get_client_ip() {
1060 return Vigilante_IP_Utils::get_client_ip();
1061 }
1062
1063 /**
1064 * Get database statistics
1065 *
1066 * @return array
1067 */
1068 public function get_stats() {
1069 $stats = array(
1070 'activity_log_count' => $this->get_activity_logs_count(),
1071 'locked_out_ips_count' => count( $this->get_locked_out_ips() ),
1072 'file_integrity_count' => 0,
1073 'modified_files_count' => 0,
1074 );
1075
1076 $table = $this->get_file_integrity_table();
1077 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
1078 $stats['file_integrity_count'] = absint( $this->wpdb->get_var( $this->wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ) );
1079 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
1080 $stats['modified_files_count'] = absint( $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status != 'ok'", $table ) ) );
1081
1082 return $stats;
1083 }
1084
1085 // =========================================================================
1086 // TWO-FACTOR AUTHENTICATION METHODS
1087 // =========================================================================
1088
1089 /**
1090 * Store 2FA verification code
1091 *
1092 * @param int $user_id User ID.
1093 * @param string $code Verification code.
1094 * @param string $expires_at Expiration datetime.
1095 * @return int|false Insert ID or false on failure.
1096 */
1097 public function store_2fa_code( $user_id, $code, $expires_at ) {
1098 $table = $this->get_2fa_codes_table();
1099
1100 // Delete any existing codes for this user
1101 $this->delete_2fa_code( $user_id );
1102
1103 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1104 $result = $this->wpdb->insert(
1105 $table,
1106 array(
1107 'user_id' => $user_id,
1108 'code' => $code,
1109 'expires_at' => $expires_at,
1110 'attempts' => 0,
1111 'used' => 0,
1112 ),
1113 array( '%d', '%s', '%s', '%d', '%d' )
1114 );
1115
1116 return $result ? $this->wpdb->insert_id : false;
1117 }
1118
1119 /**
1120 * Get 2FA code for user
1121 *
1122 * @param int $user_id User ID.
1123 * @return array|null Code data or null if not found.
1124 */
1125 public function get_2fa_code( $user_id ) {
1126 $table = $this->get_2fa_codes_table();
1127
1128 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1129 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1130 return $this->wpdb->get_row(
1131 $this->wpdb->prepare(
1132 'SELECT * FROM %i WHERE user_id = %d AND used = 0 ORDER BY created_at DESC LIMIT 1',
1133 $table,
1134 $user_id
1135 ),
1136 ARRAY_A
1137 );
1138 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1139 }
1140
1141 /**
1142 * Increment 2FA code attempts
1143 *
1144 * @param int $user_id User ID.
1145 * @return bool
1146 */
1147 public function increment_2fa_attempts( $user_id ) {
1148 $table = $this->get_2fa_codes_table();
1149
1150 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1151 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1152 return false !== $this->wpdb->query(
1153 $this->wpdb->prepare(
1154 'UPDATE %i SET attempts = attempts + 1 WHERE user_id = %d AND used = 0',
1155 $table,
1156 $user_id
1157 )
1158 );
1159 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1160 }
1161
1162 /**
1163 * Mark 2FA code as used
1164 *
1165 * @param int $user_id User ID.
1166 * @return bool
1167 */
1168 public function mark_2fa_code_used( $user_id ) {
1169 $table = $this->get_2fa_codes_table();
1170
1171 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1172 return false !== $this->wpdb->update(
1173 $table,
1174 array( 'used' => 1 ),
1175 array( 'user_id' => $user_id ),
1176 array( '%d' ),
1177 array( '%d' )
1178 );
1179 }
1180
1181 /**
1182 * Delete 2FA code for user
1183 *
1184 * @param int $user_id User ID.
1185 * @return bool
1186 */
1187 public function delete_2fa_code( $user_id ) {
1188 $table = $this->get_2fa_codes_table();
1189
1190 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1191 return false !== $this->wpdb->delete(
1192 $table,
1193 array( 'user_id' => $user_id ),
1194 array( '%d' )
1195 );
1196 }
1197
1198 /**
1199 * Cleanup expired 2FA codes
1200 *
1201 * @return int Number of deleted rows.
1202 */
1203 public function cleanup_expired_2fa_codes() {
1204 $table = $this->get_2fa_codes_table();
1205 $now = current_time( 'mysql', true );
1206
1207 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1208 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1209 $this->wpdb->query(
1210 $this->wpdb->prepare(
1211 'DELETE FROM %i WHERE expires_at < %s OR used = 1',
1212 $table,
1213 $now
1214 )
1215 );
1216 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1217
1218 return $this->wpdb->rows_affected;
1219 }
1220
1221 /**
1222 * Trust a device for 2FA
1223 *
1224 * @param int $user_id User ID.
1225 * @param string $device_hash Device hash.
1226 * @param string $user_agent User agent.
1227 * @param string $expires_at Expiration datetime.
1228 * @return int|false Insert ID or false on failure.
1229 */
1230 public function trust_device( $user_id, $device_hash, $user_agent, $expires_at ) {
1231 $table = $this->get_2fa_devices_table();
1232
1233 // Delete existing entry for this device
1234 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1235 $this->wpdb->delete(
1236 $table,
1237 array(
1238 'user_id' => $user_id,
1239 'device_hash' => $device_hash,
1240 ),
1241 array( '%d', '%s' )
1242 );
1243
1244 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1245 $result = $this->wpdb->insert(
1246 $table,
1247 array(
1248 'user_id' => $user_id,
1249 'device_hash' => $device_hash,
1250 'user_agent' => $user_agent,
1251 'expires_at' => $expires_at,
1252 ),
1253 array( '%d', '%s', '%s', '%s' )
1254 );
1255
1256 return $result ? $this->wpdb->insert_id : false;
1257 }
1258
1259 /**
1260 * Check if device is trusted
1261 *
1262 * @param int $user_id User ID.
1263 * @param string $device_hash Device hash.
1264 * @return bool
1265 */
1266 public function is_device_trusted( $user_id, $device_hash ) {
1267 $table = $this->get_2fa_devices_table();
1268 $now = current_time( 'mysql', true );
1269
1270 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1271 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1272 $result = $this->wpdb->get_var(
1273 $this->wpdb->prepare(
1274 'SELECT id FROM %i WHERE user_id = %d AND device_hash = %s AND expires_at > %s LIMIT 1',
1275 $table,
1276 $user_id,
1277 $device_hash,
1278 $now
1279 )
1280 );
1281 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1282
1283 return ! empty( $result );
1284 }
1285
1286 /**
1287 * Get trusted devices for user
1288 *
1289 * @param int $user_id User ID.
1290 * @return array
1291 */
1292 public function get_trusted_devices( $user_id ) {
1293 $table = $this->get_2fa_devices_table();
1294 $now = current_time( 'mysql', true );
1295
1296 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1297 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1298 $results = $this->wpdb->get_results(
1299 $this->wpdb->prepare(
1300 'SELECT * FROM %i WHERE user_id = %d AND expires_at > %s ORDER BY created_at DESC',
1301 $table,
1302 $user_id,
1303 $now
1304 ),
1305 ARRAY_A
1306 );
1307 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1308
1309 return $results ? $results : array();
1310 }
1311
1312 /**
1313 * Revoke all trusted devices for user
1314 *
1315 * @param int $user_id User ID.
1316 * @return bool
1317 */
1318 public function revoke_trusted_devices( $user_id ) {
1319 $table = $this->get_2fa_devices_table();
1320
1321 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1322 return false !== $this->wpdb->delete(
1323 $table,
1324 array( 'user_id' => $user_id ),
1325 array( '%d' )
1326 );
1327 }
1328
1329 /**
1330 * Cleanup expired trusted devices
1331 *
1332 * @return int Number of deleted rows.
1333 */
1334 public function cleanup_expired_trusted_devices() {
1335 $table = $this->get_2fa_devices_table();
1336 $now = current_time( 'mysql', true );
1337
1338 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1339 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1340 $this->wpdb->query(
1341 $this->wpdb->prepare(
1342 'DELETE FROM %i WHERE expires_at < %s',
1343 $table,
1344 $now
1345 )
1346 );
1347 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1348
1349 return $this->wpdb->rows_affected;
1350 }
1351
1352 /**
1353 * Mark user as notified about 2FA
1354 *
1355 * @param int $user_id User ID.
1356 * @return bool
1357 */
1358 public function mark_2fa_notified( $user_id ) {
1359 $table = $this->get_2fa_notifications_table();
1360
1361 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1362 $result = $this->wpdb->replace(
1363 $table,
1364 array(
1365 'user_id' => $user_id,
1366 'sent_at' => current_time( 'mysql', true ),
1367 ),
1368 array( '%d', '%s' )
1369 );
1370
1371 return false !== $result;
1372 }
1373
1374 /**
1375 * Check if user was notified about 2FA
1376 *
1377 * @param int $user_id User ID.
1378 * @return bool
1379 */
1380 public function user_was_2fa_notified( $user_id ) {
1381 $table = $this->get_2fa_notifications_table();
1382
1383 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1384 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1385 $result = $this->wpdb->get_var(
1386 $this->wpdb->prepare(
1387 'SELECT id FROM %i WHERE user_id = %d LIMIT 1',
1388 $table,
1389 $user_id
1390 )
1391 );
1392 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1393
1394 return ! empty( $result );
1395 }
1396
1397 /**
1398 * Clear 2FA notification records
1399 *
1400 * @return bool
1401 */
1402 public function clear_2fa_notifications() {
1403 $table = $this->get_2fa_notifications_table();
1404
1405 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1406 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1407 return false !== $this->wpdb->query(
1408 $this->wpdb->prepare( 'TRUNCATE TABLE %i', $table )
1409 );
1410 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1411 }
1412
1413 // =========================================================================
1414 // TOTP METHODS
1415 // =========================================================================
1416
1417 /**
1418 * Get TOTP data for a user
1419 *
1420 * @param int $user_id User ID.
1421 * @return array|null
1422 */
1423 public function get_totp_data( $user_id ) {
1424 $table = $this->get_totp_table();
1425
1426 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1427 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1428 return $this->wpdb->get_row(
1429 $this->wpdb->prepare(
1430 'SELECT * FROM %i WHERE user_id = %d LIMIT 1',
1431 $table,
1432 $user_id
1433 ),
1434 ARRAY_A
1435 );
1436 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1437 }
1438
1439 /**
1440 * Create TOTP placeholder row (grace period tracking)
1441 *
1442 * @param int $user_id User ID.
1443 * @param string $grace_expires Grace period expiry datetime.
1444 * @return bool
1445 */
1446 public function create_totp_placeholder( $user_id, $grace_expires ) {
1447 $table = $this->get_totp_table();
1448
1449 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1450 return false !== $this->wpdb->replace(
1451 $table,
1452 array(
1453 'user_id' => $user_id,
1454 'secret' => '',
1455 'is_configured' => 0,
1456 'grace_period_expires' => $grace_expires,
1457 ),
1458 array( '%d', '%s', '%d', '%s' )
1459 );
1460 }
1461
1462 /**
1463 * Save TOTP data after successful setup
1464 *
1465 * @param int $user_id User ID.
1466 * @param string $encrypted Encrypted secret.
1467 * @return bool
1468 */
1469 public function save_totp_data( $user_id, $encrypted ) {
1470 $table = $this->get_totp_table();
1471 $now = current_time( 'mysql', true );
1472
1473 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1474 return false !== $this->wpdb->replace(
1475 $table,
1476 array(
1477 'user_id' => $user_id,
1478 'secret' => $encrypted,
1479 'is_configured' => 1,
1480 'configured_at' => $now,
1481 'grace_period_expires' => null,
1482 ),
1483 array( '%d', '%s', '%d', '%s', '%s' )
1484 );
1485 }
1486
1487 /**
1488 * Store backup codes for a user
1489 *
1490 * @param int $user_id User ID.
1491 * @param string $hashed_codes JSON-encoded hashed codes.
1492 * @return bool
1493 */
1494 public function store_totp_backup_codes( $user_id, $hashed_codes ) {
1495 $table = $this->get_totp_table();
1496
1497 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1498 return false !== $this->wpdb->update(
1499 $table,
1500 array( 'backup_codes' => $hashed_codes ),
1501 array( 'user_id' => $user_id ),
1502 array( '%s' ),
1503 array( '%d' )
1504 );
1505 }
1506
1507 /**
1508 * Update TOTP last used timestamp
1509 *
1510 * @param int $user_id User ID.
1511 * @return bool
1512 */
1513 public function update_totp_last_used( $user_id ) {
1514 $table = $this->get_totp_table();
1515 $now = current_time( 'mysql', true );
1516
1517 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1518 return false !== $this->wpdb->update(
1519 $table,
1520 array( 'last_used_at' => $now ),
1521 array( 'user_id' => $user_id ),
1522 array( '%s' ),
1523 array( '%d' )
1524 );
1525 }
1526
1527 /**
1528 * Reset TOTP data for a user (admin reset)
1529 *
1530 * @param int $user_id User ID.
1531 * @return bool
1532 */
1533 public function reset_totp_data( $user_id ) {
1534 $table = $this->get_totp_table();
1535
1536 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1537 return false !== $this->wpdb->delete(
1538 $table,
1539 array( 'user_id' => $user_id ),
1540 array( '%d' )
1541 );
1542 }
1543
1544 /**
1545 * Get all users with TOTP configured
1546 *
1547 * @return array
1548 */
1549 public function get_totp_configured_users() {
1550 $table = $this->get_totp_table();
1551
1552 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1553 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1554 $results = $this->wpdb->get_results(
1555 $this->wpdb->prepare(
1556 'SELECT user_id, configured_at, last_used_at FROM %i WHERE is_configured = 1 ORDER BY configured_at DESC',
1557 $table
1558 ),
1559 ARRAY_A
1560 );
1561 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1562
1563 return $results ? $results : array();
1564 }
1565
1566 /**
1567 * Search users with TOTP configured by name or email
1568 *
1569 * @param string $query Search query.
1570 * @param int $limit Max results.
1571 * @return array
1572 */
1573 public function search_totp_users( $query, $limit = 10 ) {
1574 $table = $this->get_totp_table();
1575
1576 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i placeholder requires WP 6.2+.
1577 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1578 $results = $this->wpdb->get_results(
1579 $this->wpdb->prepare(
1580 "SELECT t.user_id, t.configured_at, t.last_used_at, u.display_name, u.user_email
1581 FROM %i AS t
1582 INNER JOIN %i AS u ON t.user_id = u.ID
1583 WHERE t.is_configured = 1
1584 AND (u.display_name LIKE %s OR u.user_email LIKE %s OR u.user_login LIKE %s)
1585 ORDER BY u.display_name ASC
1586 LIMIT %d",
1587 $table,
1588 $this->wpdb->users,
1589 '%' . $this->wpdb->esc_like( $query ) . '%',
1590 '%' . $this->wpdb->esc_like( $query ) . '%',
1591 '%' . $this->wpdb->esc_like( $query ) . '%',
1592 $limit
1593 ),
1594 ARRAY_A
1595 );
1596 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
1597
1598 return $results ? $results : array();
1599 }
1600 }