@nodefony/http 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +77 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +108 -0
- package/dist/nodefony/command/assetsPublishCommand.js +102 -0
- package/dist/nodefony/command/certificatesCommand.js +47 -0
- package/dist/nodefony/command/networkCommand.js +27 -0
- package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
- package/dist/nodefony/config/config.js +335 -0
- package/dist/nodefony/config/defineModuleConfig.js +93 -0
- package/dist/nodefony/interfaces/IContext.js +1 -0
- package/dist/nodefony/interfaces/ICookie.js +1 -0
- package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
- package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
- package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
- package/dist/nodefony/interfaces/IRequest.js +1 -0
- package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
- package/dist/nodefony/interfaces/IResponse.js +1 -0
- package/dist/nodefony/interfaces/ISession.js +1 -0
- package/dist/nodefony/interfaces/IUpload.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +376 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
- package/dist/nodefony/service/audit-logger.js +159 -0
- package/dist/nodefony/service/certificates.js +545 -0
- package/dist/nodefony/service/error-renderer.js +320 -0
- package/dist/nodefony/service/http-kernel.js +948 -0
- package/dist/nodefony/service/pretty-request-logger.js +72 -0
- package/dist/nodefony/service/request-logger.js +54 -0
- package/dist/nodefony/service/servers/clientError.js +20 -0
- package/dist/nodefony/service/servers/server-http.js +135 -0
- package/dist/nodefony/service/servers/server-https.js +204 -0
- package/dist/nodefony/service/servers/server-static.js +192 -0
- package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
- package/dist/nodefony/service/servers/server-websocket.js +104 -0
- package/dist/nodefony/service/servers/serverShutdown.js +31 -0
- package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
- package/dist/nodefony/service/sessions/sessions-service.js +580 -0
- package/dist/nodefony/service/trace.js +72 -0
- package/dist/nodefony/service/upload/upload-service.js +171 -0
- package/dist/nodefony/src/assets/collectAssets.js +34 -0
- package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
- package/dist/nodefony/src/context/Context.js +415 -0
- package/dist/nodefony/src/context/domainMatcher.js +88 -0
- package/dist/nodefony/src/context/forwarded.js +185 -0
- package/dist/nodefony/src/context/http/HttpContext.js +309 -0
- package/dist/nodefony/src/context/http/Request.js +543 -0
- package/dist/nodefony/src/context/http/Response.js +368 -0
- package/dist/nodefony/src/context/http/parser.js +188 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
- package/dist/nodefony/src/context/http2/Request.js +29 -0
- package/dist/nodefony/src/context/http2/Response.js +97 -0
- package/dist/nodefony/src/context/metaData.js +47 -0
- package/dist/nodefony/src/context/requestId.js +41 -0
- package/dist/nodefony/src/context/trustProxy.js +167 -0
- package/dist/nodefony/src/context/websocket/Response.js +181 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
- package/dist/nodefony/src/cookies/cookie.js +258 -0
- package/dist/nodefony/src/errors/httpError.js +69 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
- package/dist/nodefony/src/profiler/Profiler.js +139 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
- package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
- package/dist/nodefony/src/servers/portBinder.js +114 -0
- package/dist/nodefony/src/session/session.js +390 -0
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
- package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
- package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
- package/dist/types/index.d.ts +83 -0
- package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
- package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
- package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
- package/dist/types/nodefony/config/config.d.ts +197 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
- package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
- package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
- package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
- package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
- package/dist/types/nodefony/service/certificates.d.ts +246 -0
- package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
- package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
- package/dist/types/nodefony/service/request-logger.d.ts +18 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
- package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
- package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
- package/dist/types/nodefony/service/trace.d.ts +39 -0
- package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
- package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
- package/dist/types/nodefony/src/context/Context.d.ts +195 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
- package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
- package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
- package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
- package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
- package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
- package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
- package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
- package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
- package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
- package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
- package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
- package/dist/types/nodefony/src/session/session.d.ts +171 -0
- package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
- package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
- package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
- package/docs/cookies.md +365 -0
- package/docs/index.md +163 -0
- package/docs/observabilite.md +460 -0
- package/docs/rate-limit.md +372 -0
- package/docs/servers.md +935 -0
- package/docs/session.md +768 -0
- package/docs/upload.md +460 -0
- package/package.json +101 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import Cookie from "../cookies/cookie.js";
|
|
2
|
+
import { extend } from "nodefony";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
//#region nodefony/src/session/session.ts
|
|
5
|
+
const defaultSessionOptions = {
|
|
6
|
+
name: "nodefony",
|
|
7
|
+
strictMode: true,
|
|
8
|
+
refererCheck: false
|
|
9
|
+
};
|
|
10
|
+
/** Taille de l'identifiant opaque (octets CSPRNG → base64url, 43 chars). */
|
|
11
|
+
const SESSION_ID_BYTES = 32;
|
|
12
|
+
/**
|
|
13
|
+
* Session serveur Nodefony — état persistant lié à une identité, indexé par un
|
|
14
|
+
* identifiant **opaque** (porté par le cookie) dans un {@link ISessionStorage}
|
|
15
|
+
* pluggable (File / Redis / SQL). Modèle BFF : le cookie ne transporte qu'un
|
|
16
|
+
* secret aléatoire, jamais de données ni de JWT.
|
|
17
|
+
*
|
|
18
|
+
* Objet **léger** : trois sacs `{}` (attributs / métas / flash)
|
|
19
|
+
* au lieu d'un `Container` DI par session.
|
|
20
|
+
*
|
|
21
|
+
* Dirty-tracking : toute mutation (`set` / `setFlashBag` / `setMetaBag` /
|
|
22
|
+
* `getFlashBag` qui consomme / …) lève {@link dirty} ; {@link save} n'écrit dans
|
|
23
|
+
* le storage **que** si `dirty`. Une requête qui ne touche pas la session ne
|
|
24
|
+
* déclenche aucune écriture (supprime la contention `write`/requête).
|
|
25
|
+
*/
|
|
26
|
+
var Session = class {
|
|
27
|
+
id = "";
|
|
28
|
+
name = "";
|
|
29
|
+
status = "none";
|
|
30
|
+
storage;
|
|
31
|
+
manager;
|
|
32
|
+
saved = false;
|
|
33
|
+
migrated = false;
|
|
34
|
+
/**
|
|
35
|
+
* Lecture seule (intent `@UseSession({ readOnly })`) : la session est reprise et
|
|
36
|
+
* lue mais **jamais persistée** — {@link save} devient un no-op. Différenciateur
|
|
37
|
+
* perf : une route qui ne fait que LIRE la session (afficher le user…) ne paie
|
|
38
|
+
* aucune écriture storage.
|
|
39
|
+
*/
|
|
40
|
+
readOnly = false;
|
|
41
|
+
context;
|
|
42
|
+
created;
|
|
43
|
+
updated;
|
|
44
|
+
options;
|
|
45
|
+
cookieSession = null;
|
|
46
|
+
lifetime;
|
|
47
|
+
user;
|
|
48
|
+
strategy;
|
|
49
|
+
/** Sac d'attributs applicatifs (clé/valeur) — exposé via {@link getAttributes}. */
|
|
50
|
+
attributesBag = {};
|
|
51
|
+
/** Sac de métadonnées techniques (host, ip, ua…) — exposé via {@link getMetas}. */
|
|
52
|
+
metaBagStore = {};
|
|
53
|
+
/** Sac de messages flash (consommés à la lecture). Public : lu par les storages. */
|
|
54
|
+
flashBag = {};
|
|
55
|
+
/** Drapeau dirty-tracking — lu via le getter {@link dirty}. */
|
|
56
|
+
mutated = false;
|
|
57
|
+
constructor(name, options, manager) {
|
|
58
|
+
this.options = extend({}, defaultSessionOptions, options);
|
|
59
|
+
this.manager = manager;
|
|
60
|
+
this.storage = this.manager.storage;
|
|
61
|
+
if (!this.storage) this.status = "disabled";
|
|
62
|
+
this.setName(name);
|
|
63
|
+
this.strategy = this.manager.sessionStrategy;
|
|
64
|
+
}
|
|
65
|
+
/** Vrai si la session a été mutée sans être encore persistée (dirty-tracking). */
|
|
66
|
+
get dirty() {
|
|
67
|
+
return this.mutated;
|
|
68
|
+
}
|
|
69
|
+
log(pci, severity) {
|
|
70
|
+
this.manager.log(pci, severity, `SESSION ${this.name}`);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Démarre (ou reprend) la session. Cookie présent → reprise depuis le storage ;
|
|
74
|
+
* sinon → nouvelle session.
|
|
75
|
+
*
|
|
76
|
+
* @param context - contexte HTTP/HTTP2/WS courant.
|
|
77
|
+
*/
|
|
78
|
+
async start(context) {
|
|
79
|
+
this.context = context;
|
|
80
|
+
const ret = this.checkStatus();
|
|
81
|
+
if (ret === false) return this;
|
|
82
|
+
if (ret === "restart") return this.start(context);
|
|
83
|
+
return this.getSession();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Lit l'identifiant opaque du cookie puis reprend la session, ou en crée une
|
|
87
|
+
* neuve si aucun cookie. Cookie-only : aucun identifiant lu depuis l'URL.
|
|
88
|
+
*/
|
|
89
|
+
async getSession() {
|
|
90
|
+
if (this.context?.cookieSession) {
|
|
91
|
+
this.id = this.context.cookieSession.value;
|
|
92
|
+
this.cookieSession = this.context.cookieSession;
|
|
93
|
+
}
|
|
94
|
+
if (this.id) return this.resume();
|
|
95
|
+
this.clear();
|
|
96
|
+
return this.create(this.lifetime ?? 0);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Reprend la session `id` depuis le storage. Invalide (→ session neuve) si
|
|
100
|
+
* introuvable en strict mode, ou expirée/illégitime.
|
|
101
|
+
*/
|
|
102
|
+
async resume() {
|
|
103
|
+
const data = await this.storage.start(this.id);
|
|
104
|
+
if (data && Object.keys(data).length) {
|
|
105
|
+
this.deSerialize(data);
|
|
106
|
+
if (!this.isValidSession(data, this.context)) {
|
|
107
|
+
this.log(`INVALID SESSION ==> ${this.name} : ${this.id}`, "WARNING");
|
|
108
|
+
await this.invalidate();
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
this.status = "active";
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
if (this.options.strictMode) {
|
|
115
|
+
this.log(`SESSION strict_mode unknown id ==> ${this.name}`, "DEBUG");
|
|
116
|
+
await this.invalidate();
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
this.status = "active";
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Crée une session neuve : identifiant opaque CSPRNG, cookie, métadonnées.
|
|
124
|
+
* Marquée `dirty` (sauf `saveUninitialized:false`) → persistée + `Set-Cookie`
|
|
125
|
+
* au prochain {@link save}.
|
|
126
|
+
*/
|
|
127
|
+
create(lifetime, id, settingsCookie = {}) {
|
|
128
|
+
this.id = id || this.generateId();
|
|
129
|
+
const settings = extend({}, this.options.cookie, settingsCookie);
|
|
130
|
+
this.log(`NEW SESSION CREATE : ${this.id}`, "DEBUG");
|
|
131
|
+
this.cookieSession = this.setCookieSession(lifetime, settings);
|
|
132
|
+
this.setMetasSession(settings);
|
|
133
|
+
this.status = "active";
|
|
134
|
+
this.mutated = true;
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
/** Génère un identifiant de session opaque (32 octets CSPRNG → base64url). */
|
|
138
|
+
generateId() {
|
|
139
|
+
return randomBytes(SESSION_ID_BYTES).toString("base64url");
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Régénère l'identifiant (nouveau secret opaque) en conservant l'état courant.
|
|
143
|
+
* Anti session-fixation (OWASP) — appelée à chaque ouverture de session
|
|
144
|
+
* authentifiée par `AuthFlow.#openSession()` (`@nodefony/security`), qui
|
|
145
|
+
* détruit ensuite l'ancienne entrée de storage. Repositionne le cookie et
|
|
146
|
+
* marque la session `dirty`.
|
|
147
|
+
*/
|
|
148
|
+
regenerateId() {
|
|
149
|
+
this.id = this.generateId();
|
|
150
|
+
this.mutated = true;
|
|
151
|
+
if (this.context?.response) this.cookieSession = this.setCookieSession(this.lifetime ?? 0, this.options.cookie ?? {});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Persiste la session **si elle est dirty** (sinon no-op). Réécrit le blob
|
|
155
|
+
* sérialisé dans le storage, repositionne created/updated, lève l'événement
|
|
156
|
+
* `onSaveSession`.
|
|
157
|
+
*
|
|
158
|
+
* @param user - principal authentifié (string) lié au blob ; défaut courant.
|
|
159
|
+
*/
|
|
160
|
+
async save(user) {
|
|
161
|
+
if (this.readOnly) {
|
|
162
|
+
if (this.mutated) this.log(`READONLY SESSION mutated — write skipped : ${this.name}`, "WARNING");
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
if (!this.mutated) return this;
|
|
166
|
+
const stored = await this.storage.write(this.id, this.serialize(user));
|
|
167
|
+
this.created = stored.createdAt ?? this.created;
|
|
168
|
+
this.updated = stored.updatedAt ?? this.updated;
|
|
169
|
+
this.mutated = false;
|
|
170
|
+
this.saved = true;
|
|
171
|
+
if (this.context) await this.context.fireAsync("onSaveSession", this);
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Détruit la session courante (storage) et en recrée une neuve (nouvel
|
|
176
|
+
* identifiant + cookie). État applicatif réinitialisé.
|
|
177
|
+
*/
|
|
178
|
+
async invalidate(lifetime = this.lifetime ?? 0, id, settingsCookie = {}) {
|
|
179
|
+
this.log(`INVALIDATE SESSION ==> ${this.name} : ${this.id}`, "DEBUG");
|
|
180
|
+
const oldId = this.id;
|
|
181
|
+
this.clear();
|
|
182
|
+
await this.storage.destroy(oldId);
|
|
183
|
+
return this.create(lifetime, id, settingsCookie);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Détruit la session : vide les sacs, supprime l'entrée storage, et (option)
|
|
187
|
+
* efface le cookie.
|
|
188
|
+
*/
|
|
189
|
+
async destroy(cookieDelete = false) {
|
|
190
|
+
this.clear();
|
|
191
|
+
await this.storage.destroy(this.id);
|
|
192
|
+
this.mutated = false;
|
|
193
|
+
this.status = "disabled";
|
|
194
|
+
if (cookieDelete) this.deleteCookieSession();
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
setCookieSession(leftTime, options = {}) {
|
|
198
|
+
if (this.context && this.context.response) {
|
|
199
|
+
const settings = extend({}, this.options.cookie, options);
|
|
200
|
+
if (leftTime) settings.maxAge = leftTime;
|
|
201
|
+
const cookie = new Cookie(this.context.getSessionCookieName(), this.id, settings);
|
|
202
|
+
this.context.response.addCookie(cookie);
|
|
203
|
+
this.cookieSession = cookie;
|
|
204
|
+
this.context.cookieSession = cookie;
|
|
205
|
+
return cookie;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
deleteCookieSession() {
|
|
210
|
+
if (this.context && this.context.response) {
|
|
211
|
+
let cookie = this.cookieSession;
|
|
212
|
+
if (cookie) cookie.expires = /* @__PURE__ */ new Date(0);
|
|
213
|
+
else cookie = new Cookie(this.context.getSessionCookieName(), "", { expires: /* @__PURE__ */ new Date(0) });
|
|
214
|
+
this.context.response.setCookie(cookie);
|
|
215
|
+
this.cookieSession = null;
|
|
216
|
+
this.context.cookieSession = null;
|
|
217
|
+
return cookie;
|
|
218
|
+
}
|
|
219
|
+
return this.cookieSession;
|
|
220
|
+
}
|
|
221
|
+
isValidSession(_data, context) {
|
|
222
|
+
if (this.options.refererCheck) try {
|
|
223
|
+
return this.checkSecureReferer(context);
|
|
224
|
+
} catch {
|
|
225
|
+
this.log(`SESSION REFERER MISMATCH ==> ${this.name} : ${this.id}`, "WARNING");
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
if (this.options.absoluteTimeoutS && this.created) {
|
|
230
|
+
if (now - new Date(this.created).getTime() > this.options.absoluteTimeoutS * 1e3) {
|
|
231
|
+
this.log(`SESSION EXPIRED (absolute) ==> ${this.name} : ${this.id}`, "WARNING");
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const idleS = this.options.idleTimeoutS;
|
|
236
|
+
if (idleS && idleS > 0 && this.updated) {
|
|
237
|
+
const lastUsed = new Date(this.updated).getTime();
|
|
238
|
+
if (lastUsed && lastUsed + idleS * 1e3 < now) {
|
|
239
|
+
this.log(`SESSION EXPIRED (idle) ==> ${this.name} : ${this.id}`, "WARNING");
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Prolonge l'idle timeout de la session active sur l'activité courante, **de
|
|
247
|
+
* façon throttlée** (1 écriture par tranche d'idle, jamais par requête) et
|
|
248
|
+
* **sans réécrire le blob** (write léger `touch` du storage) — l'activité
|
|
249
|
+
* HTTP/WS réelle, **y compris en lecture seule**, empêche l'expiration d'une
|
|
250
|
+
* session utilisée (NIST/OWASP : idle « since the last request »). N'affecte
|
|
251
|
+
* **jamais** l'absolute timeout (borné à la création).
|
|
252
|
+
*
|
|
253
|
+
* No-op si : pas active, pas d'entrée storage à prolonger (`!updated`), idle
|
|
254
|
+
* désactivé, store sans `touch`, ou dernière activité trop récente (throttle =
|
|
255
|
+
* mi-vie de l'idle). Met à jour `updated` localement → le throttle compte
|
|
256
|
+
* depuis ce touch.
|
|
257
|
+
*/
|
|
258
|
+
async touchIfNeeded() {
|
|
259
|
+
if (this.status !== "active") return;
|
|
260
|
+
const idleS = this.options.idleTimeoutS;
|
|
261
|
+
if (!idleS || idleS <= 0) return;
|
|
262
|
+
const storage = this.storage;
|
|
263
|
+
if (typeof storage.touch !== "function") return;
|
|
264
|
+
if (!this.updated) return;
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
const lastUsed = new Date(this.updated).getTime();
|
|
267
|
+
if (lastUsed && now - lastUsed < idleS * 1e3 / 2) return;
|
|
268
|
+
await storage.touch(this.id, idleS);
|
|
269
|
+
this.updated = new Date(now);
|
|
270
|
+
}
|
|
271
|
+
checkSecureReferer(context) {
|
|
272
|
+
const host = context.getHost();
|
|
273
|
+
const meta = this.getMetaBag("host");
|
|
274
|
+
if (host === meta) return true;
|
|
275
|
+
this.log(`SESSION REFERER NOT SAME, HOST: ${host} META: ${String(meta)}`, "WARNING");
|
|
276
|
+
throw new Error("session referer mismatch");
|
|
277
|
+
}
|
|
278
|
+
setMetasSession(cookieSetting = {}) {
|
|
279
|
+
this.setMetaBag("lifetime", cookieSetting.maxAge ?? this.options.cookie?.maxAge);
|
|
280
|
+
const ctx = this.context;
|
|
281
|
+
this.setMetaBag("request", ctx?.type);
|
|
282
|
+
try {
|
|
283
|
+
this.setMetaBag("remoteAddress", ctx?.getRemoteAddress());
|
|
284
|
+
this.setMetaBag("host", ctx?.getHost());
|
|
285
|
+
this.setMetaBag("user_agent", ctx?.getUserAgent() || "Not Defined");
|
|
286
|
+
} catch (e) {
|
|
287
|
+
this.log(e, "DEBUG");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
get(key) {
|
|
291
|
+
const v = this.attributesBag[key];
|
|
292
|
+
return v === void 0 ? null : v;
|
|
293
|
+
}
|
|
294
|
+
set(key, value) {
|
|
295
|
+
this.attributesBag[key] = value;
|
|
296
|
+
this.mutated = true;
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
getAttributes() {
|
|
300
|
+
return this.attributesBag;
|
|
301
|
+
}
|
|
302
|
+
getMetaBag(key) {
|
|
303
|
+
const v = this.metaBagStore[key];
|
|
304
|
+
return v === void 0 ? null : v;
|
|
305
|
+
}
|
|
306
|
+
setMetaBag(key, value) {
|
|
307
|
+
this.metaBagStore[key] = value;
|
|
308
|
+
this.mutated = true;
|
|
309
|
+
}
|
|
310
|
+
getMetas() {
|
|
311
|
+
return this.metaBagStore;
|
|
312
|
+
}
|
|
313
|
+
getFlashBag(key) {
|
|
314
|
+
const v = this.flashBag[key];
|
|
315
|
+
if (v !== void 0) {
|
|
316
|
+
delete this.flashBag[key];
|
|
317
|
+
this.mutated = true;
|
|
318
|
+
return v;
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
setFlashBag(key, value) {
|
|
323
|
+
if (!key) throw new Error(`FlashBag key must be define : ${key}`);
|
|
324
|
+
this.flashBag[key] = value;
|
|
325
|
+
this.mutated = true;
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
flashBags() {
|
|
329
|
+
return this.flashBag;
|
|
330
|
+
}
|
|
331
|
+
clearFlashBag(key) {
|
|
332
|
+
if (!key) throw new Error(`clearFlashBag key must be define : ${key}`);
|
|
333
|
+
if (this.flashBag[key] !== void 0) {
|
|
334
|
+
delete this.flashBag[key];
|
|
335
|
+
this.mutated = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
clearFlashBags() {
|
|
339
|
+
this.flashBag = {};
|
|
340
|
+
this.mutated = true;
|
|
341
|
+
}
|
|
342
|
+
serialize(user) {
|
|
343
|
+
return {
|
|
344
|
+
Attributes: this.attributesBag,
|
|
345
|
+
metaBag: this.metaBagStore,
|
|
346
|
+
flashBag: this.flashBag,
|
|
347
|
+
user: user ?? this.user ?? ""
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
deSerialize(data) {
|
|
351
|
+
if (data.Attributes) for (const k in data.Attributes) this.attributesBag[k] = data.Attributes[k];
|
|
352
|
+
if (data.metaBag) for (const k in data.metaBag) this.metaBagStore[k] = data.metaBag[k];
|
|
353
|
+
if (data.flashBag) for (const k in data.flashBag) this.flashBag[k] = data.flashBag[k];
|
|
354
|
+
this.created = data.createdAt ?? this.created;
|
|
355
|
+
this.updated = data.updatedAt ?? this.updated;
|
|
356
|
+
if (data.user) this.user = data.user;
|
|
357
|
+
}
|
|
358
|
+
/** Réinitialise les trois sacs (attributs, métas, flash) — état vide. */
|
|
359
|
+
clear() {
|
|
360
|
+
this.attributesBag = {};
|
|
361
|
+
this.metaBagStore = {};
|
|
362
|
+
this.flashBag = {};
|
|
363
|
+
}
|
|
364
|
+
getName() {
|
|
365
|
+
return this.name;
|
|
366
|
+
}
|
|
367
|
+
setName(name) {
|
|
368
|
+
this.name = name || this.options.name;
|
|
369
|
+
}
|
|
370
|
+
checkStatus() {
|
|
371
|
+
switch (this.status) {
|
|
372
|
+
case "active":
|
|
373
|
+
this.log(`SESSION ALREADY STARTED ==> ${this.name} : ${this.id}`, "WARNING");
|
|
374
|
+
return false;
|
|
375
|
+
case "disabled": {
|
|
376
|
+
const storage = this.manager.initializeStorage();
|
|
377
|
+
if (storage) {
|
|
378
|
+
this.storage = storage;
|
|
379
|
+
this.status = "none";
|
|
380
|
+
return "restart";
|
|
381
|
+
}
|
|
382
|
+
this.log("SESSION STORAGE HANDLER NOT FOUND", "ERROR");
|
|
383
|
+
throw new Error("SESSION STORAGE HANDLER NOT FOUND");
|
|
384
|
+
}
|
|
385
|
+
default: return true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
//#endregion
|
|
390
|
+
export { Session as default };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { SESSION_DEFAULT_ORDER, SESSION_SORTABLE_FIELDS } from "./sessionSort.js";
|
|
2
|
+
import { assertPageQuery, compareByOrder, pickOrder } from "nodefony";
|
|
3
|
+
//#region nodefony/src/session/storage/MemorySessionStorage.ts
|
|
4
|
+
/**
|
|
5
|
+
* Store de sessions **en mémoire** (Map process) — implémentation de référence
|
|
6
|
+
* d'{@link ISessionStorage}, pendant `session` des `Memory*Store` de sécurité.
|
|
7
|
+
*
|
|
8
|
+
* **Volatil** : les sessions vivent dans la RAM du process et disparaissent au
|
|
9
|
+
* redémarrage ET ne sont PAS partagées entre pods/workers. Cible : **tests de
|
|
10
|
+
* charge** (mesurer le framework sans le goulot disque/SQL), CI, environnements
|
|
11
|
+
* éphémères. Pour la persistance mono-nœud → `drizzle` (sqlite) ; multi-nœud →
|
|
12
|
+
* `redis`/`drizzle`/`mongoose`.
|
|
13
|
+
*
|
|
14
|
+
* Bornes NIST/OWASP portées par des horodatages internes : `updatedAt` = dernière
|
|
15
|
+
* activité (idle, rafraîchi par {@link touch}), `createdAt` = création (absolute,
|
|
16
|
+
* JAMAIS prolongé). Même sémantique que les stores SQL — l'idle glissant et
|
|
17
|
+
* l'absolute s'appliquent identiquement.
|
|
18
|
+
*/
|
|
19
|
+
var MemorySessionStorage = class {
|
|
20
|
+
manager;
|
|
21
|
+
idleTimeoutS;
|
|
22
|
+
absoluteTimeoutS;
|
|
23
|
+
/** id → session sérialisée (source de vérité, horodatages inclus). */
|
|
24
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
25
|
+
/**
|
|
26
|
+
* Trie sur tout le vocabulaire public : les données sont déjà en RAM, aucun
|
|
27
|
+
* champ n'est plus coûteux qu'un autre. Aucune traduction — les clés internes
|
|
28
|
+
* portent déjà ces noms (`id` étant la clé de la Map).
|
|
29
|
+
*/
|
|
30
|
+
sortableFields = SESSION_SORTABLE_FIELDS;
|
|
31
|
+
constructor(manager) {
|
|
32
|
+
this.manager = manager;
|
|
33
|
+
this.idleTimeoutS = manager.options.idleTimeoutS;
|
|
34
|
+
this.absoluteTimeoutS = manager.options.absoluteTimeoutS;
|
|
35
|
+
}
|
|
36
|
+
/** Lecture par id — copie superficielle (le consommateur ne mute pas le store). */
|
|
37
|
+
read(id) {
|
|
38
|
+
const stored = this.#sessions.get(id);
|
|
39
|
+
return Promise.resolve(stored ? { ...stored } : {});
|
|
40
|
+
}
|
|
41
|
+
start(id) {
|
|
42
|
+
return this.read(id);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Écrit (upsert) le blob. `createdAt` est FIXÉ à la création et préservé aux
|
|
46
|
+
* updates (borne absolute) ; `updatedAt` est posé à chaque écriture (borne idle).
|
|
47
|
+
*/
|
|
48
|
+
write(id, data) {
|
|
49
|
+
const now = /* @__PURE__ */ new Date();
|
|
50
|
+
const existing = this.#sessions.get(id);
|
|
51
|
+
const record = {
|
|
52
|
+
...data,
|
|
53
|
+
createdAt: existing?.createdAt ?? data.createdAt ?? now,
|
|
54
|
+
updatedAt: now
|
|
55
|
+
};
|
|
56
|
+
this.#sessions.set(id, record);
|
|
57
|
+
return Promise.resolve(data);
|
|
58
|
+
}
|
|
59
|
+
/** Compte des sessions présentes (+ passe GC comme les autres stores à l'open). */
|
|
60
|
+
async open() {
|
|
61
|
+
await this.gc();
|
|
62
|
+
this.manager.log(`SESSIONS STORAGE ==> ${this.manager.options.store.toUpperCase()} COUNT SESSIONS : ${this.#sessions.size}`);
|
|
63
|
+
return this.#sessions.size;
|
|
64
|
+
}
|
|
65
|
+
close() {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
destroy(id) {
|
|
69
|
+
this.#sessions.delete(id);
|
|
70
|
+
return Promise.resolve(true);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Prolonge l'idle (timeout glissant) : rafraîchit `updatedAt` SANS toucher
|
|
74
|
+
* `createdAt` (borne absolute intacte). Session absente (purgée) → no-op.
|
|
75
|
+
*/
|
|
76
|
+
touch(id) {
|
|
77
|
+
const stored = this.#sessions.get(id);
|
|
78
|
+
if (stored) stored.updatedAt = /* @__PURE__ */ new Date();
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Purge idle (inactivité depuis `updatedAt`) ET absolute (âge depuis `createdAt`,
|
|
83
|
+
* jamais prolongé). Une borne à 0 = désactivée. Déterministe (synchrone).
|
|
84
|
+
*/
|
|
85
|
+
gc(idleSeconds, absoluteSeconds) {
|
|
86
|
+
const idleMs = (idleSeconds ?? this.idleTimeoutS) * 1e3;
|
|
87
|
+
const absoluteMs = (absoluteSeconds ?? this.absoluteTimeoutS) * 1e3;
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
let deleted = 0;
|
|
90
|
+
for (const [id, s] of this.#sessions) {
|
|
91
|
+
const updated = s.updatedAt ? s.updatedAt.getTime() : now;
|
|
92
|
+
const created = s.createdAt ? s.createdAt.getTime() : now;
|
|
93
|
+
const idleExpired = idleMs > 0 && updated + idleMs < now;
|
|
94
|
+
const absoluteExpired = absoluteMs > 0 && created + absoluteMs < now;
|
|
95
|
+
if (idleExpired || absoluteExpired) {
|
|
96
|
+
this.#sessions.delete(id);
|
|
97
|
+
deleted++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (deleted > 0) this.manager.log(`MEMORY SESSIONS STORAGE GARBAGE COLLECTOR ==> ${deleted} DELETED`);
|
|
101
|
+
return Promise.resolve();
|
|
102
|
+
}
|
|
103
|
+
/** Énumération admin — filtre `user` appliqué en mémoire. */
|
|
104
|
+
listAll(filter) {
|
|
105
|
+
const records = [];
|
|
106
|
+
for (const [id, data] of this.#sessions) {
|
|
107
|
+
if (filter?.user !== void 0 && data.user !== filter.user) continue;
|
|
108
|
+
records.push({
|
|
109
|
+
id,
|
|
110
|
+
data: { ...data }
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return Promise.resolve(records);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* `true` si l'entrée passe les filtres — prédicat partagé par {@link listPage}
|
|
117
|
+
* et {@link countSessions} (une seule définition du périmètre : compter et
|
|
118
|
+
* lister ne peuvent pas diverger).
|
|
119
|
+
*/
|
|
120
|
+
#matches(data, query) {
|
|
121
|
+
if (!query) return true;
|
|
122
|
+
if (query.user !== void 0 && data.user !== query.user) return false;
|
|
123
|
+
if (query.authenticated !== void 0) {
|
|
124
|
+
if (!!data.user !== query.authenticated) return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Pagination **offset** avec `total` exact, ordre `updatedAt` DESC (id ASC en
|
|
130
|
+
* départage). Les données étant déjà en RAM par conception, le coût par requête
|
|
131
|
+
* est celui du tri des **références** filtrées — aucune copie de blob n'est
|
|
132
|
+
* faite hors de la page rendue.
|
|
133
|
+
*
|
|
134
|
+
* **Redaction par construction** (garantie du contrat, pas une optimisation) :
|
|
135
|
+
* `Attributes`/`flashBag` sortent VIDES, comme chez les stores SQL/NoSQL qui ne
|
|
136
|
+
* les SELECTent pas. Ici c'est gratuit — on ne recopie simplement pas ces deux
|
|
137
|
+
* bags — et ça aligne le store mémoire sur la même garantie : un record
|
|
138
|
+
* d'énumération admin ne porte jamais de donnée métier.
|
|
139
|
+
*/
|
|
140
|
+
listPage(query) {
|
|
141
|
+
assertPageQuery(query, "offset");
|
|
142
|
+
const limit = Math.max(0, query.limit);
|
|
143
|
+
const offset = Math.max(0, query.offset ?? 0);
|
|
144
|
+
const matched = [];
|
|
145
|
+
for (const entry of this.#sessions) if (this.#matches(entry[1], query)) matched.push(entry);
|
|
146
|
+
const order = pickOrder(query.order, this.sortableFields, SESSION_DEFAULT_ORDER);
|
|
147
|
+
matched.sort(compareByOrder(order, ([id, data], field) => field === "id" ? id : data[field]));
|
|
148
|
+
const items = matched.slice(offset, offset + limit).map(([id, data]) => ({
|
|
149
|
+
id,
|
|
150
|
+
data: {
|
|
151
|
+
...data,
|
|
152
|
+
Attributes: {},
|
|
153
|
+
flashBag: {}
|
|
154
|
+
}
|
|
155
|
+
}));
|
|
156
|
+
return Promise.resolve({
|
|
157
|
+
items,
|
|
158
|
+
total: query.withTotal === false ? void 0 : matched.length,
|
|
159
|
+
limit: query.limit,
|
|
160
|
+
offset,
|
|
161
|
+
hasNext: offset + items.length < matched.length
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** `COUNT` filtré — parcourt sans allouer (aucun record matérialisé). */
|
|
165
|
+
countSessions(query) {
|
|
166
|
+
let count = 0;
|
|
167
|
+
for (const data of this.#sessions.values()) if (this.#matches(data, query)) count++;
|
|
168
|
+
return Promise.resolve(count);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* `COUNT(DISTINCT user)` en mémoire. Le `Set` est alloué à l'appel et relâché
|
|
172
|
+
* aussitôt : c'est un chemin d'administration, appelé à l'ouverture d'un
|
|
173
|
+
* écran, jamais dans le pipeline de requête.
|
|
174
|
+
*/
|
|
175
|
+
countDistinctUsers(query) {
|
|
176
|
+
const users = /* @__PURE__ */ new Set();
|
|
177
|
+
for (const data of this.#sessions.values()) {
|
|
178
|
+
if (!this.#matches(data, query)) continue;
|
|
179
|
+
if (typeof data.user === "string" && data.user) users.add(data.user);
|
|
180
|
+
}
|
|
181
|
+
return Promise.resolve(users.size);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
//#endregion
|
|
185
|
+
export { MemorySessionStorage as default };
|