Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
RateLimiter
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
5 / 5
8
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attempt
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 release
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Security;
6
7use Celema\Quma\Database;
8use Cosray\Config;
9
10/**
11 * Counts attempts per bucket in fixed windows, like failed sign-ins per
12 * client address. A window starts with the first attempt in a bucket. The
13 * database stores buckets as an HMAC with `app.secret`, so its counters
14 * hold no addresses or logins.
15 *
16 * @internal
17 */
18final class RateLimiter
19{
20    public function __construct(
21        private readonly Database $db,
22        private readonly Config $config,
23    ) {}
24
25    /**
26     * Counts an attempt in each bucket. Returns 0 while every bucket is
27     * within its limit, else the seconds until the attempt is allowed again.
28     *
29     * @param array<string, positive-int> $limits the most attempts per window by bucket
30     * @param positive-int $window seconds
31     */
32    public function attempt(array $limits, int $window): int
33    {
34        $wait = 0;
35
36        foreach ($limits as $bucket => $limit) {
37            /** @var array{hits: int, seconds: int} $counter */
38            $counter = $this->db->rateLimits->hit([
39                'bucket' => $this->hash((string) $bucket),
40                'window' => $window,
41            ])->one();
42
43            if ($counter['hits'] === 1) {
44                $this->db->rateLimits->prune()->run();
45            }
46
47            if ($counter['hits'] > $limit) {
48                $wait = max($wait, $counter['seconds'], 1);
49            }
50        }
51
52        return $wait;
53    }
54
55    /** Takes back one attempt, like one that turned out to be legitimate. */
56    public function release(string $bucket): void
57    {
58        $this->db->rateLimits->release(['bucket' => $this->hash($bucket)])->run();
59    }
60
61    public function clear(string $bucket): void
62    {
63        $this->db->rateLimits->clear(['bucket' => $this->hash($bucket)])->run();
64    }
65
66    private function hash(string $bucket): string
67    {
68        return hash_hmac('sha256', $bucket, $this->config->app->secret ?? '');
69    }
70}