@nodefony/user 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +120 -0
- package/dist/index.js +16 -0
- package/dist/nodefony/contracts/IOAuthUserProvisioner.js +1 -0
- package/dist/nodefony/contracts/IPasswordBlocklist.js +1 -0
- package/dist/nodefony/contracts/IPasswordEncoder.js +1 -0
- package/dist/nodefony/contracts/IPasswordVerifier.js +1 -0
- package/dist/nodefony/contracts/IUser.js +1 -0
- package/dist/nodefony/contracts/IUserProfile.js +1 -0
- package/dist/nodefony/contracts/IUserProvider.js +1 -0
- package/dist/nodefony/contracts/IUserRepository.js +1 -0
- package/dist/nodefony/contracts/index.js +1 -0
- package/dist/nodefony/errors/UserNotFoundError.js +19 -0
- package/dist/nodefony/errors/WeakPasswordError.js +16 -0
- package/dist/nodefony/service/UserService.js +280 -0
- package/dist/nodefony/src/AnonymousUser.js +37 -0
- package/dist/nodefony/src/BaseUser.js +121 -0
- package/dist/nodefony/src/InMemoryUserRepository.js +230 -0
- package/dist/nodefony/src/admin/UserAdminApi.js +588 -0
- package/dist/nodefony/src/encoders/Argon2idEncoder.js +107 -0
- package/dist/nodefony/src/encoders/BcryptEncoder.js +75 -0
- package/dist/nodefony/src/encoders/MigratingEncoder.js +88 -0
- package/dist/nodefony/src/encoders/encoderFromConfig.js +37 -0
- package/dist/nodefony/src/userContract.js +247 -0
- package/dist/nodefony/src/userFilters.js +66 -0
- package/dist/nodefony/src/userProfile.js +195 -0
- package/dist/nodefony/src/userSort.js +53 -0
- package/dist/nodefony/src/userStoreRegistry.js +41 -0
- package/dist/types/index.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthUserProvisioner.d.ts +69 -0
- package/dist/types/nodefony/contracts/IPasswordBlocklist.d.ts +20 -0
- package/dist/types/nodefony/contracts/IPasswordEncoder.d.ts +49 -0
- package/dist/types/nodefony/contracts/IPasswordVerifier.d.ts +26 -0
- package/dist/types/nodefony/contracts/IUser.d.ts +68 -0
- package/dist/types/nodefony/contracts/IUserProfile.d.ts +29 -0
- package/dist/types/nodefony/contracts/IUserProvider.d.ts +44 -0
- package/dist/types/nodefony/contracts/IUserRepository.d.ts +129 -0
- package/dist/types/nodefony/contracts/index.d.ts +7 -0
- package/dist/types/nodefony/errors/UserNotFoundError.d.ts +15 -0
- package/dist/types/nodefony/errors/WeakPasswordError.d.ts +12 -0
- package/dist/types/nodefony/service/UserService.d.ts +179 -0
- package/dist/types/nodefony/src/AnonymousUser.d.ts +27 -0
- package/dist/types/nodefony/src/BaseUser.d.ts +98 -0
- package/dist/types/nodefony/src/InMemoryUserRepository.d.ts +73 -0
- package/dist/types/nodefony/src/admin/UserAdminApi.d.ts +119 -0
- package/dist/types/nodefony/src/encoders/Argon2idEncoder.d.ts +83 -0
- package/dist/types/nodefony/src/encoders/BcryptEncoder.d.ts +55 -0
- package/dist/types/nodefony/src/encoders/MigratingEncoder.d.ts +68 -0
- package/dist/types/nodefony/src/encoders/encoderFromConfig.d.ts +36 -0
- package/dist/types/nodefony/src/userContract.d.ts +198 -0
- package/dist/types/nodefony/src/userFilters.d.ts +80 -0
- package/dist/types/nodefony/src/userProfile.d.ts +56 -0
- package/dist/types/nodefony/src/userSort.d.ts +41 -0
- package/dist/types/nodefony/src/userStoreRegistry.d.ts +15 -0
- package/docs/ajouter-des-champs.md +189 -0
- package/docs/index.md +1113 -0
- package/package.json +90 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { nodefonyError } from "nodefony";
|
|
2
|
+
//#region nodefony/errors/UserNotFoundError.ts
|
|
3
|
+
/**
|
|
4
|
+
* Utilisateur introuvable dans la source d'identité — levée par les méthodes
|
|
5
|
+
* {@link IUserProvider} (contrat : jamais `null`, l'absence d'identité est un
|
|
6
|
+
* échec explicite).
|
|
7
|
+
*
|
|
8
|
+
* `code = 404` (sémantique interne). Les authenticators de `@nodefony/security`
|
|
9
|
+
* la convertissent en `AuthenticationError` générique : le détail (identifiant
|
|
10
|
+
* inconnu vs mauvais mot de passe) ne doit JAMAIS atteindre le client
|
|
11
|
+
* (anti-énumération de comptes) — il reste réservé aux logs/audit serveur.
|
|
12
|
+
*/
|
|
13
|
+
var UserNotFoundError = class extends nodefonyError {
|
|
14
|
+
constructor(detail) {
|
|
15
|
+
super(`User not found: ${detail}`, 404);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { UserNotFoundError, UserNotFoundError as default };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { nodefonyError } from "nodefony";
|
|
2
|
+
//#region nodefony/errors/WeakPasswordError.ts
|
|
3
|
+
/**
|
|
4
|
+
* Mot de passe refusé par la politique (connu-compromis / interdit) — `code = 400`.
|
|
5
|
+
*
|
|
6
|
+
* Levée par `UserService.createUser`/`changePassword` quand le
|
|
7
|
+
* {@link IPasswordBlocklist} branché rejette le candidat. Le message reste
|
|
8
|
+
* générique : ni la source de la liste ni le mot de passe ne sont exposés.
|
|
9
|
+
*/
|
|
10
|
+
var WeakPasswordError = class extends nodefonyError {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("Password rejected by policy", 400);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { WeakPasswordError, WeakPasswordError as default };
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { USER_FACETS } from "../src/userFilters.js";
|
|
2
|
+
import { UserNotFoundError } from "../errors/UserNotFoundError.js";
|
|
3
|
+
import { WeakPasswordError } from "../errors/WeakPasswordError.js";
|
|
4
|
+
import { profileFromClaims } from "../src/userProfile.js";
|
|
5
|
+
import { countFacets } from "nodefony";
|
|
6
|
+
import { AbstractCrudService } from "@nodefony/orm-core";
|
|
7
|
+
//#region nodefony/service/UserService.ts
|
|
8
|
+
const DUMMY_PLAINTEXT = "nodefony.dummy.timing.guard";
|
|
9
|
+
/**
|
|
10
|
+
* Service applicatif **utilisateur** — CRUD haché + authentification + events de cycle de vie.
|
|
11
|
+
*
|
|
12
|
+
* Spécialisation d'{@link AbstractCrudService} sur l'entité utilisateur : hérite du
|
|
13
|
+
* CRUD générique (`find`/`findOne`/`findById`/`count`/`create`/`update`/`delete` +
|
|
14
|
+
* events `onCreated`/`onUpdated`/`onDeleted`) et n'ajoute que le **spécifique
|
|
15
|
+
* credential** : hachage à la création (`createUser`), changement de mot de passe
|
|
16
|
+
* (`changePassword`), recherche par identifiant fonctionnel et authentification.
|
|
17
|
+
*
|
|
18
|
+
* Singleton DI stateless (cf {@link AbstractCrudService}) : aucun état par requête
|
|
19
|
+
* (le credential est lu depuis le repository, le hash leurre est un cache immuable).
|
|
20
|
+
*
|
|
21
|
+
* Events propres (en plus des events CRUD hérités) : `onPasswordChanged`,
|
|
22
|
+
* `onAuthenticated`, `onAuthenticationFailure` (avec {@link AuthFailureReason}).
|
|
23
|
+
*
|
|
24
|
+
* @typeParam — fixé : `T = IPasswordAuthenticatedUser` (le repository est la
|
|
25
|
+
* frontière credential), `R = IUserRepository` (conserve les finders métier).
|
|
26
|
+
*/
|
|
27
|
+
var UserService = class extends AbstractCrudService {
|
|
28
|
+
encoder;
|
|
29
|
+
#dummyHash = null;
|
|
30
|
+
/**
|
|
31
|
+
* Liste de blocage des mots de passe compromis (NIST SP 800-63B §5.1.1.2) —
|
|
32
|
+
* hook opt-in consulté à la création/changement (jamais au login). `null`
|
|
33
|
+
* par défaut : le framework fournit le point d'extension, l'application
|
|
34
|
+
* branche sa source (top-10k, fichier, API k-anonymity).
|
|
35
|
+
*/
|
|
36
|
+
passwordBlocklist = null;
|
|
37
|
+
/**
|
|
38
|
+
* @param repository - source de persistance des utilisateurs (credential inclus).
|
|
39
|
+
* @param encoder - encodeur de mot de passe (hash/verify/needsRehash).
|
|
40
|
+
* @param wiring - câblage Service ({@link ServiceWiring}) — quasi toujours omis.
|
|
41
|
+
*/
|
|
42
|
+
constructor(repository, encoder, ...wiring) {
|
|
43
|
+
super("users", repository, ...wiring);
|
|
44
|
+
this.encoder = encoder;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Crée un utilisateur — hache le mot de passe en clair s'il est fourni, puis
|
|
48
|
+
* délègue au `create` générique (hooks + event `onCreated`).
|
|
49
|
+
*
|
|
50
|
+
* @param input - identité + mot de passe en clair optionnel + rôles.
|
|
51
|
+
* @returns l'utilisateur persisté (id généré, hash stocké).
|
|
52
|
+
*/
|
|
53
|
+
async createUser(input) {
|
|
54
|
+
if (input.plainPassword != null) await this.#assertNotBlocked(input.plainPassword);
|
|
55
|
+
const password = input.plainPassword != null ? await this.encoder.hash(input.plainPassword) : null;
|
|
56
|
+
return this.create({
|
|
57
|
+
identifier: input.identifier,
|
|
58
|
+
roles: input.roles ?? [],
|
|
59
|
+
password
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Charge un utilisateur par son identifiant fonctionnel (email, login...).
|
|
64
|
+
*
|
|
65
|
+
* @param identifier - identifiant unique.
|
|
66
|
+
* @returns l'utilisateur, ou `null`.
|
|
67
|
+
*/
|
|
68
|
+
findByIdentifier(identifier) {
|
|
69
|
+
return this.repository.findByIdentifier(identifier);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Liste **paginée nativement** d'utilisateurs (filtres role/enabled/q) — délègue
|
|
73
|
+
* au repository, qui ne matérialise jamais plus d'une page. À préférer
|
|
74
|
+
* systématiquement à `find()` pour tout listing (data plane admin, écran).
|
|
75
|
+
*
|
|
76
|
+
* @param query - filtres + fenêtre de page.
|
|
77
|
+
* @returns une page d'utilisateurs ({@link IPage}).
|
|
78
|
+
*/
|
|
79
|
+
listPage(query) {
|
|
80
|
+
return this.repository.listPage(query);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Champs de tri que le repository **actuellement branché** sait honorer.
|
|
84
|
+
*
|
|
85
|
+
* La capacité se CONSTATE au runtime plutôt que de se déduire : un adapter
|
|
86
|
+
* tiers qui ne trierait pas rend une liste vide, et le data plane refuse alors
|
|
87
|
+
* tout `?order=` (400) au lieu de servir une page dans un ordre arbitraire.
|
|
88
|
+
*
|
|
89
|
+
* @returns les champs triables, liste vide si le repository ne trie pas.
|
|
90
|
+
*/
|
|
91
|
+
sortableFields() {
|
|
92
|
+
return this.repository.sortableFields ?? [];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compte les administrateurs actifs porteurs de `adminRole` — garde-fou
|
|
96
|
+
* anti-lockout calculé au store (jamais en chargeant tous les utilisateurs).
|
|
97
|
+
*
|
|
98
|
+
* @param adminRole - rôle d'administration à dénombrer.
|
|
99
|
+
* @returns le nombre d'admins actifs.
|
|
100
|
+
*/
|
|
101
|
+
countActiveAdmins(adminRole) {
|
|
102
|
+
return this.repository.countActiveAdmins(adminRole);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Les compteurs de tête de la console — posés sur l'annuaire ENTIER, pas sur
|
|
106
|
+
* la page affichée.
|
|
107
|
+
*
|
|
108
|
+
* Les populations se **recoupent** (un compte peut être désactivé ET
|
|
109
|
+
* verrouillé, un administrateur peut avoir un lien social) : chacune est
|
|
110
|
+
* comptée, aucune n'est déduite d'une autre.
|
|
111
|
+
*
|
|
112
|
+
* `admins` est composé ici et non déclaré dans {@link USER_FACETS} : le rôle
|
|
113
|
+
* d'administration est une valeur de configuration, pas une constante du
|
|
114
|
+
* vocabulaire — l'inscrire dans la table figerait `ROLE_NODEFONY_ADMIN` pour
|
|
115
|
+
* une plateforme qui peut le renommer.
|
|
116
|
+
*
|
|
117
|
+
* @param adminRole - rôle d'administration à dénombrer.
|
|
118
|
+
* @param query - filtres à appliquer avant comptage (sans fenêtre).
|
|
119
|
+
*/
|
|
120
|
+
async countUserFacets(adminRole, query) {
|
|
121
|
+
const repo = this.repository;
|
|
122
|
+
const [facets, admins] = await Promise.all([countFacets(USER_FACETS, (facet) => repo.countUsers({
|
|
123
|
+
...query,
|
|
124
|
+
...facet
|
|
125
|
+
})), repo.countUsers({
|
|
126
|
+
...query,
|
|
127
|
+
role: adminRole
|
|
128
|
+
})]);
|
|
129
|
+
return {
|
|
130
|
+
...facets,
|
|
131
|
+
admins: admins >= 0 ? admins : null
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Change le mot de passe d'un utilisateur — hache le clair avant persistance.
|
|
136
|
+
*
|
|
137
|
+
* Distinct du `update` générique : émet l'event credential `onPasswordChanged`,
|
|
138
|
+
* pas `onUpdated`.
|
|
139
|
+
*
|
|
140
|
+
* @param id - identifiant interne ciblé.
|
|
141
|
+
* @param plainPassword - nouveau mot de passe en clair.
|
|
142
|
+
* @returns l'utilisateur mis à jour, ou `null`.
|
|
143
|
+
*/
|
|
144
|
+
async changePassword(id, plainPassword) {
|
|
145
|
+
await this.#assertNotBlocked(plainPassword);
|
|
146
|
+
const password = await this.encoder.hash(plainPassword);
|
|
147
|
+
const updated = await this.repository.updateOne({ id }, { password });
|
|
148
|
+
if (updated !== null) this.fire("onPasswordChanged", updated);
|
|
149
|
+
return updated;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Authentifie par identifiant + mot de passe.
|
|
153
|
+
*
|
|
154
|
+
* Vérifie l'existence, l'état du compte (actif, non verrouillé), la présence d'un
|
|
155
|
+
* credential local, puis le mot de passe. Au succès, re-hache de façon transparente
|
|
156
|
+
* si le coût stocké est obsolète ({@link IPasswordEncoder.needsRehash}). **Tous** les
|
|
157
|
+
* chemins d'échec (identifiant inconnu, compte verrouillé/désactivé, compte sans
|
|
158
|
+
* password, mauvais mot de passe) consomment exactement une opération de hachage
|
|
159
|
+
* (vérification réelle ou hash leurre) pour niveler le temps de réponse : le message
|
|
160
|
+
* 401 est uniforme, le timing doit l'être aussi (anti énumération de comptes, OWASP).
|
|
161
|
+
*
|
|
162
|
+
* @param identifier - identifiant fonctionnel saisi.
|
|
163
|
+
* @param plain - mot de passe en clair saisi.
|
|
164
|
+
* @returns l'utilisateur authentifié, ou `null` en cas d'échec. Émet
|
|
165
|
+
* `onAuthenticated` (succès) ou `onAuthenticationFailure` (échec + raison).
|
|
166
|
+
*/
|
|
167
|
+
async authenticate(identifier, plain) {
|
|
168
|
+
const user = await this.repository.findByIdentifier(identifier);
|
|
169
|
+
if (user === null) {
|
|
170
|
+
await this.consumeDummy(plain);
|
|
171
|
+
return this.fail(identifier, "unknown_identifier");
|
|
172
|
+
}
|
|
173
|
+
if (user.isLocked()) {
|
|
174
|
+
await this.consumeDummy(plain);
|
|
175
|
+
return this.fail(identifier, "locked");
|
|
176
|
+
}
|
|
177
|
+
if (!user.isActive()) {
|
|
178
|
+
await this.consumeDummy(plain);
|
|
179
|
+
return this.fail(identifier, "disabled");
|
|
180
|
+
}
|
|
181
|
+
const hash = user.password;
|
|
182
|
+
if (hash === null) {
|
|
183
|
+
await this.consumeDummy(plain);
|
|
184
|
+
return this.fail(identifier, "no_password");
|
|
185
|
+
}
|
|
186
|
+
let ok = false;
|
|
187
|
+
try {
|
|
188
|
+
ok = await this.encoder.verify(plain, hash);
|
|
189
|
+
} catch {
|
|
190
|
+
ok = false;
|
|
191
|
+
}
|
|
192
|
+
if (!ok) return this.fail(identifier, "bad_credentials");
|
|
193
|
+
if (this.encoder.needsRehash(hash)) {
|
|
194
|
+
const fresh = await this.encoder.hash(plain);
|
|
195
|
+
const rehashed = await this.repository.updateOne({ id: user.id }, { password: fresh });
|
|
196
|
+
this.fire("onPasswordChanged", rehashed ?? user);
|
|
197
|
+
}
|
|
198
|
+
this.fire("onAuthenticated", user);
|
|
199
|
+
return user;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* {@inheritDoc IUserProvider.loadUserByIdentifier}
|
|
203
|
+
*/
|
|
204
|
+
async loadUserByIdentifier(identifier) {
|
|
205
|
+
const user = await this.repository.findByIdentifier(identifier);
|
|
206
|
+
if (user === null) throw new UserNotFoundError(`identifier "${identifier}"`);
|
|
207
|
+
return user;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* {@inheritDoc IUserProvider.loadUserByOAuth}
|
|
211
|
+
*
|
|
212
|
+
* @remarks Pas de provisionnement *Shadow User* ici : la création de la ligne
|
|
213
|
+
* locale au premier login externe est portée par `provisionOAuthUser()`, que
|
|
214
|
+
* `OAuth2Service.exchangeAndProvision()` (`@nodefony/security`) appelle après
|
|
215
|
+
* l'échange du code — le provider, lui, ne fait que lire.
|
|
216
|
+
*/
|
|
217
|
+
async loadUserByOAuth(provider, providerId) {
|
|
218
|
+
const user = await this.repository.findBySocialProvider(provider, providerId);
|
|
219
|
+
if (user === null) throw new UserNotFoundError(`social ${provider}:${providerId}`);
|
|
220
|
+
return user;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* {@inheritDoc IUserProvider.refreshUser}
|
|
224
|
+
*/
|
|
225
|
+
async refreshUser(user) {
|
|
226
|
+
const fresh = await this.findById(user.id);
|
|
227
|
+
if (fresh === null) throw new UserNotFoundError(`id "${user.id}" (compte supprimé)`);
|
|
228
|
+
return fresh;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* {@inheritDoc IOAuthUserProvisioner.provisionOAuthUser}
|
|
232
|
+
*
|
|
233
|
+
* @remarks Implémentation **par défaut** (find-or-create) : lit le lien
|
|
234
|
+
* existant, sinon — si `allowSignup` — crée une ligne locale 100 % OAuth
|
|
235
|
+
* (`password: null`, rôles = `policy.defaultRoles`) liée au compte externe.
|
|
236
|
+
* **Aucune liaison automatique** à un compte local existant par email (un email
|
|
237
|
+
* non vérifié serait un vecteur d'usurpation, OWASP) : un compte externe non lié
|
|
238
|
+
* donne TOUJOURS un nouvel utilisateur. Le rattachement à un compte existant se
|
|
239
|
+
* fait explicitement, utilisateur connecté (hors P6).
|
|
240
|
+
*/
|
|
241
|
+
async provisionOAuthUser(profile, policy) {
|
|
242
|
+
const existing = await this.repository.findBySocialProvider(profile.provider, profile.providerId);
|
|
243
|
+
if (existing !== null) return existing;
|
|
244
|
+
if (!policy.allowSignup) throw new UserNotFoundError(`social ${profile.provider}:${profile.providerId} (signup disabled)`);
|
|
245
|
+
const identifier = profile.email ?? `${profile.provider}:${profile.providerId}`;
|
|
246
|
+
const link = {
|
|
247
|
+
provider: profile.provider,
|
|
248
|
+
providerId: profile.providerId,
|
|
249
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
250
|
+
};
|
|
251
|
+
const claims = { ...profile.raw };
|
|
252
|
+
if (profile.name) claims.name = profile.name;
|
|
253
|
+
if (profile.email) claims.email = profile.email;
|
|
254
|
+
const oauthProfile = profileFromClaims(claims);
|
|
255
|
+
const data = {
|
|
256
|
+
identifier,
|
|
257
|
+
roles: [...policy.defaultRoles],
|
|
258
|
+
password: null,
|
|
259
|
+
socialProviders: [link]
|
|
260
|
+
};
|
|
261
|
+
if (Object.keys(oauthProfile).length > 0) data.metadata = { profile: oauthProfile };
|
|
262
|
+
return this.create(data);
|
|
263
|
+
}
|
|
264
|
+
async #assertNotBlocked(plain) {
|
|
265
|
+
if (this.passwordBlocklist === null) return;
|
|
266
|
+
if (await this.passwordBlocklist.isBlocked(plain)) throw new WeakPasswordError();
|
|
267
|
+
}
|
|
268
|
+
fail(identifier, reason) {
|
|
269
|
+
this.fire("onAuthenticationFailure", identifier, reason);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
async consumeDummy(plain) {
|
|
273
|
+
if (this.#dummyHash === null) this.#dummyHash = await this.encoder.hash(DUMMY_PLAINTEXT);
|
|
274
|
+
try {
|
|
275
|
+
await this.encoder.verify(plain, this.#dummyHash);
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
//#endregion
|
|
280
|
+
export { UserService };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region nodefony/src/AnonymousUser.ts
|
|
2
|
+
/** Rôle unique d'un utilisateur non authentifié. */
|
|
3
|
+
const ROLE_ANONYMOUS = "ROLE_ANONYMOUS";
|
|
4
|
+
const ANONYMOUS_ROLES = [ROLE_ANONYMOUS];
|
|
5
|
+
Object.freeze(ANONYMOUS_ROLES);
|
|
6
|
+
/**
|
|
7
|
+
* Utilisateur **non authentifié** — implémente {@link IUser} sans credential.
|
|
8
|
+
*
|
|
9
|
+
* Permet de typer le contexte de sécurité sans `null` (Zero Trust : un visiteur
|
|
10
|
+
* est un utilisateur anonyme, pas une absence d'utilisateur). `@CurrentUser`
|
|
11
|
+
* retourne `IUser | AnonymousUser`, jamais `null`. Sans état mutable : un
|
|
12
|
+
* {@link anonymousUser} singleton est réutilisé pour éviter toute allocation par
|
|
13
|
+
* requête.
|
|
14
|
+
*/
|
|
15
|
+
var AnonymousUser = class {
|
|
16
|
+
id = "anonymous";
|
|
17
|
+
identifier = "anon.";
|
|
18
|
+
roles = ANONYMOUS_ROLES;
|
|
19
|
+
hasRole(role) {
|
|
20
|
+
return role === ROLE_ANONYMOUS;
|
|
21
|
+
}
|
|
22
|
+
/** Un anonyme est utilisable (non désactivé). @returns `true`. */
|
|
23
|
+
isActive() {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
/** Un anonyme n'est jamais verrouillé. @returns `false`. */
|
|
27
|
+
isLocked() {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Singleton d'{@link AnonymousUser} — instance partagée et gelée à réutiliser à
|
|
33
|
+
* chaque requête non authentifiée (zéro allocation dans le hot path).
|
|
34
|
+
*/
|
|
35
|
+
const anonymousUser = Object.freeze(new AnonymousUser());
|
|
36
|
+
//#endregion
|
|
37
|
+
export { AnonymousUser, ROLE_ANONYMOUS, anonymousUser };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
//#region nodefony/src/BaseUser.ts
|
|
2
|
+
/**
|
|
3
|
+
* Implémentation POJO de référence d'un utilisateur — base partagée par tous les ORM.
|
|
4
|
+
*
|
|
5
|
+
* Porte le contrat {@link IPasswordAuthenticatedUser} plus les **champs
|
|
6
|
+
* anti-migration** : `socialProviders` (JSON, pas de colonnes par fournisseur),
|
|
7
|
+
* `metadata` (extras libres typés `Record<string, unknown>`, jamais `any`),
|
|
8
|
+
* `currentRole` (profil actif de session). Les entités persistées des adapters
|
|
9
|
+
* (`MongooseUser`, `DrizzleUser`...) étendent cette classe ; Drizzle la mappe.
|
|
10
|
+
*
|
|
11
|
+
* Hors hot path requête (instanciée à l'authentification, pas par requête) : les
|
|
12
|
+
* allocations de `roles`/`socialProviders`/`metadata` y sont acceptables. Pour un
|
|
13
|
+
* utilisateur anonyme (créé par requête non authentifiée), préférer
|
|
14
|
+
* {@link AnonymousUser} et son singleton.
|
|
15
|
+
*/
|
|
16
|
+
var BaseUser = class {
|
|
17
|
+
id;
|
|
18
|
+
identifier;
|
|
19
|
+
roles;
|
|
20
|
+
password;
|
|
21
|
+
/** Profil de rôle actif en session (P5.11) — distinct des rôles plats. */
|
|
22
|
+
currentRole;
|
|
23
|
+
socialProviders;
|
|
24
|
+
metadata;
|
|
25
|
+
enabled;
|
|
26
|
+
locked;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.id = options.id;
|
|
29
|
+
this.identifier = options.identifier;
|
|
30
|
+
this.roles = options.roles ? [...options.roles] : [];
|
|
31
|
+
this.password = options.password ?? null;
|
|
32
|
+
this.enabled = options.enabled ?? true;
|
|
33
|
+
this.locked = options.locked ?? false;
|
|
34
|
+
this.currentRole = options.currentRole ?? null;
|
|
35
|
+
this.socialProviders = options.socialProviders ? [...options.socialProviders] : [];
|
|
36
|
+
this.metadata = options.metadata ?? {};
|
|
37
|
+
}
|
|
38
|
+
hasRole(role) {
|
|
39
|
+
return this.roles.includes(role);
|
|
40
|
+
}
|
|
41
|
+
isActive() {
|
|
42
|
+
return this.enabled;
|
|
43
|
+
}
|
|
44
|
+
isLocked() {
|
|
45
|
+
return this.locked;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Ajoute un rôle s'il n'est pas déjà présent (idempotent).
|
|
49
|
+
*
|
|
50
|
+
* @param role - rôle à accorder.
|
|
51
|
+
* @returns `this` (chaînable).
|
|
52
|
+
*/
|
|
53
|
+
addRole(role) {
|
|
54
|
+
if (!this.roles.includes(role)) this.roles.push(role);
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Retire un rôle s'il est présent (idempotent).
|
|
59
|
+
*
|
|
60
|
+
* @param role - rôle à révoquer.
|
|
61
|
+
* @returns `this` (chaînable).
|
|
62
|
+
*/
|
|
63
|
+
removeRole(role) {
|
|
64
|
+
const i = this.roles.indexOf(role);
|
|
65
|
+
if (i !== -1) this.roles.splice(i, 1);
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lie un compte externe (OAuth/OIDC) — pattern Shadow User. Idempotent sur la
|
|
70
|
+
* paire `(provider, providerId)`.
|
|
71
|
+
*
|
|
72
|
+
* @param link - référence du compte externe.
|
|
73
|
+
* @returns `this` (chaînable).
|
|
74
|
+
*/
|
|
75
|
+
addSocialProvider(link) {
|
|
76
|
+
if (!this.socialProviders.some((p) => p.provider === link.provider && p.providerId === link.providerId)) this.socialProviders.push(link);
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
/** Active le compte. @returns `this`. */
|
|
80
|
+
enable() {
|
|
81
|
+
this.enabled = true;
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
/** Désactive le compte. @returns `this`. */
|
|
85
|
+
disable() {
|
|
86
|
+
this.enabled = false;
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
/** Verrouille le compte. @returns `this`. */
|
|
90
|
+
lock() {
|
|
91
|
+
this.locked = true;
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
/** Déverrouille le compte. @returns `this`. */
|
|
95
|
+
unlock() {
|
|
96
|
+
this.locked = false;
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Définit le profil de rôle actif (session). N'altère pas {@link roles}.
|
|
101
|
+
*
|
|
102
|
+
* @param role - rôle actif, ou `null` pour réinitialiser.
|
|
103
|
+
* @returns `this`.
|
|
104
|
+
*/
|
|
105
|
+
setCurrentRole(role) {
|
|
106
|
+
this.currentRole = role;
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Remplace le hash de mot de passe stocké.
|
|
111
|
+
*
|
|
112
|
+
* @param hash - nouveau hash (déjà produit par un {@link IPasswordEncoder}), ou `null`.
|
|
113
|
+
* @returns `this`.
|
|
114
|
+
*/
|
|
115
|
+
setPassword(hash) {
|
|
116
|
+
this.password = hash;
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
//#endregion
|
|
121
|
+
export { BaseUser };
|