@adonis-agora/authkit-server 0.70.0 → 0.72.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/commands/commands.json +18 -0
- package/build/commands/import_users.js +1 -6
- package/build/commands/normalize_emails.d.ts +21 -0
- package/build/commands/normalize_emails.js +106 -0
- package/build/host/views/mfa-challenge.edge +8 -3
- package/build/index.d.ts +3 -4
- package/build/index.js +2 -2
- package/build/src/accounts/account_store.d.ts +33 -1
- package/build/src/accounts/account_store.js +4 -0
- package/build/src/accounts/lucid_store/core.d.ts +2 -2
- package/build/src/accounts/lucid_store/core.js +16 -0
- package/build/src/accounts/lucid_store/mfa.js +9 -1
- package/build/src/commands/import_users.d.ts +0 -7
- package/build/src/commands/import_users.js +5 -12
- package/build/src/commands/normalize_emails.d.ts +78 -0
- package/build/src/commands/normalize_emails.js +129 -0
- package/build/src/define_config.d.ts +0 -16
- package/build/src/define_config.js +0 -1
- package/build/src/host/admin_api/admin_users_service.js +5 -7
- package/build/src/host/controllers/account_session_controller.js +6 -11
- package/build/src/host/controllers/interaction_controller.d.ts +33 -5
- package/build/src/host/controllers/interaction_controller.js +154 -124
- package/build/src/host/controllers/registration_controller.js +11 -27
- package/build/src/host/controllers/social_controller.js +5 -7
- package/build/src/host/email_identifier.d.ts +7 -70
- package/build/src/host/email_identifier.js +7 -210
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
|
4
4
|
import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
|
|
5
5
|
import { brandFor, isFirstParty } from '../branding.js';
|
|
6
6
|
import { sendMagicLinkEmail, sendOtpUnlockEmail } from '../default_mailer.js';
|
|
7
|
-
import {
|
|
7
|
+
import { normalizeEmailIdentifier } from '../email_identifier.js';
|
|
8
8
|
import { translate } from '../i18n.js';
|
|
9
9
|
import { assertLoginAllowed, attemptPasswordLogin, isEmailUnverifiedBlock, } from '../login_attempt.js';
|
|
10
10
|
import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
|
|
@@ -43,30 +43,19 @@ function accountStatusErrorKey(reason) {
|
|
|
43
43
|
* para que a canonicalização não vire um oráculo de "esta conta existe".
|
|
44
44
|
*/
|
|
45
45
|
const SESSION_KEY = 'authkit_login_email';
|
|
46
|
-
/**
|
|
47
|
-
* E-mail sob o qual a conta está GRAVADA, quando a ponte de compatibilidade
|
|
48
|
-
* precisou de uma forma diferente da digitada ({@link resolveEmailIdentifier}).
|
|
49
|
-
* É o e-mail que vai para o account store em TODA busca/emissão de token.
|
|
50
|
-
* Ausente = igual ao digitado.
|
|
51
|
-
*/
|
|
52
|
-
const SESSION_LOOKUP_KEY = 'authkit_login_email_lookup';
|
|
53
46
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
54
47
|
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
55
48
|
/** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
|
|
56
49
|
const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
|
|
57
50
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
51
|
+
* Esquece o e-mail do login.
|
|
52
|
+
*
|
|
53
|
+
* Helper de UMA linha de propósito: os fluxos que encerram o passo de login
|
|
54
|
+
* (sucesso, "usar outro e-mail", expiração) chamam ISTO em vez de `forget` da
|
|
55
|
+
* chave crua, para que nenhum caminho deixe o e-mail pendurado na sessão.
|
|
61
56
|
*/
|
|
62
|
-
function lookupEmail(ctx) {
|
|
63
|
-
return (ctx.session.get(SESSION_LOOKUP_KEY) ??
|
|
64
|
-
ctx.session.get(SESSION_KEY));
|
|
65
|
-
}
|
|
66
|
-
/** Esquece o e-mail do login (digitado + forma de busca) de uma vez só. */
|
|
67
57
|
function forgetLoginEmail(ctx) {
|
|
68
58
|
ctx.session.forget(SESSION_KEY);
|
|
69
|
-
ctx.session.forget(SESSION_LOOKUP_KEY);
|
|
70
59
|
}
|
|
71
60
|
export default class AuthInteractionController {
|
|
72
61
|
/**
|
|
@@ -188,8 +177,6 @@ export default class AuthInteractionController {
|
|
|
188
177
|
});
|
|
189
178
|
}
|
|
190
179
|
const email = ctx.session.get(SESSION_KEY);
|
|
191
|
-
// O que a tela mostra é `email` (o digitado); o que busca a conta é este.
|
|
192
|
-
const storeEmail = lookupEmail(ctx);
|
|
193
180
|
// Métodos de login efetivos (magic link, OTP, social) — account-independent
|
|
194
181
|
// neste ponto (passkey-first depende da conta, resolvido no step 2). Reusa o
|
|
195
182
|
// helper compartilhado por TODOS os renders do passo login, para que
|
|
@@ -199,7 +186,7 @@ export default class AuthInteractionController {
|
|
|
199
186
|
// Com email na sessão (passo 2+), aplica a preferência POR USUÁRIO — os
|
|
200
187
|
// métodos que o dono da conta desligou não aparecem nem aqui nem nos POSTs.
|
|
201
188
|
const loginMethods = email
|
|
202
|
-
? await this.#userScopedLoginMethods(ctx, cfg,
|
|
189
|
+
? await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettingsForMaintenance)
|
|
203
190
|
: await this.#loginMethods(ctx, cfg, runtimeSettingsForMaintenance);
|
|
204
191
|
const authMethods = loginMethods.authMethods;
|
|
205
192
|
if (!email) {
|
|
@@ -216,7 +203,7 @@ export default class AuthInteractionController {
|
|
|
216
203
|
});
|
|
217
204
|
}
|
|
218
205
|
// Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
|
|
219
|
-
const acc = await cfg.accountStore.findByEmail(
|
|
206
|
+
const acc = await cfg.accountStore.findByEmail(email);
|
|
220
207
|
const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
|
|
221
208
|
// Passwordless: magic link disponível vem do helper (`loginMethods`).
|
|
222
209
|
// Passkey-first disponível se ligado, o store suporta, a conta tem passkeys E auth_methods permite.
|
|
@@ -249,24 +236,17 @@ export default class AuthInteractionController {
|
|
|
249
236
|
* `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
|
|
250
237
|
* no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
|
|
251
238
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
239
|
+
* À PROVA DE ENUMERAÇÃO: o passo NÃO toca no account store — só normaliza e
|
|
240
|
+
* guarda. A resposta é o MESMO redirect incondicional, exista a conta ou não,
|
|
241
|
+
* e não há busca alguma aqui para diferenciar os dois casos nem no tempo de
|
|
242
|
+
* resposta. Quem decide se a conta existe é o passo 2, que renderiza a mesma
|
|
243
|
+
* tela nos dois casos.
|
|
257
244
|
*/
|
|
258
245
|
async identifier(ctx) {
|
|
259
|
-
const service = await ctx.containerResolver.make('authkit.server');
|
|
260
|
-
const cfg = service.config;
|
|
261
246
|
const { email } = ctx.request.only(['email']);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
ctx.session.put(SESSION_KEY, resolved.email);
|
|
266
|
-
if (resolved.viaLegacyFallback)
|
|
267
|
-
ctx.session.put(SESSION_LOOKUP_KEY, resolved.lookupEmail);
|
|
268
|
-
else
|
|
269
|
-
ctx.session.forget(SESSION_LOOKUP_KEY);
|
|
247
|
+
// `request.only` não valida tipo (`email[]=x` chega array): a normalização
|
|
248
|
+
// devolve string vazia para não-string, e o passo segue com o redirect.
|
|
249
|
+
ctx.session.put(SESSION_KEY, normalizeEmailIdentifier(email));
|
|
270
250
|
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
271
251
|
}
|
|
272
252
|
/**
|
|
@@ -284,8 +264,6 @@ export default class AuthInteractionController {
|
|
|
284
264
|
// Session expired or tampered — send back to step 1
|
|
285
265
|
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
286
266
|
}
|
|
287
|
-
// `email` é o digitado (o que a tela mostra); `storeEmail` é o que busca a conta.
|
|
288
|
-
const storeEmail = lookupEmail(ctx) ?? email;
|
|
289
267
|
const { password } = ctx.request.only(['password']);
|
|
290
268
|
const ip = ctx.request.ip?.() ?? null;
|
|
291
269
|
const clientId = details.params.client_id ?? null;
|
|
@@ -302,15 +280,15 @@ export default class AuthInteractionController {
|
|
|
302
280
|
// We use a cfg-compatible object: pass effectiveBotLogin merged into cfg.
|
|
303
281
|
const cfgWithEffectiveBot = effectiveBotLogin !== cfg.botProtection ? { ...cfg, botProtection: effectiveBotLogin } : cfg;
|
|
304
282
|
if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', {
|
|
305
|
-
email
|
|
283
|
+
email,
|
|
306
284
|
clientId,
|
|
307
285
|
}))) {
|
|
308
|
-
const found = await cfg.accountStore.findByEmail(
|
|
286
|
+
const found = await cfg.accountStore.findByEmail(email);
|
|
309
287
|
const account = found
|
|
310
288
|
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
311
289
|
: null;
|
|
312
290
|
return render(ctx, 'login', {
|
|
313
|
-
...(await this.#userScopedLoginMethods(ctx, cfg,
|
|
291
|
+
...(await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettings)),
|
|
314
292
|
uid: ctx.request.param('uid'),
|
|
315
293
|
csrfToken: ctx.request.csrfToken,
|
|
316
294
|
step: 'password',
|
|
@@ -324,9 +302,9 @@ export default class AuthInteractionController {
|
|
|
324
302
|
// Preferência por usuário: senha desligada para ESTA conta → recusa ANTES de
|
|
325
303
|
// verificar credenciais (mesma tela/erro de credencial inválida — não vaza
|
|
326
304
|
// se a conta existe nem qual preferência está gravada).
|
|
327
|
-
const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg,
|
|
305
|
+
const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettings);
|
|
328
306
|
if (!userMethodsForLogin.authMethods.password) {
|
|
329
|
-
const found = await cfg.accountStore.findByEmail(
|
|
307
|
+
const found = await cfg.accountStore.findByEmail(email);
|
|
330
308
|
const account = found
|
|
331
309
|
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
332
310
|
: null;
|
|
@@ -355,7 +333,7 @@ export default class AuthInteractionController {
|
|
|
355
333
|
// ainda. A sequência verificação + lockout + auditoria de falha é centralizada
|
|
356
334
|
// em attemptPasswordLogin; a renderização (lookup p/ personalização) fica aqui.
|
|
357
335
|
const result = await attemptPasswordLogin(cfg, {
|
|
358
|
-
email
|
|
336
|
+
email,
|
|
359
337
|
password,
|
|
360
338
|
ip,
|
|
361
339
|
clientId,
|
|
@@ -379,7 +357,7 @@ export default class AuthInteractionController {
|
|
|
379
357
|
botProtection: undefined,
|
|
380
358
|
});
|
|
381
359
|
}
|
|
382
|
-
const found = await cfg.accountStore.findByEmail(
|
|
360
|
+
const found = await cfg.accountStore.findByEmail(email);
|
|
383
361
|
const account = found
|
|
384
362
|
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
385
363
|
: null;
|
|
@@ -427,55 +405,24 @@ export default class AuthInteractionController {
|
|
|
427
405
|
}
|
|
428
406
|
// Admin: prossegue normalmente (sem bloqueio).
|
|
429
407
|
}
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
|
|
446
|
-
noEnrollment: true,
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
// Trusted device: se o mecanismo está ligado, a conta JÁ tem MFA enrolado e
|
|
450
|
-
// o request NÃO é um step-up (que sempre força o MFA), um cookie de confiança
|
|
451
|
-
// válido para ESTA conta pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
|
|
452
|
-
if (cfg.trustedDevices.enabled && mfa.enabled && !mfaRequired) {
|
|
453
|
-
const trusted = await this.checkTrustedDevice(ctx, acc.id, mfa.enabledAt ?? null);
|
|
454
|
-
if (trusted) {
|
|
455
|
-
await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
|
|
456
|
-
await notifyLoginSuccess(ctx, cfg, {
|
|
457
|
-
accountId: acc.id,
|
|
458
|
-
email,
|
|
459
|
-
ip,
|
|
460
|
-
clientId,
|
|
461
|
-
trustedDevice: true,
|
|
462
|
-
});
|
|
463
|
-
forgetLoginEmail(ctx);
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
ctx.session.put(MFA_PENDING_KEY, acc.id);
|
|
468
|
-
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
469
|
-
// tem ao menos uma credencial registrada.
|
|
470
|
-
const passkeyAvailable = await this.hasPasskeys(cfg, acc.id);
|
|
471
|
-
return render(ctx, 'mfa-challenge', {
|
|
472
|
-
uid: ctx.request.param('uid'),
|
|
473
|
-
csrfToken: ctx.request.csrfToken,
|
|
474
|
-
brand,
|
|
475
|
-
passkeyAvailable,
|
|
476
|
-
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
477
|
-
trustedDeviceDays: cfg.trustedDevices.days,
|
|
408
|
+
// Gate do 2º fator — a MESMA regra dos três caminhos de login (ver
|
|
409
|
+
// `secondFactorGate`). Quando desafia, não finaliza a interaction: guarda o
|
|
410
|
+
// accountId pendente e devolve a tela.
|
|
411
|
+
const gate = await this.secondFactorGate(ctx, cfg, acc.id, details);
|
|
412
|
+
if (gate.kind === 'challenge')
|
|
413
|
+
return gate.response;
|
|
414
|
+
if (gate.kind === 'trusted') {
|
|
415
|
+
// Dispositivo confiável: pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
|
|
416
|
+
await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
|
|
417
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
418
|
+
accountId: acc.id,
|
|
419
|
+
email,
|
|
420
|
+
ip,
|
|
421
|
+
clientId,
|
|
422
|
+
trustedDevice: true,
|
|
478
423
|
});
|
|
424
|
+
forgetLoginEmail(ctx);
|
|
425
|
+
return;
|
|
479
426
|
}
|
|
480
427
|
// Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
|
|
481
428
|
// Resolve session_policy para remember-me e single-session.
|
|
@@ -553,6 +500,7 @@ export default class AuthInteractionController {
|
|
|
553
500
|
error: translate(cfg.messages, 'errors.otp_locked'),
|
|
554
501
|
brand,
|
|
555
502
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
503
|
+
totpAvailable: await this.hasTotp(cfg, accountId),
|
|
556
504
|
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
557
505
|
trustedDeviceDays: cfg.trustedDevices.days,
|
|
558
506
|
otpLocked: true,
|
|
@@ -586,6 +534,7 @@ export default class AuthInteractionController {
|
|
|
586
534
|
error: translate(cfg.messages, 'errors.otp_locked'),
|
|
587
535
|
brand,
|
|
588
536
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
537
|
+
totpAvailable: await this.hasTotp(cfg, accountId),
|
|
589
538
|
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
590
539
|
trustedDeviceDays: cfg.trustedDevices.days,
|
|
591
540
|
otpLocked: true,
|
|
@@ -597,6 +546,7 @@ export default class AuthInteractionController {
|
|
|
597
546
|
error: translate(cfg.messages, 'errors.invalid_code'),
|
|
598
547
|
brand,
|
|
599
548
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
549
|
+
totpAvailable: await this.hasTotp(cfg, accountId),
|
|
600
550
|
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
601
551
|
trustedDeviceDays: cfg.trustedDevices.days,
|
|
602
552
|
});
|
|
@@ -641,6 +591,84 @@ export default class AuthInteractionController {
|
|
|
641
591
|
return undefined;
|
|
642
592
|
return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
|
|
643
593
|
}
|
|
594
|
+
/**
|
|
595
|
+
* Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
|
|
596
|
+
* mágico, código por e-mail e troca forçada de senha. Antes disto só o login
|
|
597
|
+
* por senha passava pelo MFA — link e código completavam a interaction direto,
|
|
598
|
+
* então uma conta com TOTP ou passkey entrava apresentando só o e-mail e o
|
|
599
|
+
* segundo fator virava enfeite.
|
|
600
|
+
*
|
|
601
|
+
* A pergunta que o gate responde é "existe um fator que esta pessoa consegue
|
|
602
|
+
* apresentar AGORA?", e não `mfa.enabled` — que liga ao registrar uma passkey
|
|
603
|
+
* e não desliga ao remover a última. Fator utilizável = TOTP confirmado
|
|
604
|
+
* (`mfa.totp`) OU ao menos uma passkey registrada. Pela regra antiga, quem
|
|
605
|
+
* registrava uma passkey e depois a removia ficava preso num desafio TOTP que
|
|
606
|
+
* não tinha como responder.
|
|
607
|
+
*
|
|
608
|
+
* - sem fator e sem step-up → `none`, o login segue;
|
|
609
|
+
* - step-up (acr) exigindo MFA numa conta sem fator → `challenge` com a
|
|
610
|
+
* instrução de enrolar (não há o que desafiar);
|
|
611
|
+
* - fator presente, trusted-device válido e sem step-up → `trusted`; quem
|
|
612
|
+
* chamou decide como finalizar, porque o amr muda conforme o caminho;
|
|
613
|
+
* - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY`.
|
|
614
|
+
*/
|
|
615
|
+
async secondFactorGate(ctx, cfg, accountId, details) {
|
|
616
|
+
const mfaRequired = this.acrRequiresMfa(cfg, details);
|
|
617
|
+
const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
|
|
618
|
+
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
619
|
+
// tem ao menos uma credencial registrada.
|
|
620
|
+
const passkeyAvailable = await this.hasPasskeys(cfg, accountId);
|
|
621
|
+
// Store que não reporta `totp` (a chave é opcional) cai no `enabled` — o
|
|
622
|
+
// comportamento antigo. Nunca o contrário: assumir "sem TOTP" por omissão
|
|
623
|
+
// deixaria de desafiar quem tem o fator.
|
|
624
|
+
const totpAvailable = mfa.totp ?? !!mfa.enabled;
|
|
625
|
+
const hasFactor = passkeyAvailable || totpAvailable;
|
|
626
|
+
if (!hasFactor && !mfaRequired)
|
|
627
|
+
return { kind: 'none' };
|
|
628
|
+
const render = cfg.render;
|
|
629
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
630
|
+
const uid = ctx.request.param('uid');
|
|
631
|
+
if (!hasFactor) {
|
|
632
|
+
return {
|
|
633
|
+
kind: 'challenge',
|
|
634
|
+
response: await render(ctx, 'mfa-challenge', {
|
|
635
|
+
uid,
|
|
636
|
+
csrfToken: ctx.request.csrfToken,
|
|
637
|
+
brand,
|
|
638
|
+
passkeyAvailable: false,
|
|
639
|
+
error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
|
|
640
|
+
noEnrollment: true,
|
|
641
|
+
}),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (cfg.trustedDevices.enabled && !mfaRequired) {
|
|
645
|
+
const trusted = await this.checkTrustedDevice(ctx, accountId, mfa.enabledAt ?? null);
|
|
646
|
+
if (trusted)
|
|
647
|
+
return { kind: 'trusted' };
|
|
648
|
+
}
|
|
649
|
+
ctx.session.put(MFA_PENDING_KEY, accountId);
|
|
650
|
+
return {
|
|
651
|
+
kind: 'challenge',
|
|
652
|
+
response: await render(ctx, 'mfa-challenge', {
|
|
653
|
+
uid,
|
|
654
|
+
csrfToken: ctx.request.csrfToken,
|
|
655
|
+
brand,
|
|
656
|
+
passkeyAvailable,
|
|
657
|
+
totpAvailable,
|
|
658
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
659
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
660
|
+
}),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* true se a conta tem um app autenticador CONFIRMADO. Stores que não reportam
|
|
665
|
+
* `totp` (a chave é opcional em {@link MfaCapability}) caem no `enabled`, o
|
|
666
|
+
* comportamento antigo.
|
|
667
|
+
*/
|
|
668
|
+
async hasTotp(cfg, accountId) {
|
|
669
|
+
const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
|
|
670
|
+
return mfa.totp ?? !!mfa.enabled;
|
|
671
|
+
}
|
|
644
672
|
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
645
673
|
async hasPasskeys(cfg, accountId) {
|
|
646
674
|
if (!supportsPasskeys(cfg.accountStore))
|
|
@@ -728,7 +756,7 @@ export default class AuthInteractionController {
|
|
|
728
756
|
return pending;
|
|
729
757
|
if (!cfg.passwordless?.passkeyFirst)
|
|
730
758
|
return undefined;
|
|
731
|
-
const email =
|
|
759
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
732
760
|
if (!email)
|
|
733
761
|
return undefined;
|
|
734
762
|
const acc = await cfg.accountStore.findByEmail(email);
|
|
@@ -749,9 +777,6 @@ export default class AuthInteractionController {
|
|
|
749
777
|
const details = await service.interactions.details(ctx);
|
|
750
778
|
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
751
779
|
const email = ctx.session.get(SESSION_KEY);
|
|
752
|
-
// `email` é o digitado (o que a tela mostra); `storeEmail` emite e ENDEREÇA o
|
|
753
|
-
// magic link — precisa ser a caixa postal sob a qual a conta está gravada.
|
|
754
|
-
const storeEmail = lookupEmail(ctx);
|
|
755
780
|
const uid = ctx.request.param('uid');
|
|
756
781
|
// Login por OTP: liga o campo de código na tela "link enviado" quando a config
|
|
757
782
|
// está ligada E o store suporta a capacidade.
|
|
@@ -763,7 +788,7 @@ export default class AuthInteractionController {
|
|
|
763
788
|
// Preferência por usuário: magic link desligado para ESTA conta → não emite
|
|
764
789
|
// token (resposta uniforme "enviado", sem vazar a preferência).
|
|
765
790
|
const userScopedMethods = email && cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore)
|
|
766
|
-
? await this.#userScopedLoginMethods(ctx, cfg,
|
|
791
|
+
? await this.#userScopedLoginMethods(ctx, cfg, email)
|
|
767
792
|
: null;
|
|
768
793
|
if (userScopedMethods && !userScopedMethods.authMethods.magicLink) {
|
|
769
794
|
return render(ctx, 'login', {
|
|
@@ -778,17 +803,17 @@ export default class AuthInteractionController {
|
|
|
778
803
|
magicChannel: 'none',
|
|
779
804
|
});
|
|
780
805
|
}
|
|
781
|
-
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) &&
|
|
806
|
+
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
|
|
782
807
|
const ip = ctx.request.ip?.() ?? null;
|
|
783
808
|
const clientId = details.params.client_id ?? null;
|
|
784
809
|
// Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
|
|
785
810
|
// senão, o magic link puro de sempre.
|
|
786
811
|
const issued = otpEnabled
|
|
787
|
-
? await cfg.accountStore.issueMagicLinkWithCode(
|
|
812
|
+
? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
|
|
788
813
|
digits: cfg.login.otp.digits,
|
|
789
814
|
ttlMinutes: cfg.login.otp.ttlMinutes,
|
|
790
815
|
})
|
|
791
|
-
: await cfg.accountStore.issueMagicLinkToken(
|
|
816
|
+
: await cfg.accountStore.issueMagicLinkToken(email);
|
|
792
817
|
if (issued) {
|
|
793
818
|
const code = 'code' in issued ? issued.code : undefined;
|
|
794
819
|
await cfg.audit?.record({
|
|
@@ -823,7 +848,7 @@ export default class AuthInteractionController {
|
|
|
823
848
|
// local usado acima para decidir a emissão) — não re-declarado aqui.
|
|
824
849
|
return render(ctx, 'login', {
|
|
825
850
|
...(email
|
|
826
|
-
? await this.#userScopedLoginMethods(ctx, cfg,
|
|
851
|
+
? await this.#userScopedLoginMethods(ctx, cfg, email)
|
|
827
852
|
: await this.#loginMethods(ctx, cfg)),
|
|
828
853
|
uid,
|
|
829
854
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -945,12 +970,21 @@ export default class AuthInteractionController {
|
|
|
945
970
|
error: translate(cfg.messages, accountStatusErrorKey(magicLinkStatusGate.reason)),
|
|
946
971
|
});
|
|
947
972
|
}
|
|
973
|
+
// Conta com segundo fator não termina o login só com o e-mail.
|
|
974
|
+
const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx));
|
|
975
|
+
if (magicGate.kind === 'challenge')
|
|
976
|
+
return magicGate.response;
|
|
948
977
|
await notifyLoginSuccess(ctx, cfg, {
|
|
949
978
|
accountId: acc.id,
|
|
950
979
|
email: acc.email,
|
|
951
980
|
ip,
|
|
952
981
|
clientId: clientId ?? null,
|
|
953
982
|
metadata: { method: 'magic_link' },
|
|
983
|
+
// `trusted` = o gate já validou o cookie de confiança nesta request; dizer
|
|
984
|
+
// isso aqui poupa `notifyLoginSuccess` de reler o mesmo cookie para decidir
|
|
985
|
+
// se é dispositivo novo. O amr continua `['email']`: o fator primário foi o
|
|
986
|
+
// e-mail, a confiança só dispensou o segundo.
|
|
987
|
+
trustedDevice: magicGate.kind === 'trusted',
|
|
954
988
|
});
|
|
955
989
|
forgetLoginEmail(ctx);
|
|
956
990
|
await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
|
|
@@ -973,11 +1007,9 @@ export default class AuthInteractionController {
|
|
|
973
1007
|
const ip = ctx.request.ip?.() ?? null;
|
|
974
1008
|
const clientId = (await service.interactions.details(ctx)).params.client_id;
|
|
975
1009
|
const email = ctx.session.get(SESSION_KEY);
|
|
976
|
-
// `email` é o digitado (o que a tela mostra); `storeEmail` verifica o código.
|
|
977
|
-
const storeEmail = lookupEmail(ctx);
|
|
978
1010
|
// Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
|
|
979
1011
|
const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
|
|
980
|
-
if (!otpEnabled || !email
|
|
1012
|
+
if (!otpEnabled || !email) {
|
|
981
1013
|
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
982
1014
|
}
|
|
983
1015
|
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
@@ -985,14 +1017,14 @@ export default class AuthInteractionController {
|
|
|
985
1017
|
// Mantém a sub-view do seletor no re-render de erro (o form de código pode
|
|
986
1018
|
// POSTar `channel=code`). Ausente = both (histórico).
|
|
987
1019
|
const channel = normalizeLoginChannel(ctx.request.input('channel'));
|
|
988
|
-
const result = await cfg.accountStore.verifyLoginCode(
|
|
1020
|
+
const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
|
|
989
1021
|
maxAttempts: cfg.login.otp.maxAttempts,
|
|
990
1022
|
});
|
|
991
1023
|
// Re-render da tela "link enviado" com o campo de código + erro localizado.
|
|
992
1024
|
// `otpEnabled` vem do spread (scoped — OTP é extensão do magic link, e a
|
|
993
1025
|
// preferência do usuário que desligou magic link já foi barrada acima).
|
|
994
1026
|
const renderOtpError = async (messageKey) => render(ctx, 'login', {
|
|
995
|
-
...(await this.#userScopedLoginMethods(ctx, cfg,
|
|
1027
|
+
...(await this.#userScopedLoginMethods(ctx, cfg, email)),
|
|
996
1028
|
uid,
|
|
997
1029
|
csrfToken: ctx.request.csrfToken,
|
|
998
1030
|
step: 'password',
|
|
@@ -1060,25 +1092,30 @@ export default class AuthInteractionController {
|
|
|
1060
1092
|
ip,
|
|
1061
1093
|
clientId,
|
|
1062
1094
|
});
|
|
1095
|
+
// Conta com segundo fator não termina o login só com o código do e-mail.
|
|
1096
|
+
const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx));
|
|
1097
|
+
if (otpGate.kind === 'challenge')
|
|
1098
|
+
return otpGate.response;
|
|
1063
1099
|
await notifyLoginSuccess(ctx, cfg, {
|
|
1064
1100
|
accountId: result.account.id,
|
|
1065
1101
|
email: result.account.email,
|
|
1066
1102
|
ip,
|
|
1067
1103
|
clientId: clientId ?? null,
|
|
1068
1104
|
metadata: { method: 'otp' },
|
|
1105
|
+
trustedDevice: otpGate.kind === 'trusted',
|
|
1069
1106
|
});
|
|
1070
1107
|
forgetLoginEmail(ctx);
|
|
1071
1108
|
return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
|
|
1072
1109
|
}
|
|
1073
1110
|
if (result.status === 'locked') {
|
|
1074
1111
|
// 5ª falha (ou já travado): código invalidado, o LINK continua válido.
|
|
1075
|
-
await cfg.audit?.record({ type: 'login.otp_invalidated', email
|
|
1112
|
+
await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
|
|
1076
1113
|
return renderOtpError('login.otp_locked');
|
|
1077
1114
|
}
|
|
1078
1115
|
if (result.status === 'expired') {
|
|
1079
1116
|
await cfg.audit?.record({
|
|
1080
1117
|
type: 'login.otp_failed',
|
|
1081
|
-
email
|
|
1118
|
+
email,
|
|
1082
1119
|
ip,
|
|
1083
1120
|
clientId,
|
|
1084
1121
|
metadata: { reason: 'expired' },
|
|
@@ -1088,7 +1125,7 @@ export default class AuthInteractionController {
|
|
|
1088
1125
|
// 'invalid' (tentativa contabilizada) ou 'no_code'.
|
|
1089
1126
|
await cfg.audit?.record({
|
|
1090
1127
|
type: 'login.otp_failed',
|
|
1091
|
-
email
|
|
1128
|
+
email,
|
|
1092
1129
|
ip,
|
|
1093
1130
|
clientId,
|
|
1094
1131
|
metadata: { reason: result.status },
|
|
@@ -1252,6 +1289,7 @@ export default class AuthInteractionController {
|
|
|
1252
1289
|
error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
|
|
1253
1290
|
brand,
|
|
1254
1291
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
1292
|
+
totpAvailable: await this.hasTotp(cfg, accountId),
|
|
1255
1293
|
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
1256
1294
|
trustedDeviceDays: cfg.trustedDevices.days,
|
|
1257
1295
|
});
|
|
@@ -1274,6 +1312,7 @@ export default class AuthInteractionController {
|
|
|
1274
1312
|
error: translate(cfg.messages, 'errors.email_unverified'),
|
|
1275
1313
|
brand,
|
|
1276
1314
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
1315
|
+
totpAvailable: await this.hasTotp(cfg, accountId),
|
|
1277
1316
|
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
1278
1317
|
trustedDeviceDays: cfg.trustedDevices.days,
|
|
1279
1318
|
});
|
|
@@ -1304,6 +1343,7 @@ export default class AuthInteractionController {
|
|
|
1304
1343
|
error: translate(cfg.messages, accountStatusErrorKey(passkeyStatusGate.reason)),
|
|
1305
1344
|
brand,
|
|
1306
1345
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
1346
|
+
totpAvailable: await this.hasTotp(cfg, accountId),
|
|
1307
1347
|
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
1308
1348
|
trustedDeviceDays: cfg.trustedDevices.days,
|
|
1309
1349
|
});
|
|
@@ -1400,20 +1440,10 @@ export default class AuthInteractionController {
|
|
|
1400
1440
|
});
|
|
1401
1441
|
// Senha trocada: limpa o step e finaliza o login.
|
|
1402
1442
|
ctx.session.forget(PASSWORD_EXPIRED_KEY);
|
|
1403
|
-
// Verifica se precisa de MFA mesmo após a troca.
|
|
1404
|
-
const
|
|
1405
|
-
if (
|
|
1406
|
-
|
|
1407
|
-
const passkeyAvailable = await this.hasPasskeys(cfg, accountId);
|
|
1408
|
-
return render(ctx, 'mfa-challenge', {
|
|
1409
|
-
uid: ctx.request.param('uid'),
|
|
1410
|
-
csrfToken: ctx.request.csrfToken,
|
|
1411
|
-
brand,
|
|
1412
|
-
passkeyAvailable,
|
|
1413
|
-
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
1414
|
-
trustedDeviceDays: cfg.trustedDevices.days,
|
|
1415
|
-
});
|
|
1416
|
-
}
|
|
1443
|
+
// Verifica se precisa de MFA mesmo após a troca — mesma regra do resto.
|
|
1444
|
+
const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details);
|
|
1445
|
+
if (expiredGate.kind === 'challenge')
|
|
1446
|
+
return expiredGate.response;
|
|
1417
1447
|
await service.interactions.completeLogin(ctx, accountId);
|
|
1418
1448
|
const account = await cfg.accountStore.findById(accountId);
|
|
1419
1449
|
await notifyLoginSuccess(ctx, cfg, {
|
|
@@ -5,7 +5,6 @@ import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
|
5
5
|
import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
|
|
6
6
|
import { brandFor } from '../branding.js';
|
|
7
7
|
import { sendEmailVerificationEmail, sendMagicLinkEmail, sendPasswordResetEmail, } from '../default_mailer.js';
|
|
8
|
-
import { resolveEmailIdentifier } from '../email_identifier.js';
|
|
9
8
|
import { translate } from '../i18n.js';
|
|
10
9
|
import { authkitOrigin } from '../origin.js';
|
|
11
10
|
import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
|
|
@@ -119,13 +118,9 @@ export default class AuthRegistrationController {
|
|
|
119
118
|
}
|
|
120
119
|
const data = await ctx.request.validateUsing(signupValidator);
|
|
121
120
|
const accountStore = cfg.accountStore;
|
|
122
|
-
// Duplicado
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
// ponte precisa da grafia original entre as candidatas).
|
|
126
|
-
const existing = (await resolveEmailIdentifier(accountStore, ctx.request.input('email'), {
|
|
127
|
-
legacyFallback: cfg.login?.legacyEmailFallback ?? true,
|
|
128
|
-
})).account;
|
|
121
|
+
// Duplicado pela MESMA forma que o login busca (`data.email` já saiu
|
|
122
|
+
// normalizado do validator).
|
|
123
|
+
const existing = await accountStore.findByEmail(data.email);
|
|
129
124
|
if (existing) {
|
|
130
125
|
return render(ctx, 'signup', {
|
|
131
126
|
uid: ctx.request.param('uid'),
|
|
@@ -224,10 +219,7 @@ export default class AuthRegistrationController {
|
|
|
224
219
|
const data = await ctx.request.validateUsing(passwordlessSignupValidator);
|
|
225
220
|
// Cria a conta se ainda não existe. Senha random inutilizável: o login é 100%
|
|
226
221
|
// passwordless (mesmo precedente das contas criadas por identidade social).
|
|
227
|
-
|
|
228
|
-
const existing = (await resolveEmailIdentifier(accountStore, ctx.request.input('email'), {
|
|
229
|
-
legacyFallback: cfg.login?.legacyEmailFallback ?? true,
|
|
230
|
-
})).account;
|
|
222
|
+
const existing = await accountStore.findByEmail(data.email);
|
|
231
223
|
if (!existing) {
|
|
232
224
|
const created = await accountStore.create({
|
|
233
225
|
email: data.email,
|
|
@@ -243,10 +235,9 @@ export default class AuthRegistrationController {
|
|
|
243
235
|
});
|
|
244
236
|
}
|
|
245
237
|
// Emite + envia o magic link (mesma construção do login por magic link).
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
const
|
|
249
|
-
const issued = await accountStore.issueMagicLinkToken(issueFor);
|
|
238
|
+
// `data.email` já é a forma normalizada — a mesma sob a qual a conta foi
|
|
239
|
+
// encontrada acima ou acabou de ser criada.
|
|
240
|
+
const issued = await accountStore.issueMagicLinkToken(data.email);
|
|
250
241
|
if (issued) {
|
|
251
242
|
const origin = authkitOrigin(cfg);
|
|
252
243
|
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
@@ -336,17 +327,10 @@ export default class AuthRegistrationController {
|
|
|
336
327
|
}
|
|
337
328
|
const { email } = await ctx.request.validateUsing(forgotPasswordValidator);
|
|
338
329
|
const accountStore = cfg.accountStore;
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
// chamada só. Resposta uniforme (a tela abaixo é a mesma, ache ou não).
|
|
344
|
-
if (!result && (cfg.login?.legacyEmailFallback ?? true)) {
|
|
345
|
-
const resolved = await resolveEmailIdentifier(accountStore, ctx.request.input('email'));
|
|
346
|
-
if (resolved.viaLegacyFallback) {
|
|
347
|
-
result = await accountStore.issuePasswordResetToken(resolved.lookupEmail);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
330
|
+
// `email` já saiu normalizado do validator (a MESMA normalização do
|
|
331
|
+
// cadastro e do login). Resposta uniforme: a tela abaixo é a mesma, ache
|
|
332
|
+
// conta ou não.
|
|
333
|
+
const result = await accountStore.issuePasswordResetToken(email);
|
|
350
334
|
if (result) {
|
|
351
335
|
await cfg.audit?.record({
|
|
352
336
|
type: 'password_reset.issued',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { supportsLoginMethodsPreference, supportsMagicLink, supportsPasskeys, supportsProviderIdentity, } from '../../accounts/account_store.js';
|
|
4
|
-
import { normalizeEmailIdentifier
|
|
4
|
+
import { normalizeEmailIdentifier } from '../email_identifier.js';
|
|
5
5
|
import { assertLoginAllowed } from '../login_attempt.js';
|
|
6
6
|
import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
|
|
7
7
|
import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
|
|
@@ -71,12 +71,10 @@ export default class AuthSocialController {
|
|
|
71
71
|
// 3. Senão → cria conta nova e liga a identidade.
|
|
72
72
|
let user = await store.findByProviderIdentity(provider, profile.id);
|
|
73
73
|
if (!user && email) {
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
const byEmail =
|
|
78
|
-
legacyFallback: cfg.login?.legacyEmailFallback ?? true,
|
|
79
|
-
})).account;
|
|
74
|
+
// Busca pela MESMA forma normalizada que o login usa. Uma conta gravada
|
|
75
|
+
// com outra grafia não é encontrada aqui e a pessoa ganharia uma SEGUNDA
|
|
76
|
+
// conta — por isso `authkit:users:normalize-emails` é passo de upgrade.
|
|
77
|
+
const byEmail = await store.findByEmail(email);
|
|
80
78
|
if (byEmail) {
|
|
81
79
|
await store.linkProviderIdentity({
|
|
82
80
|
accountId: byEmail.id,
|