176 lines
4.4 KiB
PHP
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 );
|
|
}
|
|
}
|