@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.
Files changed (57) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +120 -0
  3. package/dist/index.js +16 -0
  4. package/dist/nodefony/contracts/IOAuthUserProvisioner.js +1 -0
  5. package/dist/nodefony/contracts/IPasswordBlocklist.js +1 -0
  6. package/dist/nodefony/contracts/IPasswordEncoder.js +1 -0
  7. package/dist/nodefony/contracts/IPasswordVerifier.js +1 -0
  8. package/dist/nodefony/contracts/IUser.js +1 -0
  9. package/dist/nodefony/contracts/IUserProfile.js +1 -0
  10. package/dist/nodefony/contracts/IUserProvider.js +1 -0
  11. package/dist/nodefony/contracts/IUserRepository.js +1 -0
  12. package/dist/nodefony/contracts/index.js +1 -0
  13. package/dist/nodefony/errors/UserNotFoundError.js +19 -0
  14. package/dist/nodefony/errors/WeakPasswordError.js +16 -0
  15. package/dist/nodefony/service/UserService.js +280 -0
  16. package/dist/nodefony/src/AnonymousUser.js +37 -0
  17. package/dist/nodefony/src/BaseUser.js +121 -0
  18. package/dist/nodefony/src/InMemoryUserRepository.js +230 -0
  19. package/dist/nodefony/src/admin/UserAdminApi.js +588 -0
  20. package/dist/nodefony/src/encoders/Argon2idEncoder.js +107 -0
  21. package/dist/nodefony/src/encoders/BcryptEncoder.js +75 -0
  22. package/dist/nodefony/src/encoders/MigratingEncoder.js +88 -0
  23. package/dist/nodefony/src/encoders/encoderFromConfig.js +37 -0
  24. package/dist/nodefony/src/userContract.js +247 -0
  25. package/dist/nodefony/src/userFilters.js +66 -0
  26. package/dist/nodefony/src/userProfile.js +195 -0
  27. package/dist/nodefony/src/userSort.js +53 -0
  28. package/dist/nodefony/src/userStoreRegistry.js +41 -0
  29. package/dist/types/index.d.ts +40 -0
  30. package/dist/types/nodefony/contracts/IOAuthUserProvisioner.d.ts +69 -0
  31. package/dist/types/nodefony/contracts/IPasswordBlocklist.d.ts +20 -0
  32. package/dist/types/nodefony/contracts/IPasswordEncoder.d.ts +49 -0
  33. package/dist/types/nodefony/contracts/IPasswordVerifier.d.ts +26 -0
  34. package/dist/types/nodefony/contracts/IUser.d.ts +68 -0
  35. package/dist/types/nodefony/contracts/IUserProfile.d.ts +29 -0
  36. package/dist/types/nodefony/contracts/IUserProvider.d.ts +44 -0
  37. package/dist/types/nodefony/contracts/IUserRepository.d.ts +129 -0
  38. package/dist/types/nodefony/contracts/index.d.ts +7 -0
  39. package/dist/types/nodefony/errors/UserNotFoundError.d.ts +15 -0
  40. package/dist/types/nodefony/errors/WeakPasswordError.d.ts +12 -0
  41. package/dist/types/nodefony/service/UserService.d.ts +179 -0
  42. package/dist/types/nodefony/src/AnonymousUser.d.ts +27 -0
  43. package/dist/types/nodefony/src/BaseUser.d.ts +98 -0
  44. package/dist/types/nodefony/src/InMemoryUserRepository.d.ts +73 -0
  45. package/dist/types/nodefony/src/admin/UserAdminApi.d.ts +119 -0
  46. package/dist/types/nodefony/src/encoders/Argon2idEncoder.d.ts +83 -0
  47. package/dist/types/nodefony/src/encoders/BcryptEncoder.d.ts +55 -0
  48. package/dist/types/nodefony/src/encoders/MigratingEncoder.d.ts +68 -0
  49. package/dist/types/nodefony/src/encoders/encoderFromConfig.d.ts +36 -0
  50. package/dist/types/nodefony/src/userContract.d.ts +198 -0
  51. package/dist/types/nodefony/src/userFilters.d.ts +80 -0
  52. package/dist/types/nodefony/src/userProfile.d.ts +56 -0
  53. package/dist/types/nodefony/src/userSort.d.ts +41 -0
  54. package/dist/types/nodefony/src/userStoreRegistry.d.ts +15 -0
  55. package/docs/ajouter-des-champs.md +189 -0
  56. package/docs/index.md +1113 -0
  57. package/package.json +90 -0
package/docs/index.md ADDED
@@ -0,0 +1,1113 @@
1
+ ---
2
+ title: "@nodefony/user — l'identité, socle de toute la sécurité"
3
+ navTitle: "@nodefony/user"
4
+ lang: fr
5
+ module: "@nodefony/user"
6
+ topic: user
7
+ section: "Identité"
8
+ audience: [developer]
9
+ tags:
10
+ [
11
+ user,
12
+ identite,
13
+ iuser,
14
+ mot-de-passe,
15
+ argon2id,
16
+ bcrypt,
17
+ oauth,
18
+ shadow-user,
19
+ repository,
20
+ rbac,
21
+ ]
22
+ version: "doc"
23
+ status: stable
24
+ updated: 2026-07-19
25
+ source: "src/packages/@nodefony/user/docs/index.md"
26
+ coverageModule: user
27
+ coverageFiles: UserService.ts,BaseUser.ts,AnonymousUser.ts,InMemoryUserRepository.ts,userProfile.ts,UserAdminApi.ts,Argon2idEncoder.ts,BcryptEncoder.ts,MigratingEncoder.ts,encoderFromConfig.ts
28
+ ---
29
+
30
+ # @nodefony/user — l'identité, socle de toute la sécurité
31
+
32
+ > **Qui est cette personne, et comment son secret est-il rangé ?** Ce module répond à ces deux
33
+ > questions — et à rien d'autre. Il porte le contrat `IUser`, les classes de base, les encodeurs de
34
+ > mot de passe (Argon2id, bcrypt, encodeur migrant), le dépôt d'utilisateurs et le `UserService`.
35
+ > **Décider si on te laisse entrer** est le travail de [`@nodefony/security`](../../security/docs/index.md),
36
+ > qui consomme ce module — jamais l'inverse. Bibliothèque pure, sans ORM, sans serveur : elle
37
+ > s'importe et se teste sans démarrer quoi que ce soit.
38
+
39
+ 📍 [Documentation](../../../../../docs/index.md) › **Identité (`@nodefony/user`)**
40
+
41
+ ## 🧠 Schéma général — la place de l'identité
42
+
43
+ Le sens de lecture est celui des **dépendances** : chaque flèche va du consommateur vers ce qu'il
44
+ consomme. `@nodefony/user` est tout en bas — c'est ce qui lui permet d'être importé partout sans
45
+ tirer la couche web.
46
+
47
+ ```mermaid
48
+ flowchart TB
49
+ APP["Ton application<br/>controllers, services"]
50
+ FW["@nodefony/framework<br/>@CurrentUser, @IsGranted"]
51
+ SEC["@nodefony/security<br/>firewall, authenticators, jetons"]
52
+ USR["@nodefony/user<br/>IUser · UserService · encodeurs"]
53
+ ORM["@nodefony/orm-core<br/>IRepository"]
54
+ DZ["@nodefony/drizzle<br/>DrizzleUserRepository"]
55
+ MG["@nodefony/mongoose<br/>MongooseUserRepository"]
56
+ MEM["InMemoryUserRepository<br/>builtin, 0 I/O"]
57
+
58
+ APP --> FW
59
+ APP --> SEC
60
+ FW --> USR
61
+ SEC --> USR
62
+ USR --> ORM
63
+ DZ -.->|implémente IUserRepository| USR
64
+ MG -.->|implémente IUserRepository| USR
65
+ MEM -.->|builtin| USR
66
+ ```
67
+
68
+ Deux conséquences pratiques :
69
+
70
+ 1. Un module qui a seulement besoin **du type d'un utilisateur** (`framework`, `studio`, un adapter
71
+ ORM, un futur module d'agents) importe `@nodefony/user` — un paquet léger, sans firewall.
72
+ 2. `@nodefony/user` **ne peut pas** importer `http`, `framework` ou `security` : ce serait une
73
+ inversion de dépendance, et le module deviendrait inutilisable seul.
74
+
75
+ ## 📖 Lexique
76
+
77
+ | Terme | Sens |
78
+ | -------------------------- | ----------------------------------------------------------------------------------------------------- |
79
+ | Identité | Qui est l'appelant (identifiant, rôles, état du compte). Ce module. |
80
+ | Authentification (authn) | Prouver cette identité (mot de passe, cookie, jeton). → `@nodefony/security`. |
81
+ | Autorisation (authz) | Décider de ses droits une fois prouvée. → `@nodefony/security`. |
82
+ | Credential | Le secret d'un compte. Ici : un **hash** de mot de passe, jamais le clair. |
83
+ | Hash / PHC | Empreinte à sens unique. Format PHC = `$argon2id$v=19$m=…,t=…,p=…$sel$empreinte`. |
84
+ | Argon2id | Fonction de dérivation **à mémoire dure** (RFC 9106) — recommandation OWASP/NIST courante. |
85
+ | bcrypt | Fonction historique, toujours acceptée (limite de 72 octets, pas de coût mémoire). |
86
+ | Re-hash (`needsRehash`) | Recalcul du hash au prochain login réussi quand ses paramètres sont dépassés. |
87
+ | Encodeur migrant | Composite qui **lit** l'ancien format et **écrit** le nouveau → migration sans coupure. |
88
+ | Repository | Le dépôt qui lit/écrit les utilisateurs. Seul composant qui voit le hash. |
89
+ | Provider (`IUserProvider`) | La source d'identité vue par la sécurité : « donne-moi l'utilisateur X » (lecture seule). |
90
+ | Shadow User | Ligne locale créée à la volée au premier login externe (OAuth) — l'identité reste chez toi. |
91
+ | JIT | _Just-In-Time_ : provisionné au moment où on en a besoin, pas importé à l'avance. |
92
+ | OIDC | OpenID Connect — couche d'identité au-dessus d'OAuth 2 ; source des « claims » standard. |
93
+ | Claim | Une information d'identité fournie par un tiers (`email`, `given_name`, `picture`…). |
94
+ | IDOR | _Insecure Direct Object Reference_ : viser l'objet d'autrui en changeant un identifiant dans l'URL. |
95
+ | Énumération de comptes | Déduire l'existence d'un compte par la différence de réponse (message, ou **temps**). |
96
+ | DTO | _Data Transfer Object_ : la projection publique d'une entité (ici, redactée par allowlist). |
97
+ | Allowlist | Liste **fermée** de ce qui est permis (l'inverse d'une liste de blocage) — le défaut sûr. |
98
+ | ALS | `AsyncLocalStorage` : le contexte de requête porté par le serveur, source de l'identité côté serveur. |
99
+ | Data plane | L'API JSON d'administration d'un module, sous `/nodefony/<module>/api/*`. |
100
+
101
+ ## 🧭 Par où commencer
102
+
103
+ Trois parcours selon ce que tu viens faire. L'ordre compte : chaque étape suppose la précédente.
104
+
105
+ **Je monte l'authentification de mon app** — le chemin nominal, 15 minutes.
106
+
107
+ 1. [Démarrage rapide](#-démarrage-rapide) — déclarer le service `users`, choisir l'encodeur, seeder
108
+ un compte. C'est **ton application** qui décide où vivent ses utilisateurs, pas le framework.
109
+ 2. [Firewall](../../security/docs/firewall.md) — déclarer les zones qui exigent une identité.
110
+ 3. [Authenticators](../../security/docs/authenticators.md) — la session BFF web, et le fait que le
111
+ **login est déjà fourni** (aucun `LoginController` à écrire).
112
+ 4. [Autorisation](../../security/docs/authorization.md) — passer de « qui » à « a-t-il le droit ».
113
+
114
+ **Je fais migrer une base existante** — comptes déjà en bcrypt, ou venus d'un autre framework.
115
+
116
+ 1. [Les encodeurs](#-les-briques-du-module) — comprendre `supports` / `hash` / `verify` / `needsRehash`.
117
+ 2. [Configuration](#-configuration--choisir-et-faire-migrer-lencodeur) — déclarer la chaîne
118
+ `argon2id` **puis** `bcrypt` : le nouveau format s'écrit, l'ancien se lit encore.
119
+ 3. [Persistance](#entités-de-persistance-et-dialectes) — la forme attendue de la table/collection.
120
+ 4. [Pièges](#-pièges-symptôme--cause--correction) — le piège de la migration inversée.
121
+
122
+ **J'ouvre un login social (« se connecter avec … »)** — et je ne veux pas offrir un compte admin.
123
+
124
+ 1. [Le Shadow User](#-loauth-et-le-shadow-user--lidentité-reste-chez-toi) — pourquoi une ligne
125
+ locale est créée même en authentification 100 % externe.
126
+ 2. [OAuth2](../../security/docs/oauth2.md) — le protocole, les fournisseurs, la config côté sécurité.
127
+ 3. [Sécurité](#-sécurité--ce-que-le-module-défend-vraiment) — les trois attaques prouvées par les
128
+ bancs `oauth.attack.test.ts`.
129
+ 4. [Profil](#le-profil-daffichage--des-claims-oidc-sous-allowlist) — pré-remplir nom/avatar depuis
130
+ les claims du fournisseur, sans jamais laisser un tiers écrire tes rôles.
131
+
132
+ ## 🗂️ Les briques du module
133
+
134
+ Le tableau pour choisir en cinq secondes ; les cards en dessous pour le détail.
135
+
136
+ | Brique | Ce qu'elle résout | Tu la touches quand… |
137
+ | -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- |
138
+ | `IUser` | le contrat minimal d'un utilisateur (identité + rôles) | tu typés un utilisateur, partout |
139
+ | `IPasswordAuthenticatedUser` | le même, **plus** le hash — contrat séparé | tu écris un authenticator ou un dépôt |
140
+ | `BaseUser` | l'implémentation POJO de référence | tu construis un utilisateur en mémoire ou en test |
141
+ | `AnonymousUser` / `anonymousUser` | le visiteur non authentifié, sans `null` | tu gères une route publique |
142
+ | `Argon2idEncoder` / `BcryptEncoder` | ranger un mot de passe de façon coûteuse à casser | tu choisis ta politique de hachage |
143
+ | `MigratingEncoder` / `encoderFromConfig()` | changer d'algorithme sans réinitialiser les mots de passe | tu reprends une base existante |
144
+ | `IUserRepository` / `InMemoryUserRepository` | lire/écrire des utilisateurs, quel que soit le stockage | tu branches Drizzle, Mongo, ou rien du tout |
145
+ | `UserService` | le CRUD + `authenticate()` + les événements de cycle de vie | c'est le service que ton app expose sous `"users"` |
146
+ | `IUserProvider` | la source d'identité vue par la sécurité | tu branches un annuaire externe (LDAP, SSO) |
147
+ | `IOAuthUserProvisioner` | créer la ligne locale au premier login social (Shadow User) | tu ouvres un « se connecter avec … » |
148
+ | `IUserProfile` + helpers | nom/prénom/avatar sous allowlist, hors du contrat d'identité | tu affiches un profil, tu acceptes un avatar |
149
+ | `UserAdminApi` | le data plane `/nodefony/user/api/*` (Studio) | tu administres des comptes |
150
+ | **Tes propres champs** | où ranger une donnée métier : colonne, `metadata`, ou entité liée | tu veux enrichir l'utilisateur — [page dédiée](ajouter-des-champs.md) |
151
+
152
+ ```nodefony-cards
153
+ [
154
+ { "icon": "📇", "title": "IUser", "href": "#-larchitecture-interne--trois-couches-étanches",
155
+ "desc": "Le contrat minimal — cinq membres, pas un de plus : id (UUID), identifier (email ou login), roles (tableau plat), hasRole(), isActive(), isLocked(). Aucun credential, aucun champ de persistance : c'est LE type que manipulent le framework, les décorateurs, Studio et les adapters ORM.",
156
+ "meta": "volontairement pauvre : plus il est petit, moins il coûte à faire circuler et à remplacer" },
157
+ { "icon": "🔒", "title": "IPasswordAuthenticatedUser", "href": "#le-split-credential--le-hash-ne-circule-pas",
158
+ "desc": "Le contrat qui voit le hash : une extension à un seul champ, readonly password. C'est la pièce d'architecture centrale — les consommateurs qui n'ont aucune raison de voir un credential (affichage, autorisation, logs) ne reçoivent que IUser.",
159
+ "meta": "un mot de passe null est une valeur normale : un compte 100 % externe" },
160
+ { "icon": "🧱", "title": "BaseUser", "href": "#-larchitecture-interne--trois-couches-étanches",
161
+ "desc": "Le POJO de référence : il implémente IPasswordAuthenticatedUser et ajoute les champs anti-migration — socialProviders en JSON (jamais de colonnes googleId/githubId), metadata typée (jamais any), currentRole. Les mutateurs sont chaînables et l'état de compte s'exprime par des verbes : enable(), disable(), lock(), unlock().",
162
+ "meta": "enabled et locked sont protected : on ne bascule pas un compte par affectation" },
163
+ { "icon": "🚶", "title": "AnonymousUser", "href": "#le-visiteur-anonyme--un-utilisateur-pas-un-null",
164
+ "desc": "Le visiteur, pas un trou : un singleton gelé avec un tableau de rôles gelé et partagé, donc zéro allocation par requête non authentifiée.",
165
+ "meta": "le contexte n'est jamais null — un visiteur EST un utilisateur, porteur de ROLE_ANONYMOUS" },
166
+ { "icon": "🔐", "title": "Argon2idEncoder", "href": "#-configuration--choisir-et-faire-migrer-lencodeur",
167
+ "desc": "Le défaut, à mémoire dure : chaque vérification exige de la RAM en plus du CPU (19 MiB par défaut), ce qui ruine les attaques massivement parallèles.",
168
+ "meta": "binding natif chargé au premier usage : instancier l'encodeur ne charge rien" },
169
+ { "icon": "🕰️", "title": "BcryptEncoder", "href": "#-configuration--choisir-et-faire-migrer-lencodeur",
170
+ "desc": "L'historique, toujours accepté : coût 12 par défaut, bornes techniques [4, 31]. À garder en lecture quand tu reprends une base existante.",
171
+ "meta": "à ne plus choisir comme algorithme principal pour une nouvelle application" },
172
+ { "icon": "🔄", "title": "MigratingEncoder", "href": "#-configuration--choisir-et-faire-migrer-lencodeur",
173
+ "desc": "Changer d'algorithme sans casse : hash() écrit toujours au format principal, verify() route vers le premier encodeur qui reconnaît le hash stocké, needsRehash() est vrai dès que le hash n'est pas au format principal.",
174
+ "meta": "chaque login réussi convertit un compte — la base bascule d'elle-même" },
175
+ { "icon": "🧠", "title": "InMemoryUserRepository", "href": "#entités-de-persistance-et-dialectes",
176
+ "desc": "Le dépôt qui n'écrit nulle part : implémentation complète d'IUserRepository sur une Map, sans ORM ni I/O. Dépôt de secours réel, fixture déterministe, et socle des mesures de charge — aucune synchronisation disque ne pollue le chiffre.",
177
+ "meta": "ce n'est pas un bouchon : il applique tout le patch d'updateOne, comme un backend réel" },
178
+ { "icon": "🧰", "title": "UserService", "href": "#-lapi-publique",
179
+ "desc": "Le service que ton application expose : une spécialisation d'AbstractCrudService qui hérite du CRUD générique et n'ajoute que le spécifique credential — hachage à la création, changement de mot de passe, authenticate().",
180
+ "meta": "implémente aussi IUserProvider, IPasswordVerifier et IOAuthUserProvisioner : la même instance est la source d'identité de la sécurité" },
181
+ { "icon": "🛠️", "title": "UserAdminApi", "href": "#-observabilité--studio-et-data-plane",
182
+ "desc": "L'administration des comptes : producteur du data plane /nodefony/user/api/* — liste paginée nativement, détail, création, modification, mot de passe, suppression, plus trois routes self-service.",
183
+ "meta": "DTO redacté par construction, mutations auditées, garde-fous anti-verrouillage" },
184
+ { "icon": "🧩", "title": "Tes propres champs", "href": "ajouter-des-champs.md",
185
+ "desc": "La table des utilisateurs t'appartient — mais elle est relue à CHAQUE requête portant une session authentifiée, donc toute colonne qu'on y pose est ramenée en mémoire à chaque requête. Trois voies, et le critère qui les départage : une colonne (court, filtrable), la colonne JSON metadata (occasionnel, aucune migration), ou une entité liée (sensible, volumineux, rarement lu).",
186
+ "meta": "une donnée réglementée n'a rien à faire sur cette table : entité liée, et chiffrée" }
187
+ ]
188
+ ```
189
+
190
+ ## Qu'est-ce que c'est ? — l'identité n'est pas l'authentification
191
+
192
+ Prends un immeuble de bureaux. Il y a **le registre du personnel** (qui travaille ici, quel service,
193
+ badge actif ou désactivé) et il y a **le poste de garde** (qui vérifie le badge à l'entrée et décide
194
+ qui monte au 4ᵉ étage). Ce sont deux métiers différents, tenus par deux équipes différentes.
195
+
196
+ - `@nodefony/user` = **le registre**. Il sait qui existe, quels rôles chacun porte, si le compte est
197
+ actif ou verrouillé, et il range les secrets dans un coffre.
198
+ - `@nodefony/security` = **le poste de garde**. Il lit le badge (cookie, jeton, clé d'API), interroge
199
+ le registre, et applique la politique d'accès.
200
+
201
+ Séparer les deux n'est pas une coquetterie : c'est ce qui permet à un module qui affiche un nom
202
+ d'utilisateur — ou à un futur module d'agents — d'importer un paquet de contrats et deux classes,
203
+ sans embarquer un pare-feu applicatif, un moteur de jetons et une pile OAuth.
204
+
205
+ > [!IMPORTANT]
206
+ > Tout ce qui relève du **firewall**, des **authenticators**, des **jetons**, du **CSRF** ou des
207
+ > **voters** vit dans [`@nodefony/security`](../../security/docs/index.md). Si tu cherches « comment
208
+ > protéger une route », tu es sur la mauvaise page.
209
+
210
+ ## La vision Nodefony
211
+
212
+ ### Trois couches étanches
213
+
214
+ L'utilisateur n'est pas UNE classe : c'est un contrat, une implémentation partagée, et des entités
215
+ par ORM. Chaque couche a le droit d'ignorer la suivante.
216
+
217
+ | Couche | Quoi | Qui l'utilise |
218
+ | ----------------- | ------------------------------- | ---------------------------------------------------- |
219
+ | ① Contrat strict | `IUser` (`IUser.ts:31`) | framework, décorateurs, Studio, agents — **partout** |
220
+ | ② POJO partagé | `BaseUser` (`BaseUser.ts:44`) | tests, fixtures, dépôt mémoire, mapping des adapters |
221
+ | ③ Entités par ORM | table Drizzle / schéma Mongoose | uniquement l'adapter concerné |
222
+
223
+ Un adapter ORM n'expose jamais sa classe à l'application : il rend du `BaseUser` (ou tout ce qui
224
+ satisfait le contrat). Changer de base de données ne change donc **rien** en amont.
225
+
226
+ ### Le split credential — le hash ne circule pas
227
+
228
+ C'est la décision la plus structurante du module, et elle tient en une ligne : **le contrat de base
229
+ ne porte pas le mot de passe**.
230
+
231
+ ```mermaid
232
+ flowchart LR
233
+ REPO["IUserRepository<br/>voit le hash"] -->|IPasswordAuthenticatedUser| SVC["UserService"]
234
+ SVC -->|IUser, sans hash| PROV["IUserProvider<br/>→ @nodefony/security"]
235
+ PROV --> CTRL["@CurrentUser<br/>ton controller"]
236
+ SVC -->|hash + clair| ENC["IPasswordEncoder<br/>verify / needsRehash"]
237
+ ```
238
+
239
+ - Le **repository** est la frontière de persistance : il lit et écrit le hash, par nature
240
+ (`IUserRepository.ts:42`).
241
+ - L'**encodeur** est le seul autre composant à le manipuler (`IPasswordEncoder.ts:11`).
242
+ - Tout ce qui est **en aval** — provider, décorateurs, DTO, logs — reçoit `IUser`, sans credential
243
+ (`IUserProvider.ts:14`).
244
+
245
+ Le bénéfice n'est pas théorique : `@nodefony/security` tape sur un contrat typé plutôt que de faire
246
+ un `as any` sur un objet dont il espère qu'il porte un champ `password`. Zéro downcast, zéro fuite
247
+ par distraction dans une sérialisation.
248
+
249
+ ### Le visiteur anonyme — un utilisateur, pas un `null`
250
+
251
+ Sur une route publique, la question « qui appelle ? » a quand même une réponse : personne
252
+ d'identifié. Nodefony matérialise ce « personne » plutôt que de rendre `null`.
253
+
254
+ ```ts ignore
255
+ // @CurrentUser() rend TOUJOURS un IUser — jamais null. Pas de `?.` défensif partout.
256
+ if (user.hasRole(ROLE_ANONYMOUS)) {
257
+ return this.renderJson({ greeting: "Bonjour, visiteur" });
258
+ }
259
+ ```
260
+
261
+ Le singleton est **gelé** et son tableau de rôles aussi, partagé entre toutes les requêtes
262
+ (`AnonymousUser.ts:8`) : sur une route publique très sollicitée, une requête anonyme n'alloue
263
+ strictement rien pour représenter son utilisateur.
264
+
265
+ ## 🚀 Démarrage rapide
266
+
267
+ Objectif : dans une app générée par `nodefony create app`, déclarer d'où viennent les utilisateurs,
268
+ choisir comment leurs mots de passe sont rangés, créer un compte et le retrouver.
269
+
270
+ ### 1. Choisir l'encodeur (`nodefony.config.ts`)
271
+
272
+ L'encodeur ne se configure pas dans ce module : il est **dérivé de la config sécurité**, parce que
273
+ c'est une politique de sécurité. Le firewall lit la section `encoders` et pose le service
274
+ `passwordEncoder` dans le container (`service/firewall.ts:394`).
275
+
276
+ ```typescript
277
+ // nodefony.config.ts — extrait du manifeste `modules`
278
+ use("@nodefony/security", {
279
+ // L'ORDRE fait tout : la 1re entrée est l'encodeur PRINCIPAL (elle écrit tous
280
+ // les nouveaux hashs) ; les suivantes sont des formats LEGACY acceptés en
281
+ // lecture seule. Un login réussi sur un hash legacy le réécrit au format
282
+ // principal — la base migre d'elle-même, sans réinitialisation de mot de passe.
283
+ encoders: {
284
+ default: { type: "argon2id" }, // défauts OWASP : m=19 MiB, t=3, p=1
285
+ legacy: { type: "bcrypt", rounds: 12 }, // à retirer quand plus aucun hash bcrypt
286
+ },
287
+ });
288
+ ```
289
+
290
+ > [!NOTE]
291
+ > Sans section `encoders`, le défaut du schéma Zod est **déjà** un Argon2id sûr
292
+ > (`security/nodefony/config/config.ts:1079`). Tu ne déclares cette section que pour ajouter un format
293
+ > legacy, ou pour ajuster les coûts.
294
+
295
+ ### 2. Déclarer le service `users` (`nodefony/security/provisionUsers.ts`)
296
+
297
+ `@nodefony/security` sait **authentifier** ; c'est ton application qui décide **qui** sont ses
298
+ utilisateurs et **où** ils vivent. Le scaffold écrit ce fichier pour toi — le voici en version
299
+ minimale, sans ORM.
300
+
301
+ ```typescript
302
+ // nodefony/security/provisionUsers.ts — dépôt mémoire, zéro base de données
303
+ import type { Module } from "nodefony";
304
+ import { InMemoryUserRepository, UserService } from "@nodefony/user";
305
+ import type { IPasswordEncoder } from "@nodefony/user";
306
+
307
+ /** Identifiant du compte d'amorçage créé au premier démarrage. */
308
+ export const ADMIN_IDENTIFIER = "admin";
309
+
310
+ export async function provisionUsers(module: Module): Promise<void> {
311
+ const container = module.container;
312
+ // Idempotent : ne JAMAIS remplacer un annuaire déjà posé (double boot, tests).
313
+ if (!container || container.has("users")) {
314
+ return;
315
+ }
316
+
317
+ // L'encodeur vient du firewall (section `encoders` ci-dessus). Son absence
318
+ // signifie que @nodefony/security n'est pas chargé → échec franc, jamais un
319
+ // repli muet sur un hachage plus faible.
320
+ const encoder = container.get<IPasswordEncoder>("passwordEncoder");
321
+ if (!encoder) {
322
+ throw new Error(
323
+ `provisionUsers: service "passwordEncoder" absent — @nodefony/security ` +
324
+ `est-il dans le manifeste modules de nodefony.config.ts ?`,
325
+ );
326
+ }
327
+
328
+ // Le service exposé sous le nom "users" EST la source d'identité du firewall.
329
+ const users = new UserService(new InMemoryUserRepository([]), encoder);
330
+ container.set("users", users);
331
+
332
+ // Seed idempotent : createUser hache le clair, jamais stocké tel quel.
333
+ if (!(await users.findByIdentifier(ADMIN_IDENTIFIER))) {
334
+ await users.createUser({
335
+ identifier: ADMIN_IDENTIFIER,
336
+ plainPassword: "change-me-now",
337
+ roles: ["ROLE_ADMIN", "ROLE_NODEFONY_ADMIN"],
338
+ });
339
+ }
340
+ }
341
+ ```
342
+
343
+ Le hook s'appelle depuis l'`index.ts` du module applicatif : `await provisionUsers(this)` dans
344
+ `onKernelReady`. Le scaffold pose ce câblage.
345
+
346
+ > [!WARNING]
347
+ > Ce dépôt mémoire est **volatil** : les comptes ne survivent pas au redémarrage. Pour une vraie
348
+ > base, remplace `new InMemoryUserRepository([])` par `DrizzleUserRepository.from(orm)` — le contrat
349
+ > est identique, rien d'autre ne bouge. Voir [Persistance](#entités-de-persistance-et-dialectes).
350
+
351
+ ### 3. Lire et modifier l'identité depuis un controller
352
+
353
+ ```typescript
354
+ // nodefony/controllers/AccountController.ts
355
+ import {
356
+ Controller,
357
+ controller,
358
+ Get,
359
+ Post,
360
+ Body,
361
+ CurrentUser,
362
+ } from "@nodefony/framework";
363
+ import type { ContextType } from "@nodefony/http";
364
+ import type { IUser, UserService } from "@nodefony/user";
365
+ import { nodefonyError } from "nodefony";
366
+
367
+ @controller("/api/account")
368
+ class AccountController extends Controller {
369
+ private users: UserService | null = null;
370
+
371
+ constructor(context: ContextType) {
372
+ super("account", context);
373
+ }
374
+
375
+ async initialize(): Promise<this> {
376
+ // Le service posé par provisionUsers, résolu depuis le container.
377
+ this.users = this.get<UserService>("users");
378
+ return this;
379
+ }
380
+
381
+ // @CurrentUser rend TOUJOURS un IUser (anonyme compris) — jamais null.
382
+ @Get("/me")
383
+ me(@CurrentUser() user: IUser) {
384
+ return { identifier: user.identifier, roles: user.roles };
385
+ }
386
+
387
+ @Post("/password")
388
+ async changePassword(
389
+ @CurrentUser() user: IUser,
390
+ @Body("current") current: string,
391
+ @Body("next") next: string,
392
+ ) {
393
+ // Re-authentification OBLIGATOIRE : une session volée ne doit pas suffire
394
+ // à changer le mot de passe (OWASP). authenticate() rend null sans dire
395
+ // pourquoi — la raison fine part dans les événements, jamais au client.
396
+ const authed = await this.users?.authenticate(user.identifier, current);
397
+ if (!authed) {
398
+ throw new nodefonyError("current password is incorrect", 403);
399
+ }
400
+ // La cible est l'id issu du re-auth, jamais un paramètre du client (anti-IDOR).
401
+ await this.users?.changePassword(authed.id, next);
402
+ return { ok: true };
403
+ }
404
+ }
405
+
406
+ export default AccountController;
407
+ ```
408
+
409
+ ### Ce qu'on observe
410
+
411
+ ```bash
412
+ # 1) Route publique : l'utilisateur anonyme est un utilisateur
413
+ curl -s http://localhost:5151/api/account/me
414
+ # {"identifier":"anon.","roles":["ROLE_ANONYMOUS"]}
415
+
416
+ # 2) Après login BFF (fourni par @nodefony/security) : l'identité réelle
417
+ curl -s -b /tmp/jar http://localhost:5151/api/account/me
418
+ # {"identifier":"admin","roles":["ROLE_ADMIN","ROLE_NODEFONY_ADMIN"]}
419
+
420
+ # 3) Mauvais mot de passe actuel → 403, sans indiquer pourquoi
421
+ curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/jar \
422
+ -H 'Content-Type: application/json' \
423
+ -d '{"current":"nope","next":"un-mot-de-passe-long"}' \
424
+ http://localhost:5151/api/account/password
425
+ # 403
426
+ ```
427
+
428
+ ## 🏗️ L'architecture interne — trois couches étanches
429
+
430
+ ### Le parcours d'une authentification par mot de passe
431
+
432
+ ```mermaid
433
+ sequenceDiagram
434
+ participant SEC as security<br/>(authenticator)
435
+ participant SVC as UserService
436
+ participant REPO as IUserRepository
437
+ participant ENC as IPasswordEncoder
438
+
439
+ SEC->>SVC: authenticate(identifier, plain)
440
+ SVC->>REPO: findByIdentifier(identifier)
441
+ alt inconnu / verrouillé / désactivé / sans mot de passe
442
+ SVC->>ENC: verify(plain, hash LEURRE)
443
+ SVC-->>SEC: null (+ événement onAuthenticationFailure)
444
+ else compte utilisable
445
+ SVC->>ENC: verify(plain, hash stocké)
446
+ alt mot de passe faux
447
+ SVC-->>SEC: null (+ raison bad_credentials)
448
+ else correct
449
+ opt needsRehash(hash)
450
+ SVC->>ENC: hash(plain) au format courant
451
+ SVC->>REPO: updateOne({ password })
452
+ end
453
+ SVC-->>SEC: IUser (+ événement onAuthenticated)
454
+ end
455
+ end
456
+ ```
457
+
458
+ Deux invariants s'y cachent. **Tous** les chemins d'échec consomment exactement une vérification de
459
+ hash, y compris ceux qui n'ont aucun hash réel à vérifier (`UserService.ts:198`) — c'est ce qui
460
+ interdit de deviner l'existence d'un compte au chronomètre
461
+ ([détail](#-sécurité--ce-que-le-module-défend-vraiment)). Et le **re-hash est transparent** : il a
462
+ lieu au seul moment où le mot de passe en clair existe côté serveur, un login réussi.
463
+
464
+ ### L'ordre des vérifications
465
+
466
+ `locked` → `disabled` → `no_password` → `bad_credentials`. Cet ordre est fixé
467
+ (`UserService.ts:212`), mais il n'est pas observable de l'extérieur : la valeur de retour est `null`
468
+ dans tous les cas, et la raison précise part dans l'événement `onAuthenticationFailure` — donc dans
469
+ l'audit serveur, jamais dans la réponse.
470
+
471
+ ### Les événements du cycle de vie
472
+
473
+ | Événement | Émis quand | Charge utile |
474
+ | ------------------------- | --------------------------------------------- | ------------------------ |
475
+ | `onCreated` | création (CRUD hérité) | l'utilisateur créé |
476
+ | `onUpdated` | mise à jour générique | l'utilisateur mis à jour |
477
+ | `onDeleted` | suppression | l'utilisateur supprimé |
478
+ | `onPasswordChanged` | `changePassword()` **ou** re-hash transparent | l'utilisateur |
479
+ | `onAuthenticated` | authentification réussie | l'utilisateur |
480
+ | `onAuthenticationFailure` | tout échec | identifiant + raison |
481
+
482
+ `onPasswordChanged` est délibérément distinct d'`onUpdated` (`UserService.ts:223`) : un changement de
483
+ credential n'est pas une modification banale, et un abonné (audit, notification, invalidation de
484
+ sessions) doit pouvoir le traiter à part.
485
+
486
+ ## 🧰 L'API publique
487
+
488
+ Les signatures exactes vivent dans le graphe généré — `jq '.symbols.UserService' .ai/symbols.json` —
489
+ et ne sont jamais recopiées ici (elles divergeraient). Ce tableau donne l'**usage**.
490
+
491
+ ### Contrats
492
+
493
+ | Contrat | Ce qu'il promet | Ancre |
494
+ | ---------------------------- | ------------------------------------------------------------- | ----------------------------- |
495
+ | `IUser` | identité + rôles plats, sans credential | `IUser.ts:31` |
496
+ | `IPasswordAuthenticatedUser` | idem + `password: string \| null` | `IUser.ts:72` |
497
+ | `ISocialProvider` | un lien vers un compte externe (`provider`/`providerId`) | `IUser.ts:9` |
498
+ | `IUserRepository` | CRUD portable + finders métier + pagination native | `IUserRepository.ts:81` |
499
+ | `IUserListQuery` | filtres de listing (`role`, `enabled`, `q`) + fenêtre de page | `IUserRepository.ts:19` |
500
+ | `IUserProvider` | source d'identité : **lève** si introuvable, jamais `null` | `IUserProvider.ts:14` |
501
+ | `IPasswordVerifier` | valide un couple identifiant/mot de passe, rend un verdict | `IPasswordVerifier.ts:15` |
502
+ | `IPasswordEncoder` | `supports`/`hash`/`verify`/`needsRehash` | `IPasswordEncoder.ts:11` |
503
+ | `IPasswordBlocklist` | point d'extension « ce mot de passe est-il compromis ? » | `IPasswordBlocklist.ts:12` |
504
+ | `IOAuthProfile` | profil normalisé issu d'un fournisseur, **sans aucun jeton** | `IOAuthUserProvisioner.ts:12` |
505
+ | `IOAuthProvisionPolicy` | rôles par défaut + autorisation de création à la volée | `IOAuthUserProvisioner.ts:37` |
506
+ | `IOAuthUserProvisioner` | crée la ligne locale au premier login externe | `IOAuthUserProvisioner.ts:61` |
507
+ | `IUserProfile` | claims OIDC d'affichage, sous allowlist | `IUserProfile.ts:15` |
508
+
509
+ ### Le repository — quatre méthodes qui comptent
510
+
511
+ `IUserRepository` étend `IRepository` d'[`@nodefony/orm-core`](../../orm-core/docs/index.md) et
512
+ ajoute quatre accès que le `Criteria` générique ne sait pas exprimer.
513
+
514
+ | Méthode | Rôle | Ancre |
515
+ | ------------------------ | --------------------------------------------------------------- | ------------------------ |
516
+ | `findByIdentifier()` | retrouver par email/login — le chemin du login | `IUserRepository.ts:92` |
517
+ | `findBySocialProvider()` | retrouver par lien externe — le chemin OAuth | `IUserRepository.ts:103` |
518
+ | `listPage()` | listing **paginé au store** (jamais un `find()` complet en RAM) | `IUserRepository.ts:121` |
519
+ | `countActiveAdmins()` | `COUNT` natif — le garde-fou anti-verrouillage | `IUserRepository.ts:131` |
520
+
521
+ > [!IMPORTANT]
522
+ > `listPage()` n'est pas un confort : c'est la règle mémoire du framework appliquée aux utilisateurs.
523
+ > Les filtres `role` (appartenance dans un tableau JSON), `enabled` et `q` (sous-chaîne insensible à
524
+ > la casse) descendent **dans le backend**. Charger 200 000 comptes en RAM pour en afficher 50 est
525
+ > exactement ce que ce contrat interdit.
526
+
527
+ ### Le service
528
+
529
+ | Appel | Ce qu'il fait | Ancre |
530
+ | ------------------------------------ | ------------------------------------------------------------- | -------------------- |
531
+ | `createUser()` | hache le clair puis délègue au `create` générique | `UserService.ts:106` |
532
+ | `findByIdentifier()` | lecture directe par identifiant fonctionnel | `UserService.ts:129` |
533
+ | `listPage()` / `countActiveAdmins()` | façades vers le dépôt (pagination et garde-fou) | `UserService.ts:143` |
534
+ | `changePassword()` | hache et persiste, émet `onPasswordChanged` | `UserService.ts:213` |
535
+ | `authenticate()` | vérifie, nivelle le temps, re-hache si besoin | `UserService.ts:243` |
536
+ | `loadUserByIdentifier()` | `IUserProvider` — **lève** `UserNotFoundError` si absent | `UserService.ts:301` |
537
+ | `loadUserByOAuth()` | `IUserProvider` — lit un lien social, ne crée jamais | `UserService.ts:317` |
538
+ | `refreshUser()` | recharge depuis la source (rôles frais, révocation immédiate) | `UserService.ts:331` |
539
+ | `provisionOAuthUser()` | Shadow User : lit, ou crée si la politique l'autorise | `UserService.ts:306` |
540
+ | `passwordBlocklist` | champ opt-in — branche ta liste de mots de passe compromis | `UserService.ts:83` |
541
+
542
+ **La distinction à retenir** : `loadUserByOAuth()` **lit** (et lève si le lien est inconnu) ;
543
+ `provisionOAuthUser()` **écrit** (et crée le compte). Deux contrats, deux responsabilités — c'est ce
544
+ qui permet de brancher un provisionnement maison sans réécrire la lecture.
545
+
546
+ ### Les erreurs
547
+
548
+ | Erreur | Code | Levée par | Ancre |
549
+ | ------------------- | ---- | --------------------------------------------------------- | ------------------------- |
550
+ | `UserNotFoundError` | 404 | les méthodes `IUserProvider`, et le provisionnement fermé | `UserNotFoundError.ts:13` |
551
+ | `WeakPasswordError` | 400 | `createUser`/`changePassword` si la blocklist refuse | `WeakPasswordError.ts:10` |
552
+
553
+ `UserNotFoundError` porte un détail (`identifier "x"`, `social github:42`) destiné aux **logs
554
+ serveur**. Les authenticators de sécurité la convertissent en 401 générique : la distinction
555
+ « identifiant inconnu » / « mauvais mot de passe » ne doit jamais atteindre le client.
556
+
557
+ ## ⚙️ Configuration — choisir et faire migrer l'encodeur
558
+
559
+ Un mot de passe ne se chiffre pas, il se **dérive** : on stocke une empreinte que l'on sait
560
+ recalculer mais pas inverser. Tout l'enjeu est de rendre ce calcul cher **pour l'attaquant** sans le
561
+ rendre insupportable pour ton serveur.
562
+
563
+ ### Argon2id ou bcrypt ?
564
+
565
+ | Critère | `Argon2idEncoder` (défaut) | `BcryptEncoder` (legacy) |
566
+ | --------------------------- | -------------------------------------- | --------------------------------------- |
567
+ | Norme | RFC 9106 | de facto |
568
+ | Coût mémoire | **oui** — 19 MiB/hash par défaut | non |
569
+ | Résistance GPU/ASIC | forte (la RAM est le goulot) | moyenne |
570
+ | Limite de longueur d'entrée | aucune | **72 octets** (silencieusement tronqué) |
571
+ | Paramètres | `memoryKiB`, `timeCost`, `parallelism` | `rounds` (4–31) |
572
+ | Binding natif | `@node-rs/argon2` (peer optionnelle) | `@node-rs/bcrypt` (peer optionnelle) |
573
+ | Détection du format | préfixe PHC `$argon2id$` | préfixe `$2a$`/`$2b$`/`$2y$` |
574
+
575
+ Les deux bindings sont des **peer dependencies optionnelles** chargées par import dynamique au
576
+ premier `hash`/`verify` (`Argon2idEncoder.ts:10`) : une app qui n'authentifie que par OAuth ou par
577
+ jeton ne les charge jamais.
578
+
579
+ > [!TIP]
580
+ > **Pourquoi `DEFAULT_TIME_COST` vaut 3 et non le minimum OWASP `t=2`** (`Argon2idEncoder.ts:39`) : une passe
581
+ > de plus renchérit l'attaquant d'environ 50 % **sans augmenter la RAM par hash**. Or c'est la
582
+ > mémoire, multipliée par le nombre de hachages simultanés, qui est le vrai budget anti-déni de
583
+ > service. Durcir par `t` est l'ajustement le moins risqué.
584
+
585
+ ### Migrer d'un algorithme à l'autre — le besoin vécu
586
+
587
+ Tu reprends une application dont les 40 000 mots de passe sont en bcrypt. Tu veux passer à Argon2id.
588
+ Tu ne peux pas convertir la base hors ligne : les hashs ne sont pas réversibles, et les mots de passe
589
+ en clair n'existent **qu'au moment d'un login**. Tu refuses de forcer 40 000 réinitialisations.
590
+
591
+ ### La config qui y répond
592
+
593
+ ```ts ignore
594
+ use("@nodefony/security", {
595
+ encoders: {
596
+ // 1re entrée = PRINCIPAL : tout nouveau hash sera de cette forme.
597
+ modern: { type: "argon2id", memoryKiB: 19456, timeCost: 3, parallelism: 1 },
598
+ // suivantes = LEGACY, acceptées en lecture seule.
599
+ historique: { type: "bcrypt", rounds: 12 },
600
+ },
601
+ });
602
+ ```
603
+
604
+ `encoderFromConfig()` traduit cette liste ordonnée en encodeur exécutable
605
+ (`encoderFromConfig.ts:56`) : une seule entrée → l'encodeur seul ; plusieurs entrées → un
606
+ `MigratingEncoder` ; liste vide → un Argon2id aux défauts OWASP (repli sûr, jamais rien de plus
607
+ faible).
608
+
609
+ ### Le comportement observable
610
+
611
+ | Le hash stocké commence par | `verify()` | `needsRehash()` | Au prochain login réussi |
612
+ | ------------------------------- | ---------- | --------------- | ------------------------------- |
613
+ | `$argon2id$` aux coûts courants | argon2id | `false` | rien |
614
+ | `$argon2id$` à coûts inférieurs | argon2id | `true` | ré-écrit aux coûts courants |
615
+ | `$2b$12$` | bcrypt | `true` | **converti en argon2id** |
616
+ | format inconnu de tous | `false` | `true` | échec (credential invérifiable) |
617
+
618
+ Chaque connexion réussie convertit un compte. Le jour où plus aucun `$2b$` ne subsiste en base, tu
619
+ retires l'entrée `historique` — et c'est tout.
620
+
621
+ Le verdict de re-hachage se lit **dans le format PHC**, sans jamais avoir besoin du mot de passe en
622
+ clair (analyse synchrone et gratuite) : variante autre que `id`, version antérieure à `0x13` ou coûts
623
+ **inférieurs** aux coûts courants pour Argon2id (`Argon2idEncoder.ts:153`) ; coût inférieur pour
624
+ bcrypt (`BcryptEncoder.ts:90`) ; format non principal pour le composite (`MigratingEncoder.ts:83`).
625
+ Des coûts **supérieurs** ne déclenchent rien — on ne rétrograde jamais une politique déjà renforcée.
626
+
627
+ > [!CAUTION]
628
+ > **Le piège inverse.** Écrire `{ historique: { type: "bcrypt" }, modern: { type: "argon2id" } }`
629
+ > fait de bcrypt le principal : tes hashs Argon2id seront jugés « legacy » et **rétrogradés** en
630
+ > bcrypt à chaque login. La première entrée est toujours la cible, jamais l'origine.
631
+
632
+ ### Le tableau des paramètres
633
+
634
+ Dérivé du schéma Zod de la section `encoders` (`security/nodefony/config/config.ts:35`) — la source
635
+ unique des bornes et des défauts.
636
+
637
+ | Option | Type | Défaut | Bornes | Effet |
638
+ | ------------- | ------------------------ | ---------- | --------- | ------------------------------------------------ |
639
+ | `type` | `"argon2id" \| "bcrypt"` | `argon2id` | — | l'algorithme |
640
+ | `memoryKiB` | entier | `19456` | `≥ 19456` | Argon2id : RAM par hachage (19 MiB = min. OWASP) |
641
+ | `timeCost` | entier | `3` | `≥ 2` | Argon2id : nombre de passes |
642
+ | `parallelism` | entier | `1` | `≥ 1` | Argon2id : lanes (chacune alloue `memoryKiB`) |
643
+ | `rounds` | entier | `12` | `10..15` | bcrypt : coût. Ignoré par Argon2id |
644
+
645
+ Le schéma **empêche** de descendre sous les minimums OWASP au boot : une config trop faible est
646
+ rejetée avant que le serveur n'accepte la moindre requête. Les encodeurs, eux, ne valident que les
647
+ bornes techniques de l'algorithme (`Argon2idEncoder.ts:76`) — c'est ce qui permet aux tests
648
+ d'utiliser des coûts bas et rapides sans affaiblir la politique de production.
649
+
650
+ ## 🔐 L'OAuth et le Shadow User — l'identité reste chez toi
651
+
652
+ ### Le besoin vécu
653
+
654
+ Tu ajoutes « se connecter avec GitHub ». La tentation est de faire confiance à GitHub pour tout :
655
+ l'identité, l'email… et les droits. C'est l'erreur.
656
+
657
+ **OAuth authentifie, il n'autorise pas.** Le fournisseur atteste que la personne contrôle un compte
658
+ chez lui. Ce qu'elle a le droit de faire **chez toi** ne se décide que chez toi.
659
+
660
+ ### La réponse Nodefony : une ligne locale, toujours
661
+
662
+ ```mermaid
663
+ flowchart TD
664
+ CB["Retour du fournisseur<br/>profil normalisé IOAuthProfile"] --> LOOK{"findBySocialProvider<br/>(provider, providerId)"}
665
+ LOOK -->|lien connu| EXIST["Compte local existant<br/>rôles INCHANGÉS"]
666
+ LOOK -->|inconnu| POL{"policy.allowSignup ?"}
667
+ POL -->|false| ERR["UserNotFoundError<br/>fail-closed"]
668
+ POL -->|true| NEW["Nouveau compte local<br/>password: null<br/>roles = policy.defaultRoles<br/>lien social persisté"]
669
+ ```
670
+
671
+ Le compte local est le **Shadow User** : ton application garde sa propre ligne, avec ses propres
672
+ rôles, son propre état actif/verrouillé. Le fournisseur n'est qu'une façon de prouver qu'on est bien
673
+ la personne rattachée à cette ligne (`UserService.ts:306`).
674
+
675
+ ### Trois invariants, et pourquoi ils existent
676
+
677
+ 1. **Aucune liaison automatique par email.** Un compte externe non lié donne **toujours** un nouvel
678
+ utilisateur, même si son email est identique à celui d'un compte local. Un email non vérifié — ou
679
+ vérifié chez un fournisseur laxiste — serait sinon un vecteur direct de prise de contrôle : je
680
+ crée un compte GitHub avec l'email de ton admin, je me connecte, j'hérite de ses droits. Le
681
+ rattachement d'un compte externe à un compte existant se fait explicitement, **utilisateur déjà
682
+ connecté**.
683
+ 2. **Les rôles ne sont écrits qu'à la création.** Un re-login ne réapplique jamais
684
+ `policy.defaultRoles` : si tu as retiré un rôle à quelqu'un, se reconnecter ne le lui rend pas
685
+ (`IOAuthUserProvisioner.ts:58`).
686
+ 3. **Les comptes sont séparés par fournisseur.** `google:777` et `github:777` sont deux identités
687
+ distinctes — la recherche porte sur la **paire** `(provider, providerId)`.
688
+
689
+ Ces trois points ne sont pas des intentions : ils sont **prouvés** par le banc d'attaque
690
+ `oauth.attack.test.ts:69` (`A1` collision d'email, `A2` élévation par re-login, `A3` collision
691
+ d'identifiant entre fournisseurs).
692
+
693
+ ### L'identifiant du Shadow User
694
+
695
+ `profile.email` s'il existe, sinon la clé stable `provider:providerId` (`UserService.ts:325`). Un
696
+ fournisseur qui n'expose pas d'email ne bloque donc pas la connexion, et deux fournisseurs ne peuvent
697
+ pas produire le même identifiant fonctionnel.
698
+
699
+ ### Le profil d'affichage — des claims OIDC sous allowlist
700
+
701
+ Nom, prénom, avatar, locale : ce sont des données d'**affichage**, pas d'identité. Elles vivent dans
702
+ `metadata.profile`, jamais dans des colonnes dédiées (`IUserProfile.ts:15`), et six clés seulement
703
+ sont reconnues (`userProfile.ts:11`).
704
+
705
+ Au provisionnement, ces champs sont pré-remplis depuis les claims du fournisseur — **une seule fois,
706
+ à la création** : un login ultérieur n'écrase jamais ce que l'utilisateur a édité depuis.
707
+ `profileFromClaims()` valide **champ par champ** (`userProfile.ts:192`) : un claim mal formé est
708
+ ignoré sans faire échouer la connexion, ni jeter les autres champs.
709
+
710
+ > [!WARNING]
711
+ > **Un avatar est du contenu hostile.** `picture` accepte une URL `http(s)` ou une data URL image,
712
+ > mais uniquement `png`/`jpeg`/`webp` en base64 strict, plafonnée à 128 Ko
713
+ > (`userProfile.ts:51`). **Le SVG est exclu par construction** : un SVG embarque du script, donc
714
+ > afficher l'avatar d'un inconnu exécuterait son code (XSS). Le GIF est exclu pour le poids et
715
+ > l'animation.
716
+
717
+ ## 🧩 Extension — brancher ses propres pièces
718
+
719
+ Le module fournit des points d'extension plutôt que des configurations. Chacun est un contrat que tu
720
+ implémentes ; rien à déclarer ailleurs.
721
+
722
+ ### Son propre dépôt
723
+
724
+ Implémente `IUserRepository` et passe-le au `UserService`. Les adapters livrés
725
+ (`DrizzleUserRepository`, `MongooseUserRepository`) ne sont rien d'autre que cela — et
726
+ `InMemoryUserRepository` est l'implémentation de référence à lire pour comprendre le contrat
727
+ (`InMemoryUserRepository.ts:35`).
728
+
729
+ **Trois pièges à ne pas reproduire**, appris en écrivant le dépôt mémoire :
730
+
731
+ - `updateOne` doit appliquer **tous** les champs du patch, pas seulement ceux qui t'arrangent —
732
+ c'est le rôle de `#apply()` (`InMemoryUserRepository.ts:130`). N'en honorer qu'une partie fait un
733
+ dépôt qui ment : un `{ enabled: false }` semble réussir sans rien désactiver.
734
+ - `create` doit persister `socialProviders`, `enabled` et `locked` — sinon le second login OAuth ne
735
+ retrouve pas le compte et crée un doublon.
736
+ - `listPage` doit filtrer **au store**, avec un tri déterministe par défaut (`identifier ASC`) :
737
+ sans ordre stable, la pagination par décalage saute et répète des lignes.
738
+
739
+ Un **banc de contrat unique** (`tests/support/userPaginationContract.ts:54`) valide ces invariants
740
+ sur n'importe quel backend : importe-le depuis ton paquet et branche ton harnais. Un écart entre
741
+ deux stores devient un échec de test, pas une surprise en production.
742
+
743
+ ### Sa propre source d'identité
744
+
745
+ Un annuaire LDAP, un SSO maison ? Implémente `IUserProvider` (`IUserProvider.ts:14`) : la sécurité ne
746
+ connaît que ce contrat. Sémantique à respecter — **lever**, jamais rendre `null` : l'absence
747
+ d'identité est un échec explicite, pas une valeur.
748
+
749
+ Si tu veux seulement valider un couple identifiant/mot de passe contre un système externe, le contrat
750
+ plus étroit `IPasswordVerifier` suffit (`IPasswordVerifier.ts:15`).
751
+
752
+ ### Sa liste de mots de passe compromis
753
+
754
+ Le NIST (SP 800-63B §5.1.1.2) recommande de refuser les mots de passe connus des fuites. Le framework
755
+ fournit le **point d'extension**, pas la liste : la source (top 10 000 embarqué, fichier
756
+ d'exploitation, API k-anonymity) est une décision de déploiement.
757
+
758
+ ```ts ignore
759
+ users.passwordBlocklist = {
760
+ async isBlocked(plain) {
761
+ return TOP_10K.has(plain.toLowerCase());
762
+ },
763
+ };
764
+ ```
765
+
766
+ Consultée à la **création** et au **changement**, jamais au login (`UserService.ts:359`) : au login,
767
+ le clair n'est plus jugeable contre une politique, et refuser une connexion existante enfermerait
768
+ l'utilisateur dehors. Un refus lève `WeakPasswordError` (400), avec un message générique.
769
+
770
+ ### Son propre provisionnement OAuth
771
+
772
+ Implémente `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts:61`). La capability est **duck-typée**
773
+ par `@nodefony/security` : si tu poses la tienne, elle est utilisée ; sinon, celle de `UserService`
774
+ s'applique. C'est là qu'on branche une politique métier (rôles déduits d'un domaine d'email,
775
+ rattachement à un tenant, refus d'un fournisseur pour certains comptes).
776
+
777
+ ## Entités de persistance et dialectes
778
+
779
+ Le module ne persiste **rien** par lui-même : il définit la forme, les adapters la déclinent.
780
+
781
+ ### Les colonnes attendues
782
+
783
+ | Champ | Type logique | Drizzle (SQL) | Mongoose | Rôle |
784
+ | ----------------- | ---------------- | --------------------- | ------------------------ | ----------------------------------- |
785
+ | `id` | UUID (`string`) | `text` clé primaire | `_id` + virtuel `id` | identifiant interne |
786
+ | `identifier` | `string` | `text` unique | `String` unique+index | email ou login |
787
+ | `password` | `string \| null` | `text` nullable | `String` défaut `null` | hash — `null` = compte externe |
788
+ | `roles` | `string[]` | `json` non nul | `[String]` | rôles **plats** |
789
+ | `enabled` | `boolean` | `bool` non nul | `Boolean` défaut `true` | `isActive()` |
790
+ | `locked` | `boolean` | `bool` non nul | `Boolean` défaut `false` | `isLocked()` |
791
+ | `currentRole` | `string \| null` | `text` nullable | `String` défaut `null` | profil de rôle actif en session |
792
+ | `socialProviders` | tableau JSON | `json` non nul | `Array` | liens externes — **anti-migration** |
793
+ | `metadata` | objet JSON | `json` non nul | `Object` | extras applicatifs + `profile` |
794
+ | `createdAt` | date | `dateMs` non nul | `timestamps: true` | création |
795
+ | `updatedAt` | date | `dateMs` + `onUpdate` | `timestamps: true` | dernière modification |
796
+
797
+ **Source unique** : `USER_COLUMNS` (`user/nodefony/src/userContract.ts:109`). Les deux adapters en
798
+ **dérivent** leur définition — `USER_TABLE_SPEC` (`drizzle/nodefony/entity/userTable.ts:82`) et
799
+ `userSchema` (`mongoose/nodefony/entity/userEntity.ts:65`) ne recopient rien, ils traduisent. Chaque
800
+ colonne y déclare aussi **qui la lit**, ce qui permet à un refus de nommer le lecteur en même temps
801
+ que la colonne absente. Le tableau ci-dessus est donc une lecture du contrat, jamais une quatrième
802
+ copie : un test par adapter refuse toute colonne du contrat sans correspondance.
803
+
804
+ > [!TIP]
805
+ > **Pourquoi `socialProviders` est du JSON et non des colonnes `googleId`, `githubId`…** Parce
806
+ > qu'ajouter un fournisseur ne doit demander **aucune migration de schéma**. Le prix à payer est une
807
+ > recherche par appartenance (`$elemMatch` en Mongo, containment JSON en SQL) : c'est exactement ce
808
+ > que `findBySocialProvider()` encapsule.
809
+
810
+ ### Ajouter tes propres champs
811
+
812
+ Ton application ajoute à sa table `User` les colonnes de son métier (`firstName`,
813
+ `department`, `tenantId`…). Deux choses à savoir, et une seule est une contrainte.
814
+
815
+ **En écriture, la porte n'est pas celle qu'on croit.** `IUserRepository` est typé sur le contrat :
816
+ `create({ firstName })` est **refusé par TypeScript**, et c'est voulu — le framework ne connaît que
817
+ ses colonnes. La porte est le **repository générique** de l'entité :
818
+
819
+ ```typescript
820
+ // Le repository générique accepte les champs de TA table.
821
+ const users = orm.getRepository<MonUtilisateur>("User");
822
+ await users.create({ identifier: "carol@example.com", firstName: "Carol" });
823
+
824
+ // `IUserRepository` reste la porte de tout ce qui touche à l'authentification.
825
+ const carol = await userRepository.findByIdentifier("carol@example.com");
826
+ ```
827
+
828
+ **En lecture, il n'y a rien à faire.** Les trois dépôts reportent sur l'utilisateur rendu toute
829
+ colonne hors contrat (`attachExtraColumns`, `userContract.ts:288`) : `carol.firstName` vaut
830
+ `"Carol"`. Sans ce report, l'écriture passerait et la lecture perdrait — **sans une erreur** —, et tu
831
+ verrais ta donnée en base et vide dans ton code.
832
+
833
+ > [!WARNING]
834
+ > **Un champ métier ne sort JAMAIS dans la console d'administration.** `toUserSummary` construit son
835
+ > résumé champ par champ (`UserAdminApi.ts:90`) : ni ton `salaire` ni ta `note RH` ne partent dans
836
+ > le data plane. Un test le garde (`UserAdminApi.test.ts:270`) — l'étanchéité ne tient pas à la
837
+ > prudence de qui édite ce fichier.
838
+
839
+ > [!TIP]
840
+ > **Où mettre la donnée ?** L'utilisateur est relu à **chaque requête portant une session**
841
+ > (`SessionAuthenticator.ts:69`) : chaque colonne de cette table est ramenée en mémoire à chaque
842
+ > fois. D'où la règle : contrainte ou index sur une donnée du quotidien → **colonne dans `User`** ;
843
+ > donnée sensible ou consultée rarement → **entité liée** ; sans schéma → **`metadata`**.
844
+
845
+ ### Les backends pris en charge
846
+
847
+ | Backend | Dépôt | Statut |
848
+ | ---------------------------------------------------- | ------------------------ | --------------------------------------------- |
849
+ | Mémoire | `InMemoryUserRepository` | **builtin** — toujours disponible, volatil |
850
+ | SQL via [Drizzle](../../drizzle/docs/index.md) | `DrizzleUserRepository` | référence — sqlite, PostgreSQL, MySQL/MariaDB |
851
+ | MongoDB via [Mongoose](../../mongoose/docs/index.md) | `MongooseUserRepository` | pris en charge |
852
+
853
+ Il n'y a **pas** de dépôt utilisateur Redis : Redis est un magasin de sessions et de jetons, pas un
854
+ annuaire d'identités interrogeable par rôle et par sous-chaîne.
855
+
856
+ Le choix du backend n'est pas résolu automatiquement — contrairement aux stores de session ou de
857
+ jetons. C'est **l'application** qui construit son dépôt (voir le
858
+ [Démarrage rapide](#-démarrage-rapide)). Un registre déclaratif énumère ce qui est branchable pour
859
+ l'écran Studio « Stores » (`userStoreRegistry.ts:23`), sans jamais rien sélectionner.
860
+
861
+ ## 🧑‍⚖️ Rôles — deux échelles, et où s'arrête ce module
862
+
863
+ Nodefony distingue deux familles de rôles, et c'est une distinction de **surface d'attaque**, pas de
864
+ nommage.
865
+
866
+ | Préfixe | Portée | Exemple |
867
+ | ----------------- | ----------------------------------------- | ------------------------- |
868
+ | `ROLE_NODEFONY_*` | la **plateforme** — console Studio, admin | `ROLE_NODEFONY_ADMIN` |
869
+ | `ROLE_*` | l'**applicatif** — ton métier, ton tenant | `ROLE_ADMIN`, `ROLE_USER` |
870
+
871
+ `ROLE_NODEFONY_ADMIN` est le rôle qui ouvre le data plane d'administration ; c'est celui que
872
+ protègent les garde-fous anti-verrouillage (`UserAdminApi.ts:25`). Un `ROLE_ADMIN` applicatif
873
+ n'ouvre **pas** la console : il administre ton domaine métier, pas le framework.
874
+
875
+ Ce module s'arrête à la **liste plate** : `IUser.roles` n'a aucune hiérarchie résolue
876
+ (`IUser.ts:39`) et `hasRole()` compare de façon **exacte** (`BaseUser.ts:71`) — c'est ce qui rend la
877
+ lecture des rôles bon marché à chaque requête et les logs non ambigus. Trois choses n'y sont donc
878
+ pas, par conception :
879
+
880
+ - **la hiérarchie** (`ROLE_ADMIN` implique `ROLE_USER`) → déclarée et résolue dans
881
+ [`@nodefony/security`](../../security/docs/authorization.md) ;
882
+ - **les scopes** (axe API des jetons et clés) → [jetons](../../security/docs/tokens.md) ;
883
+ - **les voters** métier → [autorisation](../../security/docs/authorization.md).
884
+
885
+ C'est aussi pourquoi la validation des rôles côté data plane est un simple contrôle de **format** :
886
+ `@nodefony/user` ne peut pas importer la hiérarchie sans inverser la dépendance. Un rôle invalide y
887
+ est donc **inerte** — il ne donne aucun droit, puisque c'est le contrôle d'accès qui tranche.
888
+
889
+ ## 🔐 Sécurité — ce que le module défend vraiment
890
+
891
+ ### Anti-énumération de comptes par le temps
892
+
893
+ **L'attaque.** Une API de login renvoie toujours « identifiants invalides », donc l'attaquant ne peut
894
+ rien lire dans le message. Mais si un identifiant inconnu répond en 2 ms (aucun hash à vérifier) et
895
+ un identifiant connu en 60 ms (un vrai Argon2id), le **chronomètre** trahit ce que le message tait.
896
+ En quelques milliers de requêtes, l'attaquant reconstitue la liste des comptes.
897
+
898
+ **La défense.** Tous les chemins d'échec consomment exactement une vérification de hash — y compris
899
+ « identifiant inconnu », « compte verrouillé », « compte désactivé » et « compte sans mot de passe »
900
+ (`UserService.ts:198`). Les trois derniers sont particulièrement traîtres : ils sont détectables
901
+ **avant** toute vérification, donc les traiter naïvement crée un oracle plus rapide encore que
902
+ l'identifiant inconnu.
903
+
904
+ Le leurre vérifie le mot de passe **réellement saisi** contre un hash factice (`UserService.ts:374`),
905
+ et non une constante : le temps de calcul suit ainsi la taille de l'entrée, comme dans le cas réel.
906
+
907
+ **La preuve.** Le banc `userServiceTiming.attack.test.ts:131` compte les appels à `verify` sur chaque
908
+ branche et vérifie qu'ils sont **égaux**, plutôt que de mesurer des durées (une mesure de temps est
909
+ instable en intégration continue ; un compteur ne l'est pas).
910
+
911
+ ### Anti-prise de contrôle par login social
912
+
913
+ Détaillée plus haut : [aucune liaison automatique par email, pas de réécriture des rôles au
914
+ re-login, séparation par fournisseur](#-loauth-et-le-shadow-user--lidentité-reste-chez-toi).
915
+ Prouvée par `oauth.attack.test.ts:71`.
916
+
917
+ ### Le hash ne fuite pas
918
+
919
+ Trois barrières, indépendantes :
920
+
921
+ 1. **Au type** : le contrat de base `IUser` n'a pas de champ `password` (`IUser.ts:70`).
922
+ 2. **Au DTO** : `toUserSummary()` construit sa sortie par **allowlist** (`UserAdminApi.ts:85`). Il
923
+ n'expose ni `password`, ni `metadata` (qui peut contenir du sensible), ni le moindre jeton dans
924
+ les liens sociaux — seulement `provider`, `providerId` et une date. Le champ `hasPassword` dit
925
+ qu'un mot de passe local **existe**, sans rien en révéler.
926
+ 3. **Au message** : le détail d'un `UserNotFoundError` ne quitte jamais le serveur.
927
+
928
+ ### Anti-verrouillage de l'administration
929
+
930
+ Une erreur d'administration ne doit pas fermer la porte définitivement. Cinq garde-fous, tous dans
931
+ `UserAdminApi.ts:345` :
932
+
933
+ | Tentative | Réponse |
934
+ | ---------------------------------------------- | ------------------------------------------- |
935
+ | retirer son propre `ROLE_NODEFONY_ADMIN` | 409 — « cannot remove your own admin role » |
936
+ | désactiver ou verrouiller son propre compte | 409 |
937
+ | supprimer son propre compte | 409 |
938
+ | déchoir le **dernier** admin actif | 409 |
939
+ | supprimer ou désactiver le dernier admin actif | 409 |
940
+
941
+ Le comptage passe par `countActiveAdmins()`, un `COUNT` natif au store — jamais un chargement complet
942
+ en mémoire (`UserService.ts:154`).
943
+
944
+ ### Cascade de révocation
945
+
946
+ Supprimer, désactiver ou verrouiller un compte émet l'événement kernel `onUserRevoked`
947
+ (`UserAdminApi.ts:219`). `@nodefony/security` s'y abonne pour éjecter **immédiatement** sessions et
948
+ jetons.
949
+
950
+ Ce n'est **pas** ce qui neutralise l'accès — c'était déjà fait, puisque les authenticators
951
+ rechargent l'utilisateur à chaque requête et rejettent un compte disparu, inactif ou verrouillé.
952
+ C'est de la défense en profondeur et de la propreté : on ne laisse pas traîner des sessions
953
+ orphelines en attendant leur expiration. C'est aussi un **point d'extension** : un module qui possède
954
+ des artefacts liés à un utilisateur s'abonne et nettoie les siens, sans modifier une ligne ici.
955
+
956
+ ### Self-service : anti-IDOR par construction
957
+
958
+ Les trois routes « moi » (`me`, `me/password`, `me/profile`) ne prennent **jamais** d'identifiant en
959
+ paramètre. La cible est lue dans le contexte de requête serveur, posé au login par le firewall
960
+ (`UserAdminApi.ts:197`). Viser le compte d'autrui est donc impossible — pas « interdit par un
961
+ contrôle », mais inexprimable.
962
+
963
+ `me/password` exige en plus le mot de passe **actuel** (`UserAdminApi.ts:702`) : une session volée ne
964
+ suffit pas à verrouiller un compte. Et comme cette re-vérification passe par `authenticate()`, qui ne
965
+ déclenche aucun verrouillage dur, un attaquant ne peut pas non plus s'en servir pour enfermer le
966
+ propriétaire légitime dehors.
967
+
968
+ ## 📜 Normes appliquées
969
+
970
+ | Domaine | Norme | Ce que le code en fait |
971
+ | ----------------------- | ------------------------ | ----------------------------------------------------------- |
972
+ | Hachage de mot de passe | RFC 9106 (Argon2) | variante `id`, version `0x13`, format PHC lu et écrit |
973
+ | Politique de hachage | OWASP Password Storage | minimums `m=19 MiB, t≥2, p=1` imposés au boot par le schéma |
974
+ | Mots de passe | NIST SP 800-63B §5.1.1.2 | point d'extension blocklist, consulté hors login |
975
+ | Longueur minimale | OWASP ASVS V2.1.1 | plancher de 8 caractères sur le changement self-service |
976
+ | Ré-authentification | OWASP Authentication | mot de passe actuel exigé avant changement |
977
+ | Énumération de comptes | OWASP | message uniforme **et** temps de réponse nivelé |
978
+ | Identité fédérée | OpenID Connect §5.1 | claims standard mappés vers `IUserProfile`, en camelCase |
979
+ | Emails | RFC 5321 §4.5.3.1.3 | longueur maximale de 254 caractères sur le profil |
980
+ | Langues | BCP 47 | validation de la forme du champ `locale` |
981
+
982
+ ## ⚡ Performance et mémoire
983
+
984
+ Le module vit majoritairement **hors du chemin chaud** : un `BaseUser` est instancié à
985
+ l'authentification, pas à chaque requête. Les points qui comptent :
986
+
987
+ | Point | Décision | Ancre |
988
+ | ------------------- | -------------------------------------------------------------- | ------------------------- |
989
+ | Utilisateur anonyme | singleton gelé + rôles partagés gelés → 0 allocation/requête | `AnonymousUser.ts:44` |
990
+ | Bindings natifs | import dynamique au 1er usage → 0 chargement si non utilisés | `BcryptEncoder.ts:10` |
991
+ | Hash leurre | calculé **paresseusement** au 1er échec, puis mis en cache | `UserService.ts:374` |
992
+ | Blocklist | `null` par défaut → aucun coût tant qu'elle n'est pas branchée | `UserService.ts:83` |
993
+ | Listing | pagination **native au store**, jamais de `find()` complet | `IUserRepository.ts:121` |
994
+ | Garde-fou admin | `COUNT` natif, pas un chargement de tous les comptes | `IUserRepository.ts:131` |
995
+ | Registre de stores | `Set` allouée au premier enregistrement | `userStoreRegistry.ts:16` |
996
+
997
+ **Le vrai budget, c'est la mémoire du hachage.** Avec les défauts Argon2id, chaque vérification
998
+ mobilise 19 MiB. Vingt logins simultanés, c'est ~380 MiB transitoires. Dimensionne en conséquence, et
999
+ préfère augmenter `timeCost` plutôt que `memoryKiB` pour durcir la politique. Le module fournit un
1000
+ banc de débit dédié (`npm run test:load` dans le paquet).
1001
+
1002
+ ## 📡 Observabilité — Studio et data plane
1003
+
1004
+ ### Les écrans
1005
+
1006
+ | Écran Studio | Ce qu'il montre |
1007
+ | --------------------- | --------------------------------------------------------------- |
1008
+ | `/nodefony/users` | la liste paginée, filtrable par rôle, état et sous-chaîne |
1009
+ | `/nodefony/users/:id` | la fiche d'un compte : rôles, état, liens sociaux, profil |
1010
+ | `/nodefony/profile` | mon propre compte (self-service : profil, avatar, mot de passe) |
1011
+ | `/nodefony/stores` | le backend d'identité résolu, parmi les backends disponibles |
1012
+
1013
+ ### Le data plane
1014
+
1015
+ Le producteur est **défini** ici (le domaine lui appartient) mais **enregistré** par
1016
+ `@nodefony/security` au démarrage (`UserAdminApi.ts:894`) : `@nodefony/user` est une bibliothèque
1017
+ pure, pas un module bootable. Le cœur prévoit explicitement ce cas.
1018
+
1019
+ | Méthode | Route | Rôle requis | Effet |
1020
+ | -------- | ---------------------------------------- | --------------------- | ---------------------------------------------- |
1021
+ | `GET` | `/nodefony/user/api/users` | `ROLE_NODEFONY_ADMIN` | liste paginée — `?role&enabled&q&limit&offset` |
1022
+ | `GET` | `/nodefony/user/api/users/status` | `ROLE_NODEFONY_ADMIN` | backend résolu, backends disponibles, effectif |
1023
+ | `GET` | `/nodefony/user/api/users/{id}` | `ROLE_NODEFONY_ADMIN` | détail redacté (404 sinon) |
1024
+ | `POST` | `/nodefony/user/api/users` | `ROLE_NODEFONY_ADMIN` | création (409 si l'identifiant existe) |
1025
+ | `PATCH` | `/nodefony/user/api/users/{id}` | `ROLE_NODEFONY_ADMIN` | `roles`/`enabled`/`locked`/`profile` |
1026
+ | `POST` | `/nodefony/user/api/users/{id}/password` | `ROLE_NODEFONY_ADMIN` | changement de mot de passe |
1027
+ | `DELETE` | `/nodefony/user/api/users/{id}` | `ROLE_NODEFONY_ADMIN` | suppression + cascade de révocation |
1028
+ | `GET` | `/nodefony/user/api/me` | authentifié | mon profil (DTO redacté) |
1029
+ | `POST` | `/nodefony/user/api/me/password` | authentifié | mon mot de passe (re-auth exigée) |
1030
+ | `POST` | `/nodefony/user/api/me/profile` | authentifié | mon profil d'affichage |
1031
+
1032
+ Les bornes de pagination sont dures : 50 par défaut, **200 maximum** (`UserAdminApi.ts:26`). Les
1033
+ mutations d'administration sont auditées dans la catégorie `authz` ; les actions self-service dans
1034
+ `authn`, **succès et échecs** — un échec de ré-authentification est un signal de sécurité.
1035
+
1036
+ ### La ligne de commande
1037
+
1038
+ `npx nodefony security:user:add <identifier> [--admin]` crée un compte sans interface. La commande
1039
+ vit dans `@nodefony/security` (elle a besoin d'un kernel bootable) mais opère sur le service `users`
1040
+ posé par ton application.
1041
+
1042
+ ## ⚠️ Pièges (symptôme → cause → correction)
1043
+
1044
+ | Symptôme | Cause | Correction |
1045
+ | ----------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------- |
1046
+ | Au boot : `service "passwordEncoder" absent` | `@nodefony/security` absent du manifeste `modules` | l'ajouter dans `nodefony.config.ts` — c'est lui qui dérive l'encodeur |
1047
+ | L'authentification échoue toujours, sans erreur | aucun service `users` posé au container | appeler `provisionUsers` au `onKernelReady` de ton module applicatif |
1048
+ | Les comptes disparaissent à chaque redémarrage | dépôt mémoire actif (repli annoncé quand l'ORM est absent) | brancher `DrizzleUserRepository` / `MongooseUserRepository` |
1049
+ | Après migration, les hashs **redeviennent** bcrypt | ordre inversé dans `encoders` : bcrypt est en 1ʳᵉ position, donc principal | mettre `argon2id` **en premier** |
1050
+ | Les mots de passe de plus de 72 caractères se valident tous | limite intrinsèque de bcrypt (troncature silencieuse) | passer à `argon2id`, qui n'a pas cette limite |
1051
+ | `Cannot find module '@node-rs/argon2'` | peer dependency **optionnelle** non installée | l'installer, ou choisir un encodeur dont le binding est présent |
1052
+ | `hasRole("ROLE_USER")` est faux alors que l'utilisateur est admin | `IUser.hasRole()` est **exact** — la hiérarchie n'est pas dans le modèle | déclarer `roleHierarchy` côté sécurité et passer par `@IsGranted` |
1053
+ | Un `PATCH` de compte renvoie 400 « no modifiable fields » | corps vide ou mal typé — un `UPDATE` vide ferait planter le SQL | envoyer au moins `roles`, `enabled`, `locked` ou `profile` |
1054
+ | Le dernier administrateur ne peut plus être modifié | garde-fou anti-verrouillage volontaire | créer un second compte administrateur d'abord |
1055
+ | Un avatar SVG est refusé | rejet **par construction** — un SVG peut embarquer du script (XSS) | convertir en `png`/`jpeg`/`webp` côté client |
1056
+ | Après un `updateOne({ enabled: false })`, le compte reste actif | dépôt maison qui n'applique qu'une partie du patch | appliquer **tout** le patch et faire tourner le banc de contrat |
1057
+ | La liste d'utilisateurs est lente ou fait gonfler la mémoire | usage de `find()` là où `listPage()` est prévu | passer par `listPage()` — filtres et fenêtre descendent au store |
1058
+ | Le 2ᵉ login OAuth crée un **doublon** | dépôt qui ne persiste pas `socialProviders` à la création | persister le champ dans `create` (voir le dépôt mémoire) |
1059
+
1060
+ ## 🧪 Tests et couverture
1061
+
1062
+ Les chiffres exacts vivent dans la carte de l'aperçu, régénérée depuis vitest — jamais figés ici.
1063
+
1064
+ **Ce qui est couvert :**
1065
+
1066
+ - **unitaires** — le contrat (`BaseUser`, `AnonymousUser`), les quatre encodeurs
1067
+ (`Argon2idEncoder`, `BcryptEncoder`, `MigratingEncoder`, `encoderFromConfig`), le dépôt mémoire
1068
+ (CRUD **et** pagination), le service (`UserService`), le provider (`userProvider`), le
1069
+ provisionnement (`oauthProvisioner`), la logique pure du profil (`userProfile`) et le data plane
1070
+ (`UserAdminApi`) ;
1071
+ - **banc de contrat** — `runUserPaginationContract()` (`userPaginationContract.ts:54`), importé
1072
+ **cross-paquet** par les adapters :
1073
+ le même seed déterministe et les mêmes assertions tournent sur mémoire, Drizzle (sqlite,
1074
+ PostgreSQL, MySQL/MariaDB) et Mongoose. Un écart entre backends est un échec, par construction ;
1075
+ - **tests d'attaque** — `oauth.attack.test.ts` (provisionnement Shadow User, sur le **vrai** dépôt et
1076
+ non un bouchon) et `userServiceTiming.attack.test.ts` (anti-énumération par le temps) ;
1077
+ - **charge** — un banc de débit de hachage (`npm run test:load`), utile pour dimensionner le coût
1078
+ Argon2id d'un pod.
1079
+
1080
+ **Ce qui manque, et c'est dit :** il n'y a pas de test d'intégration HTTP **dans ce paquet** — c'est
1081
+ cohérent, le module est une bibliothèque sans serveur. Le data plane est exercé unitairement ici, et
1082
+ de bout en bout depuis `@nodefony/security` et Studio.
1083
+
1084
+ > [!WARNING]
1085
+ > **Un compteur vert ne prouve pas une capacité.** Les bancs qui exigent une base réelle
1086
+ > (PostgreSQL, MySQL, MongoDB) se **sautent** quand leurs variables d'infrastructure sont absentes —
1087
+ > et un test sauté compte comme réussi. Lis le bloc de portes affiché en fin d'exécution avant de
1088
+ > conclure qu'un dialecte est prouvé.
1089
+
1090
+ Couverture : `npm run coverage` dans `@nodefony/user`. Les skills de vérification associés :
1091
+ `nodefony-security-review` (revue et campagne d'attaque), `nodefony-load-test` (charge et
1092
+ dimensionnement).
1093
+
1094
+ ## 🔗 Pour aller plus loin
1095
+
1096
+ - ⬆️ **Retour au hub** : [Toute la documentation](../../../../../docs/index.md) ·
1097
+ [Démarrer avec Nodefony](../../../../../docs/demarrer.md)
1098
+ - 🧭 **Le module qui consomme celui-ci** : [`@nodefony/security`](../../security/docs/index.md) —
1099
+ [firewall](../../security/docs/firewall.md) (les zones) ·
1100
+ [authenticators](../../security/docs/authenticators.md) (prouver l'identité) ·
1101
+ [autorisation](../../security/docs/authorization.md) (rôles, scopes, voters) ·
1102
+ [OAuth2](../../security/docs/oauth2.md) (le login social) ·
1103
+ [jetons](../../security/docs/tokens.md) · [journal d'audit](../../security/docs/audit.md)
1104
+ - 🗄️ **La persistance** : [`@nodefony/orm-core`](../../orm-core/docs/index.md) (le contrat
1105
+ `IRepository`) · [`@nodefony/drizzle`](../../drizzle/docs/index.md) (SQL, par défaut) ·
1106
+ [`@nodefony/mongoose`](../../mongoose/docs/index.md) (MongoDB) ·
1107
+ [écrire une entité](../../orm-core/docs/tutorial-entity.md)
1108
+ - 🧰 **Consommer l'identité dans ton code** :
1109
+ [décorateurs du framework](../../framework/docs/decorateurs.md) (`@CurrentUser`, `@IsGranted`) ·
1110
+ [pipeline de requête](../../../../../docs/architecture/pipeline-requete.md) (où l'identité est
1111
+ résolue)
1112
+ - 📡 **L'administrer** : [`@nodefony/studio`](../../studio/docs/index.md)
1113
+ - 📖 [Lexique général](../../../../../docs/lexique.md) du framework