Created
May 6, 2020 17:29
-
-
Save mloberg/bc981d3b4e56502875c651a76d7d562e to your computer and use it in GitHub Desktop.
Track login attempts and prevent brute forcing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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