@adonis-agora/authkit-server 0.68.4 → 0.70.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/import_users.js +6 -1
- package/build/index.d.ts +5 -1
- package/build/index.js +7 -1
- package/build/src/accounts/account_store.d.ts +40 -0
- package/build/src/accounts/account_store.js +20 -0
- package/build/src/accounts/lucid_store/mfa.js +22 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +4 -0
- package/build/src/commands/import_users.d.ts +7 -0
- package/build/src/commands/import_users.js +15 -3
- package/build/src/define_config.d.ts +81 -0
- package/build/src/define_config.js +18 -3
- package/build/src/host/account_api/account_api_controller.d.ts +2 -0
- package/build/src/host/account_api/account_api_controller.js +22 -3
- package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
- package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
- package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
- package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
- package/build/src/host/account_lockout.js +7 -2
- package/build/src/host/admin_api/admin_orgs_service.js +3 -2
- package/build/src/host/admin_api/admin_users_service.js +13 -3
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_validators.d.ts +2 -2
- package/build/src/host/admin_validators.js +3 -2
- package/build/src/host/controllers/account_mfa_controller.js +1 -2
- package/build/src/host/controllers/account_orgs_controller.js +19 -11
- package/build/src/host/controllers/account_security_controller.js +3 -1
- package/build/src/host/controllers/account_session_controller.js +17 -6
- package/build/src/host/controllers/interaction_controller.d.ts +10 -0
- package/build/src/host/controllers/interaction_controller.js +93 -34
- package/build/src/host/controllers/registration_controller.js +35 -9
- package/build/src/host/controllers/social_controller.js +11 -2
- package/build/src/host/email_identifier.d.ts +92 -0
- package/build/src/host/email_identifier.js +234 -0
- package/build/src/host/idp_session_bridge.d.ts +55 -0
- package/build/src/host/idp_session_bridge.js +108 -0
- package/build/src/host/middleware/account_auth.js +3 -2
- package/build/src/host/org_policy.d.ts +26 -0
- package/build/src/host/org_policy.js +35 -0
- package/build/src/host/passkey_registration_challenge.d.ts +12 -0
- package/build/src/host/passkey_registration_challenge.js +12 -0
- package/build/src/host/register_auth_host.js +56 -1
- package/build/src/host/runtime_toggles.d.ts +2 -2
- package/build/src/host/runtime_toggles.js +3 -1
- package/build/src/host/sudo_mode.d.ts +17 -0
- package/build/src/host/sudo_mode.js +31 -12
- package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/host/validators.d.ts +5 -5
- package/build/src/host/validators.js +17 -5
- package/build/src/provider/build_provider.js +46 -14
- package/build/src/provider/registration_policy.d.ts +99 -0
- package/build/src/provider/registration_policy.js +229 -0
- package/package.json +2 -2
|
@@ -5,9 +5,13 @@ import { accountPath } from '../account_paths.js';
|
|
|
5
5
|
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
6
6
|
import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
|
|
7
7
|
import { sendOrgInvitationEmail } from '../default_mailer.js';
|
|
8
|
+
import { ensureConsoleSession } from '../idp_session_bridge.js';
|
|
9
|
+
// Política efetiva compartilhada com o espelho JSON (`account_orgs_api_controller`):
|
|
10
|
+
// ponto de verdade único, para as duas superfícies nunca divergirem.
|
|
11
|
+
import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
|
|
8
12
|
import { authkitOrigin } from '../origin.js';
|
|
9
13
|
import { resolveRuntimeSettings } from '../runtime_settings.js';
|
|
10
|
-
import { isRoleInCatalog } from '../runtime_toggles.js';
|
|
14
|
+
import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy } from '../runtime_toggles.js';
|
|
11
15
|
/**
|
|
12
16
|
* Console de conta — Organizations. Server-rendered, padrão dos outros controllers
|
|
13
17
|
* de conta (account_tokens_controller, account_security_controller, etc.).
|
|
@@ -51,12 +55,13 @@ export default class AccountOrgsController {
|
|
|
51
55
|
const members = canManage ? await store.listOrgMembers(org.id) : [];
|
|
52
56
|
return { ...org, members, canManage, isActive: org.id === activeOrgId };
|
|
53
57
|
}));
|
|
58
|
+
const policy = await effectiveOrgPolicy(ctx, cfg);
|
|
54
59
|
const props = {
|
|
55
60
|
supported: true,
|
|
56
61
|
orgs: orgsWithMembers,
|
|
57
62
|
pendingInvitations: invitationsWithOrg,
|
|
58
|
-
allowSelfCreate:
|
|
59
|
-
availableRoles:
|
|
63
|
+
allowSelfCreate: policy.allowSelfCreate,
|
|
64
|
+
availableRoles: policy.roles,
|
|
60
65
|
messages,
|
|
61
66
|
csrfToken: ctx.request.csrfToken,
|
|
62
67
|
};
|
|
@@ -69,9 +74,10 @@ export default class AccountOrgsController {
|
|
|
69
74
|
const cfg = service.config;
|
|
70
75
|
const store = cfg.accountStore;
|
|
71
76
|
const accountId = session.get(ACCOUNT_SESSION_KEY);
|
|
72
|
-
if (!supportsOrganizations(store)
|
|
77
|
+
if (!supportsOrganizations(store))
|
|
78
|
+
return response.forbidden();
|
|
79
|
+
if (!(await effectiveOrgPolicy(ctx, cfg)).allowSelfCreate)
|
|
73
80
|
return response.forbidden();
|
|
74
|
-
}
|
|
75
81
|
const name = request.input('name', '').trim();
|
|
76
82
|
const slug = request.input('slug', '').trim();
|
|
77
83
|
if (!name || !slug)
|
|
@@ -179,11 +185,7 @@ export default class AccountOrgsController {
|
|
|
179
185
|
// config → defaults). Role fora do catálogo é rejeitada (não cria convite).
|
|
180
186
|
// Usa o helper PURO `isRoleInCatalog` (mesmo ponto de verdade do caminho admin).
|
|
181
187
|
const settings = await resolveRuntimeSettings(ctx);
|
|
182
|
-
const roleValid = await isRoleInCatalog(role, settings,
|
|
183
|
-
roles: cfg.organizations.roles,
|
|
184
|
-
allowSelfCreate: cfg.organizations.allowSelfCreate,
|
|
185
|
-
invitationTtlHours: cfg.organizations.invitationTtlHours,
|
|
186
|
-
}, params.id);
|
|
188
|
+
const roleValid = await isRoleInCatalog(role, settings, orgPolicyDefaults(cfg), params.id);
|
|
187
189
|
if (!roleValid) {
|
|
188
190
|
return response.unprocessableEntity({
|
|
189
191
|
error: { code: 'invalid_role', message: 'Role inválida.' },
|
|
@@ -194,12 +196,14 @@ export default class AccountOrgsController {
|
|
|
194
196
|
if (role === 'owner' && membership.role !== 'owner') {
|
|
195
197
|
return response.forbidden();
|
|
196
198
|
}
|
|
199
|
+
// TTL da política efetiva da org (setting org → global → config), não só do config.
|
|
200
|
+
const policy = await resolveEffectiveOrganizationsPolicy(settings, orgPolicyDefaults(cfg), params.id);
|
|
197
201
|
const { invitation, token } = await store.createOrgInvitation({
|
|
198
202
|
organizationId: params.id,
|
|
199
203
|
email,
|
|
200
204
|
role,
|
|
201
205
|
invitedBy: accountId,
|
|
202
|
-
ttlHours:
|
|
206
|
+
ttlHours: policy.invitationTtlHours,
|
|
203
207
|
});
|
|
204
208
|
// Sends the invitation email (best-effort). The host hook wins when present;
|
|
205
209
|
// otherwise the host-kit sends its own branded/translated email, like every
|
|
@@ -243,6 +247,8 @@ export default class AccountOrgsController {
|
|
|
243
247
|
const { createHash } = await import('node:crypto');
|
|
244
248
|
if (!supportsOrganizations(store))
|
|
245
249
|
return response.notFound();
|
|
250
|
+
// Rota fora do guard: a sessão do IdP (SSO) também vale, se configurado.
|
|
251
|
+
await ensureConsoleSession(ctx);
|
|
246
252
|
const accountId = session.get(ACCOUNT_SESSION_KEY);
|
|
247
253
|
if (!accountId) {
|
|
248
254
|
// Não logado: redireciona para login (configurável) com return URL
|
|
@@ -272,6 +278,8 @@ export default class AccountOrgsController {
|
|
|
272
278
|
const { createHash } = await import('node:crypto');
|
|
273
279
|
if (!supportsOrganizations(store))
|
|
274
280
|
return response.notFound();
|
|
281
|
+
// Rota fora do guard: a sessão do IdP (SSO) também vale, se configurado.
|
|
282
|
+
await ensureConsoleSession(ctx);
|
|
275
283
|
const accountId = session.get(ACCOUNT_SESSION_KEY);
|
|
276
284
|
if (!accountId)
|
|
277
285
|
return response.redirect(getAccountLoginUrl());
|
|
@@ -10,6 +10,7 @@ import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
|
10
10
|
import { syncAdonisAuthLogout } from '../adonis_auth_sync.js';
|
|
11
11
|
import { AvatarUploadError, isAvatarUploadSupported, storeAvatar } from '../avatar_storage.js';
|
|
12
12
|
import { sendEmailChangeConfirmationEmail, sendEmailChangedCompletedEmail, sendEmailChangeNoticeEmail, } from '../default_mailer.js';
|
|
13
|
+
import { normalizeEmailIdentifier } from '../email_identifier.js';
|
|
13
14
|
import { translate } from '../i18n.js';
|
|
14
15
|
import { authkitOrigin } from '../origin.js';
|
|
15
16
|
import { resolveRuntimeSettings } from '../runtime_settings.js';
|
|
@@ -164,7 +165,8 @@ export default class AccountSecurityController {
|
|
|
164
165
|
confirmed = !!(await store.verifyCredentials(account.email, currentPassword));
|
|
165
166
|
}
|
|
166
167
|
if (!confirmed && confirmEmail) {
|
|
167
|
-
confirmed =
|
|
168
|
+
confirmed =
|
|
169
|
+
normalizeEmailIdentifier(confirmEmail) === normalizeEmailIdentifier(account.email);
|
|
168
170
|
}
|
|
169
171
|
if (!confirmed) {
|
|
170
172
|
ctx.session.flash('deleteError', translate(cfg.messages, 'account.delete.invalid_confirmation'));
|
|
@@ -3,7 +3,9 @@ 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 { resolveEmailIdentifier } from '../email_identifier.js';
|
|
6
7
|
import { translate } from '../i18n.js';
|
|
8
|
+
import { endBridgedIdpSession } from '../idp_session_bridge.js';
|
|
7
9
|
import { attemptPasswordLogin } from '../login_attempt.js';
|
|
8
10
|
import { notifyLoginSuccess } from '../login_notify.js';
|
|
9
11
|
import { resolveRuntimeSettings } from '../runtime_settings.js';
|
|
@@ -59,10 +61,6 @@ export default class AccountSessionController {
|
|
|
59
61
|
const cfg = service.config;
|
|
60
62
|
const render = cfg.render;
|
|
61
63
|
const { email: rawEmail, password } = ctx.request.only(['email', 'password']);
|
|
62
|
-
// L6: normaliza o e-mail (trim + lowercase) ANTES de usar — garante que o
|
|
63
|
-
// lookup, o lockout (keyed por email) e a auditoria usem a forma canônica,
|
|
64
|
-
// independente do casing/espaços digitados.
|
|
65
|
-
const email = typeof rawEmail === 'string' ? rawEmail.trim().toLowerCase() : rawEmail;
|
|
66
64
|
const ip = ctx.request.ip?.() ?? null;
|
|
67
65
|
// Lê e valida o return_to do corpo do formulário (hidden input) — nunca confiar sem revalidar.
|
|
68
66
|
const rawReturnTo = ctx.request.input?.('return_to');
|
|
@@ -70,8 +68,18 @@ export default class AccountSessionController {
|
|
|
70
68
|
// Verificação + lockout + auditoria de falha centralizados (sem clientId no console).
|
|
71
69
|
// M1: passa `settings` p/ o lockout (e verified-email/expiração) runtime valerem aqui também.
|
|
72
70
|
const settings = await resolveRuntimeSettings(ctx);
|
|
71
|
+
// L6: `resolveEmailIdentifier` normaliza (trim + lowercase, a MESMA função do
|
|
72
|
+
// cadastro e do passo de identificador do login OIDC) e devolve o endereço sob
|
|
73
|
+
// o qual a conta está gravada — de modo que o lookup, o lockout (keyed por
|
|
74
|
+
// e-mail) e a auditoria usem a forma canônica, independente do casing/espaços
|
|
75
|
+
// digitados. A ponte legada faz a conta que nasceu com o endereço mutilado
|
|
76
|
+
// entrar no console pelo endereço REAL, como no login OIDC. A tela de erro é a
|
|
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
|
+
});
|
|
73
81
|
const result = await attemptPasswordLogin(cfg, {
|
|
74
|
-
email,
|
|
82
|
+
email: lookupEmail,
|
|
75
83
|
password,
|
|
76
84
|
ip,
|
|
77
85
|
logger: ctx.logger,
|
|
@@ -105,7 +113,7 @@ export default class AccountSessionController {
|
|
|
105
113
|
// Opt-in: sincroniza ctx.auth.use(cfg.adonisAuth.guard) com a mesma conta —
|
|
106
114
|
// no-op sem `adonisAuth` configurado ou sem @adonisjs/auth inicializado.
|
|
107
115
|
await syncAdonisAuthLogin(ctx, cfg, acc);
|
|
108
|
-
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip });
|
|
116
|
+
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email: acc.email, ip });
|
|
109
117
|
// Redireciona pro destino original (validado), ou cai no accountHome configurado.
|
|
110
118
|
return ctx.response.redirect(returnTo ?? accountHome(cfg));
|
|
111
119
|
}
|
|
@@ -134,6 +142,9 @@ export default class AccountSessionController {
|
|
|
134
142
|
// timestamp, sem dono. Passou a ser vinculada — por isso este `forget`
|
|
135
143
|
// continua sendo só do `ACCOUNT_SESSION_KEY`: trocada a conta, a marca de
|
|
136
144
|
// sudo remanescente já não vale para ninguém.
|
|
145
|
+
// Console aberto pela sessão do IdP (SSO): encerra também essa sessão, senão
|
|
146
|
+
// o próximo request reabriria o console pela ponte e o "Sair" não valeria.
|
|
147
|
+
await endBridgedIdpSession(ctx);
|
|
137
148
|
ctx.session.forget(ACCOUNT_SESSION_KEY);
|
|
138
149
|
await ctx.session.regenerate();
|
|
139
150
|
// Opt-in: espelha o logout no guard de @adonisjs/auth (ver adonisAuth em define_config.ts).
|
|
@@ -7,6 +7,16 @@ export default class AuthInteractionController {
|
|
|
7
7
|
* POST /auth/interaction/:uid/identifier
|
|
8
8
|
* Step 1: receive email, store in session, redirect to step 2.
|
|
9
9
|
* ENUMERATION-SAFE: always advances regardless of whether the email exists.
|
|
10
|
+
*
|
|
11
|
+
* Normaliza o e-mail com a MESMA regra do cadastro (`normalizeEmailIdentifier`:
|
|
12
|
+
* `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
|
|
13
|
+
* no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
|
|
14
|
+
*
|
|
15
|
+
* A resolução acontece AQUI (uma vez, onde o valor digitado ainda existe) e
|
|
16
|
+
* não a cada request do passo 2. Guarda DUAS coisas: o digitado (o que a tela
|
|
17
|
+
* mostra) e, se a ponte de compatibilidade precisou de outra forma, o endereço
|
|
18
|
+
* sob o qual a conta está gravada (o que vai para o store). A resposta continua
|
|
19
|
+
* sendo o mesmo redirect incondicional, ache conta ou não.
|
|
10
20
|
*/
|
|
11
21
|
identifier(ctx: HttpContext): Promise<void>;
|
|
12
22
|
/**
|
|
@@ -4,6 +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 { resolveEmailIdentifier } from '../email_identifier.js';
|
|
7
8
|
import { translate } from '../i18n.js';
|
|
8
9
|
import { assertLoginAllowed, attemptPasswordLogin, isEmailUnverifiedBlock, } from '../login_attempt.js';
|
|
9
10
|
import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
|
|
@@ -36,11 +37,37 @@ function accountStatusErrorKey(reason) {
|
|
|
36
37
|
return 'login.password_expired_intro';
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* E-mail DIGITADO no passo de identificador, já normalizado (`trim` +
|
|
42
|
+
* `toLowerCase`). É o que a tela SEMPRE mostra — nunca o endereço gravado —,
|
|
43
|
+
* para que a canonicalização não vire um oráculo de "esta conta existe".
|
|
44
|
+
*/
|
|
39
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';
|
|
40
53
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
41
54
|
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
42
55
|
/** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
|
|
43
56
|
const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
|
|
57
|
+
/**
|
|
58
|
+
* E-mail para BUSCAR a conta no store. Cai no e-mail digitado quando não há
|
|
59
|
+
* forma de compatibilidade gravada — inclusive nas sessões que já estavam
|
|
60
|
+
* abertas antes deste deploy.
|
|
61
|
+
*/
|
|
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
|
+
function forgetLoginEmail(ctx) {
|
|
68
|
+
ctx.session.forget(SESSION_KEY);
|
|
69
|
+
ctx.session.forget(SESSION_LOOKUP_KEY);
|
|
70
|
+
}
|
|
44
71
|
export default class AuthInteractionController {
|
|
45
72
|
/**
|
|
46
73
|
* Métodos de login efetivos (com os pins do `cfg.authMethods`) para QUALQUER render do
|
|
@@ -161,6 +188,8 @@ export default class AuthInteractionController {
|
|
|
161
188
|
});
|
|
162
189
|
}
|
|
163
190
|
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);
|
|
164
193
|
// Métodos de login efetivos (magic link, OTP, social) — account-independent
|
|
165
194
|
// neste ponto (passkey-first depende da conta, resolvido no step 2). Reusa o
|
|
166
195
|
// helper compartilhado por TODOS os renders do passo login, para que
|
|
@@ -170,7 +199,7 @@ export default class AuthInteractionController {
|
|
|
170
199
|
// Com email na sessão (passo 2+), aplica a preferência POR USUÁRIO — os
|
|
171
200
|
// métodos que o dono da conta desligou não aparecem nem aqui nem nos POSTs.
|
|
172
201
|
const loginMethods = email
|
|
173
|
-
? await this.#userScopedLoginMethods(ctx, cfg,
|
|
202
|
+
? await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettingsForMaintenance)
|
|
174
203
|
: await this.#loginMethods(ctx, cfg, runtimeSettingsForMaintenance);
|
|
175
204
|
const authMethods = loginMethods.authMethods;
|
|
176
205
|
if (!email) {
|
|
@@ -187,7 +216,7 @@ export default class AuthInteractionController {
|
|
|
187
216
|
});
|
|
188
217
|
}
|
|
189
218
|
// Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
|
|
190
|
-
const acc = await cfg.accountStore.findByEmail(email);
|
|
219
|
+
const acc = await cfg.accountStore.findByEmail(storeEmail ?? email);
|
|
191
220
|
const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
|
|
192
221
|
// Passwordless: magic link disponível vem do helper (`loginMethods`).
|
|
193
222
|
// Passkey-first disponível se ligado, o store suporta, a conta tem passkeys E auth_methods permite.
|
|
@@ -215,10 +244,29 @@ export default class AuthInteractionController {
|
|
|
215
244
|
* POST /auth/interaction/:uid/identifier
|
|
216
245
|
* Step 1: receive email, store in session, redirect to step 2.
|
|
217
246
|
* ENUMERATION-SAFE: always advances regardless of whether the email exists.
|
|
247
|
+
*
|
|
248
|
+
* Normaliza o e-mail com a MESMA regra do cadastro (`normalizeEmailIdentifier`:
|
|
249
|
+
* `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
|
|
250
|
+
* no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
|
|
251
|
+
*
|
|
252
|
+
* A resolução acontece AQUI (uma vez, onde o valor digitado ainda existe) e
|
|
253
|
+
* não a cada request do passo 2. Guarda DUAS coisas: o digitado (o que a tela
|
|
254
|
+
* mostra) e, se a ponte de compatibilidade precisou de outra forma, o endereço
|
|
255
|
+
* sob o qual a conta está gravada (o que vai para o store). A resposta continua
|
|
256
|
+
* sendo o mesmo redirect incondicional, ache conta ou não.
|
|
218
257
|
*/
|
|
219
258
|
async identifier(ctx) {
|
|
259
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
260
|
+
const cfg = service.config;
|
|
220
261
|
const { email } = ctx.request.only(['email']);
|
|
221
|
-
|
|
262
|
+
const resolved = await resolveEmailIdentifier(cfg.accountStore, email, {
|
|
263
|
+
legacyFallback: cfg.login?.legacyEmailFallback ?? true,
|
|
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);
|
|
222
270
|
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
223
271
|
}
|
|
224
272
|
/**
|
|
@@ -236,6 +284,8 @@ export default class AuthInteractionController {
|
|
|
236
284
|
// Session expired or tampered — send back to step 1
|
|
237
285
|
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
238
286
|
}
|
|
287
|
+
// `email` é o digitado (o que a tela mostra); `storeEmail` é o que busca a conta.
|
|
288
|
+
const storeEmail = lookupEmail(ctx) ?? email;
|
|
239
289
|
const { password } = ctx.request.only(['password']);
|
|
240
290
|
const ip = ctx.request.ip?.() ?? null;
|
|
241
291
|
const clientId = details.params.client_id ?? null;
|
|
@@ -251,13 +301,16 @@ export default class AuthInteractionController {
|
|
|
251
301
|
// credenciais. Fail-safe: erro/timeout no verify do host PERMITE o fluxo.
|
|
252
302
|
// We use a cfg-compatible object: pass effectiveBotLogin merged into cfg.
|
|
253
303
|
const cfgWithEffectiveBot = effectiveBotLogin !== cfg.botProtection ? { ...cfg, botProtection: effectiveBotLogin } : cfg;
|
|
254
|
-
if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', {
|
|
255
|
-
|
|
304
|
+
if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', {
|
|
305
|
+
email: storeEmail,
|
|
306
|
+
clientId,
|
|
307
|
+
}))) {
|
|
308
|
+
const found = await cfg.accountStore.findByEmail(storeEmail);
|
|
256
309
|
const account = found
|
|
257
310
|
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
258
311
|
: null;
|
|
259
312
|
return render(ctx, 'login', {
|
|
260
|
-
...(await this.#userScopedLoginMethods(ctx, cfg,
|
|
313
|
+
...(await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettings)),
|
|
261
314
|
uid: ctx.request.param('uid'),
|
|
262
315
|
csrfToken: ctx.request.csrfToken,
|
|
263
316
|
step: 'password',
|
|
@@ -271,9 +324,9 @@ export default class AuthInteractionController {
|
|
|
271
324
|
// Preferência por usuário: senha desligada para ESTA conta → recusa ANTES de
|
|
272
325
|
// verificar credenciais (mesma tela/erro de credencial inválida — não vaza
|
|
273
326
|
// se a conta existe nem qual preferência está gravada).
|
|
274
|
-
const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg,
|
|
327
|
+
const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettings);
|
|
275
328
|
if (!userMethodsForLogin.authMethods.password) {
|
|
276
|
-
const found = await cfg.accountStore.findByEmail(
|
|
329
|
+
const found = await cfg.accountStore.findByEmail(storeEmail);
|
|
277
330
|
const account = found
|
|
278
331
|
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
279
332
|
: null;
|
|
@@ -302,7 +355,7 @@ export default class AuthInteractionController {
|
|
|
302
355
|
// ainda. A sequência verificação + lockout + auditoria de falha é centralizada
|
|
303
356
|
// em attemptPasswordLogin; a renderização (lookup p/ personalização) fica aqui.
|
|
304
357
|
const result = await attemptPasswordLogin(cfg, {
|
|
305
|
-
email,
|
|
358
|
+
email: storeEmail,
|
|
306
359
|
password,
|
|
307
360
|
ip,
|
|
308
361
|
clientId,
|
|
@@ -326,7 +379,7 @@ export default class AuthInteractionController {
|
|
|
326
379
|
botProtection: undefined,
|
|
327
380
|
});
|
|
328
381
|
}
|
|
329
|
-
const found = await cfg.accountStore.findByEmail(
|
|
382
|
+
const found = await cfg.accountStore.findByEmail(storeEmail);
|
|
330
383
|
const account = found
|
|
331
384
|
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
332
385
|
: null;
|
|
@@ -407,7 +460,7 @@ export default class AuthInteractionController {
|
|
|
407
460
|
clientId,
|
|
408
461
|
trustedDevice: true,
|
|
409
462
|
});
|
|
410
|
-
ctx
|
|
463
|
+
forgetLoginEmail(ctx);
|
|
411
464
|
return;
|
|
412
465
|
}
|
|
413
466
|
}
|
|
@@ -447,7 +500,7 @@ export default class AuthInteractionController {
|
|
|
447
500
|
}
|
|
448
501
|
}
|
|
449
502
|
await service.interactions.completeLogin(ctx, acc.id, { remember });
|
|
450
|
-
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
|
|
503
|
+
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email: acc.email, ip, clientId });
|
|
451
504
|
// single-session: após o completeLogin, revoga sessões que existiam ANTES.
|
|
452
505
|
// A sessão nova foi criada pelo provider no resume — ela NÃO está em prevSessionIds.
|
|
453
506
|
if (sessionPolicy.singleSession && prevSessionIds.size > 0) {
|
|
@@ -467,7 +520,7 @@ export default class AuthInteractionController {
|
|
|
467
520
|
}
|
|
468
521
|
}
|
|
469
522
|
// Clean up the session key after a successful login.
|
|
470
|
-
ctx
|
|
523
|
+
forgetLoginEmail(ctx);
|
|
471
524
|
}
|
|
472
525
|
/**
|
|
473
526
|
* POST /auth/interaction/:uid/mfa
|
|
@@ -554,7 +607,7 @@ export default class AuthInteractionController {
|
|
|
554
607
|
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
555
608
|
// Finaliza a interaction para o accountId pendente.
|
|
556
609
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
557
|
-
ctx
|
|
610
|
+
forgetLoginEmail(ctx);
|
|
558
611
|
await notifyLoginSuccess(ctx, cfg, {
|
|
559
612
|
accountId,
|
|
560
613
|
ip,
|
|
@@ -675,7 +728,7 @@ export default class AuthInteractionController {
|
|
|
675
728
|
return pending;
|
|
676
729
|
if (!cfg.passwordless?.passkeyFirst)
|
|
677
730
|
return undefined;
|
|
678
|
-
const email = ctx
|
|
731
|
+
const email = lookupEmail(ctx);
|
|
679
732
|
if (!email)
|
|
680
733
|
return undefined;
|
|
681
734
|
const acc = await cfg.accountStore.findByEmail(email);
|
|
@@ -696,6 +749,9 @@ export default class AuthInteractionController {
|
|
|
696
749
|
const details = await service.interactions.details(ctx);
|
|
697
750
|
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
698
751
|
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);
|
|
699
755
|
const uid = ctx.request.param('uid');
|
|
700
756
|
// Login por OTP: liga o campo de código na tela "link enviado" quando a config
|
|
701
757
|
// está ligada E o store suporta a capacidade.
|
|
@@ -707,7 +763,7 @@ export default class AuthInteractionController {
|
|
|
707
763
|
// Preferência por usuário: magic link desligado para ESTA conta → não emite
|
|
708
764
|
// token (resposta uniforme "enviado", sem vazar a preferência).
|
|
709
765
|
const userScopedMethods = email && cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore)
|
|
710
|
-
? await this.#userScopedLoginMethods(ctx, cfg,
|
|
766
|
+
? await this.#userScopedLoginMethods(ctx, cfg, storeEmail)
|
|
711
767
|
: null;
|
|
712
768
|
if (userScopedMethods && !userScopedMethods.authMethods.magicLink) {
|
|
713
769
|
return render(ctx, 'login', {
|
|
@@ -722,23 +778,23 @@ export default class AuthInteractionController {
|
|
|
722
778
|
magicChannel: 'none',
|
|
723
779
|
});
|
|
724
780
|
}
|
|
725
|
-
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) &&
|
|
781
|
+
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && storeEmail) {
|
|
726
782
|
const ip = ctx.request.ip?.() ?? null;
|
|
727
783
|
const clientId = details.params.client_id ?? null;
|
|
728
784
|
// Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
|
|
729
785
|
// senão, o magic link puro de sempre.
|
|
730
786
|
const issued = otpEnabled
|
|
731
|
-
? await cfg.accountStore.issueMagicLinkWithCode(
|
|
787
|
+
? await cfg.accountStore.issueMagicLinkWithCode(storeEmail, uid, {
|
|
732
788
|
digits: cfg.login.otp.digits,
|
|
733
789
|
ttlMinutes: cfg.login.otp.ttlMinutes,
|
|
734
790
|
})
|
|
735
|
-
: await cfg.accountStore.issueMagicLinkToken(
|
|
791
|
+
: await cfg.accountStore.issueMagicLinkToken(storeEmail);
|
|
736
792
|
if (issued) {
|
|
737
793
|
const code = 'code' in issued ? issued.code : undefined;
|
|
738
794
|
await cfg.audit?.record({
|
|
739
795
|
type: 'login.magic_link_sent',
|
|
740
796
|
accountId: issued.account.id,
|
|
741
|
-
email,
|
|
797
|
+
email: issued.account.email,
|
|
742
798
|
ip,
|
|
743
799
|
clientId,
|
|
744
800
|
});
|
|
@@ -746,18 +802,19 @@ export default class AuthInteractionController {
|
|
|
746
802
|
await cfg.audit?.record({
|
|
747
803
|
type: 'login.otp_sent',
|
|
748
804
|
accountId: issued.account.id,
|
|
749
|
-
email,
|
|
805
|
+
email: issued.account.email,
|
|
750
806
|
ip,
|
|
751
807
|
clientId,
|
|
752
808
|
});
|
|
753
809
|
}
|
|
754
810
|
const origin = authkitOrigin(cfg);
|
|
755
811
|
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
812
|
+
const to = issued.account.email;
|
|
756
813
|
if (cfg.mail?.onMagicLink) {
|
|
757
|
-
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
|
|
814
|
+
await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token, code, channel });
|
|
758
815
|
}
|
|
759
816
|
else {
|
|
760
|
-
await sendMagicLinkEmail(ctx, { email, magicUrl, code, channel });
|
|
817
|
+
await sendMagicLinkEmail(ctx, { email: to, magicUrl, code, channel });
|
|
761
818
|
}
|
|
762
819
|
}
|
|
763
820
|
}
|
|
@@ -766,7 +823,7 @@ export default class AuthInteractionController {
|
|
|
766
823
|
// local usado acima para decidir a emissão) — não re-declarado aqui.
|
|
767
824
|
return render(ctx, 'login', {
|
|
768
825
|
...(email
|
|
769
|
-
? await this.#userScopedLoginMethods(ctx, cfg,
|
|
826
|
+
? await this.#userScopedLoginMethods(ctx, cfg, storeEmail)
|
|
770
827
|
: await this.#loginMethods(ctx, cfg)),
|
|
771
828
|
uid,
|
|
772
829
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -895,7 +952,7 @@ export default class AuthInteractionController {
|
|
|
895
952
|
clientId: clientId ?? null,
|
|
896
953
|
metadata: { method: 'magic_link' },
|
|
897
954
|
});
|
|
898
|
-
ctx
|
|
955
|
+
forgetLoginEmail(ctx);
|
|
899
956
|
await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
|
|
900
957
|
}
|
|
901
958
|
/**
|
|
@@ -916,9 +973,11 @@ export default class AuthInteractionController {
|
|
|
916
973
|
const ip = ctx.request.ip?.() ?? null;
|
|
917
974
|
const clientId = (await service.interactions.details(ctx)).params.client_id;
|
|
918
975
|
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);
|
|
919
978
|
// Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
|
|
920
979
|
const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
|
|
921
|
-
if (!otpEnabled || !email) {
|
|
980
|
+
if (!otpEnabled || !email || !storeEmail) {
|
|
922
981
|
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
923
982
|
}
|
|
924
983
|
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
@@ -926,14 +985,14 @@ export default class AuthInteractionController {
|
|
|
926
985
|
// Mantém a sub-view do seletor no re-render de erro (o form de código pode
|
|
927
986
|
// POSTar `channel=code`). Ausente = both (histórico).
|
|
928
987
|
const channel = normalizeLoginChannel(ctx.request.input('channel'));
|
|
929
|
-
const result = await cfg.accountStore.verifyLoginCode(
|
|
988
|
+
const result = await cfg.accountStore.verifyLoginCode(storeEmail, uid, code, {
|
|
930
989
|
maxAttempts: cfg.login.otp.maxAttempts,
|
|
931
990
|
});
|
|
932
991
|
// Re-render da tela "link enviado" com o campo de código + erro localizado.
|
|
933
992
|
// `otpEnabled` vem do spread (scoped — OTP é extensão do magic link, e a
|
|
934
993
|
// preferência do usuário que desligou magic link já foi barrada acima).
|
|
935
994
|
const renderOtpError = async (messageKey) => render(ctx, 'login', {
|
|
936
|
-
...(await this.#userScopedLoginMethods(ctx, cfg,
|
|
995
|
+
...(await this.#userScopedLoginMethods(ctx, cfg, storeEmail)),
|
|
937
996
|
uid,
|
|
938
997
|
csrfToken: ctx.request.csrfToken,
|
|
939
998
|
step: 'password',
|
|
@@ -1008,18 +1067,18 @@ export default class AuthInteractionController {
|
|
|
1008
1067
|
clientId: clientId ?? null,
|
|
1009
1068
|
metadata: { method: 'otp' },
|
|
1010
1069
|
});
|
|
1011
|
-
ctx
|
|
1070
|
+
forgetLoginEmail(ctx);
|
|
1012
1071
|
return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
|
|
1013
1072
|
}
|
|
1014
1073
|
if (result.status === 'locked') {
|
|
1015
1074
|
// 5ª falha (ou já travado): código invalidado, o LINK continua válido.
|
|
1016
|
-
await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
|
|
1075
|
+
await cfg.audit?.record({ type: 'login.otp_invalidated', email: storeEmail, ip, clientId });
|
|
1017
1076
|
return renderOtpError('login.otp_locked');
|
|
1018
1077
|
}
|
|
1019
1078
|
if (result.status === 'expired') {
|
|
1020
1079
|
await cfg.audit?.record({
|
|
1021
1080
|
type: 'login.otp_failed',
|
|
1022
|
-
email,
|
|
1081
|
+
email: storeEmail,
|
|
1023
1082
|
ip,
|
|
1024
1083
|
clientId,
|
|
1025
1084
|
metadata: { reason: 'expired' },
|
|
@@ -1029,7 +1088,7 @@ export default class AuthInteractionController {
|
|
|
1029
1088
|
// 'invalid' (tentativa contabilizada) ou 'no_code'.
|
|
1030
1089
|
await cfg.audit?.record({
|
|
1031
1090
|
type: 'login.otp_failed',
|
|
1032
|
-
email,
|
|
1091
|
+
email: storeEmail,
|
|
1033
1092
|
ip,
|
|
1034
1093
|
clientId,
|
|
1035
1094
|
metadata: { reason: result.status },
|
|
@@ -1252,7 +1311,7 @@ export default class AuthInteractionController {
|
|
|
1252
1311
|
// Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
|
|
1253
1312
|
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
1254
1313
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
1255
|
-
ctx
|
|
1314
|
+
forgetLoginEmail(ctx);
|
|
1256
1315
|
await notifyLoginSuccess(ctx, cfg, {
|
|
1257
1316
|
accountId,
|
|
1258
1317
|
ip,
|
|
@@ -1268,7 +1327,7 @@ export default class AuthInteractionController {
|
|
|
1268
1327
|
* Clears the stored email and redirects back to step 1.
|
|
1269
1328
|
*/
|
|
1270
1329
|
async switchIdentifier(ctx) {
|
|
1271
|
-
ctx
|
|
1330
|
+
forgetLoginEmail(ctx);
|
|
1272
1331
|
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
1273
1332
|
}
|
|
1274
1333
|
/**
|
|
@@ -5,6 +5,7 @@ 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';
|
|
8
9
|
import { translate } from '../i18n.js';
|
|
9
10
|
import { authkitOrigin } from '../origin.js';
|
|
10
11
|
import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
|
|
@@ -118,7 +119,13 @@ export default class AuthRegistrationController {
|
|
|
118
119
|
}
|
|
119
120
|
const data = await ctx.request.validateUsing(signupValidator);
|
|
120
121
|
const accountStore = cfg.accountStore;
|
|
121
|
-
|
|
122
|
+
// Duplicado: além do endereço normalizado, enxerga a conta que o cadastro
|
|
123
|
+
// antigo gravou mutilada — senão a mesma pessoa ganharia uma SEGUNDA conta.
|
|
124
|
+
// Usa o valor CRU do formulário (o validator já normalizou `data.email`, e a
|
|
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;
|
|
122
129
|
if (existing) {
|
|
123
130
|
return render(ctx, 'signup', {
|
|
124
131
|
uid: ctx.request.param('uid'),
|
|
@@ -217,7 +224,10 @@ export default class AuthRegistrationController {
|
|
|
217
224
|
const data = await ctx.request.validateUsing(passwordlessSignupValidator);
|
|
218
225
|
// Cria a conta se ainda não existe. Senha random inutilizável: o login é 100%
|
|
219
226
|
// passwordless (mesmo precedente das contas criadas por identidade social).
|
|
220
|
-
|
|
227
|
+
// Valor CRU do formulário pelo mesmo motivo do cadastro com senha.
|
|
228
|
+
const existing = (await resolveEmailIdentifier(accountStore, ctx.request.input('email'), {
|
|
229
|
+
legacyFallback: cfg.login?.legacyEmailFallback ?? true,
|
|
230
|
+
})).account;
|
|
221
231
|
if (!existing) {
|
|
222
232
|
const created = await accountStore.create({
|
|
223
233
|
email: data.email,
|
|
@@ -233,15 +243,19 @@ export default class AuthRegistrationController {
|
|
|
233
243
|
});
|
|
234
244
|
}
|
|
235
245
|
// Emite + envia o magic link (mesma construção do login por magic link).
|
|
236
|
-
|
|
246
|
+
// Conta já existente entra pelo endereço sob o qual está GRAVADA (pode ser a
|
|
247
|
+
// forma mutilada pelo cadastro antigo); conta nova, pelo endereço digitado.
|
|
248
|
+
const issueFor = existing?.email ?? data.email;
|
|
249
|
+
const issued = await accountStore.issueMagicLinkToken(issueFor);
|
|
237
250
|
if (issued) {
|
|
238
251
|
const origin = authkitOrigin(cfg);
|
|
239
252
|
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
253
|
+
const to = issued.account.email;
|
|
240
254
|
if (cfg.mail?.onMagicLink) {
|
|
241
|
-
await cfg.mail.onMagicLink({ email:
|
|
255
|
+
await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
|
|
242
256
|
}
|
|
243
257
|
else {
|
|
244
|
-
await sendMagicLinkEmail(ctx, { email:
|
|
258
|
+
await sendMagicLinkEmail(ctx, { email: to, magicUrl });
|
|
245
259
|
}
|
|
246
260
|
}
|
|
247
261
|
// Resposta uniforme: "enviamos um link" (não vaza existência da conta).
|
|
@@ -322,21 +336,33 @@ export default class AuthRegistrationController {
|
|
|
322
336
|
}
|
|
323
337
|
const { email } = await ctx.request.validateUsing(forgotPasswordValidator);
|
|
324
338
|
const accountStore = cfg.accountStore;
|
|
325
|
-
|
|
339
|
+
let result = await accountStore.issuePasswordResetToken(email);
|
|
340
|
+
// Ponte legada: quem teve o endereço mutilado pelo cadastro antigo precisa
|
|
341
|
+
// conseguir resetar a senha digitando o endereço REAL. Só entra quando o
|
|
342
|
+
// endereço normalizado não achou nada — o caminho feliz segue com UMA
|
|
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
|
+
}
|
|
326
350
|
if (result) {
|
|
327
351
|
await cfg.audit?.record({
|
|
328
352
|
type: 'password_reset.issued',
|
|
329
|
-
email,
|
|
353
|
+
email: result.account.email,
|
|
330
354
|
ip: ctx.request.ip?.() ?? null,
|
|
331
355
|
});
|
|
332
356
|
const origin = authkitOrigin(cfg);
|
|
333
357
|
const url = `${origin}/auth/reset-password?token=${result.token}`;
|
|
334
358
|
// Hook do config tem prioridade (override); senão usa o mailer default do host.
|
|
359
|
+
// O link vai para a caixa postal sob a qual a conta está gravada.
|
|
360
|
+
const to = result.account.email;
|
|
335
361
|
if (cfg.mail?.onPasswordReset) {
|
|
336
|
-
await cfg.mail.onPasswordReset({ email, resetUrl: url, token: result.token });
|
|
362
|
+
await cfg.mail.onPasswordReset({ email: to, resetUrl: url, token: result.token });
|
|
337
363
|
}
|
|
338
364
|
else {
|
|
339
|
-
await sendPasswordResetEmail(ctx, { email, resetUrl: url });
|
|
365
|
+
await sendPasswordResetEmail(ctx, { email: to, resetUrl: url });
|
|
340
366
|
}
|
|
341
367
|
}
|
|
342
368
|
return render(ctx, 'forgot', {
|