Skip to content

Commit b168ca8

Browse files
Merge pull request #7835 from getkirby/v6/refact/auth-limits
refact: `Kirby\Auth\Limits`
2 parents c3bb2e3 + 69972d8 commit b168ca8

14 files changed

Lines changed: 442 additions & 242 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Kirby\Auth\Exception;
4+
5+
use Kirby\Exception\PermissionException;
6+
7+
/**
8+
* Thrown when a rate limit has been exceeded
9+
*/
10+
class RateLimitException extends PermissionException
11+
{
12+
protected static string $defaultKey = 'error.auth.limit';
13+
protected static string $defaultFallback = 'Rate limit exceeded';
14+
}

src/Auth/Limits.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
namespace Kirby\Auth;
4+
5+
use Kirby\Auth\Exception\RateLimitException;
6+
use Kirby\Cms\App;
7+
use Kirby\Data\Data;
8+
use Kirby\Filesystem\F;
9+
use Kirby\Toolkit\A;
10+
11+
/**
12+
* Handler to enforce the auth rate limits
13+
*
14+
* @package Kirby Auth
15+
* @author Lukas Bestle <lukas@getkirby.com>
16+
* @link https://getkirby.com
17+
* @copyright Bastian Allgeier
18+
* @license https://getkirby.com/license
19+
* @since 6.0.0
20+
*/
21+
class Limits
22+
{
23+
public function __construct(
24+
protected App $kirby
25+
) {
26+
}
27+
28+
public function ensure(string $email): void
29+
{
30+
if ($this->isBlocked($email) === true) {
31+
$this->kirby->trigger('user.login:failed', ['email' => $email]);
32+
throw new RateLimitException();
33+
}
34+
}
35+
36+
public function file(): string
37+
{
38+
return $this->kirby->root('accounts') . '/.logins';
39+
}
40+
41+
public function isBlocked(string $email): bool
42+
{
43+
$log = $this->log();
44+
$ip = $this->kirby->visitor()->ip(hash: true);
45+
$trials = $this->kirby->option('auth.trials', 10);
46+
47+
if (($log['by-ip'][$ip]['trials'] ?? null) >= $trials) {
48+
return true;
49+
}
50+
51+
if ($this->kirby->users()->find($email)) {
52+
if (($log['by-email'][$email]['trials'] ?? null) >= $trials) {
53+
return true;
54+
}
55+
}
56+
57+
return false;
58+
}
59+
60+
public function log(): array
61+
{
62+
$log = Data::read($this->file(), 'json', fail: false);
63+
64+
// ensure that the category arrays are defined
65+
$log['by-ip'] ??= [];
66+
$log['by-email'] ??= [];
67+
68+
// remove all elements on the top level
69+
// with different keys (old structure)
70+
$log = array_intersect_key($log, array_flip(['by-ip', 'by-email']));
71+
72+
// remove entries that are no longer needed
73+
$time = time() - $this->kirby->option('auth.timeout', 3600);
74+
$updated = A::map(
75+
$log,
76+
fn ($category) => A::filter(
77+
$category,
78+
fn ($entry) => $entry['time'] > $time
79+
)
80+
);
81+
82+
// write new log to the file system if it changed
83+
if ($updated['by-ip'] === [] && $updated['by-email'] === []) {
84+
F::remove($this->file());
85+
} elseif ($updated !== $log) {
86+
Data::write($this->file(), $updated, 'json');
87+
}
88+
89+
return $updated;
90+
}
91+
92+
public function track(string|null $email, bool $triggerHook = true): bool
93+
{
94+
if ($triggerHook === true) {
95+
$this->kirby->trigger('user.login:failed', ['email' => $email]);
96+
}
97+
98+
$log = $this->log();
99+
$ip = $this->kirby->visitor()->ip(hash: true);
100+
$time = time();
101+
102+
$log['by-ip'][$ip] ??= ['trials' => 0];
103+
$log['by-ip'][$ip]['time'] = $time;
104+
$log['by-ip'][$ip]['trials'] += 1;
105+
106+
if ($email !== null && $this->kirby->users()->find($email)) {
107+
$log['by-email'][$email] ??= ['trials' => 0];
108+
$log['by-email'][$email]['time'] = $time;
109+
$log['by-email'][$email]['trials'] += 1;
110+
}
111+
112+
return Data::write($this->file(), $log, 'json');
113+
}
114+
}

0 commit comments

Comments
 (0)