@adonis-agora/authkit-server 0.82.3 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/host/views/login.edge +25 -5
- package/build/host/views/partials/styles.edge +1 -1
- package/build/src/define_config.d.ts +44 -4
- package/build/src/define_config.js +1 -0
- package/build/src/host/account_roles.d.ts +14 -3
- package/build/src/host/account_roles.js +15 -4
- package/build/src/host/admin_api/api_users_controller.d.ts +3 -0
- package/build/src/host/admin_api/api_users_controller.js +9 -5
- package/build/src/host/admin_api/dto.d.ts +24 -0
- package/build/src/host/admin_api/dto.js +14 -0
- package/build/src/host/admin_api/token_verify_service.js +7 -2
- package/build/src/host/admin_console/admin_shell_controller.js +3 -1
- package/build/src/host/admin_console/console_users_controller.d.ts +4 -0
- package/build/src/host/admin_console/console_users_controller.js +12 -6
- package/build/src/host/controllers/interaction_controller.d.ts +8 -0
- package/build/src/host/controllers/interaction_controller.js +59 -12
- package/build/src/host/controllers/pat_introspection_controller.d.ts +1 -1
- package/build/src/host/controllers/pat_introspection_controller.js +2 -1
- package/build/src/host/controllers/registration_controller.d.ts +10 -0
- package/build/src/host/controllers/registration_controller.js +97 -30
- package/build/src/host/controllers/social_controller.js +64 -1
- package/build/src/host/i18n.d.ts +2 -0
- package/build/src/host/i18n.js +3 -0
- package/build/src/host/last_login_method.d.ts +67 -0
- package/build/src/host/last_login_method.js +119 -0
- package/build/src/host/register_auth_host.js +4 -0
- package/build/src/host/signup_token.d.ts +9 -0
- package/build/src/host/signup_token.js +31 -0
- package/build/src/host/social_pending.d.ts +17 -0
- package/build/src/host/social_pending.js +11 -0
- package/build/src/host/ui-dist/assets/{index-C1eSfG8B.js → index-DhQzXw2i.js} +1 -1
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/interaction_actions.js +7 -0
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { supportsLoginMethodsPreference, supportsMagicLink, supportsOtpLogin, supportsPasskeys, } from '../../accounts/account_store.js';
|
|
3
|
+
import { resolveAccountRoles } from '../account_roles.js';
|
|
3
4
|
import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
4
5
|
import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
|
|
5
6
|
import { brandFor, isFirstParty } from '../branding.js';
|
|
@@ -9,6 +10,7 @@ import { beginCustomMfa, clearMfaFlow, customMfaViewProps, getMfaFlow, initializ
|
|
|
9
10
|
import { sendMagicLinkEmail, sendOtpUnlockEmail } from '../default_mailer.js';
|
|
10
11
|
import { normalizeEmailIdentifier } from '../email_identifier.js';
|
|
11
12
|
import { translate } from '../i18n.js';
|
|
13
|
+
import { readLastLoginMethod, rememberLastLoginMethod } from '../last_login_method.js';
|
|
12
14
|
import { assertLoginAllowed, attemptPasswordLogin, isEmailUnverifiedBlock, } from '../login_attempt.js';
|
|
13
15
|
import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
|
|
14
16
|
import { notifyLoginSuccess } from '../login_notify.js';
|
|
@@ -16,8 +18,19 @@ import { authkitOrigin } from '../origin.js';
|
|
|
16
18
|
import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
|
|
17
19
|
import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
|
|
18
20
|
import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
|
|
21
|
+
import { SOCIAL_PENDING_KEY, SOCIAL_PENDING_TTL_MS, } from '../social_pending.js';
|
|
19
22
|
import { buildTrustedDevicePayload, isTrustedDeviceValid, TRUSTED_DEVICE_COOKIE, } from '../trusted_device.js';
|
|
20
23
|
import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
|
|
24
|
+
/**
|
|
25
|
+
* Conta com papel administrativo (`cfg.admin.roles`, default `['ADMIN']`) — o
|
|
26
|
+
* bypass do modo manutenção. Os papéis vêm de `resolveAccountRoles`, a mesma
|
|
27
|
+
* fonte do claim (`resolveTokenRoles`, ex.: authz), não só do `globalRoles`.
|
|
28
|
+
*/
|
|
29
|
+
async function isAdminAccount(cfg, account) {
|
|
30
|
+
const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
|
|
31
|
+
const roles = await resolveAccountRoles(cfg, account);
|
|
32
|
+
return adminRoles.some((role) => roles.includes(role));
|
|
33
|
+
}
|
|
21
34
|
/**
|
|
22
35
|
* Chave i18n do erro de status de conta (disabled/expired) compartilhada pelos
|
|
23
36
|
* fluxos passwordless (magic link, OTP, passkey).
|
|
@@ -105,8 +118,7 @@ export default class AuthInteractionController {
|
|
|
105
118
|
if (!status.allowed)
|
|
106
119
|
return fail(accountStatusErrorKey(status.reason));
|
|
107
120
|
const maintenance = await resolveEffectiveMaintenanceMode(settings);
|
|
108
|
-
if (maintenance.enabled &&
|
|
109
|
-
!(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
|
|
121
|
+
if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
|
|
110
122
|
return render(ctx, 'maintenance', {
|
|
111
123
|
uid: details.uid,
|
|
112
124
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -114,6 +126,7 @@ export default class AuthInteractionController {
|
|
|
114
126
|
message: maintenance.message ?? translate(cfg.messages, 'maintenance.default_message'),
|
|
115
127
|
});
|
|
116
128
|
}
|
|
129
|
+
rememberLastLoginMethod(ctx, cfg, `custom:${input.method}`);
|
|
117
130
|
const gate = await this.secondFactorGate(ctx, cfg, account.id, details, input.method, input.factorId, input.remember, input.passwordless === true);
|
|
118
131
|
if (gate.kind === 'challenge') {
|
|
119
132
|
ctx.session.put(MFA_CUSTOM_PRIMARY_KEY, {
|
|
@@ -164,6 +177,8 @@ export default class AuthInteractionController {
|
|
|
164
177
|
authMethods,
|
|
165
178
|
magicLinkAvailable: authMethods.magicLink && magicLinkCapableConfig,
|
|
166
179
|
otpEnabled,
|
|
180
|
+
// Selo "último usado": método com que ESTE dispositivo entrou da última vez.
|
|
181
|
+
lastUsedLoginMethod: readLastLoginMethod(ctx, cfg),
|
|
167
182
|
};
|
|
168
183
|
}
|
|
169
184
|
/**
|
|
@@ -468,9 +483,7 @@ export default class AuthInteractionController {
|
|
|
468
483
|
// Contas admin (role em cfg.admin.roles) CONTINUAM podendo logar.
|
|
469
484
|
// Contas comuns são bloqueadas com a tela de manutenção.
|
|
470
485
|
if (maintenance.enabled) {
|
|
471
|
-
const
|
|
472
|
-
const accountRoles = acc.globalRoles ?? [];
|
|
473
|
-
const isAdmin = adminRoles.some((r) => accountRoles.includes(r));
|
|
486
|
+
const isAdmin = await isAdminAccount(cfg, acc);
|
|
474
487
|
if (!isAdmin) {
|
|
475
488
|
return render(ctx, 'maintenance', {
|
|
476
489
|
uid: details.uid,
|
|
@@ -486,6 +499,7 @@ export default class AuthInteractionController {
|
|
|
486
499
|
// Gate do 2º fator — a MESMA regra dos três caminhos de login (ver
|
|
487
500
|
// `secondFactorGate`). Quando desafia, não finaliza a interaction: guarda o
|
|
488
501
|
// accountId pendente e devolve a tela.
|
|
502
|
+
rememberLastLoginMethod(ctx, cfg, 'password');
|
|
489
503
|
const gate = await this.secondFactorGate(ctx, cfg, acc.id, details, 'pwd');
|
|
490
504
|
if (gate.kind === 'challenge')
|
|
491
505
|
return gate.response;
|
|
@@ -563,9 +577,9 @@ export default class AuthInteractionController {
|
|
|
563
577
|
...(used.has('webauthn') ? { passkeyAvailable: false } : {}),
|
|
564
578
|
});
|
|
565
579
|
}
|
|
566
|
-
async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd') {
|
|
580
|
+
async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
|
|
567
581
|
const requiredFactors = cfg.mfa?.requiredFactors ?? 2;
|
|
568
|
-
const mfaRequired = this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
|
|
582
|
+
const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
|
|
569
583
|
const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
|
|
570
584
|
const enrolledTotp = mfa.totp ?? mfa.enabled;
|
|
571
585
|
const enrolledPasskey = await this.hasPasskeys(cfg, accountId);
|
|
@@ -736,8 +750,7 @@ export default class AuthInteractionController {
|
|
|
736
750
|
});
|
|
737
751
|
}
|
|
738
752
|
const maintenance = await resolveEffectiveMaintenanceMode(settings);
|
|
739
|
-
if (maintenance.enabled &&
|
|
740
|
-
!(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
|
|
753
|
+
if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
|
|
741
754
|
return cfg.render(ctx, 'maintenance', {
|
|
742
755
|
uid: flow.uid,
|
|
743
756
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -950,12 +963,12 @@ export default class AuthInteractionController {
|
|
|
950
963
|
* - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
|
|
951
964
|
* primário (`primary`) em `MFA_PRIMARY_KEY`.
|
|
952
965
|
*/
|
|
953
|
-
async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd') {
|
|
966
|
+
async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
|
|
954
967
|
ctx.session.forget(MFA_CUSTOM_PRIMARY_KEY);
|
|
955
968
|
clearMfaFlow(ctx);
|
|
956
969
|
if (cfg.mfa)
|
|
957
|
-
return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless);
|
|
958
|
-
const mfaRequired = this.acrRequiresMfa(cfg, details);
|
|
970
|
+
return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless, forceRequired);
|
|
971
|
+
const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details);
|
|
959
972
|
const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
|
|
960
973
|
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
961
974
|
// tem ao menos uma credencial registrada.
|
|
@@ -1212,6 +1225,32 @@ export default class AuthInteractionController {
|
|
|
1212
1225
|
* Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
|
|
1213
1226
|
* inválido/expirado volta ao início do login.
|
|
1214
1227
|
*/
|
|
1228
|
+
/**
|
|
1229
|
+
* GET /auth/interaction/:uid/social/continue — segunda metade do login social
|
|
1230
|
+
* quando a conta deve o 2º fator (ver `AuthSocialController#callback`). Lê o
|
|
1231
|
+
* login pendente da sessão UMA vez, confere que é desta interaction e passa
|
|
1232
|
+
* pelo mesmo `secondFactorGate` dos outros fluxos, com `fed` como fator
|
|
1233
|
+
* primário no `amr`.
|
|
1234
|
+
*/
|
|
1235
|
+
async socialContinue(ctx) {
|
|
1236
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
1237
|
+
const cfg = service.config;
|
|
1238
|
+
const uid = ctx.request.param('uid');
|
|
1239
|
+
const pending = ctx.session.get(SOCIAL_PENDING_KEY);
|
|
1240
|
+
ctx.session.forget(SOCIAL_PENDING_KEY);
|
|
1241
|
+
const details = await service.interactions.details(ctx);
|
|
1242
|
+
if (!pending ||
|
|
1243
|
+
pending.uid !== uid ||
|
|
1244
|
+
details.uid !== uid ||
|
|
1245
|
+
details.prompt.name !== 'login' ||
|
|
1246
|
+
Date.now() - pending.at > SOCIAL_PENDING_TTL_MS) {
|
|
1247
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
1248
|
+
}
|
|
1249
|
+
const gate = await this.secondFactorGate(ctx, cfg, pending.accountId, details, 'fed', 'fed', undefined, true, pending.required);
|
|
1250
|
+
if (gate.kind === 'challenge')
|
|
1251
|
+
return gate.response;
|
|
1252
|
+
return service.interactions.completeLogin(ctx, pending.accountId, { amr: ['fed'] });
|
|
1253
|
+
}
|
|
1215
1254
|
async magicLinkConsume(ctx) {
|
|
1216
1255
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
1217
1256
|
const cfg = service.config;
|
|
@@ -1315,6 +1354,7 @@ export default class AuthInteractionController {
|
|
|
1315
1354
|
});
|
|
1316
1355
|
}
|
|
1317
1356
|
// Conta com segundo fator não termina o login só com o e-mail.
|
|
1357
|
+
rememberLastLoginMethod(ctx, cfg, 'magic_link');
|
|
1318
1358
|
const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx), 'email');
|
|
1319
1359
|
if (magicGate.kind === 'challenge')
|
|
1320
1360
|
return magicGate.response;
|
|
@@ -1437,6 +1477,7 @@ export default class AuthInteractionController {
|
|
|
1437
1477
|
clientId,
|
|
1438
1478
|
});
|
|
1439
1479
|
// Conta com segundo fator não termina o login só com o código do e-mail.
|
|
1480
|
+
rememberLastLoginMethod(ctx, cfg, 'otp');
|
|
1440
1481
|
const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx), 'email');
|
|
1441
1482
|
if (otpGate.kind === 'challenge')
|
|
1442
1483
|
return otpGate.response;
|
|
@@ -1704,6 +1745,11 @@ export default class AuthInteractionController {
|
|
|
1704
1745
|
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
1705
1746
|
return this.finishVerifiedMfa(ctx, cfg, details, 'webauthn', 'webauthn');
|
|
1706
1747
|
}
|
|
1748
|
+
// Passkey como fator PRIMÁRIO (passkey-first/autofill). Como 2º fator ela não
|
|
1749
|
+
// é o método com que a pessoa se identificou — o selo fica com o primário.
|
|
1750
|
+
if (ctx.session.get(MFA_PENDING_KEY) !== accountId) {
|
|
1751
|
+
rememberLastLoginMethod(ctx, cfg, 'passkey');
|
|
1752
|
+
}
|
|
1707
1753
|
if (cfg.mfa) {
|
|
1708
1754
|
const gate = await this.secondFactorGate(ctx, cfg, accountId, details, 'webauthn');
|
|
1709
1755
|
if (gate.kind === 'challenge')
|
|
@@ -1811,6 +1857,7 @@ export default class AuthInteractionController {
|
|
|
1811
1857
|
// Senha trocada: limpa o step e finaliza o login.
|
|
1812
1858
|
ctx.session.forget(PASSWORD_EXPIRED_KEY);
|
|
1813
1859
|
// Verifica se precisa de MFA mesmo após a troca — mesma regra do resto.
|
|
1860
|
+
rememberLastLoginMethod(ctx, cfg, 'password');
|
|
1814
1861
|
const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details, 'pwd');
|
|
1815
1862
|
if (expiredGate.kind === 'challenge')
|
|
1816
1863
|
return expiredGate.response;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { resolveAccountRoles } from '../account_roles.js';
|
|
2
3
|
function bearerMatches(header, expected) {
|
|
3
4
|
if (!header || !header.startsWith('Bearer '))
|
|
4
5
|
return false;
|
|
@@ -47,7 +48,7 @@ export default class PatIntrospectionController {
|
|
|
47
48
|
sub: account.id,
|
|
48
49
|
email: account.email,
|
|
49
50
|
name: account.name ?? null,
|
|
50
|
-
roles:
|
|
51
|
+
roles: await resolveAccountRoles(cfg, account),
|
|
51
52
|
scopes: meta.scopes,
|
|
52
53
|
audience: meta.audience,
|
|
53
54
|
exp: meta.exp,
|
|
@@ -6,6 +6,16 @@ export default class AuthRegistrationController {
|
|
|
6
6
|
showSignup(ctx: HttpContext): Promise<any>;
|
|
7
7
|
/** POST /auth/interaction/:uid/signup — cria o usuário e finaliza o login. */
|
|
8
8
|
signup(ctx: HttpContext): Promise<any>;
|
|
9
|
+
/**
|
|
10
|
+
* GET /auth/interaction/:uid/signup/confirm?token= — o dono do e-mail abriu o
|
|
11
|
+
* link do cadastro passwordless. Cria a conta (e-mail já verificado: quem
|
|
12
|
+
* clicou provou a caixa de entrada) e conclui pelo fluxo de magic link, que
|
|
13
|
+
* aplica os mesmos gates do login (status, manutenção, 2º fator).
|
|
14
|
+
*
|
|
15
|
+
* Conta que já existe → o link é recusado e a pessoa volta ao login: é o que
|
|
16
|
+
* faz o link valer uma vez só, sem estado no banco.
|
|
17
|
+
*/
|
|
18
|
+
signupConfirm(ctx: HttpContext): Promise<void>;
|
|
9
19
|
/** GET /auth/forgot-password — tela standalone. */
|
|
10
20
|
showForgot(ctx: HttpContext): Promise<any>;
|
|
11
21
|
/** POST /auth/forgot-password — gera token e (dev) loga o link. Sempre responde sucesso (não vaza emails). */
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { supportsAccountDeletion, supportsMagicLink } from '../../accounts/account_store.js';
|
|
3
4
|
import { PasswordPolicyError } from '../../password/password_manager.js';
|
|
4
5
|
import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
5
6
|
import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
|
|
6
7
|
import { brandFor } from '../branding.js';
|
|
7
8
|
import { sendEmailVerificationEmail, sendMagicLinkEmail, sendPasswordResetEmail, } from '../default_mailer.js';
|
|
8
9
|
import { translate } from '../i18n.js';
|
|
10
|
+
import { rememberLastLoginMethod } from '../last_login_method.js';
|
|
9
11
|
import { authkitOrigin } from '../origin.js';
|
|
10
12
|
import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
|
|
11
13
|
import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, } from '../runtime_toggles.js';
|
|
12
14
|
import { dispatchSecurityNotice } from '../security_notice_service.js';
|
|
15
|
+
import { issueSignupToken, readSignupToken } from '../signup_token.js';
|
|
13
16
|
import { forgotPasswordValidator, passwordlessSignupValidator, resetPasswordValidator, signupValidator, } from '../validators.js';
|
|
14
17
|
/**
|
|
15
18
|
* Best-effort: returns a RuntimeSettings backed by the container DB, or a no-op
|
|
@@ -163,6 +166,8 @@ export default class AuthRegistrationController {
|
|
|
163
166
|
ip: ctx.request.ip?.() ?? null,
|
|
164
167
|
clientId: details.params.client_id ?? null,
|
|
165
168
|
});
|
|
169
|
+
// Conta nova entra por senha: o selo "último usado" já nasce no método certo.
|
|
170
|
+
rememberLastLoginMethod(ctx, cfg, 'password');
|
|
166
171
|
// Finaliza a interaction como login (interactionFinished escreve o redirect 303 que
|
|
167
172
|
// retoma o authorize; o form nativo da tela segue esse redirect no sucesso).
|
|
168
173
|
const result = await service.interactions.login(ctx, {
|
|
@@ -170,6 +175,13 @@ export default class AuthRegistrationController {
|
|
|
170
175
|
password: data.password,
|
|
171
176
|
});
|
|
172
177
|
if (!result.ok) {
|
|
178
|
+
// A conta acabou de nascer e o login dela falhou: sem desfazer, a pessoa
|
|
179
|
+
// ficaria com um e-mail "já cadastrado" numa conta em que nunca entrou.
|
|
180
|
+
if (created && supportsAccountDeletion(accountStore)) {
|
|
181
|
+
await accountStore.deleteAccount(created.id).catch((error) => {
|
|
182
|
+
ctx.logger.error({ err: error, accountId: created.id }, 'authkit: falha ao desfazer conta após signup sem login');
|
|
183
|
+
});
|
|
184
|
+
}
|
|
173
185
|
return render(ctx, 'signup', {
|
|
174
186
|
uid: ctx.request.param('uid'),
|
|
175
187
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -206,47 +218,46 @@ export default class AuthRegistrationController {
|
|
|
206
218
|
}
|
|
207
219
|
}
|
|
208
220
|
/**
|
|
209
|
-
* Cadastro passwordless: valida e-mail + nome
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* link
|
|
221
|
+
* Cadastro passwordless: valida e-mail + nome e envia um link. Conta existente
|
|
222
|
+
* recebe um magic link de login; e-mail novo recebe o link de confirmação do
|
|
223
|
+
* cadastro (`signupConfirm`), e só então a conta é criada. Sempre responde
|
|
224
|
+
* "link enviado" (anti-enumeração), exista a conta ou não.
|
|
213
225
|
*/
|
|
214
226
|
async #passwordlessSignup(ctx, deps) {
|
|
215
|
-
const { cfg, brand
|
|
227
|
+
const { cfg, brand } = deps;
|
|
216
228
|
const render = cfg.render;
|
|
217
229
|
const accountStore = cfg.accountStore;
|
|
218
230
|
const uid = ctx.request.param('uid');
|
|
219
231
|
const data = await ctx.request.validateUsing(passwordlessSignupValidator);
|
|
220
|
-
|
|
221
|
-
// passwordless (mesmo precedente das contas criadas por identidade social).
|
|
232
|
+
const origin = authkitOrigin(cfg);
|
|
222
233
|
const existing = await accountStore.findByEmail(data.email);
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
if (existing) {
|
|
235
|
+
// Conta já existe: o "cadastro" vira um login por magic link.
|
|
236
|
+
// `data.email` já é a forma normalizada, a mesma do login.
|
|
237
|
+
const issued = await accountStore.issueMagicLinkToken(data.email);
|
|
238
|
+
if (issued) {
|
|
239
|
+
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
240
|
+
const to = issued.account.email;
|
|
241
|
+
if (cfg.mail?.onMagicLink) {
|
|
242
|
+
await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
await sendMagicLinkEmail(ctx, { email: to, magicUrl });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
236
248
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const magicUrl = `${origin}/auth/interaction/${uid}/
|
|
244
|
-
const to = issued.account.email;
|
|
249
|
+
else {
|
|
250
|
+
// Conta nova: NÃO cria aqui. O link de confirmação leva e-mail + nome num
|
|
251
|
+
// token cifrado; a conta só nasce quando o dono do e-mail o abre
|
|
252
|
+
// (`signupConfirm`). Um envio que falha não deixa conta órfã, e digitar o
|
|
253
|
+
// e-mail de outra pessoa não cria nada.
|
|
254
|
+
const token = await issueSignupToken({ email: data.email, fullName: data.fullName ?? null });
|
|
255
|
+
const magicUrl = `${origin}/auth/interaction/${uid}/signup/confirm?token=${encodeURIComponent(token)}`;
|
|
245
256
|
if (cfg.mail?.onMagicLink) {
|
|
246
|
-
await cfg.mail.onMagicLink({ email:
|
|
257
|
+
await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, kind: 'signup' });
|
|
247
258
|
}
|
|
248
259
|
else {
|
|
249
|
-
await sendMagicLinkEmail(ctx, { email:
|
|
260
|
+
await sendMagicLinkEmail(ctx, { email: data.email, magicUrl });
|
|
250
261
|
}
|
|
251
262
|
}
|
|
252
263
|
// Resposta uniforme: "enviamos um link" (não vaza existência da conta).
|
|
@@ -257,6 +268,62 @@ export default class AuthRegistrationController {
|
|
|
257
268
|
magicLinkSent: true,
|
|
258
269
|
});
|
|
259
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* GET /auth/interaction/:uid/signup/confirm?token= — o dono do e-mail abriu o
|
|
273
|
+
* link do cadastro passwordless. Cria a conta (e-mail já verificado: quem
|
|
274
|
+
* clicou provou a caixa de entrada) e conclui pelo fluxo de magic link, que
|
|
275
|
+
* aplica os mesmos gates do login (status, manutenção, 2º fator).
|
|
276
|
+
*
|
|
277
|
+
* Conta que já existe → o link é recusado e a pessoa volta ao login: é o que
|
|
278
|
+
* faz o link valer uma vez só, sem estado no banco.
|
|
279
|
+
*/
|
|
280
|
+
async signupConfirm(ctx) {
|
|
281
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
282
|
+
const cfg = service.config;
|
|
283
|
+
const accountStore = cfg.accountStore;
|
|
284
|
+
const uid = ctx.request.param('uid');
|
|
285
|
+
const backToLogin = `/auth/interaction/${uid}`;
|
|
286
|
+
const token = ctx.request.qs().token ?? '';
|
|
287
|
+
if (!cfg.passwordless?.signup || !supportsMagicLink(accountStore) || !token) {
|
|
288
|
+
return ctx.response.redirect(backToLogin);
|
|
289
|
+
}
|
|
290
|
+
const runtimeSettings = await getRuntimeSettings(ctx);
|
|
291
|
+
const registrationEnabled = await resolveEffectiveRegistration(cfg.registration?.enabled ?? true, runtimeSettings);
|
|
292
|
+
if (!registrationEnabled)
|
|
293
|
+
return ctx.response.redirect(backToLogin);
|
|
294
|
+
const claim = await readSignupToken(token);
|
|
295
|
+
if (!claim)
|
|
296
|
+
return ctx.response.redirect(backToLogin);
|
|
297
|
+
if (await accountStore.findByEmail(claim.email))
|
|
298
|
+
return ctx.response.redirect(backToLogin);
|
|
299
|
+
let created;
|
|
300
|
+
try {
|
|
301
|
+
created = await accountStore.create({
|
|
302
|
+
email: claim.email,
|
|
303
|
+
fullName: claim.fullName,
|
|
304
|
+
password: randomBytes(24).toString('hex'),
|
|
305
|
+
emailVerified: true,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
// Dois cliques simultâneos: o segundo perde na unicidade do e-mail.
|
|
310
|
+
if (await accountStore.findByEmail(claim.email))
|
|
311
|
+
return ctx.response.redirect(backToLogin);
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
const details = await service.interactions.details(ctx);
|
|
315
|
+
await cfg.audit?.record({
|
|
316
|
+
type: 'signup',
|
|
317
|
+
accountId: created.id,
|
|
318
|
+
email: claim.email,
|
|
319
|
+
ip: ctx.request.ip?.() ?? null,
|
|
320
|
+
clientId: details.params.client_id ?? null,
|
|
321
|
+
});
|
|
322
|
+
const issued = await accountStore.issueMagicLinkToken(claim.email);
|
|
323
|
+
if (!issued)
|
|
324
|
+
return ctx.response.redirect(backToLogin);
|
|
325
|
+
return ctx.response.redirect(`/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`);
|
|
326
|
+
}
|
|
260
327
|
/** GET /auth/forgot-password — tela standalone. */
|
|
261
328
|
async showForgot(ctx) {
|
|
262
329
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { supportsLoginMethodsPreference, supportsMagicLink, supportsPasskeys, supportsProviderIdentity, } from '../../accounts/account_store.js';
|
|
4
|
+
import { resolveAccountRoles } from '../account_roles.js';
|
|
4
5
|
import { normalizeEmailIdentifier } from '../email_identifier.js';
|
|
6
|
+
import { rememberLastLoginMethod } from '../last_login_method.js';
|
|
5
7
|
import { assertLoginAllowed } from '../login_attempt.js';
|
|
6
8
|
import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
|
|
7
9
|
import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
|
|
10
|
+
import { SOCIAL_PENDING_KEY } from '../social_pending.js';
|
|
8
11
|
import { resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
|
|
9
12
|
const UID_SESSION_KEY = 'authkit_social_uid';
|
|
10
13
|
/**
|
|
@@ -29,6 +32,31 @@ async function resolveGlobalAuthMethodsForUser(cfg, settings) {
|
|
|
29
32
|
configOverrides: cfg.authMethods,
|
|
30
33
|
});
|
|
31
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Política de 2º fator do login social para esta conta (ver
|
|
37
|
+
* `AuthSocialConfig.secondFactor`). Default `'enrolled'`. Os papéis passados à
|
|
38
|
+
* função vêm de `resolveAccountRoles` (o `resolveTokenRoles` do host, ex.: authz).
|
|
39
|
+
*/
|
|
40
|
+
async function resolveSecondFactorPolicy(cfg, account) {
|
|
41
|
+
const policy = cfg.social?.secondFactor ?? 'enrolled';
|
|
42
|
+
if (typeof policy !== 'function')
|
|
43
|
+
return policy;
|
|
44
|
+
return await policy({
|
|
45
|
+
id: account.id,
|
|
46
|
+
email: account.email,
|
|
47
|
+
roles: await resolveAccountRoles(cfg, account),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** true se a conta tem TOTP confirmado ou ao menos uma passkey. */
|
|
51
|
+
async function hasEnrolledSecondFactor(store, accountId) {
|
|
52
|
+
const mfa = (await store.getMfaState?.(accountId)) ?? { enabled: false };
|
|
53
|
+
if (mfa.totp ?? mfa.enabled)
|
|
54
|
+
return true;
|
|
55
|
+
if (!supportsPasskeys(store))
|
|
56
|
+
return false;
|
|
57
|
+
const list = await store.listPasskeys(accountId);
|
|
58
|
+
return Array.isArray(list) && list.length > 0;
|
|
59
|
+
}
|
|
32
60
|
export default class AuthSocialController {
|
|
33
61
|
/** GET /auth/:provider/redirect/:uid — guarda o uid e redireciona para o provider OAuth. */
|
|
34
62
|
async redirect(ctx) {
|
|
@@ -70,6 +98,20 @@ export default class AuthSocialController {
|
|
|
70
98
|
// 2. Senão, e-mail conhecido → acha por e-mail e LIGA a identidade (linking).
|
|
71
99
|
// 3. Senão → cria conta nova e liga a identidade.
|
|
72
100
|
let user = await store.findByProviderIdentity(provider, profile.id);
|
|
101
|
+
// E-mail que o provider declara NÃO verificado não identifica ninguém: ligar
|
|
102
|
+
// por ele entregaria a conta de quem é dono do endereço a quem só o digitou
|
|
103
|
+
// no provider. `unsupported`/ausente (provider que não informa) segue valendo.
|
|
104
|
+
const emailUnverified = profile.emailVerificationState === 'unverified';
|
|
105
|
+
if (!user && email && emailUnverified) {
|
|
106
|
+
await cfg.audit?.record({
|
|
107
|
+
type: 'login.failure',
|
|
108
|
+
email,
|
|
109
|
+
ip: ctx.request.ip?.() ?? null,
|
|
110
|
+
metadata: { stage: 'social', provider, reason: 'provider_email_unverified' },
|
|
111
|
+
});
|
|
112
|
+
ctx.session.forget(UID_SESSION_KEY);
|
|
113
|
+
return ctx.response.redirect(backToLogin);
|
|
114
|
+
}
|
|
73
115
|
if (!user && email) {
|
|
74
116
|
// Busca pela MESMA forma normalizada que o login usa. Uma conta gravada
|
|
75
117
|
// com outra grafia não é encontrada aqui e a pessoa ganharia uma SEGUNDA
|
|
@@ -95,7 +137,8 @@ export default class AuthSocialController {
|
|
|
95
137
|
email,
|
|
96
138
|
password: randomUUID(),
|
|
97
139
|
fullName: profile.name ?? null,
|
|
98
|
-
|
|
140
|
+
// Só o provider que confirma o e-mail o torna verificado aqui.
|
|
141
|
+
emailVerified: profile.emailVerificationState === 'verified',
|
|
99
142
|
});
|
|
100
143
|
await store.linkProviderIdentity({
|
|
101
144
|
accountId: created.id,
|
|
@@ -154,6 +197,26 @@ export default class AuthSocialController {
|
|
|
154
197
|
}
|
|
155
198
|
}
|
|
156
199
|
ctx.session.forget(UID_SESSION_KEY);
|
|
200
|
+
rememberLastLoginMethod(ctx, cfg, `social:${provider}`);
|
|
201
|
+
// 2º fator: o provider provou a identidade, mas quem cadastrou TOTP/passkey
|
|
202
|
+
// aqui espera que o fator valha também neste caminho. O desafio mora no
|
|
203
|
+
// interaction controller (mesmo gate de senha/magic link/OTP), então este
|
|
204
|
+
// passo só registra o login pendente e manda o browser para lá.
|
|
205
|
+
const policy = await resolveSecondFactorPolicy(cfg, user);
|
|
206
|
+
const needsGate = policy === 'required' ||
|
|
207
|
+
(policy === 'enrolled' && (!!cfg.mfa || (await hasEnrolledSecondFactor(store, user.id))));
|
|
208
|
+
if (needsGate) {
|
|
209
|
+
if (!uid)
|
|
210
|
+
return ctx.response.redirect(backToLogin);
|
|
211
|
+
const pending = {
|
|
212
|
+
accountId: user.id,
|
|
213
|
+
uid,
|
|
214
|
+
required: policy === 'required',
|
|
215
|
+
at: Date.now(),
|
|
216
|
+
};
|
|
217
|
+
ctx.session.put(SOCIAL_PENDING_KEY, pending);
|
|
218
|
+
return ctx.response.redirect(`/auth/interaction/${uid}/social/continue`);
|
|
219
|
+
}
|
|
157
220
|
// Conclui a interaction OIDC para este usuário (escreve o 303 de volta ao authorize).
|
|
158
221
|
// O cookie de interaction (path '/') sobrevive ao round-trip do provider OAuth.
|
|
159
222
|
await service.interactions.completeLogin(ctx, user.id);
|
package/build/src/host/i18n.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ 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.last_used': string;
|
|
53
54
|
'login.otp_label': string;
|
|
54
55
|
'login.otp_placeholder': string;
|
|
55
56
|
'login.otp_submit': string;
|
|
@@ -809,6 +810,7 @@ export declare const PT_BR_MESSAGES: {
|
|
|
809
810
|
'login.magic_link_sent': string;
|
|
810
811
|
'signup.magic_link_sent': string;
|
|
811
812
|
'login.passkey_button': string;
|
|
813
|
+
'login.last_used': string;
|
|
812
814
|
'login.otp_label': string;
|
|
813
815
|
'login.otp_placeholder': string;
|
|
814
816
|
'login.otp_submit': string;
|
package/build/src/host/i18n.js
CHANGED
|
@@ -41,6 +41,8 @@ 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
|
+
// Selo do método com que este dispositivo entrou da última vez.
|
|
45
|
+
'login.last_used': 'Last used',
|
|
44
46
|
// Login por OTP (código digitável).
|
|
45
47
|
'login.otp_label': 'Enter the login code from the email',
|
|
46
48
|
'login.otp_placeholder': '000000',
|
|
@@ -891,6 +893,7 @@ export const PT_BR_MESSAGES = {
|
|
|
891
893
|
'login.magic_link_sent': 'Se a conta existir, enviamos um link de login.',
|
|
892
894
|
'signup.magic_link_sent': 'Enviamos um link para o seu e-mail. Abra-o para concluir o cadastro.',
|
|
893
895
|
'login.passkey_button': 'Entrar com passkey',
|
|
896
|
+
'login.last_used': 'Último usado',
|
|
894
897
|
// Login por OTP (código digitável).
|
|
895
898
|
'login.otp_label': 'Digite o código de login do e-mail',
|
|
896
899
|
'login.otp_placeholder': '000000',
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/**
|
|
3
|
+
* "Último método usado" no login (o selo "Last used" do Cloudflare, Vercel, Clerk…).
|
|
4
|
+
*
|
|
5
|
+
* O método fica num cookie DESTE DISPOSITIVO, não na conta. É de propósito:
|
|
6
|
+
* - o passo 1 (identifier) ainda não sabe quem é o usuário, e é exatamente ali
|
|
7
|
+
* que o selo mais ajuda ("da última vez você entrou com Google");
|
|
8
|
+
* - guardar por conta e mostrar no passo 2 diria a quem digita um e-mail qualquer
|
|
9
|
+
* como aquela conta entra — vazamento de enumeração. O cookie só conta ao
|
|
10
|
+
* próprio browser o que ele mesmo fez.
|
|
11
|
+
*
|
|
12
|
+
* O cookie não carrega identidade nenhuma (nem e-mail, nem accountId), só o método.
|
|
13
|
+
* É puramente de SUPERFÍCIE: a tela destaca um botão, mas nada no fluxo muda — os
|
|
14
|
+
* métodos oferecidos continuam vindo de `authMethods`, e um valor adulterado no
|
|
15
|
+
* cookie só destaca outro botão (ou nenhum, se não passar na validação).
|
|
16
|
+
*
|
|
17
|
+
* Gravado quando o fator PRIMÁRIO é aceito. Numa conta com 2º fator, isso acontece
|
|
18
|
+
* antes do desafio de MFA: o selo responde "como você se identificou", que é o
|
|
19
|
+
* botão que a pessoa procura na próxima vez.
|
|
20
|
+
*/
|
|
21
|
+
/** Nome do cookie do último método de login. */
|
|
22
|
+
export declare const LAST_LOGIN_METHOD_COOKIE = "authkit_last_login";
|
|
23
|
+
/**
|
|
24
|
+
* Método de login primário. `social:<provider>` e `custom:<id>` carregam o id do
|
|
25
|
+
* provedor/método para a tela destacar o botão certo entre vários.
|
|
26
|
+
*/
|
|
27
|
+
export type LastLoginMethod = 'password' | 'magic_link' | 'otp' | 'passkey' | `social:${string}` | `custom:${string}`;
|
|
28
|
+
/**
|
|
29
|
+
* Valida um valor lido do cookie. Só os métodos conhecidos passam; qualquer outra
|
|
30
|
+
* coisa (ausente, lixo, valor adulterado) vira `undefined` — sem selo.
|
|
31
|
+
*/
|
|
32
|
+
export declare function normalizeLastLoginMethod(raw: unknown): LastLoginMethod | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Lê o último método deste dispositivo para a tela de login. Fail-safe: feature
|
|
35
|
+
* desligada, ctx sem cookies ou qualquer erro → `undefined` (sem selo).
|
|
36
|
+
*/
|
|
37
|
+
export declare function readLastLoginMethod(ctx: HttpContext, cfg: {
|
|
38
|
+
login?: {
|
|
39
|
+
lastUsedMethod?: boolean;
|
|
40
|
+
};
|
|
41
|
+
}): LastLoginMethod | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Registra o método primário que acabou de ser aceito. Grava via resposta do
|
|
44
|
+
* Adonis; quando o login termina no `interactionFinished` (que encerra a resposta
|
|
45
|
+
* crua), o `completeLogin` repassa o cookie pendente — ver
|
|
46
|
+
* {@link relayPendingCookies}. Best-effort: nunca derruba o login.
|
|
47
|
+
*/
|
|
48
|
+
export declare function rememberLastLoginMethod(ctx: HttpContext, cfg: {
|
|
49
|
+
login?: {
|
|
50
|
+
lastUsedMethod?: boolean;
|
|
51
|
+
};
|
|
52
|
+
}, method: LastLoginMethod): void;
|
|
53
|
+
/**
|
|
54
|
+
* Repassa os `Set-Cookie` que o handler gravou na resposta do Adonis para a
|
|
55
|
+
* resposta crua do Node.
|
|
56
|
+
*
|
|
57
|
+
* O Adonis só escreve os headers no `finish()`, depois do handler. Mas o
|
|
58
|
+
* `interactionFinished` do oidc-provider escreve o 303 e dá `res.end()` direto na
|
|
59
|
+
* resposta crua — quando o Adonis chega no `finish()`, a resposta já saiu e todo
|
|
60
|
+
* cookie gravado via `ctx.response.cookie()` no mesmo request é descartado em
|
|
61
|
+
* silêncio. Chamado pelo `completeLogin` logo antes do `interactionFinished`.
|
|
62
|
+
*
|
|
63
|
+
* Os cookies saem do Adonis ao serem repassados, então não há como irem em dobro.
|
|
64
|
+
* O `Set-Cookie` que o próprio oidc-provider grava depois é ACRESCENTADO aos
|
|
65
|
+
* existentes (a lib `cookies` do Koa preserva o header), então nada se perde.
|
|
66
|
+
*/
|
|
67
|
+
export declare function relayPendingCookies(ctx: HttpContext): void;
|