robotstxt-2fa/includes/class-attempts-log.php
2026-08-07 14:15:58 +00:00

176 lines
4.4 KiB
PHP

<?php
/**
* Failed 2FA attempts log.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Maintains a ring-buffer of recent failed 2FA verification attempts.
*/
class Attempts_Log {
/**
* Option name for storing the failed attempts log.
*/
private const OPTION_NAME = 'robotstxt_2fa_failed_log';
/**
* Maximum number of entries to retain in the log.
*/
private const MAX_ENTRIES = 100;
/**
* Register WordPress hooks.
*
* @since 1.3.0
*
* @return void
*/
public function register_hooks(): void {
add_action( 'robotstxt_2fa_verification_failed', array( $this, 'append' ), 10, 2 );
add_action( 'robotstxt_2fa_verification_success', array( $this, 'clear_on_success' ) );
}
/**
* Append a failed attempt entry to the log.
*
* Called by the robotstxt_2fa_verification_failed action hook.
*
* @since 1.3.0
*
* @param \WP_User $user User who failed the challenge.
* @param string $method Verification method that was attempted.
*
* @return void
*/
public function append( \WP_User $user, string $method ): void {
$raw_ip = '';
if ( isset( $_SERVER['REMOTE_ADDR'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below.
$raw_ip = is_string( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : '';
$raw_ip = sanitize_text_field( wp_unslash( $raw_ip ) );
}
$entry = array(
'user_id' => $user->ID,
'user_login' => $user->user_login,
'method' => sanitize_key( $method ),
'ip' => wp_privacy_anonymize_ip( $raw_ip ),
'timestamp' => time(),
);
$log = get_option( self::OPTION_NAME, array() );
if ( ! is_array( $log ) ) {
$log = array();
}
array_unshift( $log, $entry );
if ( count( $log ) > self::MAX_ENTRIES ) {
$log = array_slice( $log, 0, self::MAX_ENTRIES );
}
update_option( self::OPTION_NAME, $log, false );
}
/**
* Retrieve the most recent failed attempt entries.
*
* @since 1.3.0
*
* @param int $limit Maximum number of entries to return.
*
* @return array<int, array{user_id: int, user_login: string, method: string, ip: string, timestamp: int}>
*/
public function get_recent( int $limit = 20 ): array {
$log = get_option( self::OPTION_NAME, array() );
if ( ! is_array( $log ) ) {
return array();
}
$result = array();
foreach ( array_slice( $log, 0, max( 1, $limit ) ) as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
$result[] = array(
'user_id' => isset( $entry['user_id'] ) && is_numeric( $entry['user_id'] ) ? (int) $entry['user_id'] : 0,
'user_login' => isset( $entry['user_login'] ) && is_string( $entry['user_login'] ) ? $entry['user_login'] : '',
'method' => isset( $entry['method'] ) && is_string( $entry['method'] ) ? $entry['method'] : '',
'ip' => isset( $entry['ip'] ) && is_string( $entry['ip'] ) ? $entry['ip'] : '',
'timestamp' => isset( $entry['timestamp'] ) && is_numeric( $entry['timestamp'] ) ? (int) $entry['timestamp'] : 0,
);
}
return $result;
}
/**
* Delete the failed attempts log option.
*
* @since 1.3.0
*
* @return void
*/
public static function delete(): void {
delete_option( self::OPTION_NAME );
}
/**
* Handle a successful verification by clearing the user's error history.
*
* Hooked to {@see 'robotstxt_2fa_verification_success'}; fires for every
* verification method (email, OTP, recovery) so a successful login with any
* method empties the user's queued failed attempts.
*
* @since 1.5.3
*
* @param \WP_User $user User who completed the challenge.
* @return void
*/
public function clear_on_success( \WP_User $user ): void {
$this->clear_for_user( $user->ID );
}
/**
* Remove every failed-attempt entry belonging to a specific user.
*
* @since 1.5.3
*
* @param int $user_id User identifier whose entries should be removed.
* @return void
*/
public function clear_for_user( int $user_id ): void {
$log = get_option( self::OPTION_NAME, array() );
if ( ! is_array( $log ) || empty( $log ) ) {
return;
}
$filtered = array();
foreach ( $log as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
$entry_user_id = isset( $entry['user_id'] ) && is_numeric( $entry['user_id'] ) ? (int) $entry['user_id'] : 0;
if ( $entry_user_id !== $user_id ) {
$filtered[] = $entry;
}
}
update_option( self::OPTION_NAME, $filtered, false );
}
}