211 lines
4.6 KiB
PHP
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;
|
|
}
|
|
}
|