@dudousxd/adonis-authkit-server 0.1.1 → 0.2.0
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/build/index.d.ts +3 -2
- package/build/index.js +2 -1
- package/build/src/accounts/account_store.d.ts +74 -17
- package/build/src/accounts/account_store.js +12 -1
- package/build/src/accounts/lucid_account_store.d.ts +12 -27
- package/build/src/accounts/lucid_account_store.js +38 -365
- package/build/src/accounts/lucid_store/core.d.ts +8 -0
- package/build/src/accounts/lucid_store/core.js +108 -0
- package/build/src/accounts/lucid_store/mfa.d.ts +8 -0
- package/build/src/accounts/lucid_store/mfa.js +77 -0
- package/build/src/accounts/lucid_store/provider_identity.d.ts +8 -0
- package/build/src/accounts/lucid_store/provider_identity.js +41 -0
- package/build/src/accounts/lucid_store/shared.d.ts +48 -0
- package/build/src/accounts/lucid_store/shared.js +15 -0
- package/build/src/accounts/lucid_store/webauthn.d.ts +8 -0
- package/build/src/accounts/lucid_store/webauthn.js +135 -0
- package/build/src/define_config.d.ts +6 -0
- package/build/src/define_config.js +20 -5
- package/build/src/host/controllers/account_mfa_controller.js +2 -1
- package/build/src/host/controllers/account_session_controller.js +10 -18
- package/build/src/host/controllers/interaction_controller.js +13 -32
- package/build/src/host/controllers/social_controller.js +7 -0
- package/build/src/host/login_attempt.d.ts +39 -0
- package/build/src/host/login_attempt.js +37 -0
- package/build/src/host/register_auth_host.d.ts +13 -0
- package/build/src/host/register_auth_host.js +9 -2
- package/build/src/mixins/json_column.d.ts +38 -0
- package/build/src/mixins/json_column.js +31 -0
- package/build/src/mixins/with_audit_log.js +2 -4
- package/build/src/mixins/with_auth_user.js +2 -4
- package/build/src/mixins/with_mfa.js +2 -6
- package/build/src/mixins/with_personal_access_token.js +2 -4
- package/build/src/mixins/with_webauthn_credential.js +6 -8
- package/package.json +1 -1
|
@@ -1,26 +1,18 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import { DateTime } from 'luxon';
|
|
3
|
-
import { authenticator } from 'otplib';
|
|
4
1
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
|
|
10
|
-
}
|
|
11
|
-
/** Comparação de hashes hex resistente a timing. */
|
|
12
|
-
function hashesEqual(a, b) {
|
|
13
|
-
const ba = Buffer.from(a);
|
|
14
|
-
const bb = Buffer.from(b);
|
|
15
|
-
if (ba.length !== bb.length)
|
|
16
|
-
return false;
|
|
17
|
-
return timingSafeEqual(ba, bb);
|
|
18
|
-
}
|
|
2
|
+
import { buildCore } from './lucid_store/core.js';
|
|
3
|
+
import { buildMfa } from './lucid_store/mfa.js';
|
|
4
|
+
import { buildProviderIdentity } from './lucid_store/provider_identity.js';
|
|
5
|
+
import { buildWebauthn } from './lucid_store/webauthn.js';
|
|
19
6
|
/**
|
|
20
7
|
* Implementação default do {@link AccountStore} sobre um model Lucid composto
|
|
21
8
|
* de `withAuthUser()` + `withCredentials()` (+ opcionalmente `withMfa()`). O
|
|
22
9
|
* model carrega `connection`/`table` (app-específico) e, por convenção, uma
|
|
23
10
|
* coluna `fullName` (mapeada de `name`).
|
|
11
|
+
*
|
|
12
|
+
* Composição por CAPACIDADE: o núcleo + MFA são sempre montados; passkeys
|
|
13
|
+
* (WebAuthn) e account linking por provider só são montados quando o model
|
|
14
|
+
* correspondente é fornecido — caso contrário a capacidade fica ABSENTE (os
|
|
15
|
+
* métodos não existem no objeto retornado, em vez de presentes-mas-lançando).
|
|
24
16
|
*/
|
|
25
17
|
export function lucidAccountStore(Model, options = {}) {
|
|
26
18
|
const mfaIssuer = options.mfaIssuer ?? 'AuthKit';
|
|
@@ -42,355 +34,36 @@ export function lucidAccountStore(Model, options = {}) {
|
|
|
42
34
|
verifyAuthenticationResponse,
|
|
43
35
|
...options.webauthnCeremonies,
|
|
44
36
|
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (!stored)
|
|
66
|
-
return null;
|
|
67
|
-
if (!encrypter)
|
|
68
|
-
return stored;
|
|
69
|
-
return encrypter.decrypt(stored);
|
|
37
|
+
const ctx = {
|
|
38
|
+
Model,
|
|
39
|
+
mfaIssuer,
|
|
40
|
+
recoveryCodeCount,
|
|
41
|
+
// Encripta o segredo antes de persistir (no-op sem encrypter).
|
|
42
|
+
sealSecret: (secret) => (encrypter ? encrypter.encrypt(secret) : secret),
|
|
43
|
+
// Decripta o segredo armazenado; retorna null em falha/adulteração (no-op sem encrypter).
|
|
44
|
+
openSecret: (stored) => {
|
|
45
|
+
if (!stored)
|
|
46
|
+
return null;
|
|
47
|
+
if (!encrypter)
|
|
48
|
+
return stored;
|
|
49
|
+
return encrypter.decrypt(stored);
|
|
50
|
+
},
|
|
51
|
+
toAccount: (row) => ({
|
|
52
|
+
id: row.id,
|
|
53
|
+
email: row.email,
|
|
54
|
+
globalRoles: row.globalRoles ?? [],
|
|
55
|
+
name: row.fullName ?? undefined,
|
|
56
|
+
}),
|
|
70
57
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
globalRoles: row.globalRoles ?? [],
|
|
75
|
-
name: row.fullName ?? undefined,
|
|
76
|
-
});
|
|
58
|
+
// Núcleo + MFA são sempre presentes. Passkeys e provider-identity só entram
|
|
59
|
+
// quando o model correspondente foi fornecido (capacidade genuinamente ausente
|
|
60
|
+
// quando não configurada).
|
|
77
61
|
return {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return row ? toAccount(row) : null;
|
|
85
|
-
},
|
|
86
|
-
async verifyCredentials(email, password) {
|
|
87
|
-
const row = await Model.query().where('email', email).first();
|
|
88
|
-
if (!row || !(await row.verifyPassword(password)))
|
|
89
|
-
return null;
|
|
90
|
-
return toAccount(row);
|
|
91
|
-
},
|
|
92
|
-
async create(input) {
|
|
93
|
-
const row = await Model.create({
|
|
94
|
-
email: input.email,
|
|
95
|
-
password: input.password,
|
|
96
|
-
fullName: input.fullName ?? null,
|
|
97
|
-
globalRoles: input.globalRoles ?? [],
|
|
98
|
-
emailVerifiedAt: input.emailVerified ? DateTime.now() : null,
|
|
99
|
-
});
|
|
100
|
-
return toAccount(row);
|
|
101
|
-
},
|
|
102
|
-
async findByProviderIdentity(provider, providerUserId) {
|
|
103
|
-
const Identity = requireProviderIdentityModel();
|
|
104
|
-
const identity = await Identity.query()
|
|
105
|
-
.where('provider', provider)
|
|
106
|
-
.where('providerUserId', providerUserId)
|
|
107
|
-
.first();
|
|
108
|
-
if (!identity)
|
|
109
|
-
return null;
|
|
110
|
-
const row = await Model.find(identity.accountId);
|
|
111
|
-
return row ? toAccount(row) : null;
|
|
112
|
-
},
|
|
113
|
-
async linkProviderIdentity(data) {
|
|
114
|
-
const Identity = requireProviderIdentityModel();
|
|
115
|
-
// Upsert idempotente na chave única (provider, providerUserId): atualiza
|
|
116
|
-
// account/email se já existir, cria caso contrário.
|
|
117
|
-
const existing = await Identity.query()
|
|
118
|
-
.where('provider', data.provider)
|
|
119
|
-
.where('providerUserId', data.providerUserId)
|
|
120
|
-
.first();
|
|
121
|
-
if (existing) {
|
|
122
|
-
existing.accountId = data.accountId;
|
|
123
|
-
if (data.email !== undefined)
|
|
124
|
-
existing.email = data.email;
|
|
125
|
-
await existing.save();
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
await Identity.create({
|
|
129
|
-
provider: data.provider,
|
|
130
|
-
providerUserId: data.providerUserId,
|
|
131
|
-
accountId: data.accountId,
|
|
132
|
-
email: data.email ?? null,
|
|
133
|
-
});
|
|
134
|
-
},
|
|
135
|
-
async issuePasswordResetToken(email) {
|
|
136
|
-
const row = await Model.query().where('email', email).first();
|
|
137
|
-
if (!row)
|
|
138
|
-
return null;
|
|
139
|
-
const token = randomBytes(32).toString('hex');
|
|
140
|
-
row.passwordResetToken = token;
|
|
141
|
-
row.passwordResetExpiresAt = DateTime.now().plus({ hours: 1 });
|
|
142
|
-
await row.save();
|
|
143
|
-
return { token, account: toAccount(row) };
|
|
144
|
-
},
|
|
145
|
-
async consumePasswordResetToken(token, newPassword) {
|
|
146
|
-
const row = await Model.query().where('passwordResetToken', token).first();
|
|
147
|
-
if (!row)
|
|
148
|
-
return false;
|
|
149
|
-
if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
|
|
150
|
-
return false;
|
|
151
|
-
row.password = newPassword;
|
|
152
|
-
row.passwordResetToken = null;
|
|
153
|
-
row.passwordResetExpiresAt = null;
|
|
154
|
-
await row.save();
|
|
155
|
-
return true;
|
|
156
|
-
},
|
|
157
|
-
async issueEmailVerificationToken(email) {
|
|
158
|
-
const row = await Model.query().where('email', email).first();
|
|
159
|
-
if (!row)
|
|
160
|
-
return null;
|
|
161
|
-
const token = randomBytes(32).toString('hex');
|
|
162
|
-
row.emailVerificationToken = token;
|
|
163
|
-
await row.save();
|
|
164
|
-
return { token, account: toAccount(row) };
|
|
165
|
-
},
|
|
166
|
-
async consumeEmailVerificationToken(token) {
|
|
167
|
-
if (!token)
|
|
168
|
-
return false;
|
|
169
|
-
const row = await Model.query().where('emailVerificationToken', token).first();
|
|
170
|
-
if (!row)
|
|
171
|
-
return false;
|
|
172
|
-
row.emailVerifiedAt = DateTime.now();
|
|
173
|
-
row.emailVerificationToken = null;
|
|
174
|
-
await row.save();
|
|
175
|
-
return true;
|
|
176
|
-
},
|
|
177
|
-
// ----- Administração (console admin) -----
|
|
178
|
-
async listAccounts(params) {
|
|
179
|
-
const page = Math.max(1, params.page ?? 1);
|
|
180
|
-
const limit = Math.max(1, params.limit ?? 20);
|
|
181
|
-
const search = params.search?.trim();
|
|
182
|
-
const base = () => {
|
|
183
|
-
const q = Model.query();
|
|
184
|
-
// Filtro por e-mail (substring, case-insensitive). `whereILike` cai no LIKE
|
|
185
|
-
// no sqlite (case-insensitive por default p/ ASCII), e em ILIKE no Postgres.
|
|
186
|
-
if (search)
|
|
187
|
-
q.whereILike('email', `%${search}%`);
|
|
188
|
-
return q;
|
|
189
|
-
};
|
|
190
|
-
const countResult = await base().count('* as total');
|
|
191
|
-
// O shape do count varia por dialeto; lê de $extras.total (Lucid).
|
|
192
|
-
const total = Number(countResult[0]?.$extras?.total ?? 0);
|
|
193
|
-
const rows = await base()
|
|
194
|
-
.orderBy('email', 'asc')
|
|
195
|
-
.offset((page - 1) * limit)
|
|
196
|
-
.limit(limit);
|
|
197
|
-
return { data: rows.map(toAccount), total };
|
|
198
|
-
},
|
|
199
|
-
async setGlobalRoles(accountId, roles) {
|
|
200
|
-
const row = await Model.find(accountId);
|
|
201
|
-
if (!row)
|
|
202
|
-
return;
|
|
203
|
-
// A coluna `globalRoles` é serializada como JSON pelo mixin withAuthUser.
|
|
204
|
-
row.globalRoles = roles;
|
|
205
|
-
await row.save();
|
|
206
|
-
},
|
|
207
|
-
// ----- MFA / TOTP -----
|
|
208
|
-
async getMfaState(accountId) {
|
|
209
|
-
const row = await Model.find(accountId);
|
|
210
|
-
return { enabled: !!row?.mfaEnabledAt };
|
|
211
|
-
},
|
|
212
|
-
async startTotpEnrollment(accountId) {
|
|
213
|
-
const row = await Model.find(accountId);
|
|
214
|
-
if (!row)
|
|
215
|
-
return null;
|
|
216
|
-
const secret = authenticator.generateSecret();
|
|
217
|
-
// Segredo PENDENTE: armazenado (encriptado em repouso) mas mfaEnabledAt continua null.
|
|
218
|
-
row.totpSecret = sealSecret(secret);
|
|
219
|
-
row.mfaEnabledAt = null;
|
|
220
|
-
row.recoveryCodes = null;
|
|
221
|
-
await row.save();
|
|
222
|
-
const otpauthUri = authenticator.keyuri(row.email, mfaIssuer, secret);
|
|
223
|
-
return { secret, otpauthUri };
|
|
224
|
-
},
|
|
225
|
-
async confirmTotpEnrollment(accountId, code) {
|
|
226
|
-
const row = await Model.find(accountId);
|
|
227
|
-
if (!row || !row.totpSecret)
|
|
228
|
-
return { ok: false };
|
|
229
|
-
const secret = openSecret(row.totpSecret);
|
|
230
|
-
if (!secret)
|
|
231
|
-
return { ok: false };
|
|
232
|
-
// Só confirma a partir de um segredo pendente (não re-confirma um já ativo).
|
|
233
|
-
const valid = authenticator.verify({ token: String(code ?? ''), secret });
|
|
234
|
-
if (!valid)
|
|
235
|
-
return { ok: false };
|
|
236
|
-
const codes = Array.from({ length: recoveryCodeCount }, () => generateRecoveryCode());
|
|
237
|
-
row.mfaEnabledAt = DateTime.now();
|
|
238
|
-
row.recoveryCodes = codes.map(sha256);
|
|
239
|
-
await row.save();
|
|
240
|
-
return { ok: true, recoveryCodes: codes };
|
|
241
|
-
},
|
|
242
|
-
async verifyTotp(accountId, code) {
|
|
243
|
-
const row = await Model.find(accountId);
|
|
244
|
-
if (!row || !row.mfaEnabledAt || !row.totpSecret)
|
|
245
|
-
return false;
|
|
246
|
-
const secret = openSecret(row.totpSecret);
|
|
247
|
-
if (!secret)
|
|
248
|
-
return false;
|
|
249
|
-
return authenticator.verify({ token: String(code ?? ''), secret });
|
|
250
|
-
},
|
|
251
|
-
async consumeRecoveryCode(accountId, code) {
|
|
252
|
-
const row = await Model.find(accountId);
|
|
253
|
-
if (!row || !row.mfaEnabledAt || !Array.isArray(row.recoveryCodes))
|
|
254
|
-
return false;
|
|
255
|
-
const target = sha256(String(code ?? '').trim());
|
|
256
|
-
const remaining = row.recoveryCodes.filter((h) => !hashesEqual(h, target));
|
|
257
|
-
if (remaining.length === row.recoveryCodes.length)
|
|
258
|
-
return false; // nada casou
|
|
259
|
-
row.recoveryCodes = remaining;
|
|
260
|
-
await row.save();
|
|
261
|
-
return true;
|
|
262
|
-
},
|
|
263
|
-
async disableMfa(accountId) {
|
|
264
|
-
const row = await Model.find(accountId);
|
|
265
|
-
if (!row)
|
|
266
|
-
return;
|
|
267
|
-
row.totpSecret = null;
|
|
268
|
-
row.mfaEnabledAt = null;
|
|
269
|
-
row.recoveryCodes = null;
|
|
270
|
-
await row.save();
|
|
271
|
-
},
|
|
272
|
-
// ----- MFA / WebAuthn (passkeys) -----
|
|
273
|
-
async generatePasskeyRegistrationOptions(accountId) {
|
|
274
|
-
const Credential = requireWebauthnModel();
|
|
275
|
-
const row = await Model.find(accountId);
|
|
276
|
-
if (!row)
|
|
277
|
-
return null;
|
|
278
|
-
const existing = await Credential.query().where('accountId', accountId);
|
|
279
|
-
const options = await ceremonies.generateRegistrationOptions({
|
|
280
|
-
rpName: webauthn.rpName,
|
|
281
|
-
rpID: webauthn.rpId,
|
|
282
|
-
userName: row.email,
|
|
283
|
-
userDisplayName: row.fullName ?? row.email,
|
|
284
|
-
// Não pede attestation (privacidade); confia na verificação local.
|
|
285
|
-
attestationType: 'none',
|
|
286
|
-
// Evita registrar a mesma credencial duas vezes.
|
|
287
|
-
excludeCredentials: existing.map((c) => ({
|
|
288
|
-
id: c.id,
|
|
289
|
-
transports: (c.transports ?? undefined),
|
|
290
|
-
})),
|
|
291
|
-
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
|
|
292
|
-
});
|
|
293
|
-
return { options: options, challenge: options.challenge };
|
|
294
|
-
},
|
|
295
|
-
async verifyPasskeyRegistration(accountId, response, expectedChallenge) {
|
|
296
|
-
const Credential = requireWebauthnModel();
|
|
297
|
-
const row = await Model.find(accountId);
|
|
298
|
-
if (!row)
|
|
299
|
-
return false;
|
|
300
|
-
let verification;
|
|
301
|
-
try {
|
|
302
|
-
verification = await ceremonies.verifyRegistrationResponse({
|
|
303
|
-
response: response,
|
|
304
|
-
expectedChallenge,
|
|
305
|
-
expectedOrigin: webauthn.origin,
|
|
306
|
-
expectedRPID: webauthn.rpId,
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
return false;
|
|
311
|
-
}
|
|
312
|
-
if (!verification.verified || !verification.registrationInfo)
|
|
313
|
-
return false;
|
|
314
|
-
const { credential } = verification.registrationInfo;
|
|
315
|
-
// publicKey vem como Uint8Array → armazenamos como base64url (texto).
|
|
316
|
-
const publicKey = Buffer.from(credential.publicKey).toString('base64url');
|
|
317
|
-
await Credential.create({
|
|
318
|
-
id: credential.id,
|
|
319
|
-
accountId,
|
|
320
|
-
publicKey,
|
|
321
|
-
counter: credential.counter,
|
|
322
|
-
transports: credential.transports ?? null,
|
|
323
|
-
label: null,
|
|
324
|
-
});
|
|
325
|
-
// Registrar uma passkey também habilita o MFA (2º fator presente).
|
|
326
|
-
if (!row.mfaEnabledAt) {
|
|
327
|
-
row.mfaEnabledAt = DateTime.now();
|
|
328
|
-
await row.save();
|
|
329
|
-
}
|
|
330
|
-
return true;
|
|
331
|
-
},
|
|
332
|
-
async generatePasskeyAuthenticationOptions(accountId) {
|
|
333
|
-
const Credential = requireWebauthnModel();
|
|
334
|
-
const creds = await Credential.query().where('accountId', accountId);
|
|
335
|
-
if (creds.length === 0)
|
|
336
|
-
return null;
|
|
337
|
-
const options = await ceremonies.generateAuthenticationOptions({
|
|
338
|
-
rpID: webauthn.rpId,
|
|
339
|
-
allowCredentials: creds.map((c) => ({
|
|
340
|
-
id: c.id,
|
|
341
|
-
transports: (c.transports ?? undefined),
|
|
342
|
-
})),
|
|
343
|
-
userVerification: 'preferred',
|
|
344
|
-
});
|
|
345
|
-
return { options: options, challenge: options.challenge };
|
|
346
|
-
},
|
|
347
|
-
async verifyPasskeyAuthentication(accountId, response, expectedChallenge) {
|
|
348
|
-
const Credential = requireWebauthnModel();
|
|
349
|
-
const resp = response;
|
|
350
|
-
// O credential id vem na resposta (base64url) → acha a credencial da conta.
|
|
351
|
-
const cred = await Credential.query()
|
|
352
|
-
.where('accountId', accountId)
|
|
353
|
-
.where('id', resp?.id ?? '')
|
|
354
|
-
.first();
|
|
355
|
-
if (!cred)
|
|
356
|
-
return false;
|
|
357
|
-
let verification;
|
|
358
|
-
try {
|
|
359
|
-
verification = await ceremonies.verifyAuthenticationResponse({
|
|
360
|
-
response: resp,
|
|
361
|
-
expectedChallenge,
|
|
362
|
-
expectedOrigin: webauthn.origin,
|
|
363
|
-
expectedRPID: webauthn.rpId,
|
|
364
|
-
credential: {
|
|
365
|
-
id: cred.id,
|
|
366
|
-
publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')),
|
|
367
|
-
counter: cred.counter,
|
|
368
|
-
transports: (cred.transports ?? undefined),
|
|
369
|
-
},
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
catch {
|
|
373
|
-
return false;
|
|
374
|
-
}
|
|
375
|
-
if (!verification.verified)
|
|
376
|
-
return false;
|
|
377
|
-
// Atualiza o signature counter (anti-replay).
|
|
378
|
-
cred.counter = verification.authenticationInfo.newCounter;
|
|
379
|
-
await cred.save();
|
|
380
|
-
return true;
|
|
381
|
-
},
|
|
382
|
-
async listPasskeys(accountId) {
|
|
383
|
-
const Credential = requireWebauthnModel();
|
|
384
|
-
const creds = await Credential.query().where('accountId', accountId).orderBy('createdAt', 'asc');
|
|
385
|
-
return creds.map((c) => ({
|
|
386
|
-
id: c.id,
|
|
387
|
-
label: c.label ?? undefined,
|
|
388
|
-
createdAt: c.createdAt?.toISO?.() ?? String(c.createdAt ?? ''),
|
|
389
|
-
}));
|
|
390
|
-
},
|
|
391
|
-
async removePasskey(accountId, credentialId) {
|
|
392
|
-
const Credential = requireWebauthnModel();
|
|
393
|
-
await Credential.query().where('accountId', accountId).where('id', credentialId).delete();
|
|
394
|
-
},
|
|
62
|
+
...buildCore(ctx),
|
|
63
|
+
...buildMfa(ctx),
|
|
64
|
+
...(ProviderIdentityModel ? buildProviderIdentity(ctx, ProviderIdentityModel) : {}),
|
|
65
|
+
...(WebauthnCredentialModel
|
|
66
|
+
? buildWebauthn(ctx, WebauthnCredentialModel, webauthn, ceremonies)
|
|
67
|
+
: {}),
|
|
395
68
|
};
|
|
396
69
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CoreAccountStore } from '../account_store.js';
|
|
2
|
+
import type { LucidStoreContext } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
|
|
5
|
+
* identidade, cadastro, reset de senha, verificação de e-mail e administração
|
|
6
|
+
* (listagem paginada + roles globais).
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
/**
|
|
4
|
+
* Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
|
|
5
|
+
* identidade, cadastro, reset de senha, verificação de e-mail e administração
|
|
6
|
+
* (listagem paginada + roles globais).
|
|
7
|
+
*/
|
|
8
|
+
export function buildCore(ctx) {
|
|
9
|
+
const { Model, toAccount } = ctx;
|
|
10
|
+
return {
|
|
11
|
+
async findById(id) {
|
|
12
|
+
const row = await Model.find(id);
|
|
13
|
+
return row ? toAccount(row) : null;
|
|
14
|
+
},
|
|
15
|
+
async findByEmail(email) {
|
|
16
|
+
const row = await Model.query().where('email', email).first();
|
|
17
|
+
return row ? toAccount(row) : null;
|
|
18
|
+
},
|
|
19
|
+
async verifyCredentials(email, password) {
|
|
20
|
+
const row = await Model.query().where('email', email).first();
|
|
21
|
+
if (!row || !(await row.verifyPassword(password)))
|
|
22
|
+
return null;
|
|
23
|
+
return toAccount(row);
|
|
24
|
+
},
|
|
25
|
+
async create(input) {
|
|
26
|
+
const row = await Model.create({
|
|
27
|
+
email: input.email,
|
|
28
|
+
password: input.password,
|
|
29
|
+
fullName: input.fullName ?? null,
|
|
30
|
+
globalRoles: input.globalRoles ?? [],
|
|
31
|
+
emailVerifiedAt: input.emailVerified ? DateTime.now() : null,
|
|
32
|
+
});
|
|
33
|
+
return toAccount(row);
|
|
34
|
+
},
|
|
35
|
+
async issuePasswordResetToken(email) {
|
|
36
|
+
const row = await Model.query().where('email', email).first();
|
|
37
|
+
if (!row)
|
|
38
|
+
return null;
|
|
39
|
+
const token = randomBytes(32).toString('hex');
|
|
40
|
+
row.passwordResetToken = token;
|
|
41
|
+
row.passwordResetExpiresAt = DateTime.now().plus({ hours: 1 });
|
|
42
|
+
await row.save();
|
|
43
|
+
return { token, account: toAccount(row) };
|
|
44
|
+
},
|
|
45
|
+
async consumePasswordResetToken(token, newPassword) {
|
|
46
|
+
const row = await Model.query().where('passwordResetToken', token).first();
|
|
47
|
+
if (!row)
|
|
48
|
+
return false;
|
|
49
|
+
if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
|
|
50
|
+
return false;
|
|
51
|
+
row.password = newPassword;
|
|
52
|
+
row.passwordResetToken = null;
|
|
53
|
+
row.passwordResetExpiresAt = null;
|
|
54
|
+
await row.save();
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
async issueEmailVerificationToken(email) {
|
|
58
|
+
const row = await Model.query().where('email', email).first();
|
|
59
|
+
if (!row)
|
|
60
|
+
return null;
|
|
61
|
+
const token = randomBytes(32).toString('hex');
|
|
62
|
+
row.emailVerificationToken = token;
|
|
63
|
+
await row.save();
|
|
64
|
+
return { token, account: toAccount(row) };
|
|
65
|
+
},
|
|
66
|
+
async consumeEmailVerificationToken(token) {
|
|
67
|
+
if (!token)
|
|
68
|
+
return false;
|
|
69
|
+
const row = await Model.query().where('emailVerificationToken', token).first();
|
|
70
|
+
if (!row)
|
|
71
|
+
return false;
|
|
72
|
+
row.emailVerifiedAt = DateTime.now();
|
|
73
|
+
row.emailVerificationToken = null;
|
|
74
|
+
await row.save();
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
// ----- Administração (console admin) -----
|
|
78
|
+
async listAccounts(params) {
|
|
79
|
+
const page = Math.max(1, params.page ?? 1);
|
|
80
|
+
const limit = Math.max(1, params.limit ?? 20);
|
|
81
|
+
const search = params.search?.trim();
|
|
82
|
+
const base = () => {
|
|
83
|
+
const q = Model.query();
|
|
84
|
+
// Filtro por e-mail (substring, case-insensitive). `whereILike` cai no LIKE
|
|
85
|
+
// no sqlite (case-insensitive por default p/ ASCII), e em ILIKE no Postgres.
|
|
86
|
+
if (search)
|
|
87
|
+
q.whereILike('email', `%${search}%`);
|
|
88
|
+
return q;
|
|
89
|
+
};
|
|
90
|
+
const countResult = await base().count('* as total');
|
|
91
|
+
// O shape do count varia por dialeto; lê de $extras.total (Lucid).
|
|
92
|
+
const total = Number(countResult[0]?.$extras?.total ?? 0);
|
|
93
|
+
const rows = await base()
|
|
94
|
+
.orderBy('email', 'asc')
|
|
95
|
+
.offset((page - 1) * limit)
|
|
96
|
+
.limit(limit);
|
|
97
|
+
return { data: rows.map(toAccount), total };
|
|
98
|
+
},
|
|
99
|
+
async setGlobalRoles(accountId, roles) {
|
|
100
|
+
const row = await Model.find(accountId);
|
|
101
|
+
if (!row)
|
|
102
|
+
return;
|
|
103
|
+
// A coluna `globalRoles` é serializada como JSON pelo mixin withAuthUser.
|
|
104
|
+
row.globalRoles = roles;
|
|
105
|
+
await row.save();
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MfaCapability } from '../account_store.js';
|
|
2
|
+
import { type LucidStoreContext } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Capacidade de MFA / TOTP sobre o model principal (composto de `withMfa()`).
|
|
5
|
+
* Sempre presente no {@link lucidAccountStore} — o model carrega as colunas
|
|
6
|
+
* `totp_secret`/`mfa_enabled_at`/`recovery_codes`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildMfa(ctx: LucidStoreContext): MfaCapability;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { authenticator } from 'otplib';
|
|
3
|
+
import { generateRecoveryCode, hashesEqual, sha256 } from './shared.js';
|
|
4
|
+
/**
|
|
5
|
+
* Capacidade de MFA / TOTP sobre o model principal (composto de `withMfa()`).
|
|
6
|
+
* Sempre presente no {@link lucidAccountStore} — o model carrega as colunas
|
|
7
|
+
* `totp_secret`/`mfa_enabled_at`/`recovery_codes`.
|
|
8
|
+
*/
|
|
9
|
+
export function buildMfa(ctx) {
|
|
10
|
+
const { Model, mfaIssuer, recoveryCodeCount, sealSecret, openSecret } = ctx;
|
|
11
|
+
return {
|
|
12
|
+
async getMfaState(accountId) {
|
|
13
|
+
const row = await Model.find(accountId);
|
|
14
|
+
return { enabled: !!row?.mfaEnabledAt };
|
|
15
|
+
},
|
|
16
|
+
async startTotpEnrollment(accountId) {
|
|
17
|
+
const row = await Model.find(accountId);
|
|
18
|
+
if (!row)
|
|
19
|
+
return null;
|
|
20
|
+
const secret = authenticator.generateSecret();
|
|
21
|
+
// Segredo PENDENTE: armazenado (encriptado em repouso) mas mfaEnabledAt continua null.
|
|
22
|
+
row.totpSecret = sealSecret(secret);
|
|
23
|
+
row.mfaEnabledAt = null;
|
|
24
|
+
row.recoveryCodes = null;
|
|
25
|
+
await row.save();
|
|
26
|
+
const otpauthUri = authenticator.keyuri(row.email, mfaIssuer, secret);
|
|
27
|
+
return { secret, otpauthUri };
|
|
28
|
+
},
|
|
29
|
+
async confirmTotpEnrollment(accountId, code) {
|
|
30
|
+
const row = await Model.find(accountId);
|
|
31
|
+
if (!row || !row.totpSecret)
|
|
32
|
+
return { ok: false };
|
|
33
|
+
const secret = openSecret(row.totpSecret);
|
|
34
|
+
if (!secret)
|
|
35
|
+
return { ok: false };
|
|
36
|
+
// Só confirma a partir de um segredo pendente (não re-confirma um já ativo).
|
|
37
|
+
const valid = authenticator.verify({ token: String(code ?? ''), secret });
|
|
38
|
+
if (!valid)
|
|
39
|
+
return { ok: false };
|
|
40
|
+
const codes = Array.from({ length: recoveryCodeCount }, () => generateRecoveryCode());
|
|
41
|
+
row.mfaEnabledAt = DateTime.now();
|
|
42
|
+
row.recoveryCodes = codes.map(sha256);
|
|
43
|
+
await row.save();
|
|
44
|
+
return { ok: true, recoveryCodes: codes };
|
|
45
|
+
},
|
|
46
|
+
async verifyTotp(accountId, code) {
|
|
47
|
+
const row = await Model.find(accountId);
|
|
48
|
+
if (!row || !row.mfaEnabledAt || !row.totpSecret)
|
|
49
|
+
return false;
|
|
50
|
+
const secret = openSecret(row.totpSecret);
|
|
51
|
+
if (!secret)
|
|
52
|
+
return false;
|
|
53
|
+
return authenticator.verify({ token: String(code ?? ''), secret });
|
|
54
|
+
},
|
|
55
|
+
async consumeRecoveryCode(accountId, code) {
|
|
56
|
+
const row = await Model.find(accountId);
|
|
57
|
+
if (!row || !row.mfaEnabledAt || !Array.isArray(row.recoveryCodes))
|
|
58
|
+
return false;
|
|
59
|
+
const target = sha256(String(code ?? '').trim());
|
|
60
|
+
const remaining = row.recoveryCodes.filter((h) => !hashesEqual(h, target));
|
|
61
|
+
if (remaining.length === row.recoveryCodes.length)
|
|
62
|
+
return false; // nada casou
|
|
63
|
+
row.recoveryCodes = remaining;
|
|
64
|
+
await row.save();
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
67
|
+
async disableMfa(accountId) {
|
|
68
|
+
const row = await Model.find(accountId);
|
|
69
|
+
if (!row)
|
|
70
|
+
return;
|
|
71
|
+
row.totpSecret = null;
|
|
72
|
+
row.mfaEnabledAt = null;
|
|
73
|
+
row.recoveryCodes = null;
|
|
74
|
+
await row.save();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ProviderIdentityCapability } from '../account_store.js';
|
|
2
|
+
import type { LucidStoreContext } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Capacidade de account linking por identidade de provider (Google, GitHub, …).
|
|
5
|
+
* Só é montada quando o `providerIdentityModel` é fornecido — quando ausente, a
|
|
6
|
+
* capacidade inteira fica ABSENTE do store (sem método presente-mas-lançando).
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildProviderIdentity(ctx: LucidStoreContext, ProviderIdentityModel: any): ProviderIdentityCapability;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capacidade de account linking por identidade de provider (Google, GitHub, …).
|
|
3
|
+
* Só é montada quando o `providerIdentityModel` é fornecido — quando ausente, a
|
|
4
|
+
* capacidade inteira fica ABSENTE do store (sem método presente-mas-lançando).
|
|
5
|
+
*/
|
|
6
|
+
export function buildProviderIdentity(ctx, ProviderIdentityModel) {
|
|
7
|
+
const { Model, toAccount } = ctx;
|
|
8
|
+
return {
|
|
9
|
+
async findByProviderIdentity(provider, providerUserId) {
|
|
10
|
+
const identity = await ProviderIdentityModel.query()
|
|
11
|
+
.where('provider', provider)
|
|
12
|
+
.where('providerUserId', providerUserId)
|
|
13
|
+
.first();
|
|
14
|
+
if (!identity)
|
|
15
|
+
return null;
|
|
16
|
+
const row = await Model.find(identity.accountId);
|
|
17
|
+
return row ? toAccount(row) : null;
|
|
18
|
+
},
|
|
19
|
+
async linkProviderIdentity(data) {
|
|
20
|
+
// Upsert idempotente na chave única (provider, providerUserId): atualiza
|
|
21
|
+
// account/email se já existir, cria caso contrário.
|
|
22
|
+
const existing = await ProviderIdentityModel.query()
|
|
23
|
+
.where('provider', data.provider)
|
|
24
|
+
.where('providerUserId', data.providerUserId)
|
|
25
|
+
.first();
|
|
26
|
+
if (existing) {
|
|
27
|
+
existing.accountId = data.accountId;
|
|
28
|
+
if (data.email !== undefined)
|
|
29
|
+
existing.email = data.email;
|
|
30
|
+
await existing.save();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await ProviderIdentityModel.create({
|
|
34
|
+
provider: data.provider,
|
|
35
|
+
providerUserId: data.providerUserId,
|
|
36
|
+
accountId: data.accountId,
|
|
37
|
+
email: data.email ?? null,
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|