@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
|
@@ -249,17 +249,41 @@ export async function sendMagicLinkEmail(ctx, data) {
|
|
|
249
249
|
try {
|
|
250
250
|
const brand = resolveBrand(ctx);
|
|
251
251
|
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
252
|
+
// Login choose-first: o `channel` decide o que o e-mail SURFA (os dois tokens
|
|
253
|
+
// continuam emitidos co-locados a montante — isto é só renderização).
|
|
254
|
+
// - 'code' → e-mail SÓ com o código (sem botão/link), quando há código;
|
|
255
|
+
// - 'link' → e-mail SÓ com o link (código suprimido);
|
|
256
|
+
// - ausente → ambos (comportamento histórico — back-compat).
|
|
257
|
+
// Degradação limpa: `channel: 'code'` sem código emitido (OTP desligado) cai
|
|
258
|
+
// no e-mail de link — não dá pra mostrar um código inexistente.
|
|
259
|
+
const codeOnly = data.channel === 'code' && !!data.code;
|
|
260
|
+
const linkOnly = data.channel === 'link';
|
|
261
|
+
const content = codeOnly
|
|
262
|
+
? renderTransactionalEmail({
|
|
263
|
+
brand,
|
|
264
|
+
locale,
|
|
265
|
+
subject: translate(t, 'mail.magic_link.code_subject'),
|
|
266
|
+
heading: translate(t, 'mail.magic_link.heading'),
|
|
267
|
+
intro: translate(t, 'mail.magic_link.code_intro'),
|
|
268
|
+
footnote: translate(t, 'mail.magic_link.fallback'),
|
|
269
|
+
// Sem `ctaUrl`: e-mail sem botão nem fallback de link — só o código.
|
|
270
|
+
code: data.code,
|
|
271
|
+
codeLabel: translate(t, 'mail.magic_link.code_only_label'),
|
|
272
|
+
})
|
|
273
|
+
: renderTransactionalEmail({
|
|
274
|
+
brand,
|
|
275
|
+
locale,
|
|
276
|
+
linkFallback: translate(t, 'mail.common.link_fallback'),
|
|
277
|
+
subject: translate(t, 'mail.magic_link.subject'),
|
|
278
|
+
heading: translate(t, 'mail.magic_link.heading'),
|
|
279
|
+
intro: translate(t, 'mail.magic_link.intro'),
|
|
280
|
+
ctaLabel: translate(t, 'mail.magic_link.cta'),
|
|
281
|
+
ctaUrl: data.magicUrl,
|
|
282
|
+
footnote: translate(t, 'mail.magic_link.fallback'),
|
|
283
|
+
// Código em destaque quando presente — suprimido no canal 'link'.
|
|
284
|
+
code: linkOnly ? undefined : data.code,
|
|
285
|
+
codeLabel: translate(t, 'mail.magic_link.code_label'),
|
|
286
|
+
});
|
|
263
287
|
const sent = await sendEmail(ctx, data.email, content);
|
|
264
288
|
if (!sent) {
|
|
265
289
|
ctx.logger.info({ magicUrl: data.magicUrl, email: data.email }, 'authkit: magic link de login (dev — @adonisjs/mail ausente)');
|
|
@@ -24,16 +24,31 @@ interface EmailTemplateInput {
|
|
|
24
24
|
heading: string;
|
|
25
25
|
/** Parágrafo de introdução (texto puro, será escapado). */
|
|
26
26
|
intro: string;
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Rótulo do botão de CTA. Opcional junto de `ctaUrl`: e-mails "só código"
|
|
29
|
+
* (login por OTP com `channel: 'code'`) não têm botão nem link.
|
|
30
|
+
*/
|
|
31
|
+
ctaLabel?: string;
|
|
32
|
+
/**
|
|
33
|
+
* URL do CTA. Quando ausente, o e-mail é renderizado SEM botão e SEM o
|
|
34
|
+
* fallback de link (usado no e-mail "só código"). Todos os callers históricos
|
|
35
|
+
* passam este campo — byte-parity preservado.
|
|
36
|
+
*/
|
|
37
|
+
ctaUrl?: string;
|
|
31
38
|
/** Linha auxiliar abaixo do botão (ex.: validade do link). */
|
|
32
39
|
footnote?: string;
|
|
33
40
|
/** Texto que precede o link de fallback (i18n). Default em inglês. */
|
|
34
41
|
linkFallback?: string;
|
|
35
42
|
/** Locale do documento HTML (atributo `lang`). Default: 'en'. */
|
|
36
43
|
locale?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Código OTP de login (dígitos). Quando presente, é renderizado em destaque
|
|
46
|
+
* (grande, monoespaçado) acima do CTA, com um rótulo. Usado pelo login por OTP
|
|
47
|
+
* (o mesmo e-mail carrega link E código). Ausente = e-mail idêntico ao de antes.
|
|
48
|
+
*/
|
|
49
|
+
code?: string;
|
|
50
|
+
/** Rótulo acima do código (i18n). Default em inglês. */
|
|
51
|
+
codeLabel?: string;
|
|
37
52
|
}
|
|
38
53
|
export declare function renderTransactionalEmail(input: EmailTemplateInput): EmailContent;
|
|
39
54
|
export {};
|
|
@@ -24,6 +24,16 @@ export function renderTransactionalEmail(input) {
|
|
|
24
24
|
const lang = input.locale || 'en';
|
|
25
25
|
const linkFallback = input.linkFallback ||
|
|
26
26
|
'If the button does not work, copy and paste this link into your browser:';
|
|
27
|
+
const codeLabel = input.codeLabel || 'Or enter this code:';
|
|
28
|
+
// Bloco do código OTP (grande/monoespaçado), renderizado só quando há código.
|
|
29
|
+
// Termina em '\n' quando presente para manter o <table> seguinte em linha própria;
|
|
30
|
+
// vazio quando ausente (sem linha em branco extra — byte-parity com o e-mail
|
|
31
|
+
// pré-OTP).
|
|
32
|
+
const codeBlock = input.code
|
|
33
|
+
? `<p style="margin:0 0 8px;font-size:13px;line-height:1.5;color:#6b7280;">${esc(codeLabel)}</p>
|
|
34
|
+
<p style="margin:0 0 24px;font-size:32px;font-weight:700;letter-spacing:6px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;color:#111827;">${esc(input.code)}</p>
|
|
35
|
+
`
|
|
36
|
+
: '';
|
|
27
37
|
const html = `<!doctype html>
|
|
28
38
|
<html lang="${esc(lang)}">
|
|
29
39
|
<head>
|
|
@@ -41,11 +51,13 @@ export function renderTransactionalEmail(input) {
|
|
|
41
51
|
<tr><td style="padding:32px 28px 8px;">
|
|
42
52
|
<h1 style="margin:0 0 12px;font-size:20px;line-height:1.3;color:#111827;">${esc(input.heading)}</h1>
|
|
43
53
|
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#374151;">${esc(input.intro)}</p>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
</
|
|
54
|
+
${codeBlock}${input.ctaUrl
|
|
55
|
+
? `<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
|
|
56
|
+
<a href="${esc(input.ctaUrl)}" style="display:inline-block;padding:12px 24px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${esc(input.ctaLabel ?? '')}</a>
|
|
57
|
+
</td></tr></table>`
|
|
58
|
+
: ''}
|
|
47
59
|
${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(input.footnote)}</p>` : ''}
|
|
48
|
-
|
|
60
|
+
${input.ctaUrl ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(linkFallback)}<br><a href="${esc(input.ctaUrl)}" style="color:${esc(accent)};word-break:break-all;">${esc(input.ctaUrl)}</a></p>` : ''}
|
|
49
61
|
</td></tr>
|
|
50
62
|
<tr><td style="padding:24px 28px 28px;border-top:1px solid #f3f4f6;">
|
|
51
63
|
<p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;">${esc(company)} ${year}</p>
|
|
@@ -59,8 +71,8 @@ ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;col
|
|
|
59
71
|
input.heading,
|
|
60
72
|
'',
|
|
61
73
|
input.intro,
|
|
62
|
-
'',
|
|
63
|
-
`${input.ctaLabel}: ${input.ctaUrl}
|
|
74
|
+
...(input.code ? ['', `${codeLabel} ${input.code}`] : []),
|
|
75
|
+
...(input.ctaUrl ? ['', `${input.ctaLabel}: ${input.ctaUrl}`] : []),
|
|
64
76
|
...(input.footnote ? ['', input.footnote] : []),
|
|
65
77
|
'',
|
|
66
78
|
`— ${company}`,
|
package/build/src/host/i18n.d.ts
CHANGED
|
@@ -50,6 +50,12 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
50
50
|
'login.magic_link_sent': string;
|
|
51
51
|
'signup.magic_link_sent': string;
|
|
52
52
|
'login.passkey_button': string;
|
|
53
|
+
'login.otp_label': string;
|
|
54
|
+
'login.otp_placeholder': string;
|
|
55
|
+
'login.otp_submit': string;
|
|
56
|
+
'login.otp_invalid': string;
|
|
57
|
+
'login.otp_expired': string;
|
|
58
|
+
'login.otp_locked': string;
|
|
53
59
|
'signup.page_title': string;
|
|
54
60
|
'signup.title': string;
|
|
55
61
|
'signup.intro': string;
|
|
@@ -511,6 +517,10 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
511
517
|
'mail.magic_link.intro': string;
|
|
512
518
|
'mail.magic_link.cta': string;
|
|
513
519
|
'mail.magic_link.fallback': string;
|
|
520
|
+
'mail.magic_link.code_label': string;
|
|
521
|
+
'mail.magic_link.code_subject': string;
|
|
522
|
+
'mail.magic_link.code_intro': string;
|
|
523
|
+
'mail.magic_link.code_only_label': string;
|
|
514
524
|
'mail.new_login.subject': string;
|
|
515
525
|
'mail.new_login.heading': string;
|
|
516
526
|
'mail.new_login.intro': string;
|
|
@@ -744,6 +754,12 @@ export declare const PT_BR_MESSAGES: {
|
|
|
744
754
|
'login.magic_link_sent': string;
|
|
745
755
|
'signup.magic_link_sent': string;
|
|
746
756
|
'login.passkey_button': string;
|
|
757
|
+
'login.otp_label': string;
|
|
758
|
+
'login.otp_placeholder': string;
|
|
759
|
+
'login.otp_submit': string;
|
|
760
|
+
'login.otp_invalid': string;
|
|
761
|
+
'login.otp_expired': string;
|
|
762
|
+
'login.otp_locked': string;
|
|
747
763
|
'signup.page_title': string;
|
|
748
764
|
'signup.title': string;
|
|
749
765
|
'signup.intro': string;
|
|
@@ -1205,6 +1221,10 @@ export declare const PT_BR_MESSAGES: {
|
|
|
1205
1221
|
'mail.magic_link.intro': string;
|
|
1206
1222
|
'mail.magic_link.cta': string;
|
|
1207
1223
|
'mail.magic_link.fallback': string;
|
|
1224
|
+
'mail.magic_link.code_label': string;
|
|
1225
|
+
'mail.magic_link.code_subject': string;
|
|
1226
|
+
'mail.magic_link.code_intro': string;
|
|
1227
|
+
'mail.magic_link.code_only_label': string;
|
|
1208
1228
|
'mail.new_login.subject': string;
|
|
1209
1229
|
'mail.new_login.heading': string;
|
|
1210
1230
|
'mail.new_login.intro': string;
|
package/build/src/host/i18n.js
CHANGED
|
@@ -41,6 +41,13 @@ export const DEFAULT_MESSAGES = {
|
|
|
41
41
|
'login.magic_link_sent': 'If the account exists, we sent you a login link.',
|
|
42
42
|
'signup.magic_link_sent': 'Check your email — we sent you a link to finish creating your account.',
|
|
43
43
|
'login.passkey_button': 'Sign in with a passkey',
|
|
44
|
+
// Login por OTP (código digitável).
|
|
45
|
+
'login.otp_label': 'Enter the login code from the email',
|
|
46
|
+
'login.otp_placeholder': '000000',
|
|
47
|
+
'login.otp_submit': 'Sign in with the code',
|
|
48
|
+
'login.otp_invalid': 'Invalid code. Please try again.',
|
|
49
|
+
'login.otp_expired': 'This code has expired. Use the login link or request a new one.',
|
|
50
|
+
'login.otp_locked': 'Too many attempts. The code was disabled — use the login link instead.',
|
|
44
51
|
// Tela de cadastro (signup).
|
|
45
52
|
'signup.page_title': 'Create account',
|
|
46
53
|
'signup.title': 'Create account',
|
|
@@ -550,6 +557,11 @@ export const DEFAULT_MESSAGES = {
|
|
|
550
557
|
'mail.magic_link.intro': 'Click the button below to sign in. The link expires shortly and can be used once.',
|
|
551
558
|
'mail.magic_link.cta': 'Sign in',
|
|
552
559
|
'mail.magic_link.fallback': 'If you did not request this, you can ignore this email.',
|
|
560
|
+
'mail.magic_link.code_label': 'Or enter this code to sign in:',
|
|
561
|
+
// E-mail "só código" (login choose-first com channel: 'code'): sem botão/link.
|
|
562
|
+
'mail.magic_link.code_subject': 'Your login code',
|
|
563
|
+
'mail.magic_link.code_intro': 'Use the code below to sign in. It expires shortly and can be used once.',
|
|
564
|
+
'mail.magic_link.code_only_label': 'Enter this code to sign in:',
|
|
553
565
|
'mail.new_login.subject': 'New login to your account',
|
|
554
566
|
'mail.new_login.heading': 'New login detected',
|
|
555
567
|
'mail.new_login.intro': 'We detected a new login to your account.',
|
|
@@ -814,6 +826,13 @@ export const PT_BR_MESSAGES = {
|
|
|
814
826
|
'login.magic_link_sent': 'Se a conta existir, enviamos um link de login.',
|
|
815
827
|
'signup.magic_link_sent': 'Enviamos um link para o seu e-mail. Abra-o para concluir o cadastro.',
|
|
816
828
|
'login.passkey_button': 'Entrar com passkey',
|
|
829
|
+
// Login por OTP (código digitável).
|
|
830
|
+
'login.otp_label': 'Digite o código de login do e-mail',
|
|
831
|
+
'login.otp_placeholder': '000000',
|
|
832
|
+
'login.otp_submit': 'Entrar com o código',
|
|
833
|
+
'login.otp_invalid': 'Código inválido. Tente novamente.',
|
|
834
|
+
'login.otp_expired': 'Este código expirou. Use o link de login ou peça um novo.',
|
|
835
|
+
'login.otp_locked': 'Tentativas demais. O código foi desativado — use o link de login.',
|
|
817
836
|
// Tela de cadastro (signup).
|
|
818
837
|
'signup.page_title': 'Criar conta',
|
|
819
838
|
'signup.title': 'Criar conta',
|
|
@@ -1319,6 +1338,11 @@ export const PT_BR_MESSAGES = {
|
|
|
1319
1338
|
'mail.magic_link.intro': 'Clique no botão abaixo para entrar. O link expira em breve e pode ser usado uma vez.',
|
|
1320
1339
|
'mail.magic_link.cta': 'Entrar',
|
|
1321
1340
|
'mail.magic_link.fallback': 'Se você não solicitou isso, pode ignorar este e-mail.',
|
|
1341
|
+
'mail.magic_link.code_label': 'Ou digite este código para entrar:',
|
|
1342
|
+
// E-mail "só código" (login choose-first com channel: 'code'): sem botão/link.
|
|
1343
|
+
'mail.magic_link.code_subject': 'Seu código de login',
|
|
1344
|
+
'mail.magic_link.code_intro': 'Use o código abaixo para entrar. Ele expira em instantes e serve para um único acesso.',
|
|
1345
|
+
'mail.magic_link.code_only_label': 'Digite este código para entrar:',
|
|
1322
1346
|
'mail.new_login.subject': 'Novo login na sua conta',
|
|
1323
1347
|
'mail.new_login.heading': 'Novo login detectado',
|
|
1324
1348
|
'mail.new_login.intro': 'Detectamos um novo login na sua conta.',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal de login escolhido no seletor "choose-first" (modelo GitHub): o usuário
|
|
3
|
+
* decide PRIMEIRO como quer entrar e só então o método é executado.
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANTE — o `channel` é puramente de SUPERFÍCIE: NÃO condiciona a emissão de
|
|
6
|
+
* token nem toca no codec `ml2:` / lockout / single-use-conjunto. A lib continua
|
|
7
|
+
* emitindo link E código co-locados (quando `login.otp.enabled`); o `channel` só
|
|
8
|
+
* decide o que o E-MAIL renderiza e qual sub-view a TELA mostra no estado
|
|
9
|
+
* `magicLinkSent`. Ausente/ inválido = comportamento histórico ("both").
|
|
10
|
+
*/
|
|
11
|
+
/** Método escolhido no seletor. `passkey` é slot documentado (ainda não emitido). */
|
|
12
|
+
export type LoginChannel = 'code' | 'link';
|
|
13
|
+
/**
|
|
14
|
+
* Valor da prop de render que a tela usa para escolher a sub-view do estado
|
|
15
|
+
* `magicLinkSent`: `'code'` = só campo de código, `'link'` = só aviso de link,
|
|
16
|
+
* `'both'` = ambos (comportamento histórico, quando o host não manda `channel`).
|
|
17
|
+
*/
|
|
18
|
+
export type MagicChannelProp = 'code' | 'link' | 'both';
|
|
19
|
+
/**
|
|
20
|
+
* Lê e valida o campo `channel` do body do POST `/magic`. Só `'code'` e `'link'`
|
|
21
|
+
* são aceitos; qualquer outro valor (ausente, vazio, lixo) vira `undefined`, que
|
|
22
|
+
* a lib trata como "both" — garantindo back-compat total com hosts que ainda
|
|
23
|
+
* POSTam sem o campo.
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeLoginChannel(raw: unknown): LoginChannel | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Mapeia o `channel` do body para a prop de render `magicChannel`. Ausente
|
|
28
|
+
* (`undefined`) → `'both'`: a tela mostra as duas sub-views, como hoje.
|
|
29
|
+
*/
|
|
30
|
+
export declare function magicChannelProp(channel: LoginChannel | undefined): MagicChannelProp;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal de login escolhido no seletor "choose-first" (modelo GitHub): o usuário
|
|
3
|
+
* decide PRIMEIRO como quer entrar e só então o método é executado.
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANTE — o `channel` é puramente de SUPERFÍCIE: NÃO condiciona a emissão de
|
|
6
|
+
* token nem toca no codec `ml2:` / lockout / single-use-conjunto. A lib continua
|
|
7
|
+
* emitindo link E código co-locados (quando `login.otp.enabled`); o `channel` só
|
|
8
|
+
* decide o que o E-MAIL renderiza e qual sub-view a TELA mostra no estado
|
|
9
|
+
* `magicLinkSent`. Ausente/ inválido = comportamento histórico ("both").
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Lê e valida o campo `channel` do body do POST `/magic`. Só `'code'` e `'link'`
|
|
13
|
+
* são aceitos; qualquer outro valor (ausente, vazio, lixo) vira `undefined`, que
|
|
14
|
+
* a lib trata como "both" — garantindo back-compat total com hosts que ainda
|
|
15
|
+
* POSTam sem o campo.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeLoginChannel(raw) {
|
|
18
|
+
return raw === 'code' || raw === 'link' ? raw : undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Mapeia o `channel` do body para a prop de render `magicChannel`. Ausente
|
|
22
|
+
* (`undefined`) → `'both'`: a tela mostra as duas sub-views, como hoje.
|
|
23
|
+
*/
|
|
24
|
+
export function magicChannelProp(channel) {
|
|
25
|
+
return channel ?? 'both';
|
|
26
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login por OTP (código digitável) — helpers puros + máquina de estados da
|
|
3
|
+
* verificação.
|
|
4
|
+
*
|
|
5
|
+
* ── Por que este módulo existe (e o porquê da decisão de armazenamento) ───────
|
|
6
|
+
* O host passwordless já tem magic link (token de 256 bits, IMPOSSÍVEL de
|
|
7
|
+
* adivinhar). O código de 6 dígitos é ADIVINHÁVEL: exige lockout dedicado +
|
|
8
|
+
* throttle — segurança que não se reimplementa por host. O mesmo e-mail passa a
|
|
9
|
+
* carregar LINK e CÓDIGO; os dois completam a MESMA interaction OIDC.
|
|
10
|
+
*
|
|
11
|
+
* ── Decisão de armazenamento (investigação registrada no código) ─────────────
|
|
12
|
+
* O SPEC ranqueia três opções e manda a investigação decidir. Resultado:
|
|
13
|
+
*
|
|
14
|
+
* 1. (preferida no spec) Guardar `otpHash`/`otpExpiresAt`/`otpAttempts` no
|
|
15
|
+
* REGISTRO DA INTERACTION do oidc-provider — **INVIÁVEL**. O modelo
|
|
16
|
+
* `Interaction` do oidc-provider só persiste os campos listados em
|
|
17
|
+
* `IN_PAYLOAD` (`base_model.js` filtra o payload por
|
|
18
|
+
* `IN_PAYLOAD.includes(key)` no construtor; `save()` chama
|
|
19
|
+
* `getValueAndPayload`). Campos custom de topo são DESCARTADOS ao persistir.
|
|
20
|
+
* O único slot livre persistido é `lastSubmission`, dono do mecanismo
|
|
21
|
+
* `mergeWithLastSubmission` — sequestrá-lo é frágil. Ver
|
|
22
|
+
* `node_modules/oidc-provider/lib/models/interaction.js:57` e
|
|
23
|
+
* `.../base_model.js:34`.
|
|
24
|
+
*
|
|
25
|
+
* 2. (ESCOLHIDA) Formato composto no slot já existente do token de magic link
|
|
26
|
+
* (`passwordResetToken`, hoje `ml:<token>`). Passa a `ml2:<...>` quando o
|
|
27
|
+
* OTP está ligado. Esta opção resolve os TRÊS requisitos duros de uma vez:
|
|
28
|
+
* • **Single-use conjunto** — código e link vivem no MESMO slot da MESMA
|
|
29
|
+
* linha: consumir qualquer um limpa o slot → o outro morre junto, sem
|
|
30
|
+
* coordenação entre stores.
|
|
31
|
+
* • **Contador de tentativas persistido SEM limiter** — o contador vive
|
|
32
|
+
* DENTRO do slot. O lockout é imposto pelo próprio contador persistido
|
|
33
|
+
* (fail-CLOSED: não depende do `@adonisjs/limiter`), ao contrário do
|
|
34
|
+
* `otp_lockout.ts`, que vira no-op sem limiter — perigoso para um código
|
|
35
|
+
* curto. O throttle de rota (`authkit_otp_login`) é camada EXTRA por IP.
|
|
36
|
+
* • **TTL herdado** — a coluna `passwordResetExpiresAt` já dá validade ao
|
|
37
|
+
* link; o código carrega o próprio `codeExpMs` embutido (mais curto).
|
|
38
|
+
*
|
|
39
|
+
* 3. Coluna nova via ensure-schema — desnecessária (a opção 2 não exige
|
|
40
|
+
* migração), então descartada.
|
|
41
|
+
*
|
|
42
|
+
* ── Formato do slot (`ml2:`) ─────────────────────────────────────────────────
|
|
43
|
+
* Armazenado: `ml2:<linkToken>:<codeHash>:<codeExpMs>:<attempts>`
|
|
44
|
+
* Na URL: `ml2:<linkToken>` (SÓ o token do link — o código, o hash e o
|
|
45
|
+
* contador NUNCA saem no e-mail/URL, então o atacante não tem como
|
|
46
|
+
* zerar o contador manipulando o que ele recebe).
|
|
47
|
+
*
|
|
48
|
+
* • `linkToken` — 32 bytes hex; é o token do magic link (mesma força de antes).
|
|
49
|
+
* • `codeHash` — `sha256(<uid>:<code>)` em hex, ou VAZIO quando o código foi
|
|
50
|
+
* invalidado por lockout (o link continua válido e localizável).
|
|
51
|
+
* Atrelar ao `uid` da interaction honra o escopo "por
|
|
52
|
+
* interaction" do spec: um código emitido numa interaction não
|
|
53
|
+
* verifica em outra, mesmo para o mesmo e-mail.
|
|
54
|
+
* • `codeExpMs` — epoch ms de expiração DO CÓDIGO (TTL curto, default 10 min).
|
|
55
|
+
* • `attempts` — contador server-side de tentativas erradas (começa em 0).
|
|
56
|
+
*
|
|
57
|
+
* Segurança do contador: como o link e o código compartilham o slot mas o
|
|
58
|
+
* LOCKOUT do código NÃO pode matar o link (spec), a invalidação por lockout zera
|
|
59
|
+
* o `codeHash` (mantendo `linkToken`) em vez de limpar o slot inteiro.
|
|
60
|
+
*/
|
|
61
|
+
/** Config de entrada do login por OTP (`login.otp` no config/authkit.ts). */
|
|
62
|
+
export interface OtpLoginConfigInput {
|
|
63
|
+
/** Liga o login por código. Default: **false** (opt-in, back-compat total). */
|
|
64
|
+
enabled?: boolean;
|
|
65
|
+
/** Número de dígitos do código. Default: 6. Faixa aceita: 4–10. */
|
|
66
|
+
digits?: number;
|
|
67
|
+
/** Validade do código em minutos. Default: 10. Mínimo: 1. */
|
|
68
|
+
ttlMinutes?: number;
|
|
69
|
+
/** Tentativas erradas antes de invalidar o código. Default: 5. Mínimo: 1. */
|
|
70
|
+
maxAttempts?: number;
|
|
71
|
+
}
|
|
72
|
+
export interface ResolvedOtpLoginConfig {
|
|
73
|
+
enabled: boolean;
|
|
74
|
+
digits: number;
|
|
75
|
+
ttlMinutes: number;
|
|
76
|
+
maxAttempts: number;
|
|
77
|
+
}
|
|
78
|
+
export declare const OTP_LOGIN_DEFAULTS: ResolvedOtpLoginConfig;
|
|
79
|
+
/** Resolve/normaliza a config `login.otp` com os defaults e limites de sanidade. */
|
|
80
|
+
export declare function resolveOtpLoginConfig(input?: OtpLoginConfigInput): ResolvedOtpLoginConfig;
|
|
81
|
+
/**
|
|
82
|
+
* Gera um código numérico de `digits` dígitos, zero-padded, SEM viés de módulo.
|
|
83
|
+
*
|
|
84
|
+
* Usa `crypto.randomInt(0, 10 ** digits)` — o `randomInt` do Node faz rejection
|
|
85
|
+
* sampling internamente, então a distribuição é uniforme (nada de `% 10`, que
|
|
86
|
+
* enviesaria os dígitos baixos). Para `digits=6` o teto é 1_000_000, bem abaixo
|
|
87
|
+
* do limite de `randomInt` (2**48).
|
|
88
|
+
*/
|
|
89
|
+
export declare function generateOtpCode(digits: number): string;
|
|
90
|
+
/**
|
|
91
|
+
* Hash do código atrelado ao `uid` da interaction: `sha256(<uid>:<code>)` em hex.
|
|
92
|
+
* Atrelar ao uid escopa o código à interaction que o emitiu.
|
|
93
|
+
*/
|
|
94
|
+
export declare function hashLoginOtp(uid: string, code: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Comparação constant-time de dois digests hex de MESMO tamanho.
|
|
97
|
+
*
|
|
98
|
+
* `timingSafeEqual` exige buffers de tamanho igual — comprimentos diferentes
|
|
99
|
+
* lançam. Por isso a guarda de tamanho vem antes (retorno `false` sem vazar
|
|
100
|
+
* timing útil: o atacante não controla o tamanho do digest server-side, que é
|
|
101
|
+
* sempre 64 hex de um sha256).
|
|
102
|
+
*/
|
|
103
|
+
export declare function safeEqualHex(a: string, b: string): boolean;
|
|
104
|
+
/** Prefixo do slot `passwordResetToken` quando o login por OTP está ativo. */
|
|
105
|
+
export declare const OTP_LOGIN_PREFIX = "ml2:";
|
|
106
|
+
/** Estado decodificado do slot `ml2:`. */
|
|
107
|
+
export interface ParsedOtpToken {
|
|
108
|
+
linkToken: string;
|
|
109
|
+
/** `sha256(<uid>:<code>)` hex; vazio quando o código foi invalidado (lockout). */
|
|
110
|
+
codeHash: string;
|
|
111
|
+
codeExpMs: number;
|
|
112
|
+
attempts: number;
|
|
113
|
+
}
|
|
114
|
+
/** Serializa o estado do OTP no formato de slot `ml2:...`. */
|
|
115
|
+
export declare function encodeOtpToken(state: ParsedOtpToken): string;
|
|
116
|
+
/**
|
|
117
|
+
* Decodifica o valor ARMAZENADO no slot (`ml2:<linkToken>:<codeHash>:<exp>:<att>`).
|
|
118
|
+
* Retorna `null` se não for um slot `ml2:` bem-formado.
|
|
119
|
+
*/
|
|
120
|
+
export declare function decodeOtpToken(value: string | null | undefined): ParsedOtpToken | null;
|
|
121
|
+
/**
|
|
122
|
+
* Extrai o `linkToken` de uma URL de magic link `ml2:<linkToken>` (a forma que
|
|
123
|
+
* vai no e-mail, SEM o estado do código). Retorna `null` se não casar o formato
|
|
124
|
+
* ou se o token não for hex de 64 (guarda contra LIKE injection na busca).
|
|
125
|
+
*/
|
|
126
|
+
export declare function linkTokenFromOtpUrl(urlToken: string): string | null;
|
|
127
|
+
export type OtpVerifyOutcome = 'ok' | 'invalid' | 'locked' | 'expired' | 'no_code';
|
|
128
|
+
export interface OtpVerifyEvaluation {
|
|
129
|
+
result: OtpVerifyOutcome;
|
|
130
|
+
/**
|
|
131
|
+
* O que persistir no slot `passwordResetToken` como efeito:
|
|
132
|
+
* • `undefined` — não escrever (nada mudou: expired/no_code/locked-já-travado).
|
|
133
|
+
* • `null` — LIMPAR o slot (sucesso: mata o link junto — single-use conjunto).
|
|
134
|
+
* • string — novo valor `ml2:` (falha: contador++ ou código invalidado).
|
|
135
|
+
*/
|
|
136
|
+
nextToken?: string | null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Avalia UMA tentativa de código, na ORDEM travada pelo spec:
|
|
140
|
+
* lockout (contador/estado do código) → TTL do código → comparação constant-time.
|
|
141
|
+
*
|
|
142
|
+
* O throttle de rota e a validade da interaction são resolvidos ANTES, no
|
|
143
|
+
* controller. Aqui mora só a lógica que precisa do estado persistido do código.
|
|
144
|
+
*
|
|
145
|
+
* IMPORTANTE (prova de mutação): a checagem de LOCKOUT é a primeira guarda. Se
|
|
146
|
+
* removida, um atacante que já esgotou as tentativas volta a poder chutar — o
|
|
147
|
+
* teste `remove-lockout` cobre exatamente isso.
|
|
148
|
+
*/
|
|
149
|
+
export declare function evaluateLoginOtp(input: {
|
|
150
|
+
parsed: ParsedOtpToken | null;
|
|
151
|
+
uid: string;
|
|
152
|
+
code: string;
|
|
153
|
+
nowMs: number;
|
|
154
|
+
maxAttempts: number;
|
|
155
|
+
}): OtpVerifyEvaluation;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login por OTP (código digitável) — helpers puros + máquina de estados da
|
|
3
|
+
* verificação.
|
|
4
|
+
*
|
|
5
|
+
* ── Por que este módulo existe (e o porquê da decisão de armazenamento) ───────
|
|
6
|
+
* O host passwordless já tem magic link (token de 256 bits, IMPOSSÍVEL de
|
|
7
|
+
* adivinhar). O código de 6 dígitos é ADIVINHÁVEL: exige lockout dedicado +
|
|
8
|
+
* throttle — segurança que não se reimplementa por host. O mesmo e-mail passa a
|
|
9
|
+
* carregar LINK e CÓDIGO; os dois completam a MESMA interaction OIDC.
|
|
10
|
+
*
|
|
11
|
+
* ── Decisão de armazenamento (investigação registrada no código) ─────────────
|
|
12
|
+
* O SPEC ranqueia três opções e manda a investigação decidir. Resultado:
|
|
13
|
+
*
|
|
14
|
+
* 1. (preferida no spec) Guardar `otpHash`/`otpExpiresAt`/`otpAttempts` no
|
|
15
|
+
* REGISTRO DA INTERACTION do oidc-provider — **INVIÁVEL**. O modelo
|
|
16
|
+
* `Interaction` do oidc-provider só persiste os campos listados em
|
|
17
|
+
* `IN_PAYLOAD` (`base_model.js` filtra o payload por
|
|
18
|
+
* `IN_PAYLOAD.includes(key)` no construtor; `save()` chama
|
|
19
|
+
* `getValueAndPayload`). Campos custom de topo são DESCARTADOS ao persistir.
|
|
20
|
+
* O único slot livre persistido é `lastSubmission`, dono do mecanismo
|
|
21
|
+
* `mergeWithLastSubmission` — sequestrá-lo é frágil. Ver
|
|
22
|
+
* `node_modules/oidc-provider/lib/models/interaction.js:57` e
|
|
23
|
+
* `.../base_model.js:34`.
|
|
24
|
+
*
|
|
25
|
+
* 2. (ESCOLHIDA) Formato composto no slot já existente do token de magic link
|
|
26
|
+
* (`passwordResetToken`, hoje `ml:<token>`). Passa a `ml2:<...>` quando o
|
|
27
|
+
* OTP está ligado. Esta opção resolve os TRÊS requisitos duros de uma vez:
|
|
28
|
+
* • **Single-use conjunto** — código e link vivem no MESMO slot da MESMA
|
|
29
|
+
* linha: consumir qualquer um limpa o slot → o outro morre junto, sem
|
|
30
|
+
* coordenação entre stores.
|
|
31
|
+
* • **Contador de tentativas persistido SEM limiter** — o contador vive
|
|
32
|
+
* DENTRO do slot. O lockout é imposto pelo próprio contador persistido
|
|
33
|
+
* (fail-CLOSED: não depende do `@adonisjs/limiter`), ao contrário do
|
|
34
|
+
* `otp_lockout.ts`, que vira no-op sem limiter — perigoso para um código
|
|
35
|
+
* curto. O throttle de rota (`authkit_otp_login`) é camada EXTRA por IP.
|
|
36
|
+
* • **TTL herdado** — a coluna `passwordResetExpiresAt` já dá validade ao
|
|
37
|
+
* link; o código carrega o próprio `codeExpMs` embutido (mais curto).
|
|
38
|
+
*
|
|
39
|
+
* 3. Coluna nova via ensure-schema — desnecessária (a opção 2 não exige
|
|
40
|
+
* migração), então descartada.
|
|
41
|
+
*
|
|
42
|
+
* ── Formato do slot (`ml2:`) ─────────────────────────────────────────────────
|
|
43
|
+
* Armazenado: `ml2:<linkToken>:<codeHash>:<codeExpMs>:<attempts>`
|
|
44
|
+
* Na URL: `ml2:<linkToken>` (SÓ o token do link — o código, o hash e o
|
|
45
|
+
* contador NUNCA saem no e-mail/URL, então o atacante não tem como
|
|
46
|
+
* zerar o contador manipulando o que ele recebe).
|
|
47
|
+
*
|
|
48
|
+
* • `linkToken` — 32 bytes hex; é o token do magic link (mesma força de antes).
|
|
49
|
+
* • `codeHash` — `sha256(<uid>:<code>)` em hex, ou VAZIO quando o código foi
|
|
50
|
+
* invalidado por lockout (o link continua válido e localizável).
|
|
51
|
+
* Atrelar ao `uid` da interaction honra o escopo "por
|
|
52
|
+
* interaction" do spec: um código emitido numa interaction não
|
|
53
|
+
* verifica em outra, mesmo para o mesmo e-mail.
|
|
54
|
+
* • `codeExpMs` — epoch ms de expiração DO CÓDIGO (TTL curto, default 10 min).
|
|
55
|
+
* • `attempts` — contador server-side de tentativas erradas (começa em 0).
|
|
56
|
+
*
|
|
57
|
+
* Segurança do contador: como o link e o código compartilham o slot mas o
|
|
58
|
+
* LOCKOUT do código NÃO pode matar o link (spec), a invalidação por lockout zera
|
|
59
|
+
* o `codeHash` (mantendo `linkToken`) em vez de limpar o slot inteiro.
|
|
60
|
+
*/
|
|
61
|
+
import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
|
|
62
|
+
export const OTP_LOGIN_DEFAULTS = {
|
|
63
|
+
enabled: false,
|
|
64
|
+
digits: 6,
|
|
65
|
+
ttlMinutes: 10,
|
|
66
|
+
maxAttempts: 5,
|
|
67
|
+
};
|
|
68
|
+
/** Resolve/normaliza a config `login.otp` com os defaults e limites de sanidade. */
|
|
69
|
+
export function resolveOtpLoginConfig(input) {
|
|
70
|
+
const digitsRaw = input?.digits;
|
|
71
|
+
const digits = typeof digitsRaw === 'number' && digitsRaw >= 4 && digitsRaw <= 10
|
|
72
|
+
? Math.floor(digitsRaw)
|
|
73
|
+
: OTP_LOGIN_DEFAULTS.digits;
|
|
74
|
+
const ttlRaw = input?.ttlMinutes;
|
|
75
|
+
const ttlMinutes = typeof ttlRaw === 'number' && ttlRaw >= 1 ? Math.floor(ttlRaw) : OTP_LOGIN_DEFAULTS.ttlMinutes;
|
|
76
|
+
const maxRaw = input?.maxAttempts;
|
|
77
|
+
const maxAttempts = typeof maxRaw === 'number' && maxRaw >= 1 ? Math.floor(maxRaw) : OTP_LOGIN_DEFAULTS.maxAttempts;
|
|
78
|
+
return {
|
|
79
|
+
enabled: input?.enabled ?? OTP_LOGIN_DEFAULTS.enabled,
|
|
80
|
+
digits,
|
|
81
|
+
ttlMinutes,
|
|
82
|
+
maxAttempts,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Geração e hashing do código
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/**
|
|
89
|
+
* Gera um código numérico de `digits` dígitos, zero-padded, SEM viés de módulo.
|
|
90
|
+
*
|
|
91
|
+
* Usa `crypto.randomInt(0, 10 ** digits)` — o `randomInt` do Node faz rejection
|
|
92
|
+
* sampling internamente, então a distribuição é uniforme (nada de `% 10`, que
|
|
93
|
+
* enviesaria os dígitos baixos). Para `digits=6` o teto é 1_000_000, bem abaixo
|
|
94
|
+
* do limite de `randomInt` (2**48).
|
|
95
|
+
*/
|
|
96
|
+
export function generateOtpCode(digits) {
|
|
97
|
+
const max = 10 ** digits;
|
|
98
|
+
const n = randomInt(0, max);
|
|
99
|
+
return String(n).padStart(digits, '0');
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Hash do código atrelado ao `uid` da interaction: `sha256(<uid>:<code>)` em hex.
|
|
103
|
+
* Atrelar ao uid escopa o código à interaction que o emitiu.
|
|
104
|
+
*/
|
|
105
|
+
export function hashLoginOtp(uid, code) {
|
|
106
|
+
return createHash('sha256').update(`${uid}:${code}`).digest('hex');
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Comparação constant-time de dois digests hex de MESMO tamanho.
|
|
110
|
+
*
|
|
111
|
+
* `timingSafeEqual` exige buffers de tamanho igual — comprimentos diferentes
|
|
112
|
+
* lançam. Por isso a guarda de tamanho vem antes (retorno `false` sem vazar
|
|
113
|
+
* timing útil: o atacante não controla o tamanho do digest server-side, que é
|
|
114
|
+
* sempre 64 hex de um sha256).
|
|
115
|
+
*/
|
|
116
|
+
export function safeEqualHex(a, b) {
|
|
117
|
+
if (a.length !== b.length || a.length === 0)
|
|
118
|
+
return false;
|
|
119
|
+
const bufA = Buffer.from(a, 'hex');
|
|
120
|
+
const bufB = Buffer.from(b, 'hex');
|
|
121
|
+
if (bufA.length !== bufB.length)
|
|
122
|
+
return false;
|
|
123
|
+
return timingSafeEqual(bufA, bufB);
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Codec do slot composto `ml2:`
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
/** Prefixo do slot `passwordResetToken` quando o login por OTP está ativo. */
|
|
129
|
+
export const OTP_LOGIN_PREFIX = 'ml2:';
|
|
130
|
+
/** Só hex minúsculo (64 chars = sha256). Guard contra metacaracteres de LIKE. */
|
|
131
|
+
const HEX_64 = /^[0-9a-f]{64}$/;
|
|
132
|
+
/** Serializa o estado do OTP no formato de slot `ml2:...`. */
|
|
133
|
+
export function encodeOtpToken(state) {
|
|
134
|
+
return `${OTP_LOGIN_PREFIX}${state.linkToken}:${state.codeHash}:${state.codeExpMs}:${state.attempts}`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Decodifica o valor ARMAZENADO no slot (`ml2:<linkToken>:<codeHash>:<exp>:<att>`).
|
|
138
|
+
* Retorna `null` se não for um slot `ml2:` bem-formado.
|
|
139
|
+
*/
|
|
140
|
+
export function decodeOtpToken(value) {
|
|
141
|
+
if (!value || !value.startsWith(OTP_LOGIN_PREFIX))
|
|
142
|
+
return null;
|
|
143
|
+
const rest = value.slice(OTP_LOGIN_PREFIX.length);
|
|
144
|
+
const parts = rest.split(':');
|
|
145
|
+
if (parts.length !== 4)
|
|
146
|
+
return null;
|
|
147
|
+
const [linkToken, codeHash, expStr, attStr] = parts;
|
|
148
|
+
if (!HEX_64.test(linkToken))
|
|
149
|
+
return null;
|
|
150
|
+
if (codeHash !== '' && !HEX_64.test(codeHash))
|
|
151
|
+
return null;
|
|
152
|
+
const codeExpMs = Number(expStr);
|
|
153
|
+
const attempts = Number(attStr);
|
|
154
|
+
if (!Number.isFinite(codeExpMs) || !Number.isInteger(attempts) || attempts < 0)
|
|
155
|
+
return null;
|
|
156
|
+
return { linkToken, codeHash, codeExpMs, attempts };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Extrai o `linkToken` de uma URL de magic link `ml2:<linkToken>` (a forma que
|
|
160
|
+
* vai no e-mail, SEM o estado do código). Retorna `null` se não casar o formato
|
|
161
|
+
* ou se o token não for hex de 64 (guarda contra LIKE injection na busca).
|
|
162
|
+
*/
|
|
163
|
+
export function linkTokenFromOtpUrl(urlToken) {
|
|
164
|
+
if (!urlToken.startsWith(OTP_LOGIN_PREFIX))
|
|
165
|
+
return null;
|
|
166
|
+
const linkToken = urlToken.slice(OTP_LOGIN_PREFIX.length);
|
|
167
|
+
return HEX_64.test(linkToken) ? linkToken : null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Avalia UMA tentativa de código, na ORDEM travada pelo spec:
|
|
171
|
+
* lockout (contador/estado do código) → TTL do código → comparação constant-time.
|
|
172
|
+
*
|
|
173
|
+
* O throttle de rota e a validade da interaction são resolvidos ANTES, no
|
|
174
|
+
* controller. Aqui mora só a lógica que precisa do estado persistido do código.
|
|
175
|
+
*
|
|
176
|
+
* IMPORTANTE (prova de mutação): a checagem de LOCKOUT é a primeira guarda. Se
|
|
177
|
+
* removida, um atacante que já esgotou as tentativas volta a poder chutar — o
|
|
178
|
+
* teste `remove-lockout` cobre exatamente isso.
|
|
179
|
+
*/
|
|
180
|
+
export function evaluateLoginOtp(input) {
|
|
181
|
+
const { parsed, uid, code, nowMs, maxAttempts } = input;
|
|
182
|
+
// Sem código pendente (slot vazio, `ml:` legado ou token de reset).
|
|
183
|
+
if (!parsed)
|
|
184
|
+
return { result: 'no_code' };
|
|
185
|
+
// LOCKOUT: código já invalidado (hash vazio) OU tentativas esgotadas.
|
|
186
|
+
// Fail-CLOSED — imposto pelo contador PERSISTIDO, sem depender de limiter.
|
|
187
|
+
if (parsed.codeHash === '' || parsed.attempts >= maxAttempts) {
|
|
188
|
+
return { result: 'locked' };
|
|
189
|
+
}
|
|
190
|
+
// TTL do código (mais curto que o do link).
|
|
191
|
+
if (parsed.codeExpMs < nowMs)
|
|
192
|
+
return { result: 'expired' };
|
|
193
|
+
// Comparação constant-time do hash atrelado ao uid.
|
|
194
|
+
const candidate = hashLoginOtp(uid, code);
|
|
195
|
+
if (safeEqualHex(candidate, parsed.codeHash)) {
|
|
196
|
+
// Sucesso: limpa o slot → mata o magic link junto (single-use conjunto).
|
|
197
|
+
return { result: 'ok', nextToken: null };
|
|
198
|
+
}
|
|
199
|
+
// Falha: incrementa o contador.
|
|
200
|
+
const attempts = parsed.attempts + 1;
|
|
201
|
+
if (attempts >= maxAttempts) {
|
|
202
|
+
// Última tentativa: INVALIDA o código (zera o hash) mas PRESERVA o link.
|
|
203
|
+
return { result: 'locked', nextToken: encodeOtpToken({ ...parsed, codeHash: '', attempts }) };
|
|
204
|
+
}
|
|
205
|
+
return { result: 'invalid', nextToken: encodeOtpToken({ ...parsed, attempts }) };
|
|
206
|
+
}
|