Progressive delays are usually the best security-usability trade-off.
In Airship, we implemented a progressive delay here (relevant config) where failed attempts from a specific IP subnet or towards a specific user account would increase the amount of time they must wait before successive attempts.
If you're looking for reusable code to get a subnet from an IP address:
<?php
declare(strict_types=1);
class StackOverflowCopyPaste
{
/** @var int $v4MaskBits */
private $v4MaskBits;
/** @var int $v6MaskBits */
private $v6MaskBits;
/**
* @param int $v4MaskBits
* @param int $v6MaskBits
*/
public function __construct(int $v4MaskBits = 24, int $v6MaskBits = 48)
{
$this->v4MaskBits = $v4MaskBits;
$this->v6MaskBits = $v6MaskBits;
}
/**
* Return the given subnet for an IP and the configured mask bits
*
* Determine if the IP is an IPv4 or IPv6 address, then pass to the correct
* method for handling that specific type.
*
* @param string $ip
* @return string
*/
public function getSubnet(string $ip): string
{
if (\preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $ip)) {
return $this->getIPv4Subnet(
$ip,
(int) ($this->v4MaskBits ?? 32)
);
}
return $this->getIPv6Subnet(
$ip,
(int) ($this->v6MaskBits ?? 128)
);
}
/**
* Return the given subnet for an IPv4 address and mask bits
*
* @param string $ip
* @param int $maskBits
* @return string
*/
public function getIPv4Subnet(string $ip, int $maskBits = 32): string
{
$binary = \inet_pton($ip);
for ($i = 32; $i > $maskBits; $i -= 8) {
$j = \intdiv($i, 8) - 1;
$k = (int) \min(8, $i - $maskBits);
$mask = (0xff - ((1 << $k) - 1));
$int = \unpack('C', $binary[$j]);
$binary[$j] = \pack('C', $int[1] & $mask);
}
return \inet_ntop($binary).'/'.$maskBits;
}
/**
* Return the given subnet for an IPv6 address and mask bits
*
* @param string $ip
* @param int $maskBits
* @return string
*/
public function getIPv6Subnet(string $ip, int $maskBits = 48): string
{
$binary = \inet_pton($ip);
for ($i = 128; $i > $maskBits; $i -= 8) {
$j = \intdiv($i, 8) - 1;
$k = (int) \min(8, $i - $maskBits);
$mask = (0xff - ((1 << $k) - 1));
$int = \unpack('C', $binary[$j]);
$binary[$j] = \pack('C', $int[1] & $mask);
}
return \inet_ntop($binary).'/'.$maskBits;
}
}
Demo available at 3v4l.
Why Subnets instead of IP addresses?
Let's say you control an entire /24 subnet, and sent 10 bad attempts from 192.168.0.1. Your delay would increase to the max (30 seconds).
If you only blocked IP addresses, you could send another request from 192.168.0.2 and have no delay. Blocking 192.168.0.0/24 would not have the same weakness.
This problem gets greatly exacerbated when you consider that IPv6 allocations typically grant entire /48 or /64 subnets instead of a single IP, so you could theoretically burn anywhere from 2^64 to 2^80 addresses on brute force attacking before you had to suffer from rate-limiting.
So to side-step these issues, treating an entire subnet (which is configurable; by default: /24 for IPv4, /48 for IPv6) as the same source is more robust against these attacks. Since the delays are merely an inconvenience, users with legitimate credentials are never truly locked out of their account.