-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSessionRecord.php
More file actions
386 lines (322 loc) · 12.8 KB
/
Copy pathSessionRecord.php
File metadata and controls
386 lines (322 loc) · 12.8 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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
<?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 Session - SessionRecord
*
* @package Italix\Session
*/
declare(strict_types=1);
namespace Italix\Session;
/**
* One session, as a record about a subject — not a blob under a key.
*
* This is the whole design in one class. In PHP natively, in Laravel and in
* Symfony a session is an opaque payload stored under an identifier that only
* the browser holding the cookie knows, and the consequence is that **the
* server cannot find a user's sessions**. Every application eventually needs to:
*
* - sign out an account that was just suspended, now rather than in 24 minutes
* - invalidate the other sessions when a password changes
* - show "you are signed in on three devices"
* - sign out everywhere
* - say who was signed in when something happened
*
* None of the five is expressible against a key→value store. All five are one
* query against the fields below, and this is the only decision in the library
* that is expensive to change later, because sessions already written do not
* have the columns.
*
* **Two indexed axes, not one.** `subject_c` is the person and `tenant_c` is the
* organisation they are inside. Anything selling seats needs both: suspend a
* subscription and every one of that organisation's people has to go, which is
* not a question the person index can answer. Keeping them apart is also what
* lets a support session belong to a staff member while sitting inside a
* customer's organisation — see `tenant_c` below.
*
* **Three times, not one.** `created_t` is when the session began — which for a
* storefront is long before anybody signed in. `last_seen_t` drives the idle
* window and the "active now" column of a device list. `authenticated_t` is
* when identity was last *proved*, and keeping it apart is what makes step-up
* possible: being signed in three hours ago is not the same as being signed in
* now, which `sudo` has known for forty years and web frameworks mostly have
* not.
*/
final class SessionRecord
{
private string $id_c;
/**
* Who this belongs to, opaquely — `"account:12"`, `"shop_customer:88"`.
*
* Null is normal and not an error: a storefront's cart belongs to a browser,
* not to a person, until checkout. Only a subject can be revoked, because
* only a subject can be looked up.
*/
private ?string $subject_c = null;
/**
* Which organisation this session is inside, opaquely — `"tenant:7"`.
*
* **The second index, and the reason it is a second one rather than a
* cleverer `subject_c`.** A session belongs to a *person*, and a person
* belongs to an *organisation*; both need to be revocable and they are not
* the same list. Suspending a subscription must sign out all of that
* organisation's people at once — a question `subject_c` cannot be asked,
* because it would need a `LIKE` over an opaque string the library promised
* never to parse.
*
* They stay apart for a second reason that only shows up later: a staff
* member supporting a customer has this session's `tenant_c` set to the
* customer's while `subject_c` stays their own. That session must fall when
* the customer is suspended, and must **not** count against whatever seat or
* concurrency limit the customer is paying for. One field collapsed into the
* other makes both of those wrong at the same time.
*
* Null is normal: an application with no organisations never sets it.
*/
private ?string $tenant_c = null;
/** @var array<string, mixed> */
private array $payload = [];
private int $created_t;
private int $last_seen_t;
private ?int $authenticated_t = null;
private ?int $expires_t = null;
private ?int $revoked_t = null;
private string $revoked_reason_c = '';
private ?int $rotated_t = null;
private ?string $successor_c = null;
private string $ip_c = '';
private string $agent_c = '';
private function __construct(string $id_c, int $now_t)
{
$this->id_c = $id_c;
$this->created_t = $now_t;
$this->last_seen_t = $now_t;
}
public static function create(string $id_c, int $now_t): self
{
return new self($id_c, $now_t);
}
/**
* The row that replaces another after a rotation.
*
* **`expires_t` is carried over, and that is what keeps the absolute cap
* honest.** Expiry is decided against the stored deadline rather than
* recomputed from the age of the row, so a session that rotates — at every
* login, at every step a careful application takes — cannot renew its cap by
* rotating. Reset it here and the one limit a stolen cookie cannot outlive
* by using it becomes the one that never fires.
*
* `created_t` comes across too, for a smaller reason: it is what a device
* list means by "signed in since", and a successor that claims to have been
* born a moment ago would make every rotation look like a new device.
*
* The payload is *not* copied here: the facade writes it, because only the
* facade knows what this request changed.
*/
public static function successor_of(SessionRecord $previous, string $id_c, int $now_t): self
{
$record = new self($id_c, $now_t);
$record->created_t = $previous->created_t;
$record->subject_c = $previous->subject_c;
$record->tenant_c = $previous->tenant_c;
$record->authenticated_t = $previous->authenticated_t;
$record->expires_t = $previous->expires_t;
$record->ip_c = $previous->ip_c;
$record->agent_c = $previous->agent_c;
return $record;
}
/**
* Rebuild from storage. Returns null when the array is not a record, which
* is what a corrupt or half-written file looks like — and a corrupt session
* must read as "no session", never as an exception in the middleware.
*
* @param array<string, mixed> $data
*/
public static function from_array(array $data): ?self
{
if (!isset($data['id_c']) || !is_string($data['id_c']) || $data['id_c'] === '') {
return null;
}
$record = new self($data['id_c'], isset($data['created_t']) ? (int) $data['created_t'] : 0);
$record->subject_c = isset($data['subject_c']) && is_string($data['subject_c']) ? $data['subject_c'] : null;
$record->tenant_c = isset($data['tenant_c']) && is_string($data['tenant_c']) ? $data['tenant_c'] : null;
$record->payload = isset($data['payload']) && is_array($data['payload']) ? $data['payload'] : [];
$record->last_seen_t = isset($data['last_seen_t']) ? (int) $data['last_seen_t'] : $record->created_t;
$record->authenticated_t = isset($data['authenticated_t']) ? (int) $data['authenticated_t'] : null;
$record->expires_t = isset($data['expires_t']) ? (int) $data['expires_t'] : null;
$record->revoked_t = isset($data['revoked_t']) ? (int) $data['revoked_t'] : null;
$record->revoked_reason_c = isset($data['revoked_reason_c']) ? (string) $data['revoked_reason_c'] : '';
$record->rotated_t = isset($data['rotated_t']) ? (int) $data['rotated_t'] : null;
$record->successor_c = isset($data['successor_c']) && is_string($data['successor_c']) ? $data['successor_c'] : null;
$record->ip_c = isset($data['ip_c']) ? (string) $data['ip_c'] : '';
$record->agent_c = isset($data['agent_c']) ? (string) $data['agent_c'] : '';
return $record;
}
/** @return array<string, mixed> */
public function to_array(): array
{
return [
'id_c' => $this->id_c,
'subject_c' => $this->subject_c,
'tenant_c' => $this->tenant_c,
'payload' => $this->payload,
'created_t' => $this->created_t,
'last_seen_t' => $this->last_seen_t,
'authenticated_t' => $this->authenticated_t,
'expires_t' => $this->expires_t,
'revoked_t' => $this->revoked_t,
'revoked_reason_c' => $this->revoked_reason_c,
'rotated_t' => $this->rotated_t,
'successor_c' => $this->successor_c,
'ip_c' => $this->ip_c,
'agent_c' => $this->agent_c,
];
}
// =========================================================================
// Reading
// =========================================================================
public function token(): string
{
return $this->id_c;
}
public function subject(): ?string
{
return $this->subject_c;
}
public function tenant(): ?string
{
return $this->tenant_c;
}
/** @return array<string, mixed> */
public function payload(): array
{
return $this->payload;
}
public function created_t(): int
{
return $this->created_t;
}
public function last_seen_t(): int
{
return $this->last_seen_t;
}
public function authenticated_t(): ?int
{
return $this->authenticated_t;
}
public function expires_t(): ?int
{
return $this->expires_t;
}
public function revoked_t(): ?int
{
return $this->revoked_t;
}
public function revoked_reason_code(): string
{
return $this->revoked_reason_c;
}
public function rotated_t(): ?int
{
return $this->rotated_t;
}
public function successor_token(): ?string
{
return $this->successor_c;
}
public function ip(): string
{
return $this->ip_c;
}
public function agent(): string
{
return $this->agent_c;
}
public function is_revoked(): bool
{
return $this->revoked_t !== null;
}
public function is_rotated(): bool
{
return $this->rotated_t !== null;
}
public function is_authenticated(): bool
{
return $this->authenticated_t !== null;
}
// =========================================================================
// Writing — mutating rather than copy-on-write, because a record crosses a
// store lock and two objects for one row would be a second way to be wrong.
// =========================================================================
public function set_subject(?string $subject_c): self
{
$this->subject_c = $subject_c;
return $this;
}
/**
* Set or clear the organisation this session is inside.
*
* Separate from `set_subject()` on purpose: the two change at different
* moments. Signing in sets both; a staff member starting or ending a support
* session changes only this one, without re-proving an identity that never
* changed.
*/
public function set_tenant(?string $tenant_c): self
{
$this->tenant_c = $tenant_c;
return $this;
}
/** @param array<string, mixed> $payload */
public function set_payload(array $payload): self
{
$this->payload = $payload;
return $this;
}
public function touch(int $now_t): self
{
$this->last_seen_t = $now_t;
return $this;
}
public function set_authenticated(int $now_t): self
{
$this->authenticated_t = $now_t;
return $this;
}
public function set_expires(?int $expires_t): self
{
$this->expires_t = $expires_t;
return $this;
}
public function set_device(string $ip_c, string $agent_c): self
{
$this->ip_c = $ip_c;
// Truncated because it is a header the client controls and it is going
// into a column. 255 is long enough to identify a browser and short
// enough that nobody can post a megabyte of it once per request.
$this->agent_c = substr($agent_c, 0, 255);
return $this;
}
public function revoke(int $now_t, string $reason_c = ''): self
{
$this->revoked_t = $now_t;
$this->revoked_reason_c = $reason_c;
return $this;
}
/**
* Mark this identifier as replaced, and remember by what.
*
* The payload is deliberately **cleared**: the successor carries the data
* from here on, and leaving a copy behind means a revocation has to find and
* clean two rows to be true.
*/
public function rotate_to(string $successor_c, int $now_t): self
{
$this->rotated_t = $now_t;
$this->successor_c = $successor_c;
$this->payload = [];
return $this;
}
}