-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAuthenticator.php
More file actions
149 lines (127 loc) · 4.9 KB
/
Copy pathAuthenticator.php
File metadata and controls
149 lines (127 loc) · 4.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
<?php
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* Italix Auth - Authenticator
*
* @package Italix\Auth
*/
declare(strict_types=1);
namespace Italix\Auth;
use Italix\Contracts\RateLimiter;
/**
* Checks a password, in the order that does not leak.
*
* $outcome = $auth->attempt($email, $password, $ip_c);
*
* if (!$outcome->is_ok()) {
* $error = $t->get('auth.' . $outcome->error_code());
* return $this->show($view, compact('error'));
* }
*
* session_regenerate_id(true);
* $_SESSION['user_id'] = $outcome->identity()->id();
*
* ## The order, and why each step is where it is
*
* 1. **Rate limit first**, before the store is touched. Refusing early is most
* of the point, and a limiter consulted after the lookup has already paid
* for the lookup.
* 2. **Look up the identity.**
* 3. **Hash-compare either way.** A missing account burns the same work through
* `verify_dummy()`, so response time does not answer "is this address
* registered?".
* 4. **Check the password before `is_active()`.** Reversing these tells an
* attacker which accounts exist merely by trying a wrong password — the
* "not activated" answer would arrive without the password ever matching.
* 5. **Reset the counter, rehash if needed, touch the login.**
*
* Session handling is not here. `$_SESSION` belongs to the application and to
* `Italix\Mvc`, and an auth library that writes to it decides the shape of
* something it does not own — see `Auth/README.md` for the four lines that
* follow a successful outcome.
*/
final class Authenticator
{
/** Attempts per account, and the window, unless the caller says otherwise */
public const DEFAULT_ATTEMPTS_N = 5;
public const DEFAULT_WINDOW = '15 minutes';
private IdentityStore $store;
private Hasher $hasher;
private ?RateLimiter $limiter;
private int $attempts_n;
/** @var string|int */
private $window;
/**
* @param RateLimiter|null $limiter null disables limiting — acceptable for a
* console command, reckless for a web form
* @param string|int $window
*/
public function __construct(
IdentityStore $store,
?Hasher $hasher = null,
?RateLimiter $limiter = null,
int $attempts_n = self::DEFAULT_ATTEMPTS_N,
$window = self::DEFAULT_WINDOW
) {
$this->store = $store;
$this->hasher = $hasher ?? new Hasher();
$this->limiter = $limiter;
$this->attempts_n = $attempts_n;
$this->window = $window;
}
public function attempt(string $login_c, string $password, string $ip_c = ''): Outcome
{
$login_c = trim($login_c);
$key = 'auth:' . mb_strtolower($login_c);
if ($this->limiter !== null) {
$verdict = $this->limiter->hit($key, $this->attempts_n, $this->window);
if ($verdict->is_exceeded()) {
return Outcome::failed('rate_limited', $verdict->retry_after_n());
}
}
$identity = $this->store->find_by_login($login_c);
if ($identity === null) {
// Same work as a real comparison, so the timing says nothing.
$this->hasher->verify_dummy($password);
return Outcome::failed('unknown_user');
}
if (!$this->hasher->verify($password, $identity->password_hash())) {
return Outcome::failed('bad_password');
}
// Only now, with the password proven, is it safe to say why a correct
// credential is still being refused.
if (!$identity->is_active()) {
return Outcome::failed('not_activated');
}
if ($this->limiter !== null) {
$this->limiter->reset($key);
}
if ($this->hasher->needs_rehash($identity->password_hash())) {
// The one moment the plaintext exists and the hash is known stale.
$this->store->store_password_hash($identity->id(), $this->hasher->hash($password));
}
$this->store->touch_login($identity->id(), $ip_c);
return Outcome::ok($identity);
}
/**
* Re-establish an identity from a session, without a password.
*
* Returns null when the account has since been deactivated or deleted —
* which is the reason to call it on every request rather than trusting the
* session alone: a suspended user should stop being able to act before
* their session expires.
*/
public function identify(int $user_id): ?Identity
{
$identity = $this->store->find_by_id($user_id);
return $identity !== null && $identity->is_active() ? $identity : null;
}
public function hasher(): Hasher
{
return $this->hasher;
}
}