@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
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
|