robotstxt-2fa/includes/class-ip-restrictions.php
2026-09-17 10:57:29 +00:00

211 lines
4.6 KiB
PHP

<?php
/**
* IP-based access restrictions.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA;
use Robotstxt\TwoFA\User\Two_Factor_Config;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles IP allow listing (skip challenge) and deny-listing (block login).
*/
class IP_Restrictions {
/**
* Global two-factor configuration.
*
* @var Two_Factor_Config
*/
private Two_Factor_Config $config;
/**
* Constructor.
*
* @param Two_Factor_Config $config Global two-factor configuration.
*/
public function __construct( Two_Factor_Config $config ) {
$this->config = $config;
}
/**
* Register WordPress hooks.
*
* @since 1.3.0
*
* @return void
*/
public function register_hooks(): void {
add_filter( 'robotstxt_2fa_skip_challenge', array( $this, 'maybe_skip_for_allowed_ip' ), 5, 2 );
add_filter( 'authenticate', array( $this, 'maybe_block_denied_ip' ), 1, 3 );
}
/**
* Return true when the request IP is on the allow list.
*
* @since 1.3.0
*
* @param bool $skip Whether to skip the challenge.
* @param \WP_User $user Authenticated user about to be challenged.
*
* @return bool
*/
public function maybe_skip_for_allowed_ip( bool $skip, \WP_User $user ): bool {
unset( $user );
if ( $skip ) {
return true;
}
$allow_list = $this->config->get_ip_allow();
if ( '' === $allow_list ) {
return false;
}
$ip = $this->get_current_ip();
if ( '' === $ip ) {
return false;
}
return $this->ip_in_list( $ip, $allow_list );
}
/**
* Block login when the request IP is on the deny list.
*
* @since 1.3.0
*
* @param mixed $user Previously authenticated user or error.
* @param string|null $username Submitted username.
* @param string|null $password Submitted password.
*
* @return mixed
*/
public function maybe_block_denied_ip( mixed $user, ?string $username, ?string $password ): mixed {
unset( $username, $password );
$deny_list = $this->config->get_ip_deny();
if ( '' === $deny_list ) {
return $user;
}
$ip = $this->get_current_ip();
if ( '' === $ip ) {
return $user;
}
if ( $this->ip_in_list( $ip, $deny_list ) ) {
return new \WP_Error(
'robotstxt_2fa_ip_blocked',
__( 'Access denied. Your IP address is not allowed to log in.', 'robotstxt-2fa' )
);
}
return $user;
}
/**
* Retrieve the client IP address from the server environment.
*
* @since 1.3.0
*
* @return string Empty string when not available.
*/
private function get_current_ip(): string {
if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) {
return '';
}
$raw = is_string( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below.
return sanitize_text_field( wp_unslash( $raw ) );
}
/**
* Check whether an IP address matches any entry in a newline-separated list.
*
* @since 1.3.0
*
* @param string $ip IP address to check.
* @param string $entries Newline-separated list of IPs or CIDR ranges.
*
* @return bool
*/
private function ip_in_list( string $ip, string $entries ): bool {
$parts = preg_split( '/\r\n|\r|\n/', $entries );
if ( ! is_array( $parts ) ) {
return false;
}
foreach ( $parts as $entry ) {
$entry = trim( $entry );
if ( '' === $entry ) {
continue;
}
if ( $this->ip_matches_cidr( $ip, $entry ) ) {
return true;
}
}
return false;
}
/**
* Check whether an IP address matches a CIDR range or exact IP.
*
* Supports both IPv4 and IPv6 via inet_pton().
*
* @since 1.3.0
*
* @param string $ip IP address to test.
* @param string $cidr CIDR range or exact IP to test against.
*
* @return bool
*/
private function ip_matches_cidr( string $ip, string $cidr ): bool {
$cidr = trim( $cidr );
if ( ! str_contains( $cidr, '/' ) ) {
return $ip === $cidr;
}
$parts = explode( '/', $cidr, 2 );
$subnet = trim( $parts[0] );
$bits_str = trim( $parts[1] );
$ip_bin = inet_pton( $ip );
$subnet_bin = inet_pton( $subnet );
if ( false === $ip_bin || false === $subnet_bin || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
return false;
}
$total_bits = strlen( $ip_bin ) * 8;
$bits_int = max( 0, min( (int) $bits_str, $total_bits ) );
$bytes = (int) floor( $bits_int / 8 );
$rem = $bits_int % 8;
if ( $bytes > 0 && substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) ) {
return false;
}
if ( $rem > 0 ) {
$mask = 0xFF & ( 0xFF << ( 8 - $rem ) );
return ( ord( substr( $ip_bin, $bytes, 1 ) ) & $mask ) === ( ord( substr( $subnet_bin, $bytes, 1 ) ) & $mask );
}
return true;
}
}