@adonis-agora/authkit-server 0.49.0 → 0.51.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/host/views/login.edge +25 -2
- package/build/index.d.ts +3 -2
- package/build/index.js +3 -1
- package/build/src/accounts/account_store.d.ts +59 -1
- package/build/src/accounts/account_store.js +5 -0
- package/build/src/accounts/lucid_store/core.d.ts +2 -2
- package/build/src/accounts/lucid_store/core.js +105 -4
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/define_config.d.ts +29 -0
- package/build/src/define_config.js +5 -0
- package/build/src/host/controllers/interaction_controller.d.ts +11 -0
- package/build/src/host/controllers/interaction_controller.js +149 -6
- package/build/src/host/default_mailer.d.ts +2 -0
- package/build/src/host/default_mailer.js +35 -11
- package/build/src/host/email_templates.d.ts +19 -4
- package/build/src/host/email_templates.js +18 -6
- package/build/src/host/i18n.d.ts +20 -0
- package/build/src/host/i18n.js +24 -0
- package/build/src/host/login_channel.d.ts +30 -0
- package/build/src/host/login_channel.js +26 -0
- package/build/src/host/otp_login.d.ts +155 -0
- package/build/src/host/otp_login.js +206 -0
- package/build/src/host/rate_limit.d.ts +6 -0
- package/build/src/host/rate_limit.js +3 -0
- package/build/src/host/register_auth_host.js +8 -0
- package/package.json +1 -1
|
@@ -190,9 +190,32 @@
|
|
|
190
190
|
@end
|
|
191
191
|
@end
|
|
192
192
|
|
|
193
|
-
{{-- Passwordless: confirmação de magic link enviado (anti-enumeração).
|
|
193
|
+
{{-- Passwordless: confirmação de magic link enviado (anti-enumeração).
|
|
194
|
+
`magicChannel` (choose-first) escolhe a sub-view: 'code' = só o campo de
|
|
195
|
+
código, 'link' = só o aviso de link, 'both'/ausente = ambos (histórico). --}}
|
|
194
196
|
@if(magicLinkSent)
|
|
195
|
-
|
|
197
|
+
@if(magicChannel !== 'code')
|
|
198
|
+
<p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
|
|
199
|
+
@end
|
|
200
|
+
|
|
201
|
+
{{-- Login por OTP: campo de código digitável (mesmo e-mail carrega link E código). --}}
|
|
202
|
+
@if(otpEnabled && magicChannel !== 'link')
|
|
203
|
+
<form method="POST" action="/auth/interaction/{{ uid }}/otp-verify" class="mt-4">
|
|
204
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
205
|
+
<input type="hidden" name="channel" value="code">
|
|
206
|
+
<label for="otp-code" class="block text-sm font-medium text-gray-700">{{ t('login.otp_label') }}</label>
|
|
207
|
+
<input id="otp-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code"
|
|
208
|
+
pattern="[0-9]*" placeholder="{{ t('login.otp_placeholder') }}"
|
|
209
|
+
class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center text-lg tracking-[0.4em] font-mono focus:border-gray-900 focus:ring-gray-900" />
|
|
210
|
+
@if(otpError)
|
|
211
|
+
<p class="mt-2 text-sm text-red-600">{{ otpError }}</p>
|
|
212
|
+
@end
|
|
213
|
+
<button type="submit"
|
|
214
|
+
class="mt-4 w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:opacity-90">
|
|
215
|
+
{{ t('login.otp_submit') }}
|
|
216
|
+
</button>
|
|
217
|
+
</form>
|
|
218
|
+
@end
|
|
196
219
|
@end
|
|
197
220
|
|
|
198
221
|
{{-- Passwordless: "me envie um link de login" (mesma sessão/e-mail; não pede senha). --}}
|
package/build/index.d.ts
CHANGED
|
@@ -23,8 +23,9 @@ export { lucidAccountStore, appKeyEncrypter } from './src/accounts/lucid_account
|
|
|
23
23
|
export { lucidStores } from './src/accounts/lucid_stores.js';
|
|
24
24
|
export type { LucidStoresModels, LucidStoresOptions, LucidStoresResult, } from './src/accounts/lucid_stores.js';
|
|
25
25
|
export type { LucidAccountStoreOptions, AccountSecretEncrypter, } from './src/accounts/lucid_account_store.js';
|
|
26
|
-
export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, ProviderIdentitySummary, AccountSecurityCapability, AccountStatusCapability, ProfileCapability, MagicLinkCapability, EmailVerificationStatusCapability, AccountDeletionCapability, AccountImportCapability, ImportAccountInput, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
|
|
27
|
-
export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
|
|
26
|
+
export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, ProviderIdentitySummary, AccountSecurityCapability, AccountStatusCapability, ProfileCapability, MagicLinkCapability, OtpLoginCapability, OtpLoginVerifyResult, EmailVerificationStatusCapability, AccountDeletionCapability, AccountImportCapability, ImportAccountInput, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
|
|
27
|
+
export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
|
|
28
|
+
export { type OtpLoginConfigInput, type ResolvedOtpLoginConfig, type OtpVerifyOutcome, resolveOtpLoginConfig, generateOtpCode, evaluateLoginOtp, OTP_LOGIN_DEFAULTS, } from './src/host/otp_login.js';
|
|
28
29
|
export { PasswordManager, PasswordPolicyError, } from './src/password/password_manager.js';
|
|
29
30
|
export type { PasswordConfigInput, LegacyPasswordVerifier, PasswordVerifyResult, } from './src/password/password_manager.js';
|
|
30
31
|
export { checkPasswordPolicy, policyViolationParams, DEFAULT_PWNED_TIMEOUT_MS, } from './src/password/policy.js';
|
package/build/index.js
CHANGED
|
@@ -14,7 +14,9 @@ export { resolveTrustedDevices, isTrustedDeviceValid, buildTrustedDevicePayload,
|
|
|
14
14
|
export { resolveBotProtection, botProtectionApplies, extractBotToken, verifyBotProtection, guardBotProtection, DEFAULT_BOT_TOKEN_FIELDS, } from './src/host/bot_protection.js';
|
|
15
15
|
export { lucidAccountStore, appKeyEncrypter } from './src/accounts/lucid_account_store.js';
|
|
16
16
|
export { lucidStores } from './src/accounts/lucid_stores.js';
|
|
17
|
-
export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
|
|
17
|
+
export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
|
|
18
|
+
// Login por OTP (código digitável): config + helpers puros.
|
|
19
|
+
export { resolveOtpLoginConfig, generateOtpCode, evaluateLoginOtp, OTP_LOGIN_DEFAULTS, } from './src/host/otp_login.js';
|
|
18
20
|
// Gerência de senha: lazy rehash + legacy verifier, política e checagem de vazamento.
|
|
19
21
|
export { PasswordManager, PasswordPolicyError, } from './src/password/password_manager.js';
|
|
20
22
|
export { checkPasswordPolicy, policyViolationParams, DEFAULT_PWNED_TIMEOUT_MS, } from './src/password/policy.js';
|
|
@@ -421,6 +421,62 @@ export interface MagicLinkCapability {
|
|
|
421
421
|
*/
|
|
422
422
|
consumeMagicLinkToken(token: string): Promise<AuthAccount | null>;
|
|
423
423
|
}
|
|
424
|
+
/** Resultado tipado da verificação de um código OTP de login. */
|
|
425
|
+
export type OtpLoginVerifyResult = {
|
|
426
|
+
status: 'ok';
|
|
427
|
+
account: AuthAccount;
|
|
428
|
+
}
|
|
429
|
+
/** Código errado, tentativa contabilizada (ainda NÃO travado). */
|
|
430
|
+
| {
|
|
431
|
+
status: 'invalid';
|
|
432
|
+
}
|
|
433
|
+
/** Tentativas esgotadas → código invalidado (o link continua válido). */
|
|
434
|
+
| {
|
|
435
|
+
status: 'locked';
|
|
436
|
+
}
|
|
437
|
+
/** TTL do código expirou. */
|
|
438
|
+
| {
|
|
439
|
+
status: 'expired';
|
|
440
|
+
}
|
|
441
|
+
/** Nenhum código pendente para esta interaction/conta. */
|
|
442
|
+
| {
|
|
443
|
+
status: 'no_code';
|
|
444
|
+
};
|
|
445
|
+
/**
|
|
446
|
+
* Login por OTP (código digitável) — extensão do magic link. CAPACIDADE
|
|
447
|
+
* opcional: quando ausente (ou `login.otp.enabled` desligado) o comportamento é
|
|
448
|
+
* exatamente o de antes (só magic link).
|
|
449
|
+
*
|
|
450
|
+
* O store default (Lucid) CO-LOCALIZA o código com o magic link no MESMO slot
|
|
451
|
+
* (`passwordResetToken`, prefixo `ml2:`), de modo que consumir um mata o outro
|
|
452
|
+
* (single-use conjunto) e o contador de tentativas fica PERSISTIDO junto do
|
|
453
|
+
* código (lockout fail-closed, sem depender de limiter). Ver `host/otp_login.ts`
|
|
454
|
+
* para a decisão de armazenamento completa.
|
|
455
|
+
*/
|
|
456
|
+
export interface OtpLoginCapability {
|
|
457
|
+
/**
|
|
458
|
+
* Emite o magic link E um código OTP de uma vez (mesmo disparo/e-mail). O
|
|
459
|
+
* `token` retornado vai na URL do link; o `code` (dígitos) vai no corpo do
|
|
460
|
+
* e-mail. Retorna null se a conta não existe (o controller sempre responde
|
|
461
|
+
* "enviado", anti-enumeração). O código fica atrelado ao `uid` da interaction.
|
|
462
|
+
*/
|
|
463
|
+
issueMagicLinkWithCode(email: string, uid: string, opts: {
|
|
464
|
+
digits: number;
|
|
465
|
+
ttlMinutes: number;
|
|
466
|
+
}): Promise<{
|
|
467
|
+
token: string;
|
|
468
|
+
code: string;
|
|
469
|
+
account: AuthAccount;
|
|
470
|
+
} | null>;
|
|
471
|
+
/**
|
|
472
|
+
* Verifica um código para a interaction `uid`. Em sucesso consome o código E o
|
|
473
|
+
* magic link (single-use conjunto). Falha incrementa o contador persistido; ao
|
|
474
|
+
* esgotar `maxAttempts` invalida o código mantendo o link válido.
|
|
475
|
+
*/
|
|
476
|
+
verifyLoginCode(email: string, uid: string, code: string, opts: {
|
|
477
|
+
maxAttempts: number;
|
|
478
|
+
}): Promise<OtpLoginVerifyResult>;
|
|
479
|
+
}
|
|
424
480
|
/** DTO público de uma organização. */
|
|
425
481
|
export interface OrgSummary {
|
|
426
482
|
id: string;
|
|
@@ -543,7 +599,7 @@ export type AccountStore = CoreAccountStore & {
|
|
|
543
599
|
* blocos `Partial<...>` de capacidades probáveis.
|
|
544
600
|
*/
|
|
545
601
|
readonly connectionName?: string;
|
|
546
|
-
} & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability>;
|
|
602
|
+
} & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability>;
|
|
547
603
|
/** Type guard: o store implementa a capacidade de MFA / TOTP. */
|
|
548
604
|
export declare function supportsMfa(store: AccountStore): store is AccountStore & MfaCapability;
|
|
549
605
|
/**
|
|
@@ -566,6 +622,8 @@ export declare function supportsAccountStatus(store: AccountStore): store is Acc
|
|
|
566
622
|
export declare function supportsProfile(store: AccountStore): store is AccountStore & ProfileCapability;
|
|
567
623
|
/** Type guard: o store implementa login por magic link (passwordless). */
|
|
568
624
|
export declare function supportsMagicLink(store: AccountStore): store is AccountStore & MagicLinkCapability;
|
|
625
|
+
/** Type guard: o store implementa o login por OTP (código digitável). */
|
|
626
|
+
export declare function supportsOtpLogin(store: AccountStore): store is AccountStore & OtpLoginCapability;
|
|
569
627
|
/** Type guard: o store consegue dizer se o e-mail de uma conta está verificado. */
|
|
570
628
|
export declare function supportsEmailVerificationStatus(store: AccountStore): store is AccountStore & EmailVerificationStatusCapability;
|
|
571
629
|
/** Type guard: o store implementa a deleção (hard delete) da conta. */
|
|
@@ -38,6 +38,11 @@ export function supportsProfile(store) {
|
|
|
38
38
|
export function supportsMagicLink(store) {
|
|
39
39
|
return typeof store.issueMagicLinkToken === 'function';
|
|
40
40
|
}
|
|
41
|
+
/** Type guard: o store implementa o login por OTP (código digitável). */
|
|
42
|
+
export function supportsOtpLogin(store) {
|
|
43
|
+
return (typeof store.issueMagicLinkWithCode === 'function' &&
|
|
44
|
+
typeof store.verifyLoginCode === 'function');
|
|
45
|
+
}
|
|
41
46
|
/** Type guard: o store consegue dizer se o e-mail de uma conta está verificado. */
|
|
42
47
|
export function supportsEmailVerificationStatus(store) {
|
|
43
48
|
return typeof store.isEmailVerified === 'function';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountImportCapability, AccountSecurityCapability, CoreAccountStore, MagicLinkCapability } from '../account_store.js';
|
|
1
|
+
import type { AccountImportCapability, AccountSecurityCapability, CoreAccountStore, MagicLinkCapability, OtpLoginCapability } from '../account_store.js';
|
|
2
2
|
import type { LucidStoreContext } from './shared.js';
|
|
3
3
|
/**
|
|
4
4
|
* Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
|
|
@@ -6,4 +6,4 @@ import type { LucidStoreContext } from './shared.js';
|
|
|
6
6
|
* (listagem paginada + roles globais) e o self-service de segurança
|
|
7
7
|
* ({@link AccountSecurityCapability}: trocar senha/e-mail).
|
|
8
8
|
*/
|
|
9
|
-
export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability & MagicLinkCapability & AccountImportCapability;
|
|
9
|
+
export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability & MagicLinkCapability & OtpLoginCapability & AccountImportCapability;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt';
|
|
3
3
|
import { DateTime } from 'luxon';
|
|
4
|
+
import { OTP_LOGIN_PREFIX, decodeOtpToken, encodeOtpToken, evaluateLoginOtp, generateOtpCode, hashLoginOtp, linkTokenFromOtpUrl, } from '../../host/otp_login.js';
|
|
4
5
|
import { hasColumn } from './status_profile.js';
|
|
5
6
|
/** Prefixo do token de troca de e-mail (reaproveita a coluna emailVerificationToken). */
|
|
6
7
|
const EMAIL_CHANGE_PREFIX = 'ec:';
|
|
@@ -131,9 +132,9 @@ export function buildCore(ctx) {
|
|
|
131
132
|
return { token, account: toAccount(row) };
|
|
132
133
|
},
|
|
133
134
|
async consumePasswordResetToken(token, newPassword) {
|
|
134
|
-
// Magic links (`ml:`) NÃO são tokens de reset de senha —
|
|
135
|
-
// consumeMagicLinkToken pode consumi-los (não trocam senha).
|
|
136
|
-
if (token.startsWith(MAGIC_LINK_PREFIX))
|
|
135
|
+
// Magic links (`ml:` e `ml2:` com OTP) NÃO são tokens de reset de senha —
|
|
136
|
+
// só o fluxo de consumeMagicLinkToken pode consumi-los (não trocam senha).
|
|
137
|
+
if (token.startsWith(MAGIC_LINK_PREFIX) || token.startsWith(OTP_LOGIN_PREFIX))
|
|
137
138
|
return false;
|
|
138
139
|
const row = await Model.query().where('passwordResetToken', token).first();
|
|
139
140
|
if (!row)
|
|
@@ -167,7 +168,29 @@ export function buildCore(ctx) {
|
|
|
167
168
|
return { token, account: toAccount(row) };
|
|
168
169
|
},
|
|
169
170
|
async consumeMagicLinkToken(token) {
|
|
170
|
-
if (!token
|
|
171
|
+
if (!token)
|
|
172
|
+
return null;
|
|
173
|
+
// Magic link com OTP ativo: o slot guarda `ml2:<linkToken>:<...>` mas a URL
|
|
174
|
+
// carrega só `ml2:<linkToken>`. Busca pelo prefixo do link (linkToken é hex
|
|
175
|
+
// validado — sem metacaractere de LIKE) e consome o slot inteiro (mata o
|
|
176
|
+
// código junto — single-use conjunto).
|
|
177
|
+
if (token.startsWith(OTP_LOGIN_PREFIX)) {
|
|
178
|
+
const linkToken = linkTokenFromOtpUrl(token);
|
|
179
|
+
if (!linkToken)
|
|
180
|
+
return null;
|
|
181
|
+
const row = await Model.query()
|
|
182
|
+
.where('passwordResetToken', 'like', `${OTP_LOGIN_PREFIX}${linkToken}:%`)
|
|
183
|
+
.first();
|
|
184
|
+
if (!row)
|
|
185
|
+
return null;
|
|
186
|
+
if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
|
|
187
|
+
return null;
|
|
188
|
+
row.passwordResetToken = null;
|
|
189
|
+
row.passwordResetExpiresAt = null;
|
|
190
|
+
await row.save();
|
|
191
|
+
return toAccount(row);
|
|
192
|
+
}
|
|
193
|
+
if (!token.startsWith(MAGIC_LINK_PREFIX))
|
|
171
194
|
return null;
|
|
172
195
|
const row = await Model.query().where('passwordResetToken', token).first();
|
|
173
196
|
if (!row)
|
|
@@ -180,6 +203,84 @@ export function buildCore(ctx) {
|
|
|
180
203
|
await row.save();
|
|
181
204
|
return toAccount(row);
|
|
182
205
|
},
|
|
206
|
+
// ----- Login por OTP (código digitável — extensão do magic link) -----
|
|
207
|
+
async issueMagicLinkWithCode(email, uid, opts) {
|
|
208
|
+
const row = await Model.query().where('email', email).first();
|
|
209
|
+
if (!row)
|
|
210
|
+
return null;
|
|
211
|
+
const linkToken = randomBytes(32).toString('hex');
|
|
212
|
+
const code = generateOtpCode(opts.digits);
|
|
213
|
+
const codeHash = hashLoginOtp(uid, code);
|
|
214
|
+
const codeExpMs = DateTime.now().plus({ minutes: opts.ttlMinutes }).toMillis();
|
|
215
|
+
// Slot `ml2:` — código + link juntos, contador em 0. Ver host/otp_login.ts.
|
|
216
|
+
row.passwordResetToken = encodeOtpToken({ linkToken, codeHash, codeExpMs, attempts: 0 });
|
|
217
|
+
// O LINK herda a validade padrão do magic link (15 min); o CÓDIGO carrega o
|
|
218
|
+
// próprio `codeExpMs` (mais curto) embutido no slot.
|
|
219
|
+
row.passwordResetExpiresAt = DateTime.now().plus({ minutes: 15 });
|
|
220
|
+
await row.save();
|
|
221
|
+
return { token: `${OTP_LOGIN_PREFIX}${linkToken}`, code, account: toAccount(row) };
|
|
222
|
+
},
|
|
223
|
+
async verifyLoginCode(email, uid, code, opts) {
|
|
224
|
+
// ── Atomicidade do contador de lockout (barreira PRIMÁRIA, fail-closed) ──
|
|
225
|
+
// O contador de tentativas vive DENTRO do slot `ml2:` e é a única barreira
|
|
226
|
+
// contra brute-force do código curto (o throttle de rota é camada EXTRA e
|
|
227
|
+
// pode estar ausente). Um read-modify-write ingênuo (first→avaliar→save) é
|
|
228
|
+
// derrotável por concorrência: N requests leem o MESMO contador, todos
|
|
229
|
+
// gravam `attempts+1` (last-write-wins) e o lockout nunca dispara — pior,
|
|
230
|
+
// como a COMPARAÇÃO do código acontece após a leitura, N requests
|
|
231
|
+
// concorrentes conseguem N comparações contra o MESMO valor do contador,
|
|
232
|
+
// varrendo o espaço de 10^6 dentro do TTL.
|
|
233
|
+
//
|
|
234
|
+
// Correção: serializa o read-compare-write numa TRANSAÇÃO com row-lock
|
|
235
|
+
// (`forUpdate`). Cada tentativa lê o estado JÁ commitado pela anterior, o
|
|
236
|
+
// contador avança 1-a-1 e — porque a comparação vive DENTRO da seção
|
|
237
|
+
// crítica — o total de comparações contra um mesmo código fica limitado a
|
|
238
|
+
// `maxAttempts` (garantia DURA, não probabilística). No Postgres o lock é
|
|
239
|
+
// por linha; no sqlite a própria transação serializa. Os demais caminhos
|
|
240
|
+
// que tocam o slot (`consumeMagicLinkToken`, sucesso do OTP) só gravam
|
|
241
|
+
// `null` (terminal) — não regridem contador — e ainda serializam atrás
|
|
242
|
+
// deste lock (todo UPDATE trava a linha), então não podem ressuscitar um
|
|
243
|
+
// slot já consumido nem apagar um incremento.
|
|
244
|
+
const trx = await Model.query().client.transaction();
|
|
245
|
+
try {
|
|
246
|
+
const row = await Model.query({ client: trx }).where('email', email).forUpdate().first();
|
|
247
|
+
if (!row) {
|
|
248
|
+
await trx.commit();
|
|
249
|
+
return { status: 'no_code' };
|
|
250
|
+
}
|
|
251
|
+
const parsed = decodeOtpToken(row.passwordResetToken);
|
|
252
|
+
const evaluation = evaluateLoginOtp({
|
|
253
|
+
parsed,
|
|
254
|
+
uid,
|
|
255
|
+
code,
|
|
256
|
+
nowMs: DateTime.now().toMillis(),
|
|
257
|
+
maxAttempts: opts.maxAttempts,
|
|
258
|
+
});
|
|
259
|
+
// Efeito de persistência: `undefined` = não escreve; `null` = limpa o slot
|
|
260
|
+
// (sucesso, mata o link junto); string = novo slot (contador++/invalidação).
|
|
261
|
+
// A escrita ocorre DENTRO da mesma transação/lock da leitura.
|
|
262
|
+
if (evaluation.nextToken === null) {
|
|
263
|
+
row.useTransaction(trx);
|
|
264
|
+
row.passwordResetToken = null;
|
|
265
|
+
row.passwordResetExpiresAt = null;
|
|
266
|
+
await row.save();
|
|
267
|
+
}
|
|
268
|
+
else if (typeof evaluation.nextToken === 'string') {
|
|
269
|
+
// Contador/invalidação: preserva a validade do LINK (só o código muda).
|
|
270
|
+
row.useTransaction(trx);
|
|
271
|
+
row.passwordResetToken = evaluation.nextToken;
|
|
272
|
+
await row.save();
|
|
273
|
+
}
|
|
274
|
+
await trx.commit();
|
|
275
|
+
if (evaluation.result === 'ok')
|
|
276
|
+
return { status: 'ok', account: toAccount(row) };
|
|
277
|
+
return { status: evaluation.result };
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
await trx.rollback();
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
},
|
|
183
284
|
async issueEmailVerificationToken(email) {
|
|
184
285
|
const row = await Model.query().where('email', email).first();
|
|
185
286
|
if (!row)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP.
|
|
3
3
|
*/
|
|
4
|
-
export type AuditEventType = 'login.success' | 'login.failure' | 'signup' | 'password_reset.issued' | 'password_reset.consumed' | 'pat.issued' | 'pat.revoked' | 'pat.used' | 'impersonation' | 'impersonation.started' | 'mfa.enabled' | 'mfa.disabled' | 'account.locked' | 'passkey.registered' | 'passkey.removed' | 'email_verification.issued' | 'email_verification.consumed' | 'client.created' | 'client.updated' | 'client.deleted' | 'session.revoked_all' | 'password.changed' | 'password.rehashed' | 'email.change_requested' | 'email.changed' | 'login.new_ip_notified' | 'login.new_device' | 'bot_protection.rejected' | 'grant.revoked_by_user' | 'user.created' | 'user.password_reset_sent' | 'user.disabled' | 'user.enabled' | 'user.deleted' | 'profile.updated' | 'account.deleted' | 'account.exported' | 'keys.rotated' | 'organization.created' | 'organization.updated' | 'organization.deleted' | 'organization.member_added' | 'organization.member_removed' | 'organization.member_role_changed' | 'organization.member_role_updated' | 'organization.switched' | 'organization.deactivated' | 'organization.invitation_sent' | 'organization.invitation_accepted' | 'organization.invitation_revoked' | 'email_change.requested' | 'email_change.confirmed' | 'email_change.cancelled' | 'security_notice.sent' | 'settings.updated' | 'maintenance.enabled' | 'maintenance.disabled' | 'trusted_device.revoked' | 'password.expired_change_forced' | 'otp.locked' | 'otp.unlocked' | 'otp.unlock_failed' | 'sudo.confirmed' | 'session.single_enforced' | 'account.expired_login_blocked' | 'account.expiration_warned';
|
|
4
|
+
export type AuditEventType = 'login.success' | 'login.failure' | 'signup' | 'password_reset.issued' | 'password_reset.consumed' | 'pat.issued' | 'pat.revoked' | 'pat.used' | 'impersonation' | 'impersonation.started' | 'mfa.enabled' | 'mfa.disabled' | 'account.locked' | 'passkey.registered' | 'passkey.removed' | 'email_verification.issued' | 'email_verification.consumed' | 'client.created' | 'client.updated' | 'client.deleted' | 'session.revoked_all' | 'password.changed' | 'password.rehashed' | 'email.change_requested' | 'email.changed' | 'login.new_ip_notified' | 'login.new_device' | 'login.otp_sent' | 'login.otp_verified' | 'login.otp_failed' | 'login.otp_invalidated' | 'bot_protection.rejected' | 'grant.revoked_by_user' | 'user.created' | 'user.password_reset_sent' | 'user.disabled' | 'user.enabled' | 'user.deleted' | 'profile.updated' | 'account.deleted' | 'account.exported' | 'keys.rotated' | 'organization.created' | 'organization.updated' | 'organization.deleted' | 'organization.member_added' | 'organization.member_removed' | 'organization.member_role_changed' | 'organization.member_role_updated' | 'organization.switched' | 'organization.deactivated' | 'organization.invitation_sent' | 'organization.invitation_accepted' | 'organization.invitation_revoked' | 'email_change.requested' | 'email_change.confirmed' | 'email_change.cancelled' | 'security_notice.sent' | 'settings.updated' | 'maintenance.enabled' | 'maintenance.disabled' | 'trusted_device.revoked' | 'password.expired_change_forced' | 'otp.locked' | 'otp.unlocked' | 'otp.unlock_failed' | 'sudo.confirmed' | 'session.single_enforced' | 'account.expired_login_blocked' | 'account.expiration_warned';
|
|
5
5
|
/**
|
|
6
6
|
* Evento de auditoria a registrar. O timestamp é definido pelo sink (não aqui).
|
|
7
7
|
*/
|
|
@@ -8,6 +8,7 @@ import { type BotProtectionConfigInput, type ResolvedBotProtectionConfig } from
|
|
|
8
8
|
import type { BrandingConfig } from './host/branding.js';
|
|
9
9
|
import type { ResolveGeo } from './host/geo.js';
|
|
10
10
|
import { type AuthMessages, type I18nConfig } from './host/i18n.js';
|
|
11
|
+
import { type OtpLoginConfigInput, type ResolvedOtpLoginConfig } from './host/otp_login.js';
|
|
11
12
|
import type { SudoMethod } from './host/sudo/types.js';
|
|
12
13
|
import { type ResolvedTrustedDevicesConfig, type TrustedDevicesConfigInput } from './host/trusted_device.js';
|
|
13
14
|
import type { PatStore } from './pat/pat_store.js';
|
|
@@ -40,6 +41,19 @@ export interface MailHooks {
|
|
|
40
41
|
email: string;
|
|
41
42
|
magicUrl: string;
|
|
42
43
|
token: string;
|
|
44
|
+
/**
|
|
45
|
+
* Código OTP de login, presente APENAS quando `login.otp.enabled` está
|
|
46
|
+
* ligado. O host pode montar o próprio e-mail com link E código. Ausente no
|
|
47
|
+
* fluxo só-magic-link (back-compat).
|
|
48
|
+
*/
|
|
49
|
+
code?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Canal escolhido no seletor "choose-first": `'code'` = o host deveria
|
|
52
|
+
* renderizar SÓ o código, `'link'` = SÓ o link. Ausente = ambos (histórico).
|
|
53
|
+
* Puramente de superfície — os dois tokens continuam emitidos co-locados;
|
|
54
|
+
* hosts existentes simplesmente ignoram este campo (back-compat).
|
|
55
|
+
*/
|
|
56
|
+
channel?: 'code' | 'link';
|
|
43
57
|
}) => Promise<void>;
|
|
44
58
|
/**
|
|
45
59
|
* Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Distinto de
|
|
@@ -191,6 +205,13 @@ export interface ResolvedRateLimitConfig {
|
|
|
191
205
|
* afrouxar — o ponto é separar a CONTAGEM, não o teto.
|
|
192
206
|
*/
|
|
193
207
|
sudo: RateLimitBucket;
|
|
208
|
+
/**
|
|
209
|
+
* Bucket da verificação de código OTP de login (`/auth/interaction/:uid/otp-verify`),
|
|
210
|
+
* keyed por IP. MAIS APERTADO que o login (5/min vs 10/min): um código de 6
|
|
211
|
+
* dígitos é adivinhável, então o teto por IP é a primeira barreira anti-brute
|
|
212
|
+
* force ANTES do lockout por interaction (contador persistido no slot do código).
|
|
213
|
+
*/
|
|
214
|
+
otpLogin: RateLimitBucket;
|
|
194
215
|
store?: string;
|
|
195
216
|
}
|
|
196
217
|
export declare function resolveRateLimit(input?: RateLimitConfigInput): ResolvedRateLimitConfig;
|
|
@@ -440,9 +461,17 @@ export declare function resolveAuthMethodsConfig(input?: AuthMethodsConfigInput)
|
|
|
440
461
|
export interface LoginConfigInput {
|
|
441
462
|
/** Exige e-mail verificado para autenticar (senha/magic link/passkey-first). Default: false. */
|
|
442
463
|
requireVerifiedEmail?: boolean;
|
|
464
|
+
/**
|
|
465
|
+
* Login por OTP (código digitável) — extensão do magic link. Quando ligado, o
|
|
466
|
+
* MESMO e-mail passa a carregar link E código, os dois completando a mesma
|
|
467
|
+
* interaction. Default: **desligado** (opt-in; sem a config o comportamento é
|
|
468
|
+
* idêntico ao de antes, e-mail idêntico). Ver `host/otp_login.ts`.
|
|
469
|
+
*/
|
|
470
|
+
otp?: OtpLoginConfigInput;
|
|
443
471
|
}
|
|
444
472
|
export interface ResolvedLoginConfig {
|
|
445
473
|
requireVerifiedEmail: boolean;
|
|
474
|
+
otp: ResolvedOtpLoginConfig;
|
|
446
475
|
}
|
|
447
476
|
export declare function resolveLogin(input?: LoginConfigInput): ResolvedLoginConfig;
|
|
448
477
|
/**
|
|
@@ -4,6 +4,7 @@ import { composeAuditSink, resolveEvents } from './events/dispatcher.js';
|
|
|
4
4
|
import { resolveBotProtection, } from './host/bot_protection.js';
|
|
5
5
|
import { deriveLockedSettingKeys } from './host/config_locks.js';
|
|
6
6
|
import { resolveMessages } from './host/i18n.js';
|
|
7
|
+
import { resolveOtpLoginConfig, } from './host/otp_login.js';
|
|
7
8
|
import { edgeRenderer } from './host/renderers/edge_renderer.js';
|
|
8
9
|
import { resolveTrustedDevices, } from './host/trusted_device.js';
|
|
9
10
|
import { generateJwks } from './keys/jwks_manager.js';
|
|
@@ -17,6 +18,8 @@ const RATE_LIMIT_DEFAULTS = {
|
|
|
17
18
|
adminIp: { points: 30, duration: '1 min' },
|
|
18
19
|
// Mesmos limites do login, bucket separado — ver `ResolvedRateLimitConfig.sudo`.
|
|
19
20
|
sudo: { points: 10, duration: '1 min' },
|
|
21
|
+
// Mais apertado que o login — verificação de código adivinhável.
|
|
22
|
+
otpLogin: { points: 5, duration: '1 min' },
|
|
20
23
|
};
|
|
21
24
|
export function resolveRateLimit(input) {
|
|
22
25
|
const enabled = input?.enabled ?? true;
|
|
@@ -26,6 +29,7 @@ export function resolveRateLimit(input) {
|
|
|
26
29
|
introspection: RATE_LIMIT_DEFAULTS.introspection,
|
|
27
30
|
adminIp: RATE_LIMIT_DEFAULTS.adminIp,
|
|
28
31
|
sudo: RATE_LIMIT_DEFAULTS.sudo,
|
|
32
|
+
otpLogin: RATE_LIMIT_DEFAULTS.otpLogin,
|
|
29
33
|
store: input?.store,
|
|
30
34
|
};
|
|
31
35
|
}
|
|
@@ -119,6 +123,7 @@ export function resolveAuthMethodsConfig(input) {
|
|
|
119
123
|
export function resolveLogin(input) {
|
|
120
124
|
return {
|
|
121
125
|
requireVerifiedEmail: input?.requireVerifiedEmail ?? false,
|
|
126
|
+
otp: resolveOtpLoginConfig(input?.otp),
|
|
122
127
|
};
|
|
123
128
|
}
|
|
124
129
|
export function resolveRegistration(input) {
|
|
@@ -70,6 +70,17 @@ export default class AuthInteractionController {
|
|
|
70
70
|
* inválido/expirado volta ao início do login.
|
|
71
71
|
*/
|
|
72
72
|
magicLinkConsume(ctx: HttpContext): Promise<any>;
|
|
73
|
+
/**
|
|
74
|
+
* POST /auth/interaction/:uid/otp-verify
|
|
75
|
+
*
|
|
76
|
+
* Verifica o CÓDIGO OTP de login (o mesmo e-mail carrega link E código). Roda
|
|
77
|
+
* atrás do throttle dedicado `authkit_otp_login` (por IP, mais apertado que o
|
|
78
|
+
* login). A ordem das checagens de segurança — lockout (contador persistido no
|
|
79
|
+
* slot) → TTL → comparação constant-time — vive no store (`verifyLoginCode` →
|
|
80
|
+
* `evaluateLoginOtp`). Em sucesso, completa a MESMA interaction que o link
|
|
81
|
+
* completaria (amr `['email']`), consumindo código E link (single-use conjunto).
|
|
82
|
+
*/
|
|
83
|
+
otpVerify(ctx: HttpContext): Promise<any>;
|
|
73
84
|
/**
|
|
74
85
|
* POST /auth/interaction/:uid/passkey/options
|
|
75
86
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
|
-
import { supportsMagicLink, supportsPasskeys } from '../../accounts/account_store.js';
|
|
2
|
+
import { supportsMagicLink, supportsOtpLogin, supportsPasskeys, } from '../../accounts/account_store.js';
|
|
3
3
|
import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
4
4
|
import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
|
|
5
5
|
import { brandFor, isFirstParty } from '../branding.js';
|
|
@@ -7,6 +7,7 @@ import { sendMagicLinkEmail } from '../default_mailer.js';
|
|
|
7
7
|
import { sendOtpUnlockEmail } from '../default_mailer.js';
|
|
8
8
|
import { translate } from '../i18n.js';
|
|
9
9
|
import { attemptPasswordLogin, isEmailUnverifiedBlock } from '../login_attempt.js';
|
|
10
|
+
import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
|
|
10
11
|
import { notifyLoginSuccess } from '../login_notify.js';
|
|
11
12
|
import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
|
|
12
13
|
import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
|
|
@@ -609,23 +610,49 @@ export default class AuthInteractionController {
|
|
|
609
610
|
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
610
611
|
const email = ctx.session.get(SESSION_KEY);
|
|
611
612
|
const uid = ctx.request.param('uid');
|
|
613
|
+
// Login por OTP: liga o campo de código na tela "link enviado" quando a config
|
|
614
|
+
// está ligada E o store suporta a capacidade.
|
|
615
|
+
const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
|
|
616
|
+
// Seletor "choose-first": o host pode POSTar `channel=code|link` para pedir que
|
|
617
|
+
// o e-mail e a tela mostrem SÓ aquele método. Ausente/ inválido = both (histórico).
|
|
618
|
+
// NÃO condiciona a emissão de token — os dois continuam saindo co-locados.
|
|
619
|
+
const channel = normalizeLoginChannel(ctx.request.input('channel'));
|
|
612
620
|
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
|
|
613
|
-
const
|
|
621
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
622
|
+
const clientId = details.params.client_id ?? null;
|
|
623
|
+
// Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
|
|
624
|
+
// senão, o magic link puro de sempre.
|
|
625
|
+
const issued = otpEnabled
|
|
626
|
+
? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
|
|
627
|
+
digits: cfg.login.otp.digits,
|
|
628
|
+
ttlMinutes: cfg.login.otp.ttlMinutes,
|
|
629
|
+
})
|
|
630
|
+
: await cfg.accountStore.issueMagicLinkToken(email);
|
|
614
631
|
if (issued) {
|
|
632
|
+
const code = 'code' in issued ? issued.code : undefined;
|
|
615
633
|
await cfg.audit?.record({
|
|
616
634
|
type: 'login.magic_link_sent',
|
|
617
635
|
accountId: issued.account.id,
|
|
618
636
|
email,
|
|
619
|
-
ip
|
|
620
|
-
clientId
|
|
637
|
+
ip,
|
|
638
|
+
clientId,
|
|
621
639
|
});
|
|
640
|
+
if (code) {
|
|
641
|
+
await cfg.audit?.record({
|
|
642
|
+
type: 'login.otp_sent',
|
|
643
|
+
accountId: issued.account.id,
|
|
644
|
+
email,
|
|
645
|
+
ip,
|
|
646
|
+
clientId,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
622
649
|
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
623
650
|
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
624
651
|
if (cfg.mail?.onMagicLink) {
|
|
625
|
-
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token });
|
|
652
|
+
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
|
|
626
653
|
}
|
|
627
654
|
else {
|
|
628
|
-
await sendMagicLinkEmail(ctx, { email, magicUrl });
|
|
655
|
+
await sendMagicLinkEmail(ctx, { email, magicUrl, code, channel });
|
|
629
656
|
}
|
|
630
657
|
}
|
|
631
658
|
}
|
|
@@ -639,6 +666,11 @@ export default class AuthInteractionController {
|
|
|
639
666
|
account: null,
|
|
640
667
|
brand,
|
|
641
668
|
magicLinkSent: true,
|
|
669
|
+
otpEnabled,
|
|
670
|
+
// Prop da tela: qual sub-view do estado `magicLinkSent` mostrar —
|
|
671
|
+
// 'code' (só o campo de código), 'link' (só o aviso de link) ou 'both'
|
|
672
|
+
// (ambos, quando o host não escolheu canal). Back-compat: ausente = 'both'.
|
|
673
|
+
magicChannel: magicChannelProp(channel),
|
|
642
674
|
});
|
|
643
675
|
}
|
|
644
676
|
/**
|
|
@@ -700,6 +732,117 @@ export default class AuthInteractionController {
|
|
|
700
732
|
ctx.session.forget(SESSION_KEY);
|
|
701
733
|
await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
|
|
702
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* POST /auth/interaction/:uid/otp-verify
|
|
737
|
+
*
|
|
738
|
+
* Verifica o CÓDIGO OTP de login (o mesmo e-mail carrega link E código). Roda
|
|
739
|
+
* atrás do throttle dedicado `authkit_otp_login` (por IP, mais apertado que o
|
|
740
|
+
* login). A ordem das checagens de segurança — lockout (contador persistido no
|
|
741
|
+
* slot) → TTL → comparação constant-time — vive no store (`verifyLoginCode` →
|
|
742
|
+
* `evaluateLoginOtp`). Em sucesso, completa a MESMA interaction que o link
|
|
743
|
+
* completaria (amr `['email']`), consumindo código E link (single-use conjunto).
|
|
744
|
+
*/
|
|
745
|
+
async otpVerify(ctx) {
|
|
746
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
747
|
+
const cfg = service.config;
|
|
748
|
+
const render = cfg.render;
|
|
749
|
+
const uid = ctx.request.param('uid');
|
|
750
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
751
|
+
const clientId = (await service.interactions.details(ctx)).params.client_id;
|
|
752
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
753
|
+
// Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
|
|
754
|
+
const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
|
|
755
|
+
if (!otpEnabled || !email) {
|
|
756
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
757
|
+
}
|
|
758
|
+
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
759
|
+
const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
|
|
760
|
+
// Mantém a sub-view do seletor no re-render de erro (o form de código pode
|
|
761
|
+
// POSTar `channel=code`). Ausente = both (histórico).
|
|
762
|
+
const channel = normalizeLoginChannel(ctx.request.input('channel'));
|
|
763
|
+
const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
|
|
764
|
+
maxAttempts: cfg.login.otp.maxAttempts,
|
|
765
|
+
});
|
|
766
|
+
// Re-render da tela "link enviado" com o campo de código + erro localizado.
|
|
767
|
+
const renderOtpError = async (messageKey) => render(ctx, 'login', {
|
|
768
|
+
...(await this.#loginMethods(ctx, cfg)),
|
|
769
|
+
uid,
|
|
770
|
+
csrfToken: ctx.request.csrfToken,
|
|
771
|
+
step: 'password',
|
|
772
|
+
email,
|
|
773
|
+
account: null,
|
|
774
|
+
brand,
|
|
775
|
+
magicLinkSent: true,
|
|
776
|
+
otpEnabled: true,
|
|
777
|
+
magicChannel: magicChannelProp(channel),
|
|
778
|
+
otpError: translate(cfg.messages, messageKey),
|
|
779
|
+
});
|
|
780
|
+
if (result.status === 'ok') {
|
|
781
|
+
// E-mail não verificado (LGPD): mesmo com código válido, não materializa a
|
|
782
|
+
// sessão se a política exige verificação. Espelha o magicLinkConsume.
|
|
783
|
+
const runtimeSettings = await getRuntimeSettings(ctx);
|
|
784
|
+
if (await isEmailUnverifiedBlock(cfg, result.account.id, runtimeSettings)) {
|
|
785
|
+
await cfg.audit?.record({
|
|
786
|
+
type: 'login.failure',
|
|
787
|
+
accountId: result.account.id,
|
|
788
|
+
email: result.account.email,
|
|
789
|
+
ip,
|
|
790
|
+
clientId,
|
|
791
|
+
metadata: { stage: 'otp', reason: 'unverified' },
|
|
792
|
+
});
|
|
793
|
+
return render(ctx, 'login', {
|
|
794
|
+
...(await this.#loginMethods(ctx, cfg)),
|
|
795
|
+
uid,
|
|
796
|
+
csrfToken: ctx.request.csrfToken,
|
|
797
|
+
step: 'password',
|
|
798
|
+
email: result.account.email,
|
|
799
|
+
account: null,
|
|
800
|
+
brand,
|
|
801
|
+
error: translate(cfg.messages, 'errors.email_unverified'),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
await cfg.audit?.record({
|
|
805
|
+
type: 'login.otp_verified',
|
|
806
|
+
accountId: result.account.id,
|
|
807
|
+
email: result.account.email,
|
|
808
|
+
ip,
|
|
809
|
+
clientId,
|
|
810
|
+
});
|
|
811
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
812
|
+
accountId: result.account.id,
|
|
813
|
+
email: result.account.email,
|
|
814
|
+
ip,
|
|
815
|
+
clientId: clientId ?? null,
|
|
816
|
+
metadata: { method: 'otp' },
|
|
817
|
+
});
|
|
818
|
+
ctx.session.forget(SESSION_KEY);
|
|
819
|
+
return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
|
|
820
|
+
}
|
|
821
|
+
if (result.status === 'locked') {
|
|
822
|
+
// 5ª falha (ou já travado): código invalidado, o LINK continua válido.
|
|
823
|
+
await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
|
|
824
|
+
return renderOtpError('login.otp_locked');
|
|
825
|
+
}
|
|
826
|
+
if (result.status === 'expired') {
|
|
827
|
+
await cfg.audit?.record({
|
|
828
|
+
type: 'login.otp_failed',
|
|
829
|
+
email,
|
|
830
|
+
ip,
|
|
831
|
+
clientId,
|
|
832
|
+
metadata: { reason: 'expired' },
|
|
833
|
+
});
|
|
834
|
+
return renderOtpError('login.otp_expired');
|
|
835
|
+
}
|
|
836
|
+
// 'invalid' (tentativa contabilizada) ou 'no_code'.
|
|
837
|
+
await cfg.audit?.record({
|
|
838
|
+
type: 'login.otp_failed',
|
|
839
|
+
email,
|
|
840
|
+
ip,
|
|
841
|
+
clientId,
|
|
842
|
+
metadata: { reason: result.status },
|
|
843
|
+
});
|
|
844
|
+
return renderOtpError('login.otp_invalid');
|
|
845
|
+
}
|
|
703
846
|
/**
|
|
704
847
|
* POST /auth/interaction/:uid/passkey/options
|
|
705
848
|
*
|
|
@@ -62,6 +62,8 @@ export declare function sendNewDeviceLoginEmail(ctx: HttpContext, data: {
|
|
|
62
62
|
export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
|
|
63
63
|
email: string;
|
|
64
64
|
magicUrl: string;
|
|
65
|
+
code?: string;
|
|
66
|
+
channel?: 'code' | 'link';
|
|
65
67
|
}): Promise<void>;
|
|
66
68
|
/**
|
|
67
69
|
* Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
|