@adonis-agora/authkit-server 0.48.0 → 0.50.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 +18 -0
- package/build/index.d.ts +18 -2
- package/build/index.js +16 -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 +22 -0
- package/build/src/define_config.js +5 -0
- package/build/src/host/account_screen_props.d.ts +163 -0
- package/build/src/host/account_screen_props.js +23 -0
- package/build/src/host/controllers/account_confirm_controller.js +3 -2
- package/build/src/host/controllers/account_mfa_controller.js +9 -6
- package/build/src/host/controllers/account_security_controller.js +3 -2
- package/build/src/host/controllers/account_session_controller.js +8 -3
- package/build/src/host/controllers/interaction_controller.d.ts +11 -0
- package/build/src/host/controllers/interaction_controller.js +136 -6
- package/build/src/host/default_mailer.d.ts +1 -0
- package/build/src/host/default_mailer.js +3 -0
- package/build/src/host/email_templates.d.ts +8 -0
- package/build/src/host/email_templates.js +12 -1
- package/build/src/host/i18n.d.ts +14 -0
- package/build/src/host/i18n.js +16 -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/build/src/host/renderers/inertia_renderer.d.ts +23 -66
- package/build/src/host/renderers/inertia_renderer.js +23 -66
- package/package.json +2 -2
|
@@ -37,7 +37,7 @@ export default class AccountConfirmController {
|
|
|
37
37
|
ctx.logger?.warn({ method: m.id }, `authkit: método de sudo "${m.id}" está em config.sudo.methods mas não teve rotas montadas por registerAuthHost — a tela vai oferecer uma opção cujo endpoint não existe`);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
const props = {
|
|
41
41
|
csrfToken: ctx.request.csrfToken,
|
|
42
42
|
returnTo: c.returnTo,
|
|
43
43
|
error: ctx.session.flashMessages.get('confirmError') ?? null,
|
|
@@ -47,6 +47,7 @@ export default class AccountConfirmController {
|
|
|
47
47
|
notice: ctx.session.flashMessages.get('confirmNotice') ?? null,
|
|
48
48
|
methods,
|
|
49
49
|
preferredId: ctx.session.get(LAST_METHOD_SESSION_KEY) ?? null,
|
|
50
|
-
}
|
|
50
|
+
};
|
|
51
|
+
return c.cfg.render(ctx, 'account/confirm', props);
|
|
51
52
|
}
|
|
52
53
|
}
|
|
@@ -40,13 +40,14 @@ export default class AccountMfaController {
|
|
|
40
40
|
// Passkeys disponíveis quando o store as suporta (model de credenciais wired).
|
|
41
41
|
const passkeysSupported = supportsPasskeys(cfg.accountStore);
|
|
42
42
|
const passkeys = passkeysSupported ? await cfg.accountStore.listPasskeys(userId) : [];
|
|
43
|
-
|
|
43
|
+
const props = {
|
|
44
44
|
csrfToken: ctx.request.csrfToken,
|
|
45
45
|
enabled: state.enabled,
|
|
46
46
|
recoveryCodes: recoveryCodes ?? null,
|
|
47
47
|
passkeysSupported,
|
|
48
48
|
passkeys,
|
|
49
|
-
}
|
|
49
|
+
};
|
|
50
|
+
return render(ctx, 'account/mfa', props);
|
|
50
51
|
}
|
|
51
52
|
/**
|
|
52
53
|
* POST /account/mfa/passkeys/options — gera as opções de registro de passkey
|
|
@@ -178,14 +179,15 @@ export default class AccountMfaController {
|
|
|
178
179
|
}
|
|
179
180
|
// QR renderizado server-side como data-URL e passado como prop.
|
|
180
181
|
const qrDataUrl = await QRCode.toDataURL(started.otpauthUri);
|
|
181
|
-
|
|
182
|
+
const props = {
|
|
182
183
|
csrfToken: ctx.request.csrfToken,
|
|
183
184
|
enabled: false,
|
|
184
185
|
enrolling: true,
|
|
185
186
|
secret: started.secret,
|
|
186
187
|
qrDataUrl,
|
|
187
188
|
recoveryCodes: null,
|
|
188
|
-
}
|
|
189
|
+
};
|
|
190
|
+
return render(ctx, 'account/mfa', props);
|
|
189
191
|
}
|
|
190
192
|
/** POST /account/mfa/confirm — confirma o código; sucesso = ativa e mostra recovery codes. */
|
|
191
193
|
async confirm(ctx) {
|
|
@@ -199,7 +201,7 @@ export default class AccountMfaController {
|
|
|
199
201
|
// Reenvia o passo de confirmação com erro SEM regenerar o segredo pendente
|
|
200
202
|
// (o usuário já escaneou o QR; um novo segredo invalidaria o app autenticador).
|
|
201
203
|
// Mostra só o campo de código para nova tentativa.
|
|
202
|
-
|
|
204
|
+
const props = {
|
|
203
205
|
csrfToken: ctx.request.csrfToken,
|
|
204
206
|
enabled: false,
|
|
205
207
|
enrolling: true,
|
|
@@ -207,7 +209,8 @@ export default class AccountMfaController {
|
|
|
207
209
|
qrDataUrl: null,
|
|
208
210
|
error: translate(cfg.messages, 'errors.invalid_code'),
|
|
209
211
|
recoveryCodes: null,
|
|
210
|
-
}
|
|
212
|
+
};
|
|
213
|
+
return render(ctx, 'account/mfa', props);
|
|
211
214
|
}
|
|
212
215
|
await cfg.audit?.record({
|
|
213
216
|
type: 'mfa.enabled',
|
|
@@ -65,7 +65,7 @@ export default class AccountSecurityController {
|
|
|
65
65
|
const ownSessions = sessionsSupported
|
|
66
66
|
? await enrichSessionsWithContext(cfg, userId, await adminSessions.listSessions(userId))
|
|
67
67
|
: [];
|
|
68
|
-
|
|
68
|
+
const props = {
|
|
69
69
|
csrfToken: ctx.request.csrfToken,
|
|
70
70
|
supported: supportsAccountSecurity(cfg.accountStore),
|
|
71
71
|
profileSupported: supportsProfile(cfg.accountStore),
|
|
@@ -94,7 +94,8 @@ export default class AccountSecurityController {
|
|
|
94
94
|
// Deleção de conta (LGPD): só quando o store suporta hard delete.
|
|
95
95
|
deletionSupported: supportsAccountDeletion(cfg.accountStore),
|
|
96
96
|
deleteError: ctx.session.flashMessages.get('deleteError') ?? null,
|
|
97
|
-
}
|
|
97
|
+
};
|
|
98
|
+
return render(ctx, 'account/security', props);
|
|
98
99
|
}
|
|
99
100
|
/**
|
|
100
101
|
* GET /account/security/export — baixa um JSON com os dados da conta logada
|
|
@@ -48,7 +48,11 @@ export default class AccountSessionController {
|
|
|
48
48
|
// Lê e valida o return_to da query-string — descarta valores inválidos (open-redirect).
|
|
49
49
|
const rawReturnTo = ctx.request.qs?.()?.return_to ?? ctx.request.input?.('return_to');
|
|
50
50
|
const returnTo = validateReturnTo(rawReturnTo);
|
|
51
|
-
|
|
51
|
+
const props = {
|
|
52
|
+
csrfToken: ctx.request.csrfToken,
|
|
53
|
+
returnTo,
|
|
54
|
+
};
|
|
55
|
+
return render(ctx, 'account/login', props);
|
|
52
56
|
}
|
|
53
57
|
async login(ctx) {
|
|
54
58
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
@@ -74,7 +78,7 @@ export default class AccountSessionController {
|
|
|
74
78
|
settings: settings ?? undefined,
|
|
75
79
|
});
|
|
76
80
|
if (!result.ok) {
|
|
77
|
-
|
|
81
|
+
const props = {
|
|
78
82
|
csrfToken: ctx.request.csrfToken,
|
|
79
83
|
returnTo,
|
|
80
84
|
error: result.locked
|
|
@@ -84,7 +88,8 @@ export default class AccountSessionController {
|
|
|
84
88
|
: result.disabled
|
|
85
89
|
? translate(cfg.messages, 'errors.account_disabled')
|
|
86
90
|
: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
87
|
-
}
|
|
91
|
+
};
|
|
92
|
+
return render(ctx, 'account/login', props);
|
|
88
93
|
}
|
|
89
94
|
const acc = result.account;
|
|
90
95
|
// M5 (session fixation): regenera a sessão IMEDIATAMENTE após autenticar e
|
|
@@ -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';
|
|
@@ -609,23 +609,45 @@ export default class AuthInteractionController {
|
|
|
609
609
|
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
610
610
|
const email = ctx.session.get(SESSION_KEY);
|
|
611
611
|
const uid = ctx.request.param('uid');
|
|
612
|
+
// Login por OTP: liga o campo de código na tela "link enviado" quando a config
|
|
613
|
+
// está ligada E o store suporta a capacidade.
|
|
614
|
+
const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
|
|
612
615
|
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
|
|
613
|
-
const
|
|
616
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
617
|
+
const clientId = details.params.client_id ?? null;
|
|
618
|
+
// Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
|
|
619
|
+
// senão, o magic link puro de sempre.
|
|
620
|
+
const issued = otpEnabled
|
|
621
|
+
? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
|
|
622
|
+
digits: cfg.login.otp.digits,
|
|
623
|
+
ttlMinutes: cfg.login.otp.ttlMinutes,
|
|
624
|
+
})
|
|
625
|
+
: await cfg.accountStore.issueMagicLinkToken(email);
|
|
614
626
|
if (issued) {
|
|
627
|
+
const code = 'code' in issued ? issued.code : undefined;
|
|
615
628
|
await cfg.audit?.record({
|
|
616
629
|
type: 'login.magic_link_sent',
|
|
617
630
|
accountId: issued.account.id,
|
|
618
631
|
email,
|
|
619
|
-
ip
|
|
620
|
-
clientId
|
|
632
|
+
ip,
|
|
633
|
+
clientId,
|
|
621
634
|
});
|
|
635
|
+
if (code) {
|
|
636
|
+
await cfg.audit?.record({
|
|
637
|
+
type: 'login.otp_sent',
|
|
638
|
+
accountId: issued.account.id,
|
|
639
|
+
email,
|
|
640
|
+
ip,
|
|
641
|
+
clientId,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
622
644
|
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
623
645
|
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
624
646
|
if (cfg.mail?.onMagicLink) {
|
|
625
|
-
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token });
|
|
647
|
+
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code });
|
|
626
648
|
}
|
|
627
649
|
else {
|
|
628
|
-
await sendMagicLinkEmail(ctx, { email, magicUrl });
|
|
650
|
+
await sendMagicLinkEmail(ctx, { email, magicUrl, code });
|
|
629
651
|
}
|
|
630
652
|
}
|
|
631
653
|
}
|
|
@@ -639,6 +661,7 @@ export default class AuthInteractionController {
|
|
|
639
661
|
account: null,
|
|
640
662
|
brand,
|
|
641
663
|
magicLinkSent: true,
|
|
664
|
+
otpEnabled,
|
|
642
665
|
});
|
|
643
666
|
}
|
|
644
667
|
/**
|
|
@@ -700,6 +723,113 @@ export default class AuthInteractionController {
|
|
|
700
723
|
ctx.session.forget(SESSION_KEY);
|
|
701
724
|
await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
|
|
702
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* POST /auth/interaction/:uid/otp-verify
|
|
728
|
+
*
|
|
729
|
+
* Verifica o CÓDIGO OTP de login (o mesmo e-mail carrega link E código). Roda
|
|
730
|
+
* atrás do throttle dedicado `authkit_otp_login` (por IP, mais apertado que o
|
|
731
|
+
* login). A ordem das checagens de segurança — lockout (contador persistido no
|
|
732
|
+
* slot) → TTL → comparação constant-time — vive no store (`verifyLoginCode` →
|
|
733
|
+
* `evaluateLoginOtp`). Em sucesso, completa a MESMA interaction que o link
|
|
734
|
+
* completaria (amr `['email']`), consumindo código E link (single-use conjunto).
|
|
735
|
+
*/
|
|
736
|
+
async otpVerify(ctx) {
|
|
737
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
738
|
+
const cfg = service.config;
|
|
739
|
+
const render = cfg.render;
|
|
740
|
+
const uid = ctx.request.param('uid');
|
|
741
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
742
|
+
const clientId = (await service.interactions.details(ctx)).params.client_id;
|
|
743
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
744
|
+
// Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
|
|
745
|
+
const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
|
|
746
|
+
if (!otpEnabled || !email) {
|
|
747
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
748
|
+
}
|
|
749
|
+
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
750
|
+
const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
|
|
751
|
+
const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
|
|
752
|
+
maxAttempts: cfg.login.otp.maxAttempts,
|
|
753
|
+
});
|
|
754
|
+
// Re-render da tela "link enviado" com o campo de código + erro localizado.
|
|
755
|
+
const renderOtpError = async (messageKey) => render(ctx, 'login', {
|
|
756
|
+
...(await this.#loginMethods(ctx, cfg)),
|
|
757
|
+
uid,
|
|
758
|
+
csrfToken: ctx.request.csrfToken,
|
|
759
|
+
step: 'password',
|
|
760
|
+
email,
|
|
761
|
+
account: null,
|
|
762
|
+
brand,
|
|
763
|
+
magicLinkSent: true,
|
|
764
|
+
otpEnabled: true,
|
|
765
|
+
otpError: translate(cfg.messages, messageKey),
|
|
766
|
+
});
|
|
767
|
+
if (result.status === 'ok') {
|
|
768
|
+
// E-mail não verificado (LGPD): mesmo com código válido, não materializa a
|
|
769
|
+
// sessão se a política exige verificação. Espelha o magicLinkConsume.
|
|
770
|
+
const runtimeSettings = await getRuntimeSettings(ctx);
|
|
771
|
+
if (await isEmailUnverifiedBlock(cfg, result.account.id, runtimeSettings)) {
|
|
772
|
+
await cfg.audit?.record({
|
|
773
|
+
type: 'login.failure',
|
|
774
|
+
accountId: result.account.id,
|
|
775
|
+
email: result.account.email,
|
|
776
|
+
ip,
|
|
777
|
+
clientId,
|
|
778
|
+
metadata: { stage: 'otp', reason: 'unverified' },
|
|
779
|
+
});
|
|
780
|
+
return render(ctx, 'login', {
|
|
781
|
+
...(await this.#loginMethods(ctx, cfg)),
|
|
782
|
+
uid,
|
|
783
|
+
csrfToken: ctx.request.csrfToken,
|
|
784
|
+
step: 'password',
|
|
785
|
+
email: result.account.email,
|
|
786
|
+
account: null,
|
|
787
|
+
brand,
|
|
788
|
+
error: translate(cfg.messages, 'errors.email_unverified'),
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
await cfg.audit?.record({
|
|
792
|
+
type: 'login.otp_verified',
|
|
793
|
+
accountId: result.account.id,
|
|
794
|
+
email: result.account.email,
|
|
795
|
+
ip,
|
|
796
|
+
clientId,
|
|
797
|
+
});
|
|
798
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
799
|
+
accountId: result.account.id,
|
|
800
|
+
email: result.account.email,
|
|
801
|
+
ip,
|
|
802
|
+
clientId: clientId ?? null,
|
|
803
|
+
metadata: { method: 'otp' },
|
|
804
|
+
});
|
|
805
|
+
ctx.session.forget(SESSION_KEY);
|
|
806
|
+
return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
|
|
807
|
+
}
|
|
808
|
+
if (result.status === 'locked') {
|
|
809
|
+
// 5ª falha (ou já travado): código invalidado, o LINK continua válido.
|
|
810
|
+
await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
|
|
811
|
+
return renderOtpError('login.otp_locked');
|
|
812
|
+
}
|
|
813
|
+
if (result.status === 'expired') {
|
|
814
|
+
await cfg.audit?.record({
|
|
815
|
+
type: 'login.otp_failed',
|
|
816
|
+
email,
|
|
817
|
+
ip,
|
|
818
|
+
clientId,
|
|
819
|
+
metadata: { reason: 'expired' },
|
|
820
|
+
});
|
|
821
|
+
return renderOtpError('login.otp_expired');
|
|
822
|
+
}
|
|
823
|
+
// 'invalid' (tentativa contabilizada) ou 'no_code'.
|
|
824
|
+
await cfg.audit?.record({
|
|
825
|
+
type: 'login.otp_failed',
|
|
826
|
+
email,
|
|
827
|
+
ip,
|
|
828
|
+
clientId,
|
|
829
|
+
metadata: { reason: result.status },
|
|
830
|
+
});
|
|
831
|
+
return renderOtpError('login.otp_invalid');
|
|
832
|
+
}
|
|
703
833
|
/**
|
|
704
834
|
* POST /auth/interaction/:uid/passkey/options
|
|
705
835
|
*
|
|
@@ -62,6 +62,7 @@ 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;
|
|
65
66
|
}): Promise<void>;
|
|
66
67
|
/**
|
|
67
68
|
* Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
|
|
@@ -259,6 +259,9 @@ export async function sendMagicLinkEmail(ctx, data) {
|
|
|
259
259
|
ctaLabel: translate(t, 'mail.magic_link.cta'),
|
|
260
260
|
ctaUrl: data.magicUrl,
|
|
261
261
|
footnote: translate(t, 'mail.magic_link.fallback'),
|
|
262
|
+
// Login por OTP: quando o código é fornecido, renderiza-o em destaque.
|
|
263
|
+
code: data.code,
|
|
264
|
+
codeLabel: translate(t, 'mail.magic_link.code_label'),
|
|
262
265
|
});
|
|
263
266
|
const sent = await sendEmail(ctx, data.email, content);
|
|
264
267
|
if (!sent) {
|
|
@@ -34,6 +34,14 @@ interface EmailTemplateInput {
|
|
|
34
34
|
linkFallback?: string;
|
|
35
35
|
/** Locale do documento HTML (atributo `lang`). Default: 'en'. */
|
|
36
36
|
locale?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Código OTP de login (dígitos). Quando presente, é renderizado em destaque
|
|
39
|
+
* (grande, monoespaçado) acima do CTA, com um rótulo. Usado pelo login por OTP
|
|
40
|
+
* (o mesmo e-mail carrega link E código). Ausente = e-mail idêntico ao de antes.
|
|
41
|
+
*/
|
|
42
|
+
code?: string;
|
|
43
|
+
/** Rótulo acima do código (i18n). Default em inglês. */
|
|
44
|
+
codeLabel?: string;
|
|
37
45
|
}
|
|
38
46
|
export declare function renderTransactionalEmail(input: EmailTemplateInput): EmailContent;
|
|
39
47
|
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,7 +51,7 @@ 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
|
-
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
|
|
54
|
+
${codeBlock}<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
|
|
45
55
|
<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>
|
|
46
56
|
</td></tr></table>
|
|
47
57
|
${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(input.footnote)}</p>` : ''}
|
|
@@ -59,6 +69,7 @@ ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;col
|
|
|
59
69
|
input.heading,
|
|
60
70
|
'',
|
|
61
71
|
input.intro,
|
|
72
|
+
...(input.code ? ['', `${codeLabel} ${input.code}`] : []),
|
|
62
73
|
'',
|
|
63
74
|
`${input.ctaLabel}: ${input.ctaUrl}`,
|
|
64
75
|
...(input.footnote ? ['', input.footnote] : []),
|
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,7 @@ 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;
|
|
514
521
|
'mail.new_login.subject': string;
|
|
515
522
|
'mail.new_login.heading': string;
|
|
516
523
|
'mail.new_login.intro': string;
|
|
@@ -744,6 +751,12 @@ export declare const PT_BR_MESSAGES: {
|
|
|
744
751
|
'login.magic_link_sent': string;
|
|
745
752
|
'signup.magic_link_sent': string;
|
|
746
753
|
'login.passkey_button': string;
|
|
754
|
+
'login.otp_label': string;
|
|
755
|
+
'login.otp_placeholder': string;
|
|
756
|
+
'login.otp_submit': string;
|
|
757
|
+
'login.otp_invalid': string;
|
|
758
|
+
'login.otp_expired': string;
|
|
759
|
+
'login.otp_locked': string;
|
|
747
760
|
'signup.page_title': string;
|
|
748
761
|
'signup.title': string;
|
|
749
762
|
'signup.intro': string;
|
|
@@ -1205,6 +1218,7 @@ export declare const PT_BR_MESSAGES: {
|
|
|
1205
1218
|
'mail.magic_link.intro': string;
|
|
1206
1219
|
'mail.magic_link.cta': string;
|
|
1207
1220
|
'mail.magic_link.fallback': string;
|
|
1221
|
+
'mail.magic_link.code_label': string;
|
|
1208
1222
|
'mail.new_login.subject': string;
|
|
1209
1223
|
'mail.new_login.heading': string;
|
|
1210
1224
|
'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,7 @@ 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:',
|
|
553
561
|
'mail.new_login.subject': 'New login to your account',
|
|
554
562
|
'mail.new_login.heading': 'New login detected',
|
|
555
563
|
'mail.new_login.intro': 'We detected a new login to your account.',
|
|
@@ -814,6 +822,13 @@ export const PT_BR_MESSAGES = {
|
|
|
814
822
|
'login.magic_link_sent': 'Se a conta existir, enviamos um link de login.',
|
|
815
823
|
'signup.magic_link_sent': 'Enviamos um link para o seu e-mail. Abra-o para concluir o cadastro.',
|
|
816
824
|
'login.passkey_button': 'Entrar com passkey',
|
|
825
|
+
// Login por OTP (código digitável).
|
|
826
|
+
'login.otp_label': 'Digite o código de login do e-mail',
|
|
827
|
+
'login.otp_placeholder': '000000',
|
|
828
|
+
'login.otp_submit': 'Entrar com o código',
|
|
829
|
+
'login.otp_invalid': 'Código inválido. Tente novamente.',
|
|
830
|
+
'login.otp_expired': 'Este código expirou. Use o link de login ou peça um novo.',
|
|
831
|
+
'login.otp_locked': 'Tentativas demais. O código foi desativado — use o link de login.',
|
|
817
832
|
// Tela de cadastro (signup).
|
|
818
833
|
'signup.page_title': 'Criar conta',
|
|
819
834
|
'signup.title': 'Criar conta',
|
|
@@ -1319,6 +1334,7 @@ export const PT_BR_MESSAGES = {
|
|
|
1319
1334
|
'mail.magic_link.intro': 'Clique no botão abaixo para entrar. O link expira em breve e pode ser usado uma vez.',
|
|
1320
1335
|
'mail.magic_link.cta': 'Entrar',
|
|
1321
1336
|
'mail.magic_link.fallback': 'Se você não solicitou isso, pode ignorar este e-mail.',
|
|
1337
|
+
'mail.magic_link.code_label': 'Ou digite este código para entrar:',
|
|
1322
1338
|
'mail.new_login.subject': 'Novo login na sua conta',
|
|
1323
1339
|
'mail.new_login.heading': 'Novo login detectado',
|
|
1324
1340
|
'mail.new_login.intro': 'Detectamos um novo login na sua conta.',
|
|
@@ -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;
|