@adonis-agora/authkit-server 0.49.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 +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 +22 -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 +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/package.json +1 -1
|
@@ -193,6 +193,24 @@
|
|
|
193
193
|
{{-- Passwordless: confirmação de magic link enviado (anti-enumeração). --}}
|
|
194
194
|
@if(magicLinkSent)
|
|
195
195
|
<p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
|
|
196
|
+
|
|
197
|
+
{{-- Login por OTP: campo de código digitável (mesmo e-mail carrega link E código). --}}
|
|
198
|
+
@if(otpEnabled)
|
|
199
|
+
<form method="POST" action="/auth/interaction/{{ uid }}/otp-verify" class="mt-4">
|
|
200
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
201
|
+
<label for="otp-code" class="block text-sm font-medium text-gray-700">{{ t('login.otp_label') }}</label>
|
|
202
|
+
<input id="otp-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code"
|
|
203
|
+
pattern="[0-9]*" placeholder="{{ t('login.otp_placeholder') }}"
|
|
204
|
+
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" />
|
|
205
|
+
@if(otpError)
|
|
206
|
+
<p class="mt-2 text-sm text-red-600">{{ otpError }}</p>
|
|
207
|
+
@end
|
|
208
|
+
<button type="submit"
|
|
209
|
+
class="mt-4 w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:opacity-90">
|
|
210
|
+
{{ t('login.otp_submit') }}
|
|
211
|
+
</button>
|
|
212
|
+
</form>
|
|
213
|
+
@end
|
|
196
214
|
@end
|
|
197
215
|
|
|
198
216
|
{{-- 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,12 @@ 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;
|
|
43
50
|
}) => Promise<void>;
|
|
44
51
|
/**
|
|
45
52
|
* Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Distinto de
|
|
@@ -191,6 +198,13 @@ export interface ResolvedRateLimitConfig {
|
|
|
191
198
|
* afrouxar — o ponto é separar a CONTAGEM, não o teto.
|
|
192
199
|
*/
|
|
193
200
|
sudo: RateLimitBucket;
|
|
201
|
+
/**
|
|
202
|
+
* Bucket da verificação de código OTP de login (`/auth/interaction/:uid/otp-verify`),
|
|
203
|
+
* keyed por IP. MAIS APERTADO que o login (5/min vs 10/min): um código de 6
|
|
204
|
+
* dígitos é adivinhável, então o teto por IP é a primeira barreira anti-brute
|
|
205
|
+
* force ANTES do lockout por interaction (contador persistido no slot do código).
|
|
206
|
+
*/
|
|
207
|
+
otpLogin: RateLimitBucket;
|
|
194
208
|
store?: string;
|
|
195
209
|
}
|
|
196
210
|
export declare function resolveRateLimit(input?: RateLimitConfigInput): ResolvedRateLimitConfig;
|
|
@@ -440,9 +454,17 @@ export declare function resolveAuthMethodsConfig(input?: AuthMethodsConfigInput)
|
|
|
440
454
|
export interface LoginConfigInput {
|
|
441
455
|
/** Exige e-mail verificado para autenticar (senha/magic link/passkey-first). Default: false. */
|
|
442
456
|
requireVerifiedEmail?: boolean;
|
|
457
|
+
/**
|
|
458
|
+
* Login por OTP (código digitável) — extensão do magic link. Quando ligado, o
|
|
459
|
+
* MESMO e-mail passa a carregar link E código, os dois completando a mesma
|
|
460
|
+
* interaction. Default: **desligado** (opt-in; sem a config o comportamento é
|
|
461
|
+
* idêntico ao de antes, e-mail idêntico). Ver `host/otp_login.ts`.
|
|
462
|
+
*/
|
|
463
|
+
otp?: OtpLoginConfigInput;
|
|
443
464
|
}
|
|
444
465
|
export interface ResolvedLoginConfig {
|
|
445
466
|
requireVerifiedEmail: boolean;
|
|
467
|
+
otp: ResolvedOtpLoginConfig;
|
|
446
468
|
}
|
|
447
469
|
export declare function resolveLogin(input?: LoginConfigInput): ResolvedLoginConfig;
|
|
448
470
|
/**
|
|
@@ -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';
|
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -30,6 +30,12 @@ export interface AuthThrottles {
|
|
|
30
30
|
* orçamentos não poderem se consumir.
|
|
31
31
|
*/
|
|
32
32
|
sudo: ThrottleMiddleware;
|
|
33
|
+
/**
|
|
34
|
+
* Throttle da verificação de código OTP de login, keyed por IP em bucket
|
|
35
|
+
* PRÓPRIO (`authkit_otp_login`) e mais apertado que o `login`. Primeira barreira
|
|
36
|
+
* anti-brute-force do código adivinhável, ANTES do lockout por interaction.
|
|
37
|
+
*/
|
|
38
|
+
otpLogin: ThrottleMiddleware;
|
|
33
39
|
}
|
|
34
40
|
/**
|
|
35
41
|
* Service do `@adonisjs/limiter` resolvido de forma preguiçosa. Tipado como `any`
|
|
@@ -96,5 +96,8 @@ export function createAuthThrottles(config) {
|
|
|
96
96
|
// mesmo vindo do mesmo IP. Sem `usingKey` próprio de propósito: inventar uma
|
|
97
97
|
// key aqui seria mudar o EIXO da contagem, e o eixo certo continua sendo o IP.
|
|
98
98
|
sudo: buildThrottle('authkit_sudo', config.sudo, config.store),
|
|
99
|
+
// Verificação de código OTP: keyed por IP (default), bucket próprio e mais
|
|
100
|
+
// apertado que o login. O namespace do nome mantém a contagem separada.
|
|
101
|
+
otpLogin: buildThrottle('authkit_otp_login', config.otpLogin, config.store),
|
|
99
102
|
};
|
|
100
103
|
}
|
|
@@ -256,6 +256,11 @@ export function registerAuthHost(router, opts = {}) {
|
|
|
256
256
|
if (throttles)
|
|
257
257
|
route.use([throttles.sudo]);
|
|
258
258
|
};
|
|
259
|
+
// Bucket PRÓPRIO da verificação de código OTP: mais apertado que o login, por IP.
|
|
260
|
+
const withOtpLogin = (route) => {
|
|
261
|
+
if (throttles)
|
|
262
|
+
route.use([throttles.otpLogin]);
|
|
263
|
+
};
|
|
259
264
|
// ─── Assets estáticos do host-kit (públicos, sem autenticação) ─────────────
|
|
260
265
|
// Bundle do @simplewebauthn/browser servido pelo próprio app, no lugar do
|
|
261
266
|
// import de CDN público que as views de login/MFA/confirm faziam.
|
|
@@ -286,6 +291,9 @@ export function registerAuthHost(router, opts = {}) {
|
|
|
286
291
|
// Magic link (passwordless): POST emite (throttled), GET consome o token do link.
|
|
287
292
|
withLogin(router.post('/auth/interaction/:uid/magic', [C.interaction, 'magicLinkRequest']));
|
|
288
293
|
router.get('/auth/interaction/:uid/magic', [C.interaction, 'magicLinkConsume']);
|
|
294
|
+
// Login por OTP (código digitável): verifica o código no bucket dedicado
|
|
295
|
+
// `authkit_otp_login` (por IP, mais apertado que o login).
|
|
296
|
+
withOtpLogin(router.post('/auth/interaction/:uid/otp-verify', [C.interaction, 'otpVerify']));
|
|
289
297
|
router.post('/auth/interaction/:uid/consent', [C.interaction, 'consent']);
|
|
290
298
|
router.get('/auth/interaction/:uid/switch', [C.interaction, 'switchIdentifier']);
|
|
291
299
|
// OTP unlock: link enviado por e-mail quando o fator TOTP/recovery é travado.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|