Skip to content

Instantly share code, notes, and snippets.

@mloberg
Created May 6, 2020 17:29
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mloberg/bc981d3b4e56502875c651a76d7d562e to your computer and use it in GitHub Desktop.
Save mloberg/bc981d3b4e56502875c651a76d7d562e to your computer and use it in GitHub Desktop.
Track login attempts and prevent brute forcing
<?php
declare(strict_types=1);
namespace App\Security;
use DateTimeImmutable;
use Generator;
use Predis\Client;
class Lockout
{
private const KEY_ATTEMPT = 'lockout:%s:attempts';
private const KEY_LOCK = 'lockout:%s:lock';
/**
* @var Client
*/
private $redis;
/**
* @var int
*/
private $window;
/**
* @var int
*/
private $threshold;
/**
* @var int
*/
private $duration;
public function __construct(Client $redis, int $window, int $threshold, int $duration)
{
$this->redis = $redis;
$this->window = $window;
$this->threshold = $threshold;
$this->duration = $duration;
}
public function locked(string $key): bool
{
return (bool) $this->redis->exists(sprintf(self::KEY_LOCK, $key));
}
/**
* @return int seconds until lock expires, -1 means no lock exists
*/
public function expires(string $key): int
{
$expires = $this->redis->ttl(sprintf(self::KEY_LOCK, $key));
return max($expires, -1);
}
public function attempts(string $key): array
{
$now = new DateTimeImmutable();
$attempts = $this->redis->zrange(sprintf(self::KEY_ATTEMPT, $key), 0, -1, ['WITHSCORES' => true]);
return array_map(function ($ts, $data) use ($now) {
$attempt = json_decode($data, true);
$attempt['timestamp'] = $now->setTimestamp($ts / 1000);
return $attempt;
}, $attempts, array_keys($attempts));
}
/**
* @return Generator<string, array>
*/
public function all(): Generator
{
foreach ($this->redis->keys(sprintf(self::KEY_LOCK, '*')) as $key) {
$id = explode(':', $key)[1];
yield $id => $this->attempts($id);
}
}
public function add(string $key, array $data = []): bool
{
$now = (int) round(microtime(true) * 1000);
$redisKey = sprintf(self::KEY_ATTEMPT, $key);
$rangeStart = $now - ($this->window * 1000);
$data['_'] = bin2hex(random_bytes(4));
$results = $this->redis->transaction()
->zadd($redisKey, $now, json_encode($data))
->expire($redisKey, $this->duration)
->zrangebyscore($redisKey, $rangeStart, $now, ['WITHSCORES' => true])
->execute();
$attempts = array_pop($results);
if (count($attempts) < $this->threshold) {
return false;
}
$this->lock($key);
return true;
}
public function lock(string $key): void
{
$this->redis->setex(sprintf(self::KEY_LOCK, $key), $this->duration, true);
}
public function unlock(string $key): void
{
$this->redis->del([sprintf(self::KEY_LOCK, $key)]);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment