-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSessionStore.php
More file actions
178 lines (161 loc) · 7.92 KB
/
Copy pathSessionStore.php
File metadata and controls
178 lines (161 loc) · 7.92 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
<?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 - SessionStore
*
* @package Italix\Session
*/
declare(strict_types=1);
namespace Italix\Session;
/**
* Where sessions live.
*
* **Why this is not `Italix\Contracts\KeyValueStore`.** That interface exists,
* it has `get`/`put`/`forget`/`expires_t`, and `Italix\Crypto` already ships two
* implementations of it — which would have made a database-backed session a line
* of configuration and no new code. It was the first plan, and it is wrong: a
* key→value interface cannot answer *"every session of subject 42"*, and that
* one question is what the whole design is for. Reusing it would have bought a
* week and cost the feature.
*
* It stays in this package rather than moving to `Italix\Contracts` because it
* has exactly one consumer. House rule 6 moves a seam to `Contracts` when two
* libraries need to talk, and `KeyValueStore`'s own docblock records that it
* lived in `italix/crypto` until that happened. When a second library wants
* sessions, this follows the same path.
*
* **Three capabilities are declared rather than assumed**, because not every
* backing store has them and pretending is worse than saying so:
*
* - `can_find_by_subject()` — a file-per-session directory cannot index. An
* application offering "sign out everywhere" must learn that at boot, not
* when the button silently does nothing.
* - `can_find_by_tenant()` — the same question about the organisation index.
* Every store shipped here answers it identically to the one above, because
* a store either keeps indexes or it does not. It is asked separately anyway:
* a store built on a key→value service maintains each index by hand, and one
* of the two being missing is an ordinary thing to have to say.
* - `can_lock()` — whether `transact()` really serialises. Where it does not,
* `SessionData::merge()` is best-effort rather than atomic, and a caller
* storing a cart should know which of the two it has.
*/
interface SessionStore
{
/** The record, or null when there is none. An expired one is still returned: expiry is the caller's judgement. */
public function find(string $id_c): ?SessionRecord;
public function save(SessionRecord $record): void;
/**
* Read, modify and write a record under whatever lock this store can offer.
*
* The mutator receives the record as it stands **now** (null when absent)
* and returns the record to write, or null to delete it. This is the only
* safe way to persist a session: reading in the middleware and writing at
* the end of the request is precisely the read-modify-write that loses a
* concurrent AJAX call's data.
*
* @param callable(?SessionRecord): ?SessionRecord $mutator
* @return SessionRecord|null what was written
*/
public function transact(string $id_c, callable $mutator): ?SessionRecord;
public function destroy(string $id_c): void;
/**
* Every **live** session of one subject, newest first — the device list.
*
* Live excludes two things on purpose: a rotated row, which is a tombstone
* rather than a session, and a revoked one, which is a device the user is no
* longer signed in on. Including either would make "you are signed in on
* three devices" mean something other than what it says.
*
* @throws SessionException when `can_find_by_subject()` is false — returning
* an empty array would read as "one device", and the caller has no
* way to tell that apart from the truth.
* @return array<int, SessionRecord>
*/
public function of_subject(string $subject_c): array;
/** @return bool false when there was nothing to revoke */
public function revoke(string $id_c, string $reason_c = ''): bool;
/**
* Revoke every session of a subject, optionally sparing one.
*
* @throws SessionException when `can_find_by_subject()` is false
* @return int how many were revoked — returned so the caller can tell the
* user the truth rather than a generic sentence
*/
public function revoke_subject(string $subject_c, string $reason_c = '', ?string $spare_c = null): int;
/**
* Every **live** session inside one organisation, newest first.
*
* Answers the question the subject index cannot: *who is working in this
* account right now* — across all of its people, and including a support
* session whose subject is a staff member rather than one of them.
*
* @throws SessionException when `can_find_by_tenant()` is false
* @return array<int, SessionRecord>
*/
public function of_tenant(string $tenant_c): array;
/**
* Revoke every session inside an organisation, optionally sparing one.
*
* The control for a suspended subscription, a closed account, and a
* confirmed compromise of a whole customer. Note what it deliberately does
* **not** do: it takes no view on seats. Which of an organisation's people
* may hold a seat is the application's rule, and enforcing it here would put
* a pricing decision inside a session library.
*
* @throws SessionException when `can_find_by_tenant()` is false
* @return int how many were revoked
*/
public function revoke_tenant(string $tenant_c, string $reason_c = '', ?string $spare_c = null): int;
/**
* Delete what is over: past its absolute deadline, or untouched for longer
* than the idle window.
*
* **`$idle_ttl_n` is not optional in practice, and leaving it out was a
* defect.** Until 1.3.0 this swept only on `expires_t`, which is set only
* when an application configures an absolute cap — and most do not. On such
* an application `gc()` deleted nothing, ever, while a row was written for
* every visitor who was shown a CSRF token. Measured on the first deployment
* of this store: 57 rows, none of them collectable. PHP's own probabilistic
* sweep used to cover this, and moving off it removed the guarantee silently,
* which is the same shape of loss as the session lock.
*
* The idle sweep also takes the tombstones with it — a rotated or revoked row
* stops being touched, so it ages out on the same rule, and no second pass is
* needed for either.
*
* Housekeeping, never correctness: expiry is decided on read, so nothing
* depends on this having run.
*
* @param int $idle_ttl_n seconds of inactivity after which a row is rubbish.
* Zero sweeps on the absolute deadline only, which is what the caller
* is asking for when their policy has no idle window.
* @return int rows removed
*/
public function gc(int $now_t, int $idle_ttl_n = 0): int;
public function can_find_by_subject(): bool;
public function can_find_by_tenant(): bool;
public function can_lock(): bool;
/**
* The store's own successor identifier, or **null** to let the facade mint
* one and leave a tombstone behind.
*
* Only one store answers this: the adapter over PHP's native session, where
* `session_regenerate_id()` owns the identifier and issuing our own would
* give one session two names. Everything else returns null, which is what
* keeps replay detection possible — a rotation the facade performs leaves
* the old row pointing at the new one, and PHP's does not.
*/
public function rotate(string $id_c): ?string;
/**
* Whether the store already puts the identifier in the browser itself.
*
* True only for the native adapter, where PHP has sent the cookie before
* the middleware gets a chance. Two authorities for one cookie is a way to
* have them disagree.
*/
public function manages_cookie(): bool;
}