@adonis-agora/authkit-server 0.70.0 → 0.71.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/index.d.ts +3 -4
- package/build/index.js +2 -2
- package/build/src/accounts/account_store.d.ts +25 -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/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 +5 -5
- package/build/src/host/controllers/interaction_controller.js +35 -62
- 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
|
@@ -3,7 +3,7 @@ import { accountHome } from '../account_home.js';
|
|
|
3
3
|
import { getAccountLoginUrl } from '../account_login_url.js';
|
|
4
4
|
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
5
5
|
import { syncAdonisAuthLogin, syncAdonisAuthLogout } from '../adonis_auth_sync.js';
|
|
6
|
-
import {
|
|
6
|
+
import { normalizeEmailIdentifier } from '../email_identifier.js';
|
|
7
7
|
import { translate } from '../i18n.js';
|
|
8
8
|
import { endBridgedIdpSession } from '../idp_session_bridge.js';
|
|
9
9
|
import { attemptPasswordLogin } from '../login_attempt.js';
|
|
@@ -68,18 +68,13 @@ export default class AccountSessionController {
|
|
|
68
68
|
// Verificação + lockout + auditoria de falha centralizados (sem clientId no console).
|
|
69
69
|
// M1: passa `settings` p/ o lockout (e verified-email/expiração) runtime valerem aqui também.
|
|
70
70
|
const settings = await resolveRuntimeSettings(ctx);
|
|
71
|
-
// L6:
|
|
72
|
-
//
|
|
73
|
-
// o qual a conta está gravada — de modo que o lookup, o lockout (keyed por
|
|
71
|
+
// L6: normaliza (trim + lowercase, a MESMA função do cadastro e do passo de
|
|
72
|
+
// identificador do login OIDC) para que o lookup, o lockout (keyed por
|
|
74
73
|
// e-mail) e a auditoria usem a forma canônica, independente do casing/espaços
|
|
75
|
-
// digitados. A
|
|
76
|
-
|
|
77
|
-
// mesma (credencial inválida), ache conta ou não.
|
|
78
|
-
const { lookupEmail } = await resolveEmailIdentifier(cfg.accountStore, rawEmail, {
|
|
79
|
-
legacyFallback: cfg.login?.legacyEmailFallback ?? true,
|
|
80
|
-
});
|
|
74
|
+
// digitados. A tela de erro é a mesma (credencial inválida), ache conta ou não.
|
|
75
|
+
const email = normalizeEmailIdentifier(rawEmail);
|
|
81
76
|
const result = await attemptPasswordLogin(cfg, {
|
|
82
|
-
email
|
|
77
|
+
email,
|
|
83
78
|
password,
|
|
84
79
|
ip,
|
|
85
80
|
logger: ctx.logger,
|
|
@@ -12,11 +12,11 @@ export default class AuthInteractionController {
|
|
|
12
12
|
* `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
|
|
13
13
|
* no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* À PROVA DE ENUMERAÇÃO: o passo NÃO toca no account store — só normaliza e
|
|
16
|
+
* guarda. A resposta é o MESMO redirect incondicional, exista a conta ou não,
|
|
17
|
+
* e não há busca alguma aqui para diferenciar os dois casos nem no tempo de
|
|
18
|
+
* resposta. Quem decide se a conta existe é o passo 2, que renderiza a mesma
|
|
19
|
+
* tela nos dois casos.
|
|
20
20
|
*/
|
|
21
21
|
identifier(ctx: HttpContext): Promise<void>;
|
|
22
22
|
/**
|
|
@@ -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;
|
|
@@ -728,7 +706,7 @@ export default class AuthInteractionController {
|
|
|
728
706
|
return pending;
|
|
729
707
|
if (!cfg.passwordless?.passkeyFirst)
|
|
730
708
|
return undefined;
|
|
731
|
-
const email =
|
|
709
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
732
710
|
if (!email)
|
|
733
711
|
return undefined;
|
|
734
712
|
const acc = await cfg.accountStore.findByEmail(email);
|
|
@@ -749,9 +727,6 @@ export default class AuthInteractionController {
|
|
|
749
727
|
const details = await service.interactions.details(ctx);
|
|
750
728
|
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
751
729
|
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
730
|
const uid = ctx.request.param('uid');
|
|
756
731
|
// Login por OTP: liga o campo de código na tela "link enviado" quando a config
|
|
757
732
|
// está ligada E o store suporta a capacidade.
|
|
@@ -763,7 +738,7 @@ export default class AuthInteractionController {
|
|
|
763
738
|
// Preferência por usuário: magic link desligado para ESTA conta → não emite
|
|
764
739
|
// token (resposta uniforme "enviado", sem vazar a preferência).
|
|
765
740
|
const userScopedMethods = email && cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore)
|
|
766
|
-
? await this.#userScopedLoginMethods(ctx, cfg,
|
|
741
|
+
? await this.#userScopedLoginMethods(ctx, cfg, email)
|
|
767
742
|
: null;
|
|
768
743
|
if (userScopedMethods && !userScopedMethods.authMethods.magicLink) {
|
|
769
744
|
return render(ctx, 'login', {
|
|
@@ -778,17 +753,17 @@ export default class AuthInteractionController {
|
|
|
778
753
|
magicChannel: 'none',
|
|
779
754
|
});
|
|
780
755
|
}
|
|
781
|
-
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) &&
|
|
756
|
+
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
|
|
782
757
|
const ip = ctx.request.ip?.() ?? null;
|
|
783
758
|
const clientId = details.params.client_id ?? null;
|
|
784
759
|
// Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
|
|
785
760
|
// senão, o magic link puro de sempre.
|
|
786
761
|
const issued = otpEnabled
|
|
787
|
-
? await cfg.accountStore.issueMagicLinkWithCode(
|
|
762
|
+
? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
|
|
788
763
|
digits: cfg.login.otp.digits,
|
|
789
764
|
ttlMinutes: cfg.login.otp.ttlMinutes,
|
|
790
765
|
})
|
|
791
|
-
: await cfg.accountStore.issueMagicLinkToken(
|
|
766
|
+
: await cfg.accountStore.issueMagicLinkToken(email);
|
|
792
767
|
if (issued) {
|
|
793
768
|
const code = 'code' in issued ? issued.code : undefined;
|
|
794
769
|
await cfg.audit?.record({
|
|
@@ -823,7 +798,7 @@ export default class AuthInteractionController {
|
|
|
823
798
|
// local usado acima para decidir a emissão) — não re-declarado aqui.
|
|
824
799
|
return render(ctx, 'login', {
|
|
825
800
|
...(email
|
|
826
|
-
? await this.#userScopedLoginMethods(ctx, cfg,
|
|
801
|
+
? await this.#userScopedLoginMethods(ctx, cfg, email)
|
|
827
802
|
: await this.#loginMethods(ctx, cfg)),
|
|
828
803
|
uid,
|
|
829
804
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -973,11 +948,9 @@ export default class AuthInteractionController {
|
|
|
973
948
|
const ip = ctx.request.ip?.() ?? null;
|
|
974
949
|
const clientId = (await service.interactions.details(ctx)).params.client_id;
|
|
975
950
|
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
951
|
// Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
|
|
979
952
|
const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
|
|
980
|
-
if (!otpEnabled || !email
|
|
953
|
+
if (!otpEnabled || !email) {
|
|
981
954
|
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
982
955
|
}
|
|
983
956
|
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
@@ -985,14 +958,14 @@ export default class AuthInteractionController {
|
|
|
985
958
|
// Mantém a sub-view do seletor no re-render de erro (o form de código pode
|
|
986
959
|
// POSTar `channel=code`). Ausente = both (histórico).
|
|
987
960
|
const channel = normalizeLoginChannel(ctx.request.input('channel'));
|
|
988
|
-
const result = await cfg.accountStore.verifyLoginCode(
|
|
961
|
+
const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
|
|
989
962
|
maxAttempts: cfg.login.otp.maxAttempts,
|
|
990
963
|
});
|
|
991
964
|
// Re-render da tela "link enviado" com o campo de código + erro localizado.
|
|
992
965
|
// `otpEnabled` vem do spread (scoped — OTP é extensão do magic link, e a
|
|
993
966
|
// preferência do usuário que desligou magic link já foi barrada acima).
|
|
994
967
|
const renderOtpError = async (messageKey) => render(ctx, 'login', {
|
|
995
|
-
...(await this.#userScopedLoginMethods(ctx, cfg,
|
|
968
|
+
...(await this.#userScopedLoginMethods(ctx, cfg, email)),
|
|
996
969
|
uid,
|
|
997
970
|
csrfToken: ctx.request.csrfToken,
|
|
998
971
|
step: 'password',
|
|
@@ -1072,13 +1045,13 @@ export default class AuthInteractionController {
|
|
|
1072
1045
|
}
|
|
1073
1046
|
if (result.status === 'locked') {
|
|
1074
1047
|
// 5ª falha (ou já travado): código invalidado, o LINK continua válido.
|
|
1075
|
-
await cfg.audit?.record({ type: 'login.otp_invalidated', email
|
|
1048
|
+
await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
|
|
1076
1049
|
return renderOtpError('login.otp_locked');
|
|
1077
1050
|
}
|
|
1078
1051
|
if (result.status === 'expired') {
|
|
1079
1052
|
await cfg.audit?.record({
|
|
1080
1053
|
type: 'login.otp_failed',
|
|
1081
|
-
email
|
|
1054
|
+
email,
|
|
1082
1055
|
ip,
|
|
1083
1056
|
clientId,
|
|
1084
1057
|
metadata: { reason: 'expired' },
|
|
@@ -1088,7 +1061,7 @@ export default class AuthInteractionController {
|
|
|
1088
1061
|
// 'invalid' (tentativa contabilizada) ou 'no_code'.
|
|
1089
1062
|
await cfg.audit?.record({
|
|
1090
1063
|
type: 'login.otp_failed',
|
|
1091
|
-
email
|
|
1064
|
+
email,
|
|
1092
1065
|
ip,
|
|
1093
1066
|
clientId,
|
|
1094
1067
|
metadata: { reason: result.status },
|
|
@@ -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,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { AuthAccount } from '../accounts/account_store.js';
|
|
2
1
|
/**
|
|
3
2
|
* Normalização ÚNICA e CONSERVADORA do e-mail usado como identidade da conta.
|
|
4
3
|
*
|
|
@@ -19,74 +18,12 @@ import type { AuthAccount } from '../accounts/account_store.js';
|
|
|
19
18
|
* Use em TODO ponto que grava ou busca a identidade: cadastro (com e sem senha),
|
|
20
19
|
* "esqueci a senha", troca de e-mail, criação por admin, convite de organização,
|
|
21
20
|
* import de usuários, cadastro social e o passo de identificador do login.
|
|
22
|
-
*/
|
|
23
|
-
export declare function normalizeEmailIdentifier(raw: unknown): string;
|
|
24
|
-
/**
|
|
25
|
-
* Réplica da normalização LEGADA — `normalizeEmail()` do validator.js com os
|
|
26
|
-
* defaults, que é o que o `.normalizeEmail()` do VineJS aplicava no cadastro até
|
|
27
|
-
* a v0.68. Existe SÓ para reencontrar as contas que nasceram com o endereço
|
|
28
|
-
* mutilado; nada novo deve ser gravado com ela.
|
|
29
21
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
22
|
+
* As contas GRAVADAS antes disto (com o endereço mutilado, ou com maiúsculas
|
|
23
|
+
* vindas de import/convite/provider social) ficam inalcançáveis pelo login, que
|
|
24
|
+
* busca a forma normalizada. Elas pedem MIGRAÇÃO do endereço gravado — é o que o
|
|
25
|
+
* comando `authkit:users:normalize-emails` faz (ver `commands/normalize_emails.ts`).
|
|
26
|
+
* A lib não tenta adivinhar grafias no caminho do login: cada tentativa extra é
|
|
27
|
+
* uma query a mais por e-mail desconhecido, que é justamente o caminho de ataque.
|
|
36
28
|
*/
|
|
37
|
-
export declare function
|
|
38
|
-
/** Só o que {@link resolveEmailIdentifier} precisa do account store. */
|
|
39
|
-
export interface EmailIdentifierLookup {
|
|
40
|
-
findByEmail(email: string): Promise<AuthAccount | null>;
|
|
41
|
-
}
|
|
42
|
-
export interface ResolvedEmailIdentifier {
|
|
43
|
-
/** O que a pessoa digitou, normalizado. É o que a tela SEMPRE mostra. */
|
|
44
|
-
email: string;
|
|
45
|
-
/**
|
|
46
|
-
* E-mail sob o qual a conta está GRAVADA — o que deve ir para o store em
|
|
47
|
-
* qualquer busca/emissão de token. Igual a {@link email} quando a conta foi
|
|
48
|
-
* achada direto (ou quando não foi achada nenhuma).
|
|
49
|
-
*/
|
|
50
|
-
lookupEmail: string;
|
|
51
|
-
/** A conta, quando alguma foi encontrada. */
|
|
52
|
-
account: AuthAccount | null;
|
|
53
|
-
/** `true` quando a conta só foi alcançada pela ponte legada. */
|
|
54
|
-
viaLegacyFallback: boolean;
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Resolve o e-mail digitado na conta correspondente.
|
|
58
|
-
*
|
|
59
|
-
* Passe o valor **CRU** (como veio do formulário, do provider ou do arquivo): a
|
|
60
|
-
* normalização acontece aqui dentro, e a forma crua é uma das candidatas da
|
|
61
|
-
* ponte. Passar um valor já normalizado apaga essa candidata e reduz o alcance
|
|
62
|
-
* da ponte às contas gravadas com o endereço mutilado.
|
|
63
|
-
*
|
|
64
|
-
* 1. Busca pela forma normalizada NOVA (trim + lowercase) — o caminho de sempre,
|
|
65
|
-
* uma única query por igualdade (indexada).
|
|
66
|
-
* 2. Não achando, e com a ponte ligada (`login.legacyEmailFallback`, default
|
|
67
|
-
* `true`), tenta as formas de compatibilidade:
|
|
68
|
-
* - o endereço EXATAMENTE como digitado (só com `trim`), para as contas que
|
|
69
|
-
* foram gravadas com maiúsculas antes desta normalização existir (import,
|
|
70
|
-
* convite, criação por admin, provider social);
|
|
71
|
-
* - a {@link legacyNormalizeEmailIdentifier normalização legada}, para as
|
|
72
|
-
* contas que nasceram com o endereço mutilado.
|
|
73
|
-
* O resultado só é aceito quando as formas de compatibilidade apontam para
|
|
74
|
-
* EXATAMENTE UMA conta. Duas contas distintas (ex.: `Davi.C@Gmail.com` criada
|
|
75
|
-
* pelo social E `davic@gmail.com` criada pelo cadastro legado) são um empate:
|
|
76
|
-
* a lib não adivinha qual é a pessoa e trata como "não achei".
|
|
77
|
-
*
|
|
78
|
-
* LIMITE CONHECIDO: a ponte só alcança grafias que dá para derivar do que foi
|
|
79
|
-
* digitado. Uma conta gravada `Davi@Acme.com` é alcançada por quem digita
|
|
80
|
-
* `Davi@Acme.com` (a forma crua), mas NÃO por quem digita `davi@acme.com` — aí as
|
|
81
|
-
* três formas coincidem e só uma busca case-insensitive no store resolveria, o
|
|
82
|
-
* que exigiria varrer a tabela a cada login com e-mail desconhecido (justamente o
|
|
83
|
-
* caminho de ataque). Essas contas pedem migração do endereço gravado, não ponte.
|
|
84
|
-
*
|
|
85
|
-
* À PROVA DE ENUMERAÇÃO: nunca lança, nunca sinaliza nada para fora — quem chama
|
|
86
|
-
* segue com `account: null` exatamente como seguia antes. As buscas extras só
|
|
87
|
-
* acontecem quando as formas de compatibilidade DIFEREM da normalizada (e-mail
|
|
88
|
-
* digitado em minúsculas e sem ponto/tag → uma query só, como hoje).
|
|
89
|
-
*/
|
|
90
|
-
export declare function resolveEmailIdentifier(store: EmailIdentifierLookup, raw: unknown, options?: {
|
|
91
|
-
legacyFallback?: boolean;
|
|
92
|
-
}): Promise<ResolvedEmailIdentifier>;
|
|
29
|
+
export declare function normalizeEmailIdentifier(raw: unknown): string;
|