@adonis-agora/authkit-server 0.87.0 → 0.88.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.
@@ -66,6 +66,22 @@
66
66
  {{ t('signup.have_account') }}
67
67
  </a>
68
68
  </form>
69
+
70
+ {{-- Código do mesmo e-mail: conclui o cadastro neste navegador. --}}
71
+ @if(magicLinkSent && otpEnabled)
72
+ <form method="POST" action="/auth/interaction/{{ uid }}/otp-verify" class="mt-4">
73
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
74
+ <input type="hidden" name="channel" value="code">
75
+ <label for="otp-code" class="block text-sm font-medium text-gray-700">{{ t('login.otp_label') }}</label>
76
+ <input id="otp-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code"
77
+ pattern="[0-9]*" placeholder="{{ t('login.otp_placeholder') }}"
78
+ 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">
79
+ <button type="submit"
80
+ class="mt-4 w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:opacity-90">
81
+ {{ t('login.otp_submit') }}
82
+ </button>
83
+ </form>
84
+ @end
69
85
  @end
70
86
  </div>
71
87
  </body></html>
@@ -76,8 +76,8 @@ export interface MailHooks {
76
76
  magicUrl: string;
77
77
  token: string;
78
78
  /**
79
- * Código OTP de login, presente APENAS quando `login.otp.enabled` está
80
- * ligado. O host pode montar o próprio e-mail com link E código. Ausente no
79
+ * Código OTP, de login ou de cadastro (`kind: 'signup'`), presente APENAS quando
80
+ * `login.otp.enabled` está ligado. O host pode montar o próprio e-mail com link E código. Ausente no
81
81
  * fluxo só-magic-link (back-compat).
82
82
  */
83
83
  code?: string;
@@ -90,7 +90,7 @@ export interface MailHooks {
90
90
  channel?: 'code' | 'link';
91
91
  /**
92
92
  * `'signup'`: link de confirmação do cadastro passwordless (a conta é criada
93
- * ao abri-lo). Ausente = login por magic link. O host pode trocar o texto do
93
+ * ao abri-lo, ou ao confirmar o `code`). Ausente = login por magic link. O host pode trocar o texto do
94
94
  * e-mail ("confirme seu cadastro"); ignorar o campo continua funcionando.
95
95
  */
96
96
  kind?: 'signup';
@@ -523,7 +523,9 @@ export interface PasswordlessConfigInput {
523
523
  * senha) e envia um link de confirmação (`onMagicLink` com `kind: 'signup'`).
524
524
  * A conta só é criada quando o dono do e-mail abre o link: nasce com o e-mail
525
525
  * verificado e uma senha aleatória inutilizável, e o login segue pelo magic
526
- * link. E-mail que já tem conta recebe um magic link de login. Default: false.
526
+ * link. E-mail que já tem conta recebe um magic link de login. Com `login.otp`
527
+ * ligado, os dois casos também levam um código para concluir no mesmo navegador.
528
+ * Default: false.
527
529
  * Exige que o accountStore implemente {@link MagicLinkCapability} e o serviço de
528
530
  * encryption do app (APP_KEY), que assina o link; sem a capacidade o cadastro
529
531
  * passwordless fica indisponível e o fluxo de senha segue valendo.
@@ -18,6 +18,7 @@ import { authkitOrigin } from '../origin.js';
18
18
  import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
19
19
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
20
20
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
21
+ import { LOGIN_EMAIL_SESSION_KEY, pendingSignupCode, verifySignupCode } from '../signup_code.js';
21
22
  import { SOCIAL_PENDING_KEY, SOCIAL_PENDING_TTL_MS, } from '../social_pending.js';
22
23
  import { buildTrustedDevicePayload, isTrustedDeviceValid, TRUSTED_DEVICE_COOKIE, } from '../trusted_device.js';
23
24
  import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
@@ -58,7 +59,7 @@ function accountStatusErrorKey(reason) {
58
59
  * `toLowerCase`). É o que a tela SEMPRE mostra — nunca o endereço gravado —,
59
60
  * para que a canonicalização não vire um oráculo de "esta conta existe".
60
61
  */
61
- const SESSION_KEY = 'authkit_login_email';
62
+ const SESSION_KEY = LOGIN_EMAIL_SESSION_KEY;
62
63
  /** accountId aguardando o 2º fator depois da senha verificada. */
63
64
  const MFA_PENDING_KEY = 'authkit_mfa_pending';
64
65
  /**
@@ -1397,13 +1398,11 @@ export default class AuthInteractionController {
1397
1398
  return ctx.response.redirect(`/auth/interaction/${uid}`);
1398
1399
  }
1399
1400
  const code = String(ctx.request.input('code', '') ?? '').trim();
1401
+ const pendingSignup = pendingSignupCode(ctx, uid, email);
1400
1402
  const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
1401
1403
  // Mantém a sub-view do seletor no re-render de erro (o form de código pode
1402
1404
  // POSTar `channel=code`). Ausente = both (histórico).
1403
1405
  const channel = normalizeLoginChannel(ctx.request.input('channel'));
1404
- const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
1405
- maxAttempts: cfg.login.otp.maxAttempts,
1406
- });
1407
1406
  // Re-render da tela "link enviado" com o campo de código + erro localizado.
1408
1407
  // `otpEnabled` vem do spread (scoped — OTP é extensão do magic link, e a
1409
1408
  // preferência do usuário que desligou magic link já foi barrada acima).
@@ -1419,6 +1418,35 @@ export default class AuthInteractionController {
1419
1418
  magicChannel: magicChannelProp(channel),
1420
1419
  otpError: translate(cfg.messages, messageKey),
1421
1420
  });
1421
+ // Cadastro sem conta ainda: o código certo leva ao mesmo link de confirmação do e-mail.
1422
+ if (pendingSignup) {
1423
+ const signupResult = verifySignupCode(ctx, pendingSignup, code, cfg.login.otp.maxAttempts);
1424
+ if (signupResult === 'ok') {
1425
+ forgetLoginEmail(ctx);
1426
+ const confirmUrl = `/auth/interaction/${uid}/signup/confirm?token=${encodeURIComponent(pendingSignup.token)}`;
1427
+ return ctx.response.redirect().status(303).toPath(confirmUrl);
1428
+ }
1429
+ const signupAudit = {
1430
+ email,
1431
+ ip,
1432
+ clientId,
1433
+ metadata: { stage: 'signup', reason: signupResult },
1434
+ };
1435
+ if (signupResult === 'locked') {
1436
+ await cfg.audit?.record({ type: 'login.otp_invalidated', ...signupAudit });
1437
+ }
1438
+ else {
1439
+ await cfg.audit?.record({ type: 'login.otp_failed', ...signupAudit });
1440
+ }
1441
+ return renderOtpError(signupResult === 'locked'
1442
+ ? 'login.otp_locked'
1443
+ : signupResult === 'expired'
1444
+ ? 'login.otp_expired'
1445
+ : 'login.otp_invalid');
1446
+ }
1447
+ const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
1448
+ maxAttempts: cfg.login.otp.maxAttempts,
1449
+ });
1422
1450
  if (result.status === 'ok') {
1423
1451
  // E-mail não verificado (LGPD): mesmo com código válido, não materializa a
1424
1452
  // sessão se a política exige verificação. Espelha o magicLinkConsume.
@@ -1,6 +1,6 @@
1
1
  import '../augmentations.js';
2
2
  import { randomBytes } from 'node:crypto';
3
- import { supportsAccountDeletion, supportsMagicLink } from '../../accounts/account_store.js';
3
+ import { supportsAccountDeletion, supportsMagicLink, supportsOtpLogin, } from '../../accounts/account_store.js';
4
4
  import { PasswordPolicyError } from '../../password/password_manager.js';
5
5
  import { AdminSessionsService } from '../admin_sessions_service.js';
6
6
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
@@ -12,6 +12,7 @@ import { authkitOrigin } from '../origin.js';
12
12
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
13
13
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, } from '../runtime_toggles.js';
14
14
  import { dispatchSecurityNotice } from '../security_notice_service.js';
15
+ import { LOGIN_EMAIL_SESSION_KEY, SIGNUP_CODE_SESSION_KEY, startSignupCode, } from '../signup_code.js';
15
16
  import { issueSignupToken, readSignupToken } from '../signup_token.js';
16
17
  import { forgotPasswordValidator, passwordlessSignupValidator, resetPasswordValidator, signupValidator, } from '../validators.js';
17
18
  /**
@@ -221,7 +222,8 @@ export default class AuthRegistrationController {
221
222
  * Cadastro passwordless: valida e-mail + nome e envia um link. Conta existente
222
223
  * recebe um magic link de login; e-mail novo recebe o link de confirmação do
223
224
  * cadastro (`signupConfirm`), e só então a conta é criada. Sempre responde
224
- * "link enviado" (anti-enumeração), exista a conta ou não.
225
+ * "link enviado" (anti-enumeração), exista a conta ou não. Com `login.otp` ligado,
226
+ * os dois casos também levam um código de digitar (`otpVerify`).
225
227
  */
226
228
  async #passwordlessSignup(ctx, deps) {
227
229
  const { cfg, brand } = deps;
@@ -230,19 +232,30 @@ export default class AuthRegistrationController {
230
232
  const uid = ctx.request.param('uid');
231
233
  const data = await ctx.request.validateUsing(passwordlessSignupValidator);
232
234
  const origin = authkitOrigin(cfg);
235
+ // Com OTP ligado, o e-mail leva também um código, para concluir neste navegador.
236
+ const otpEnabled = cfg.login?.otp?.enabled === true && supportsOtpLogin(accountStore);
233
237
  const existing = await accountStore.findByEmail(data.email);
238
+ ctx.session.forget(SIGNUP_CODE_SESSION_KEY);
239
+ if (otpEnabled)
240
+ ctx.session.put(LOGIN_EMAIL_SESSION_KEY, data.email);
234
241
  if (existing) {
235
242
  // Conta já existe: o "cadastro" vira um login por magic link.
236
243
  // `data.email` já é a forma normalizada, a mesma do login.
237
- const issued = await accountStore.issueMagicLinkToken(data.email);
244
+ const issued = otpEnabled
245
+ ? await accountStore.issueMagicLinkWithCode(data.email, uid, {
246
+ digits: cfg.login.otp.digits,
247
+ ttlMinutes: cfg.login.otp.ttlMinutes,
248
+ })
249
+ : await accountStore.issueMagicLinkToken(data.email);
238
250
  if (issued) {
251
+ const code = 'code' in issued ? issued.code : undefined;
239
252
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
240
253
  const to = issued.account.email;
241
254
  if (cfg.mail?.onMagicLink) {
242
- await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
255
+ await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token, code });
243
256
  }
244
257
  else {
245
- await sendMagicLinkEmail(ctx, { email: to, magicUrl });
258
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl, code });
246
259
  }
247
260
  }
248
261
  }
@@ -253,11 +266,20 @@ export default class AuthRegistrationController {
253
266
  // e-mail de outra pessoa não cria nada.
254
267
  const token = await issueSignupToken({ email: data.email, fullName: data.fullName ?? null });
255
268
  const magicUrl = `${origin}/auth/interaction/${uid}/signup/confirm?token=${encodeURIComponent(token)}`;
269
+ const code = otpEnabled
270
+ ? startSignupCode(ctx, {
271
+ uid,
272
+ email: data.email,
273
+ token,
274
+ digits: cfg.login.otp.digits,
275
+ ttlMinutes: cfg.login.otp.ttlMinutes,
276
+ })
277
+ : undefined;
256
278
  if (cfg.mail?.onMagicLink) {
257
- await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, kind: 'signup' });
279
+ await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, code, kind: 'signup' });
258
280
  }
259
281
  else {
260
- await sendMagicLinkEmail(ctx, { email: data.email, magicUrl });
282
+ await sendMagicLinkEmail(ctx, { email: data.email, magicUrl, code });
261
283
  }
262
284
  }
263
285
  // Resposta uniforme: "enviamos um link" (não vaza existência da conta).
@@ -266,6 +288,7 @@ export default class AuthRegistrationController {
266
288
  csrfToken: ctx.request.csrfToken,
267
289
  brand,
268
290
  magicLinkSent: true,
291
+ otpEnabled,
269
292
  });
270
293
  }
271
294
  /**
@@ -0,0 +1,41 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * Código digitado do cadastro passwordless.
4
+ *
5
+ * Quem abre o link do e-mail noutro navegador termina logado longe de onde começou (um convite
6
+ * aberto dentro do WhatsApp, por exemplo). Com o código, a pessoa conclui no mesmo navegador e na
7
+ * mesma interaction. A conta ainda não existe, então não há slot no store: o pendente fica na sessão
8
+ * deste navegador, e o código correto só libera o mesmo link de confirmação que foi por e-mail.
9
+ *
10
+ * As tentativas ficam na sessão. Num driver de cookie dá para repetir um cookie antigo e zerá-las,
11
+ * e o limite que sobra é o throttle da rota `otp-verify`.
12
+ */
13
+ export declare const SIGNUP_CODE_SESSION_KEY = "authkit_signup_code";
14
+ /** Onde o login guarda o e-mail da interaction: é o que o `otp-verify` lê. */
15
+ export declare const LOGIN_EMAIL_SESSION_KEY = "authkit_login_email";
16
+ export interface PendingSignupCode {
17
+ uid: string;
18
+ email: string;
19
+ /** O token do link de confirmação (já cifrado). */
20
+ token: string;
21
+ codeHash: string;
22
+ /** Epoch em ms. */
23
+ expiresAt: number;
24
+ attempts: number;
25
+ }
26
+ export type SignupCodeResult = 'ok' | 'invalid' | 'expired' | 'locked';
27
+ /** Gera o código e o guarda na sessão, presos à interaction e ao token. Devolve o código. */
28
+ export declare function startSignupCode(ctx: HttpContext, input: {
29
+ uid: string;
30
+ email: string;
31
+ token: string;
32
+ digits: number;
33
+ ttlMinutes: number;
34
+ }): string;
35
+ /**
36
+ * O cadastro pendente desta interaction. Se a pessoa voltou e digitou outro e-mail no login, o
37
+ * e-mail da sessão muda e o código que vale é o do login.
38
+ */
39
+ export declare function pendingSignupCode(ctx: HttpContext, uid: string, sessionEmail: string | undefined): PendingSignupCode | null;
40
+ /** Confere o código e conta a tentativa. Ao travar, nem o código certo entra; o link continua. */
41
+ export declare function verifySignupCode(ctx: HttpContext, pending: PendingSignupCode, code: string, maxAttempts: number): SignupCodeResult;
@@ -0,0 +1,56 @@
1
+ import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * Código digitado do cadastro passwordless.
4
+ *
5
+ * Quem abre o link do e-mail noutro navegador termina logado longe de onde começou (um convite
6
+ * aberto dentro do WhatsApp, por exemplo). Com o código, a pessoa conclui no mesmo navegador e na
7
+ * mesma interaction. A conta ainda não existe, então não há slot no store: o pendente fica na sessão
8
+ * deste navegador, e o código correto só libera o mesmo link de confirmação que foi por e-mail.
9
+ *
10
+ * As tentativas ficam na sessão. Num driver de cookie dá para repetir um cookie antigo e zerá-las,
11
+ * e o limite que sobra é o throttle da rota `otp-verify`.
12
+ */
13
+ export const SIGNUP_CODE_SESSION_KEY = 'authkit_signup_code';
14
+ /** Onde o login guarda o e-mail da interaction: é o que o `otp-verify` lê. */
15
+ export const LOGIN_EMAIL_SESSION_KEY = 'authkit_login_email';
16
+ function hashCode(uid, token, code) {
17
+ return createHash('sha256').update(`${uid}\u0000${token}\u0000${code}`).digest('hex');
18
+ }
19
+ /** Gera o código e o guarda na sessão, presos à interaction e ao token. Devolve o código. */
20
+ export function startSignupCode(ctx, input) {
21
+ const code = String(randomInt(0, 10 ** input.digits)).padStart(input.digits, '0');
22
+ const pending = {
23
+ uid: input.uid,
24
+ email: input.email,
25
+ token: input.token,
26
+ codeHash: hashCode(input.uid, input.token, code),
27
+ expiresAt: Date.now() + input.ttlMinutes * 60_000,
28
+ attempts: 0,
29
+ };
30
+ ctx.session.put(SIGNUP_CODE_SESSION_KEY, pending);
31
+ return code;
32
+ }
33
+ /**
34
+ * O cadastro pendente desta interaction. Se a pessoa voltou e digitou outro e-mail no login, o
35
+ * e-mail da sessão muda e o código que vale é o do login.
36
+ */
37
+ export function pendingSignupCode(ctx, uid, sessionEmail) {
38
+ const pending = ctx.session.get(SIGNUP_CODE_SESSION_KEY);
39
+ return pending?.uid === uid && pending.email === sessionEmail ? pending : null;
40
+ }
41
+ /** Confere o código e conta a tentativa. Ao travar, nem o código certo entra; o link continua. */
42
+ export function verifySignupCode(ctx, pending, code, maxAttempts) {
43
+ if (pending.attempts >= maxAttempts)
44
+ return 'locked';
45
+ if (Date.now() >= pending.expiresAt)
46
+ return 'expired';
47
+ const expected = Buffer.from(pending.codeHash);
48
+ const given = Buffer.from(hashCode(pending.uid, pending.token, code));
49
+ if (expected.length === given.length && timingSafeEqual(expected, given)) {
50
+ ctx.session.forget(SIGNUP_CODE_SESSION_KEY);
51
+ return 'ok';
52
+ }
53
+ const attempts = pending.attempts + 1;
54
+ ctx.session.put(SIGNUP_CODE_SESSION_KEY, { ...pending, attempts });
55
+ return attempts >= maxAttempts ? 'locked' : 'invalid';
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.87.0",
3
+ "version": "0.88.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",