@nodefony/user 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 +120 -0
- package/dist/index.js +16 -0
- package/dist/nodefony/contracts/IOAuthUserProvisioner.js +1 -0
- package/dist/nodefony/contracts/IPasswordBlocklist.js +1 -0
- package/dist/nodefony/contracts/IPasswordEncoder.js +1 -0
- package/dist/nodefony/contracts/IPasswordVerifier.js +1 -0
- package/dist/nodefony/contracts/IUser.js +1 -0
- package/dist/nodefony/contracts/IUserProfile.js +1 -0
- package/dist/nodefony/contracts/IUserProvider.js +1 -0
- package/dist/nodefony/contracts/IUserRepository.js +1 -0
- package/dist/nodefony/contracts/index.js +1 -0
- package/dist/nodefony/errors/UserNotFoundError.js +19 -0
- package/dist/nodefony/errors/WeakPasswordError.js +16 -0
- package/dist/nodefony/service/UserService.js +280 -0
- package/dist/nodefony/src/AnonymousUser.js +37 -0
- package/dist/nodefony/src/BaseUser.js +121 -0
- package/dist/nodefony/src/InMemoryUserRepository.js +230 -0
- package/dist/nodefony/src/admin/UserAdminApi.js +588 -0
- package/dist/nodefony/src/encoders/Argon2idEncoder.js +107 -0
- package/dist/nodefony/src/encoders/BcryptEncoder.js +75 -0
- package/dist/nodefony/src/encoders/MigratingEncoder.js +88 -0
- package/dist/nodefony/src/encoders/encoderFromConfig.js +37 -0
- package/dist/nodefony/src/userContract.js +247 -0
- package/dist/nodefony/src/userFilters.js +66 -0
- package/dist/nodefony/src/userProfile.js +195 -0
- package/dist/nodefony/src/userSort.js +53 -0
- package/dist/nodefony/src/userStoreRegistry.js +41 -0
- package/dist/types/index.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthUserProvisioner.d.ts +69 -0
- package/dist/types/nodefony/contracts/IPasswordBlocklist.d.ts +20 -0
- package/dist/types/nodefony/contracts/IPasswordEncoder.d.ts +49 -0
- package/dist/types/nodefony/contracts/IPasswordVerifier.d.ts +26 -0
- package/dist/types/nodefony/contracts/IUser.d.ts +68 -0
- package/dist/types/nodefony/contracts/IUserProfile.d.ts +29 -0
- package/dist/types/nodefony/contracts/IUserProvider.d.ts +44 -0
- package/dist/types/nodefony/contracts/IUserRepository.d.ts +129 -0
- package/dist/types/nodefony/contracts/index.d.ts +7 -0
- package/dist/types/nodefony/errors/UserNotFoundError.d.ts +15 -0
- package/dist/types/nodefony/errors/WeakPasswordError.d.ts +12 -0
- package/dist/types/nodefony/service/UserService.d.ts +179 -0
- package/dist/types/nodefony/src/AnonymousUser.d.ts +27 -0
- package/dist/types/nodefony/src/BaseUser.d.ts +98 -0
- package/dist/types/nodefony/src/InMemoryUserRepository.d.ts +73 -0
- package/dist/types/nodefony/src/admin/UserAdminApi.d.ts +119 -0
- package/dist/types/nodefony/src/encoders/Argon2idEncoder.d.ts +83 -0
- package/dist/types/nodefony/src/encoders/BcryptEncoder.d.ts +55 -0
- package/dist/types/nodefony/src/encoders/MigratingEncoder.d.ts +68 -0
- package/dist/types/nodefony/src/encoders/encoderFromConfig.d.ts +36 -0
- package/dist/types/nodefony/src/userContract.d.ts +198 -0
- package/dist/types/nodefony/src/userFilters.d.ts +80 -0
- package/dist/types/nodefony/src/userProfile.d.ts +56 -0
- package/dist/types/nodefony/src/userSort.d.ts +41 -0
- package/dist/types/nodefony/src/userStoreRegistry.d.ts +15 -0
- package/docs/ajouter-des-champs.md +189 -0
- package/docs/index.md +1113 -0
- package/package.json +90 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { listUserStores } from "../userStoreRegistry.js";
|
|
2
|
+
import { USER_FACETS, USER_FILTERS, USER_STATS_FILTERS } from "../userFilters.js";
|
|
3
|
+
import { WeakPasswordError } from "../../errors/WeakPasswordError.js";
|
|
4
|
+
import { mergeProfileIntoMetadata, projectProfile, validateProfilePatch } from "../userProfile.js";
|
|
5
|
+
import { parseFilters, parsePageQuery } from "nodefony";
|
|
6
|
+
//#region nodefony/src/admin/UserAdminApi.ts
|
|
7
|
+
/**
|
|
8
|
+
* Rôle critique : porteur de l'accès au data plane d'administration (Studio).
|
|
9
|
+
* Les garde-fous anti-lockout protègent **ce** rôle (jamais déchoir le dernier).
|
|
10
|
+
*/
|
|
11
|
+
const ADMIN_ROLE = "ROLE_NODEFONY_ADMIN";
|
|
12
|
+
/** Longueur minimale d'un mot de passe self-service (OWASP ASVS V2.1.1 — plancher). */
|
|
13
|
+
const MIN_PASSWORD_LENGTH = 8;
|
|
14
|
+
/** Convertit un timestamp (`Date`/string ISO/number) en epoch ms — `null` si invalide. */
|
|
15
|
+
function toEpoch(value) {
|
|
16
|
+
if (value === void 0 || value === null) return null;
|
|
17
|
+
const t = new Date(value).getTime();
|
|
18
|
+
return Number.isNaN(t) ? null : t;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Projette un {@link IUser} en {@link IUserSummary} redacté. Fonction **pure**
|
|
22
|
+
* (cœur de la garantie anti-fuite, testée isolément) : `currentRole`/
|
|
23
|
+
* `socialProviders`/timestamps sont lus **défensivement** (présents sur l'entité
|
|
24
|
+
* ORM, absents du contrat strict `IUser`) — `password`/`metadata` jamais lus.
|
|
25
|
+
*/
|
|
26
|
+
function toUserSummary(user) {
|
|
27
|
+
const ext = user;
|
|
28
|
+
const social = Array.isArray(ext.socialProviders) ? ext.socialProviders : [];
|
|
29
|
+
return {
|
|
30
|
+
id: user.id,
|
|
31
|
+
identifier: user.identifier,
|
|
32
|
+
roles: [...user.roles],
|
|
33
|
+
enabled: user.isActive(),
|
|
34
|
+
locked: user.isLocked(),
|
|
35
|
+
hasPassword: typeof ext.password === "string" && ext.password.length > 0,
|
|
36
|
+
currentRole: typeof ext.currentRole === "string" ? ext.currentRole : null,
|
|
37
|
+
socialProviders: social.map((p) => ({
|
|
38
|
+
provider: p.provider,
|
|
39
|
+
providerId: p.providerId,
|
|
40
|
+
createdAt: toEpoch(p.createdAt)
|
|
41
|
+
})),
|
|
42
|
+
profile: projectProfile(ext.metadata),
|
|
43
|
+
createdAt: toEpoch(ext.createdAt),
|
|
44
|
+
updatedAt: toEpoch(ext.updatedAt),
|
|
45
|
+
tenantId: null
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Identité de l'admin appelant (id pour comparer self, label pour l'audit). */
|
|
49
|
+
function adminActor(user) {
|
|
50
|
+
if (user && typeof user === "object") {
|
|
51
|
+
const u = user;
|
|
52
|
+
return {
|
|
53
|
+
id: typeof u.id === "string" ? u.id : null,
|
|
54
|
+
label: typeof u.username === "string" && u.username || typeof u.identifier === "string" && u.identifier || "admin"
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
id: null,
|
|
59
|
+
label: "admin"
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Émet un événement d'audit si `@nodefony/security` est monté — no-op sinon. */
|
|
63
|
+
function audit(container, action, actor, resource, metadata) {
|
|
64
|
+
container.get("auditService")?.record({
|
|
65
|
+
category: "authz",
|
|
66
|
+
action,
|
|
67
|
+
outcome: "success",
|
|
68
|
+
actor,
|
|
69
|
+
resource,
|
|
70
|
+
metadata: {
|
|
71
|
+
...metadata,
|
|
72
|
+
viaAdmin: true
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Identité fonctionnelle (identifier) de l'appelant, lue depuis l'objet user de
|
|
78
|
+
* l'ALS serveur (posé au login par le firewall) — **JAMAIS** un paramètre client.
|
|
79
|
+
* C'est le socle anti-IDOR du self-service : le périmètre d'une action « moi » est
|
|
80
|
+
* fermé par cette valeur, pas par un id reçu dans l'URL ou le corps. `null` si non
|
|
81
|
+
* authentifié (ne devrait pas arriver sous une zone firewall fermée → 401 défensif).
|
|
82
|
+
*/
|
|
83
|
+
function currentIdentifier(user) {
|
|
84
|
+
if (user && typeof user === "object") {
|
|
85
|
+
const u = user;
|
|
86
|
+
if (typeof u.identifier === "string" && u.identifier.length > 0) return u.identifier;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Émet un événement d'audit d'AUTHENTIFICATION self-service (le propriétaire agit
|
|
92
|
+
* sur son propre compte) — `viaAdmin: false` (distinct des mutations admin), avec
|
|
93
|
+
* un `outcome` paramétrable car **succès ET échec** sont audités (un échec de
|
|
94
|
+
* re-auth est un signal de sécurité). No-op si `@nodefony/security` n'est pas monté.
|
|
95
|
+
*/
|
|
96
|
+
function auditSelf(container, action, outcome, actor, resource, reason) {
|
|
97
|
+
container.get("auditService")?.record({
|
|
98
|
+
category: "authn",
|
|
99
|
+
action,
|
|
100
|
+
outcome,
|
|
101
|
+
actor,
|
|
102
|
+
resource,
|
|
103
|
+
reason,
|
|
104
|
+
metadata: { viaAdmin: false }
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Nom de l'événement kernel émis quand l'accès d'un utilisateur doit être révoqué
|
|
109
|
+
* partout (suppression / désactivation / verrouillage). **Point d'extension** : un
|
|
110
|
+
* module qui possède des artefacts liés à un user (sessions, tokens, webhooks…)
|
|
111
|
+
* s'y abonne et nettoie LES SIENS — zéro couplage avec `@nodefony/user`.
|
|
112
|
+
*/
|
|
113
|
+
const USER_REVOKED_EVENT = "onUserRevoked";
|
|
114
|
+
/**
|
|
115
|
+
* Émet {@link USER_REVOKED_EVENT} sur le bus kernel (no-op si pas de kernel).
|
|
116
|
+
* Déclenche la cascade de révocation chez tous les abonnés (sessions, tokens,
|
|
117
|
+
* webhooks futurs). L'accès était DÉJÀ neutralisé par le re-fetch des
|
|
118
|
+
* authenticators ; ceci force le nettoyage immédiat (défense en profondeur).
|
|
119
|
+
*/
|
|
120
|
+
function emitUserRevoked(container, user, reason) {
|
|
121
|
+
const kernel = container.get("kernel");
|
|
122
|
+
const event = {
|
|
123
|
+
id: user.id,
|
|
124
|
+
identifier: user.identifier,
|
|
125
|
+
tenantId: null,
|
|
126
|
+
reason
|
|
127
|
+
};
|
|
128
|
+
kernel?.fire(USER_REVOKED_EVENT, event);
|
|
129
|
+
}
|
|
130
|
+
/** Tableau de strings non vides depuis une valeur inconnue, ou `undefined`. */
|
|
131
|
+
function readRoles(value) {
|
|
132
|
+
if (!Array.isArray(value)) return void 0;
|
|
133
|
+
const roles = [];
|
|
134
|
+
for (const entry of value) {
|
|
135
|
+
if (typeof entry !== "string" || entry.trim().length === 0) continue;
|
|
136
|
+
const role = entry.trim();
|
|
137
|
+
if (!roles.includes(role)) roles.push(role);
|
|
138
|
+
}
|
|
139
|
+
return roles;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Déduit le **backend** (store) de persistance du nom de classe du repository —
|
|
143
|
+
* convention de nommage des adapters (`InMemoryUserRepository`/
|
|
144
|
+
* `DrizzleUserRepository`/`MongooseUserRepository`). `null` si le nom ne matche
|
|
145
|
+
* aucun adapter connu (jamais throw — un repo custom reste affichable via `repository`).
|
|
146
|
+
*/
|
|
147
|
+
function deduceUserStore(repositoryName) {
|
|
148
|
+
const n = repositoryName.toLowerCase();
|
|
149
|
+
if (n.includes("inmemory") || n.includes("memory")) return "memory";
|
|
150
|
+
if (n.includes("drizzle")) return "drizzle";
|
|
151
|
+
if (n.includes("mongoose") || n.includes("mongo")) return "mongoose";
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Producteur `IAdminApi` du domaine **utilisateur** — exposé sous
|
|
156
|
+
* `/nodefony/user/api/users`. Défini DANS `@nodefony/user` (propriétaire du
|
|
157
|
+
* `UserService`/`IUser`) mais **enregistré par un module bootable**
|
|
158
|
+
* (`@nodefony/security`, via {@link registerUserAdminApi}) car `@nodefony/user`
|
|
159
|
+
* est une lib pure non-bootable — exactement le cas prévu par le core : un
|
|
160
|
+
* module qui ne dépend que de `nodefony` produit sa donnée d'admin.
|
|
161
|
+
*
|
|
162
|
+
* RBAC `ROLE_NODEFONY_ADMIN` (défaut broker, 403 sinon). Mutations = HTTP
|
|
163
|
+
* (pipeline CSRF), auditées (catégorie `authz`). Garde-fous **anti-lockout** :
|
|
164
|
+
* pas d'auto-déchéance, pas de suppression/désactivation du dernier admin actif.
|
|
165
|
+
*
|
|
166
|
+
* @param container - container du kernel (résolution lazy du service `users`).
|
|
167
|
+
*/
|
|
168
|
+
function createUserAdminApi(container) {
|
|
169
|
+
const resolveUsers = () => container.get("users");
|
|
170
|
+
const endpoints = [
|
|
171
|
+
{
|
|
172
|
+
path: "users",
|
|
173
|
+
method: "GET",
|
|
174
|
+
summary: "Utilisateurs (DTO redacté, jamais le hash). Filtres : ?role&enabled&q (identifier) ; pagination ?limit&offset.",
|
|
175
|
+
page: {
|
|
176
|
+
sortable: () => resolveUsers()?.sortableFields() ?? [],
|
|
177
|
+
filters: USER_FILTERS,
|
|
178
|
+
search: () => true
|
|
179
|
+
},
|
|
180
|
+
handler: async (request) => {
|
|
181
|
+
const users = resolveUsers();
|
|
182
|
+
if (!users) return {
|
|
183
|
+
status: 503,
|
|
184
|
+
body: { error: "user service unavailable" }
|
|
185
|
+
};
|
|
186
|
+
const pageQuery = parsePageQuery(request.query, {
|
|
187
|
+
sortable: users.sortableFields(),
|
|
188
|
+
searchable: true
|
|
189
|
+
});
|
|
190
|
+
const filters = parseFilters(request.query, USER_FILTERS);
|
|
191
|
+
const limit = pageQuery.limit;
|
|
192
|
+
const offset = pageQuery.offset ?? 0;
|
|
193
|
+
const page = await users.listPage({
|
|
194
|
+
limit,
|
|
195
|
+
offset,
|
|
196
|
+
...filters,
|
|
197
|
+
...pageQuery.order ? { order: pageQuery.order } : {},
|
|
198
|
+
...pageQuery.q !== void 0 ? { q: pageQuery.q } : {}
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
items: page.items.map(toUserSummary),
|
|
202
|
+
total: page.total ?? page.items.length,
|
|
203
|
+
limit,
|
|
204
|
+
offset
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
path: "users/stats",
|
|
210
|
+
method: "GET",
|
|
211
|
+
summary: "Compteurs sur l'annuaire ENTIER (total, actifs, désactivés, verrouillés, administrateurs, comptes liés à un fournisseur externe). Filtre `role` seulement. `null` = l'annuaire ne sait pas compter.",
|
|
212
|
+
page: {
|
|
213
|
+
filters: USER_STATS_FILTERS,
|
|
214
|
+
facets: USER_FACETS,
|
|
215
|
+
search: () => true
|
|
216
|
+
},
|
|
217
|
+
handler: async (request) => {
|
|
218
|
+
const users = resolveUsers();
|
|
219
|
+
if (!users) return {
|
|
220
|
+
status: 503,
|
|
221
|
+
body: { error: "user service unavailable" }
|
|
222
|
+
};
|
|
223
|
+
const page = parsePageQuery(request.query, { searchable: true });
|
|
224
|
+
return users.countUserFacets(ADMIN_ROLE, {
|
|
225
|
+
...parseFilters(request.query, USER_STATS_FILTERS),
|
|
226
|
+
...page.q !== void 0 ? { q: page.q } : {}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
path: "users/status",
|
|
232
|
+
method: "GET",
|
|
233
|
+
summary: "Statut du sous-système utilisateur (store/driver/count) — jamais de hash.",
|
|
234
|
+
handler: async () => {
|
|
235
|
+
const users = resolveUsers();
|
|
236
|
+
if (!users) return {
|
|
237
|
+
enabled: false,
|
|
238
|
+
store: null,
|
|
239
|
+
available: listUserStores(),
|
|
240
|
+
repository: "none",
|
|
241
|
+
count: null,
|
|
242
|
+
tenantId: null
|
|
243
|
+
};
|
|
244
|
+
const repository = users.repository?.constructor?.name ?? "none";
|
|
245
|
+
const store = deduceUserStore(repository);
|
|
246
|
+
const available = listUserStores();
|
|
247
|
+
if (store && !available.includes(store)) available.push(store);
|
|
248
|
+
let count = null;
|
|
249
|
+
try {
|
|
250
|
+
count = await users.count();
|
|
251
|
+
} catch {
|
|
252
|
+
count = null;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
enabled: true,
|
|
256
|
+
store,
|
|
257
|
+
available,
|
|
258
|
+
repository,
|
|
259
|
+
count,
|
|
260
|
+
tenantId: null
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
path: "users/{id}",
|
|
266
|
+
method: "GET",
|
|
267
|
+
summary: "Détail d'un utilisateur (DTO redacté). 404 si introuvable.",
|
|
268
|
+
handler: async (request) => {
|
|
269
|
+
const users = resolveUsers();
|
|
270
|
+
if (!users) return {
|
|
271
|
+
status: 503,
|
|
272
|
+
body: { error: "user service unavailable" }
|
|
273
|
+
};
|
|
274
|
+
const user = await users.findById(request.params.id);
|
|
275
|
+
if (!user) return {
|
|
276
|
+
status: 404,
|
|
277
|
+
body: { error: "not found" }
|
|
278
|
+
};
|
|
279
|
+
return toUserSummary(user);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
path: "users",
|
|
284
|
+
method: "POST",
|
|
285
|
+
summary: "Crée un utilisateur (identifier requis ; plainPassword/roles optionnels). Audité. 409 si l'identifiant existe déjà.",
|
|
286
|
+
handler: async (request) => {
|
|
287
|
+
const users = resolveUsers();
|
|
288
|
+
if (!users) return {
|
|
289
|
+
status: 503,
|
|
290
|
+
body: { error: "user service unavailable" }
|
|
291
|
+
};
|
|
292
|
+
const body = request.body ?? {};
|
|
293
|
+
const identifier = typeof body.identifier === "string" ? body.identifier.trim() : "";
|
|
294
|
+
if (identifier.length === 0) return {
|
|
295
|
+
status: 400,
|
|
296
|
+
body: { error: "identifier required" }
|
|
297
|
+
};
|
|
298
|
+
if (await users.findByIdentifier(identifier)) return {
|
|
299
|
+
status: 409,
|
|
300
|
+
body: { error: "identifier already exists" }
|
|
301
|
+
};
|
|
302
|
+
const plainPassword = typeof body.plainPassword === "string" ? body.plainPassword : null;
|
|
303
|
+
const created = await users.createUser({
|
|
304
|
+
identifier,
|
|
305
|
+
plainPassword,
|
|
306
|
+
roles: readRoles(body.roles) ?? []
|
|
307
|
+
});
|
|
308
|
+
audit(container, "user.created", adminActor(request.user).label, created.id, { identifier });
|
|
309
|
+
return {
|
|
310
|
+
status: 201,
|
|
311
|
+
body: toUserSummary(created)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
path: "users/{id}",
|
|
317
|
+
method: "PATCH",
|
|
318
|
+
summary: "Modifie roles/enabled/locked/profile. Audité. Garde-fous anti-lockout (pas d'auto-déchéance ADMIN, pas de déchéance du dernier admin).",
|
|
319
|
+
handler: async (request) => {
|
|
320
|
+
const users = resolveUsers();
|
|
321
|
+
if (!users) return {
|
|
322
|
+
status: 503,
|
|
323
|
+
body: { error: "user service unavailable" }
|
|
324
|
+
};
|
|
325
|
+
const target = await users.findById(request.params.id);
|
|
326
|
+
if (!target) return {
|
|
327
|
+
status: 404,
|
|
328
|
+
body: { error: "not found" }
|
|
329
|
+
};
|
|
330
|
+
const actor = adminActor(request.user);
|
|
331
|
+
const isSelf = actor.id !== null && actor.id === target.id;
|
|
332
|
+
const body = request.body ?? {};
|
|
333
|
+
const patch = {};
|
|
334
|
+
const roles = readRoles(body.roles);
|
|
335
|
+
if (roles !== void 0) {
|
|
336
|
+
const losesAdmin = target.roles.includes(ADMIN_ROLE) && !roles.includes(ADMIN_ROLE);
|
|
337
|
+
if (losesAdmin && isSelf) return {
|
|
338
|
+
status: 409,
|
|
339
|
+
body: { error: "cannot remove your own admin role" }
|
|
340
|
+
};
|
|
341
|
+
if (losesAdmin) {
|
|
342
|
+
if (await users.countActiveAdmins(ADMIN_ROLE) <= 1) return {
|
|
343
|
+
status: 409,
|
|
344
|
+
body: { error: "cannot demote the last active admin" }
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
patch.roles = roles;
|
|
348
|
+
}
|
|
349
|
+
if (typeof body.enabled === "boolean") {
|
|
350
|
+
if (body.enabled === false) {
|
|
351
|
+
if (isSelf) return {
|
|
352
|
+
status: 409,
|
|
353
|
+
body: { error: "cannot disable your own account" }
|
|
354
|
+
};
|
|
355
|
+
if (target.isActive() && target.roles.includes(ADMIN_ROLE)) {
|
|
356
|
+
if (await users.countActiveAdmins(ADMIN_ROLE) <= 1) return {
|
|
357
|
+
status: 409,
|
|
358
|
+
body: { error: "cannot disable the last active admin" }
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
patch.enabled = body.enabled;
|
|
363
|
+
}
|
|
364
|
+
if (typeof body.locked === "boolean") {
|
|
365
|
+
if (body.locked === true && isSelf) return {
|
|
366
|
+
status: 409,
|
|
367
|
+
body: { error: "cannot lock your own account" }
|
|
368
|
+
};
|
|
369
|
+
patch.locked = body.locked;
|
|
370
|
+
}
|
|
371
|
+
if (body.profile !== void 0) {
|
|
372
|
+
const parsed = validateProfilePatch(body.profile);
|
|
373
|
+
if (!parsed.ok) return {
|
|
374
|
+
status: 400,
|
|
375
|
+
body: { error: parsed.error }
|
|
376
|
+
};
|
|
377
|
+
if (Object.keys(parsed.value).length > 0) patch.metadata = mergeProfileIntoMetadata(target.metadata, parsed.value);
|
|
378
|
+
}
|
|
379
|
+
if (Object.keys(patch).length === 0) return {
|
|
380
|
+
status: 400,
|
|
381
|
+
body: { error: "no modifiable fields (roles/enabled/locked/profile)" }
|
|
382
|
+
};
|
|
383
|
+
const updated = await users.updateOne({ id: target.id }, patch);
|
|
384
|
+
if (!updated) return {
|
|
385
|
+
status: 404,
|
|
386
|
+
body: { error: "not found" }
|
|
387
|
+
};
|
|
388
|
+
audit(container, "user.updated", actor.label, target.id, { fields: Object.keys(patch) });
|
|
389
|
+
if (patch.enabled === false) emitUserRevoked(container, target, "disabled");
|
|
390
|
+
else if (patch.locked === true) emitUserRevoked(container, target, "locked");
|
|
391
|
+
return toUserSummary(updated);
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
path: "users/{id}/password",
|
|
396
|
+
method: "POST",
|
|
397
|
+
summary: "Change le mot de passe d'un utilisateur (plainPassword requis). Audité (jamais la valeur). 404 si introuvable.",
|
|
398
|
+
handler: async (request) => {
|
|
399
|
+
const users = resolveUsers();
|
|
400
|
+
if (!users) return {
|
|
401
|
+
status: 503,
|
|
402
|
+
body: { error: "user service unavailable" }
|
|
403
|
+
};
|
|
404
|
+
const body = request.body ?? {};
|
|
405
|
+
const plainPassword = typeof body.plainPassword === "string" ? body.plainPassword : "";
|
|
406
|
+
if (plainPassword.length === 0) return {
|
|
407
|
+
status: 400,
|
|
408
|
+
body: { error: "plainPassword required" }
|
|
409
|
+
};
|
|
410
|
+
if (!await users.changePassword(request.params.id, plainPassword)) return {
|
|
411
|
+
status: 404,
|
|
412
|
+
body: { error: "not found" }
|
|
413
|
+
};
|
|
414
|
+
audit(container, "user.password_changed", adminActor(request.user).label, request.params.id);
|
|
415
|
+
return { ok: true };
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
path: "me",
|
|
420
|
+
method: "GET",
|
|
421
|
+
public: true,
|
|
422
|
+
summary: "MON profil (self-service) — identifiant, rôles, rôle actif, comptes externes liés. DTO redacté (jamais le hash ni de jeton).",
|
|
423
|
+
handler: async (request) => {
|
|
424
|
+
const users = resolveUsers();
|
|
425
|
+
if (!users) return {
|
|
426
|
+
status: 503,
|
|
427
|
+
body: { error: "user service unavailable" }
|
|
428
|
+
};
|
|
429
|
+
const principal = currentIdentifier(request.user);
|
|
430
|
+
if (!principal) return {
|
|
431
|
+
status: 401,
|
|
432
|
+
body: { error: "unauthenticated" }
|
|
433
|
+
};
|
|
434
|
+
const me = await users.findByIdentifier(principal);
|
|
435
|
+
if (!me) return {
|
|
436
|
+
status: 404,
|
|
437
|
+
body: { error: "not found" }
|
|
438
|
+
};
|
|
439
|
+
return toUserSummary(me);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
path: "me/password",
|
|
444
|
+
method: "POST",
|
|
445
|
+
public: true,
|
|
446
|
+
summary: "Change MON mot de passe (self-service). Body { currentPassword, newPassword }. Re-auth du mot de passe actuel (403 sinon). Audité.",
|
|
447
|
+
handler: async (request) => {
|
|
448
|
+
const users = resolveUsers();
|
|
449
|
+
if (!users) return {
|
|
450
|
+
status: 503,
|
|
451
|
+
body: { error: "user service unavailable" }
|
|
452
|
+
};
|
|
453
|
+
const principal = currentIdentifier(request.user);
|
|
454
|
+
if (!principal) return {
|
|
455
|
+
status: 401,
|
|
456
|
+
body: { error: "unauthenticated" }
|
|
457
|
+
};
|
|
458
|
+
const body = request.body ?? {};
|
|
459
|
+
const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : "";
|
|
460
|
+
const newPassword = typeof body.newPassword === "string" ? body.newPassword : "";
|
|
461
|
+
if (currentPassword.length === 0) return {
|
|
462
|
+
status: 400,
|
|
463
|
+
body: { error: "currentPassword required" }
|
|
464
|
+
};
|
|
465
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) return {
|
|
466
|
+
status: 400,
|
|
467
|
+
body: { error: `newPassword must be at least ${MIN_PASSWORD_LENGTH} characters` }
|
|
468
|
+
};
|
|
469
|
+
if (newPassword === currentPassword) return {
|
|
470
|
+
status: 400,
|
|
471
|
+
body: { error: "newPassword must differ from the current password" }
|
|
472
|
+
};
|
|
473
|
+
const authed = await users.authenticate(principal, currentPassword);
|
|
474
|
+
if (!authed) {
|
|
475
|
+
auditSelf(container, "user.password_change_self", "failure", principal, principal, "bad_current_password");
|
|
476
|
+
return {
|
|
477
|
+
status: 403,
|
|
478
|
+
body: { error: "current password is incorrect" }
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
await users.changePassword(authed.id, newPassword);
|
|
483
|
+
} catch (err) {
|
|
484
|
+
if (err instanceof WeakPasswordError) return {
|
|
485
|
+
status: 400,
|
|
486
|
+
body: { error: "password rejected (too weak)" }
|
|
487
|
+
};
|
|
488
|
+
throw err;
|
|
489
|
+
}
|
|
490
|
+
auditSelf(container, "user.password_change_self", "success", principal, authed.id);
|
|
491
|
+
return { ok: true };
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
path: "me/profile",
|
|
496
|
+
method: "POST",
|
|
497
|
+
public: true,
|
|
498
|
+
summary: "Modifie MON profil (givenName/familyName/displayName/email/locale/picture). Self-service (identité ALS, anti-IDOR). DTO redacté en retour.",
|
|
499
|
+
handler: async (request) => {
|
|
500
|
+
const users = resolveUsers();
|
|
501
|
+
if (!users) return {
|
|
502
|
+
status: 503,
|
|
503
|
+
body: { error: "user service unavailable" }
|
|
504
|
+
};
|
|
505
|
+
const principal = currentIdentifier(request.user);
|
|
506
|
+
if (!principal) return {
|
|
507
|
+
status: 401,
|
|
508
|
+
body: { error: "unauthenticated" }
|
|
509
|
+
};
|
|
510
|
+
const me = await users.findByIdentifier(principal);
|
|
511
|
+
if (!me) return {
|
|
512
|
+
status: 404,
|
|
513
|
+
body: { error: "not found" }
|
|
514
|
+
};
|
|
515
|
+
const parsed = validateProfilePatch(request.body ?? {});
|
|
516
|
+
if (!parsed.ok) return {
|
|
517
|
+
status: 400,
|
|
518
|
+
body: { error: parsed.error }
|
|
519
|
+
};
|
|
520
|
+
const metadata = mergeProfileIntoMetadata(me.metadata, parsed.value);
|
|
521
|
+
const updated = await users.updateOne({ id: me.id }, { metadata });
|
|
522
|
+
if (!updated) return {
|
|
523
|
+
status: 404,
|
|
524
|
+
body: { error: "not found" }
|
|
525
|
+
};
|
|
526
|
+
return toUserSummary(updated);
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
path: "users/{id}",
|
|
531
|
+
method: "DELETE",
|
|
532
|
+
summary: "Supprime un utilisateur. Audité. Garde-fous : pas d'auto-suppression, pas de suppression du dernier admin actif.",
|
|
533
|
+
handler: async (request) => {
|
|
534
|
+
const users = resolveUsers();
|
|
535
|
+
if (!users) return {
|
|
536
|
+
status: 503,
|
|
537
|
+
body: { error: "user service unavailable" }
|
|
538
|
+
};
|
|
539
|
+
const target = await users.findById(request.params.id);
|
|
540
|
+
if (!target) return {
|
|
541
|
+
status: 404,
|
|
542
|
+
body: { error: "not found" }
|
|
543
|
+
};
|
|
544
|
+
const actor = adminActor(request.user);
|
|
545
|
+
if (actor.id !== null && actor.id === target.id) return {
|
|
546
|
+
status: 409,
|
|
547
|
+
body: { error: "cannot delete your own account" }
|
|
548
|
+
};
|
|
549
|
+
if (target.isActive() && target.roles.includes(ADMIN_ROLE)) {
|
|
550
|
+
if (await users.countActiveAdmins(ADMIN_ROLE) <= 1) return {
|
|
551
|
+
status: 409,
|
|
552
|
+
body: { error: "cannot delete the last active admin" }
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
await users.delete({ id: target.id });
|
|
556
|
+
audit(container, "user.deleted", actor.label, target.id, { identifier: target.identifier });
|
|
557
|
+
emitUserRevoked(container, target, "deleted");
|
|
558
|
+
return { ok: true };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
];
|
|
562
|
+
const descriptor = {
|
|
563
|
+
label: "Utilisateurs",
|
|
564
|
+
icon: "users",
|
|
565
|
+
order: 16,
|
|
566
|
+
role: ADMIN_ROLE
|
|
567
|
+
};
|
|
568
|
+
return {
|
|
569
|
+
adminNamespace: "user",
|
|
570
|
+
adminDescriptor: () => descriptor,
|
|
571
|
+
adminEndpoints: () => endpoints
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Enregistre le producteur admin utilisateur sur le broker — **idempotent**.
|
|
576
|
+
* À appeler au `onKernelBoot` d'un module **bootable** qui dépend de
|
|
577
|
+
* `@nodefony/user` (typiquement `@nodefony/security`), `@nodefony/user` n'étant
|
|
578
|
+
* pas lui-même un module.
|
|
579
|
+
*
|
|
580
|
+
* @param registry - broker admin (`container.get("adminBroker")`).
|
|
581
|
+
* @param container - container du kernel (capturé par les handlers lazy).
|
|
582
|
+
*/
|
|
583
|
+
function registerUserAdminApi(registry, container) {
|
|
584
|
+
if (registry.has("user")) return;
|
|
585
|
+
registry.register(createUserAdminApi(container));
|
|
586
|
+
}
|
|
587
|
+
//#endregion
|
|
588
|
+
export { USER_REVOKED_EVENT, createUserAdminApi, registerUserAdminApi, toUserSummary };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//#region nodefony/src/encoders/Argon2idEncoder.ts
|
|
2
|
+
let argon2 = null;
|
|
3
|
+
const loadArgon2 = async () => argon2 ??= await import("@node-rs/argon2");
|
|
4
|
+
/** Défauts alignés sur le schéma Zod security (m = minimum OWASP). */
|
|
5
|
+
const DEFAULT_MEMORY_KIB = 19456;
|
|
6
|
+
const DEFAULT_TIME_COST = 3;
|
|
7
|
+
const DEFAULT_PARALLELISM = 1;
|
|
8
|
+
const ARGON2_HASH_RE = /^\$argon2(d|i|id)\$v=(\d+)\$m=(\d+),t=(\d+),p=(\d+)\$/;
|
|
9
|
+
/** Version courante de l'algorithme (0x13) — toute version antérieure est re-hashée. */
|
|
10
|
+
const ARGON2_VERSION = 19;
|
|
11
|
+
/**
|
|
12
|
+
* Encodeur de mot de passe **Argon2id** (RFC 9106) — recommandation NIST/OWASP 2026.
|
|
13
|
+
*
|
|
14
|
+
* Fonction de dérivation à MÉMOIRE DURE : chaque vérification exige `memoryKiB`
|
|
15
|
+
* de RAM en plus du CPU, ce qui ruine les attaques massivement parallèles
|
|
16
|
+
* (GPU/ASIC ont des milliers de cœurs mais pas 19 MiB de mémoire dédiée par
|
|
17
|
+
* cœur). Le variant `id` est hybride : résistant aux canaux auxiliaires
|
|
18
|
+
* (première moitié indépendante du mot de passe) ET aux compromis temps-mémoire.
|
|
19
|
+
*
|
|
20
|
+
* Délègue à `@node-rs/argon2` (binding NAPI Rust, exécuté hors thread principal,
|
|
21
|
+
* **peerDependency optionnelle**) : le binaire natif est importé DYNAMIQUEMENT
|
|
22
|
+
* au premier `hash`/`verify` — instancier l'encodeur ne charge rien.
|
|
23
|
+
*/
|
|
24
|
+
var Argon2idEncoder = class {
|
|
25
|
+
/** Mémoire par hash (KiB) utilisée pour produire les nouveaux hashs. */
|
|
26
|
+
memoryKiB;
|
|
27
|
+
/** Passes sur la mémoire utilisées pour produire les nouveaux hashs. */
|
|
28
|
+
timeCost;
|
|
29
|
+
/** Lanes parallèles utilisées pour produire les nouveaux hashs. */
|
|
30
|
+
parallelism;
|
|
31
|
+
/**
|
|
32
|
+
* @param options - coûts Argon2id ; défauts = minimum OWASP (19 MiB, t=2, p=1).
|
|
33
|
+
* @throws {RangeError} si un paramètre viole les bornes techniques de
|
|
34
|
+
* l'algorithme (entiers, `t ≥ 1`, `1 ≤ p ≤ 255`, `m ≥ 8×p`).
|
|
35
|
+
*/
|
|
36
|
+
constructor(options = {}) {
|
|
37
|
+
const m = options.memoryKiB ?? DEFAULT_MEMORY_KIB;
|
|
38
|
+
const t = options.timeCost ?? DEFAULT_TIME_COST;
|
|
39
|
+
const p = options.parallelism ?? DEFAULT_PARALLELISM;
|
|
40
|
+
if (!Number.isInteger(t) || t < 1) throw new RangeError(`Argon2idEncoder: timeCost must be an integer >= 1, got ${t}`);
|
|
41
|
+
if (!Number.isInteger(p) || p < 1 || p > 255) throw new RangeError(`Argon2idEncoder: parallelism must be an integer in [1, 255], got ${p}`);
|
|
42
|
+
if (!Number.isInteger(m) || m < 8 * p) throw new RangeError(`Argon2idEncoder: memoryKiB must be an integer >= 8×parallelism (${8 * p}), got ${m}`);
|
|
43
|
+
this.memoryKiB = m;
|
|
44
|
+
this.timeCost = t;
|
|
45
|
+
this.parallelism = p;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Reconnaît un hash argon2 (tous variants `$argon2d|i|id$`) — parsing pur, sync.
|
|
49
|
+
*
|
|
50
|
+
* Tous les variants sont supportés à la VÉRIFICATION (le binding parse le
|
|
51
|
+
* format PHC) ; {@link needsRehash} se charge de moderniser `d`/`i` vers `id`.
|
|
52
|
+
*
|
|
53
|
+
* @param hash - hash stocké à inspecter.
|
|
54
|
+
* @returns `true` si le hash est au format argon2.
|
|
55
|
+
*/
|
|
56
|
+
supports(hash) {
|
|
57
|
+
return ARGON2_HASH_RE.test(hash);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Hache un mot de passe en clair (sel généré, paramètres inclus dans la sortie PHC).
|
|
61
|
+
*
|
|
62
|
+
* @param plain - mot de passe en clair.
|
|
63
|
+
* @returns le hash argon2id à persister.
|
|
64
|
+
*/
|
|
65
|
+
async hash(plain) {
|
|
66
|
+
const mod = await loadArgon2();
|
|
67
|
+
return mod.hash(plain, {
|
|
68
|
+
algorithm: mod.Algorithm.Argon2id,
|
|
69
|
+
memoryCost: this.memoryKiB,
|
|
70
|
+
timeCost: this.timeCost,
|
|
71
|
+
parallelism: this.parallelism
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Vérifie un mot de passe en clair contre un hash stocké (temps constant interne).
|
|
76
|
+
*
|
|
77
|
+
* Les paramètres de vérification sont LUS DANS le hash PHC (pas ceux de
|
|
78
|
+
* l'encodeur) : un hash produit avec d'anciens coûts reste vérifiable.
|
|
79
|
+
*
|
|
80
|
+
* @param plain - mot de passe fourni à la connexion.
|
|
81
|
+
* @param hash - hash argon2 stocké.
|
|
82
|
+
* @returns `true` si la correspondance est valide.
|
|
83
|
+
*/
|
|
84
|
+
async verify(plain, hash) {
|
|
85
|
+
return (await loadArgon2()).verify(hash, plain);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Indique si un hash doit être recalculé : format non argon2, variant non
|
|
89
|
+
* `id`, version antérieure à 0x13, ou coûts stockés INFÉRIEURS aux coûts
|
|
90
|
+
* courants (politique affaiblie). Des coûts supérieurs ne déclenchent pas de
|
|
91
|
+
* re-hash (jamais de downgrade silencieux).
|
|
92
|
+
*
|
|
93
|
+
* @param hash - hash stocké à inspecter.
|
|
94
|
+
* @returns `true` si un re-hash est recommandé au prochain login réussi.
|
|
95
|
+
*/
|
|
96
|
+
needsRehash(hash) {
|
|
97
|
+
const match = ARGON2_HASH_RE.exec(hash);
|
|
98
|
+
if (match === null) return true;
|
|
99
|
+
if (match[1] !== "id") return true;
|
|
100
|
+
if (Number.parseInt(match[2], 10) < ARGON2_VERSION) return true;
|
|
101
|
+
const m = Number.parseInt(match[3], 10);
|
|
102
|
+
const t = Number.parseInt(match[4], 10);
|
|
103
|
+
return m < this.memoryKiB || t < this.timeCost;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
//#endregion
|
|
107
|
+
export { Argon2idEncoder };
|