@adonis-agora/authkit-server 0.82.2 → 0.83.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/src/accounts/account_store.d.ts +12 -0
- package/build/src/accounts/lucid_account_store.js +30 -0
- package/build/src/accounts/lucid_store/organizations.d.ts +6 -0
- package/build/src/accounts/lucid_store/organizations.js +65 -1
- package/build/src/define_config.d.ts +36 -4
- 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/admin_orgs_service.js +5 -0
- 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 +46 -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 +94 -30
- package/build/src/host/controllers/social_controller.js +62 -1
- 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/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';
|
|
@@ -16,8 +17,19 @@ import { authkitOrigin } from '../origin.js';
|
|
|
16
17
|
import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
|
|
17
18
|
import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
|
|
18
19
|
import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
|
|
20
|
+
import { SOCIAL_PENDING_KEY, SOCIAL_PENDING_TTL_MS, } from '../social_pending.js';
|
|
19
21
|
import { buildTrustedDevicePayload, isTrustedDeviceValid, TRUSTED_DEVICE_COOKIE, } from '../trusted_device.js';
|
|
20
22
|
import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
|
|
23
|
+
/**
|
|
24
|
+
* Conta com papel administrativo (`cfg.admin.roles`, default `['ADMIN']`) — o
|
|
25
|
+
* bypass do modo manutenção. Os papéis vêm de `resolveAccountRoles`, a mesma
|
|
26
|
+
* fonte do claim (`resolveTokenRoles`, ex.: authz), não só do `globalRoles`.
|
|
27
|
+
*/
|
|
28
|
+
async function isAdminAccount(cfg, account) {
|
|
29
|
+
const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
|
|
30
|
+
const roles = await resolveAccountRoles(cfg, account);
|
|
31
|
+
return adminRoles.some((role) => roles.includes(role));
|
|
32
|
+
}
|
|
21
33
|
/**
|
|
22
34
|
* Chave i18n do erro de status de conta (disabled/expired) compartilhada pelos
|
|
23
35
|
* fluxos passwordless (magic link, OTP, passkey).
|
|
@@ -105,8 +117,7 @@ export default class AuthInteractionController {
|
|
|
105
117
|
if (!status.allowed)
|
|
106
118
|
return fail(accountStatusErrorKey(status.reason));
|
|
107
119
|
const maintenance = await resolveEffectiveMaintenanceMode(settings);
|
|
108
|
-
if (maintenance.enabled &&
|
|
109
|
-
!(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
|
|
120
|
+
if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
|
|
110
121
|
return render(ctx, 'maintenance', {
|
|
111
122
|
uid: details.uid,
|
|
112
123
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -468,9 +479,7 @@ export default class AuthInteractionController {
|
|
|
468
479
|
// Contas admin (role em cfg.admin.roles) CONTINUAM podendo logar.
|
|
469
480
|
// Contas comuns são bloqueadas com a tela de manutenção.
|
|
470
481
|
if (maintenance.enabled) {
|
|
471
|
-
const
|
|
472
|
-
const accountRoles = acc.globalRoles ?? [];
|
|
473
|
-
const isAdmin = adminRoles.some((r) => accountRoles.includes(r));
|
|
482
|
+
const isAdmin = await isAdminAccount(cfg, acc);
|
|
474
483
|
if (!isAdmin) {
|
|
475
484
|
return render(ctx, 'maintenance', {
|
|
476
485
|
uid: details.uid,
|
|
@@ -563,9 +572,9 @@ export default class AuthInteractionController {
|
|
|
563
572
|
...(used.has('webauthn') ? { passkeyAvailable: false } : {}),
|
|
564
573
|
});
|
|
565
574
|
}
|
|
566
|
-
async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd') {
|
|
575
|
+
async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
|
|
567
576
|
const requiredFactors = cfg.mfa?.requiredFactors ?? 2;
|
|
568
|
-
const mfaRequired = this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
|
|
577
|
+
const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
|
|
569
578
|
const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
|
|
570
579
|
const enrolledTotp = mfa.totp ?? mfa.enabled;
|
|
571
580
|
const enrolledPasskey = await this.hasPasskeys(cfg, accountId);
|
|
@@ -736,8 +745,7 @@ export default class AuthInteractionController {
|
|
|
736
745
|
});
|
|
737
746
|
}
|
|
738
747
|
const maintenance = await resolveEffectiveMaintenanceMode(settings);
|
|
739
|
-
if (maintenance.enabled &&
|
|
740
|
-
!(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
|
|
748
|
+
if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
|
|
741
749
|
return cfg.render(ctx, 'maintenance', {
|
|
742
750
|
uid: flow.uid,
|
|
743
751
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -950,12 +958,12 @@ export default class AuthInteractionController {
|
|
|
950
958
|
* - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
|
|
951
959
|
* primário (`primary`) em `MFA_PRIMARY_KEY`.
|
|
952
960
|
*/
|
|
953
|
-
async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd') {
|
|
961
|
+
async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
|
|
954
962
|
ctx.session.forget(MFA_CUSTOM_PRIMARY_KEY);
|
|
955
963
|
clearMfaFlow(ctx);
|
|
956
964
|
if (cfg.mfa)
|
|
957
|
-
return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless);
|
|
958
|
-
const mfaRequired = this.acrRequiresMfa(cfg, details);
|
|
965
|
+
return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless, forceRequired);
|
|
966
|
+
const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details);
|
|
959
967
|
const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
|
|
960
968
|
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
961
969
|
// tem ao menos uma credencial registrada.
|
|
@@ -1212,6 +1220,32 @@ export default class AuthInteractionController {
|
|
|
1212
1220
|
* Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
|
|
1213
1221
|
* inválido/expirado volta ao início do login.
|
|
1214
1222
|
*/
|
|
1223
|
+
/**
|
|
1224
|
+
* GET /auth/interaction/:uid/social/continue — segunda metade do login social
|
|
1225
|
+
* quando a conta deve o 2º fator (ver `AuthSocialController#callback`). Lê o
|
|
1226
|
+
* login pendente da sessão UMA vez, confere que é desta interaction e passa
|
|
1227
|
+
* pelo mesmo `secondFactorGate` dos outros fluxos, com `fed` como fator
|
|
1228
|
+
* primário no `amr`.
|
|
1229
|
+
*/
|
|
1230
|
+
async socialContinue(ctx) {
|
|
1231
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
1232
|
+
const cfg = service.config;
|
|
1233
|
+
const uid = ctx.request.param('uid');
|
|
1234
|
+
const pending = ctx.session.get(SOCIAL_PENDING_KEY);
|
|
1235
|
+
ctx.session.forget(SOCIAL_PENDING_KEY);
|
|
1236
|
+
const details = await service.interactions.details(ctx);
|
|
1237
|
+
if (!pending ||
|
|
1238
|
+
pending.uid !== uid ||
|
|
1239
|
+
details.uid !== uid ||
|
|
1240
|
+
details.prompt.name !== 'login' ||
|
|
1241
|
+
Date.now() - pending.at > SOCIAL_PENDING_TTL_MS) {
|
|
1242
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
1243
|
+
}
|
|
1244
|
+
const gate = await this.secondFactorGate(ctx, cfg, pending.accountId, details, 'fed', 'fed', undefined, true, pending.required);
|
|
1245
|
+
if (gate.kind === 'challenge')
|
|
1246
|
+
return gate.response;
|
|
1247
|
+
return service.interactions.completeLogin(ctx, pending.accountId, { amr: ['fed'] });
|
|
1248
|
+
}
|
|
1215
1249
|
async magicLinkConsume(ctx) {
|
|
1216
1250
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
1217
1251
|
const cfg = service.config;
|
|
@@ -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,5 +1,6 @@
|
|
|
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';
|
|
@@ -10,6 +11,7 @@ import { authkitOrigin } from '../origin.js';
|
|
|
10
11
|
import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
|
|
11
12
|
import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, } from '../runtime_toggles.js';
|
|
12
13
|
import { dispatchSecurityNotice } from '../security_notice_service.js';
|
|
14
|
+
import { issueSignupToken, readSignupToken } from '../signup_token.js';
|
|
13
15
|
import { forgotPasswordValidator, passwordlessSignupValidator, resetPasswordValidator, signupValidator, } from '../validators.js';
|
|
14
16
|
/**
|
|
15
17
|
* Best-effort: returns a RuntimeSettings backed by the container DB, or a no-op
|
|
@@ -170,6 +172,13 @@ export default class AuthRegistrationController {
|
|
|
170
172
|
password: data.password,
|
|
171
173
|
});
|
|
172
174
|
if (!result.ok) {
|
|
175
|
+
// A conta acabou de nascer e o login dela falhou: sem desfazer, a pessoa
|
|
176
|
+
// ficaria com um e-mail "já cadastrado" numa conta em que nunca entrou.
|
|
177
|
+
if (created && supportsAccountDeletion(accountStore)) {
|
|
178
|
+
await accountStore.deleteAccount(created.id).catch((error) => {
|
|
179
|
+
ctx.logger.error({ err: error, accountId: created.id }, 'authkit: falha ao desfazer conta após signup sem login');
|
|
180
|
+
});
|
|
181
|
+
}
|
|
173
182
|
return render(ctx, 'signup', {
|
|
174
183
|
uid: ctx.request.param('uid'),
|
|
175
184
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -206,47 +215,46 @@ export default class AuthRegistrationController {
|
|
|
206
215
|
}
|
|
207
216
|
}
|
|
208
217
|
/**
|
|
209
|
-
* Cadastro passwordless: valida e-mail + nome
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* link
|
|
218
|
+
* Cadastro passwordless: valida e-mail + nome e envia um link. Conta existente
|
|
219
|
+
* recebe um magic link de login; e-mail novo recebe o link de confirmação do
|
|
220
|
+
* cadastro (`signupConfirm`), e só então a conta é criada. Sempre responde
|
|
221
|
+
* "link enviado" (anti-enumeração), exista a conta ou não.
|
|
213
222
|
*/
|
|
214
223
|
async #passwordlessSignup(ctx, deps) {
|
|
215
|
-
const { cfg, brand
|
|
224
|
+
const { cfg, brand } = deps;
|
|
216
225
|
const render = cfg.render;
|
|
217
226
|
const accountStore = cfg.accountStore;
|
|
218
227
|
const uid = ctx.request.param('uid');
|
|
219
228
|
const data = await ctx.request.validateUsing(passwordlessSignupValidator);
|
|
220
|
-
|
|
221
|
-
// passwordless (mesmo precedente das contas criadas por identidade social).
|
|
229
|
+
const origin = authkitOrigin(cfg);
|
|
222
230
|
const existing = await accountStore.findByEmail(data.email);
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
231
|
+
if (existing) {
|
|
232
|
+
// Conta já existe: o "cadastro" vira um login por magic link.
|
|
233
|
+
// `data.email` já é a forma normalizada, a mesma do login.
|
|
234
|
+
const issued = await accountStore.issueMagicLinkToken(data.email);
|
|
235
|
+
if (issued) {
|
|
236
|
+
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
237
|
+
const to = issued.account.email;
|
|
238
|
+
if (cfg.mail?.onMagicLink) {
|
|
239
|
+
await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
await sendMagicLinkEmail(ctx, { email: to, magicUrl });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
236
245
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const magicUrl = `${origin}/auth/interaction/${uid}/
|
|
244
|
-
const to = issued.account.email;
|
|
246
|
+
else {
|
|
247
|
+
// Conta nova: NÃO cria aqui. O link de confirmação leva e-mail + nome num
|
|
248
|
+
// token cifrado; a conta só nasce quando o dono do e-mail o abre
|
|
249
|
+
// (`signupConfirm`). Um envio que falha não deixa conta órfã, e digitar o
|
|
250
|
+
// e-mail de outra pessoa não cria nada.
|
|
251
|
+
const token = await issueSignupToken({ email: data.email, fullName: data.fullName ?? null });
|
|
252
|
+
const magicUrl = `${origin}/auth/interaction/${uid}/signup/confirm?token=${encodeURIComponent(token)}`;
|
|
245
253
|
if (cfg.mail?.onMagicLink) {
|
|
246
|
-
await cfg.mail.onMagicLink({ email:
|
|
254
|
+
await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, kind: 'signup' });
|
|
247
255
|
}
|
|
248
256
|
else {
|
|
249
|
-
await sendMagicLinkEmail(ctx, { email:
|
|
257
|
+
await sendMagicLinkEmail(ctx, { email: data.email, magicUrl });
|
|
250
258
|
}
|
|
251
259
|
}
|
|
252
260
|
// Resposta uniforme: "enviamos um link" (não vaza existência da conta).
|
|
@@ -257,6 +265,62 @@ export default class AuthRegistrationController {
|
|
|
257
265
|
magicLinkSent: true,
|
|
258
266
|
});
|
|
259
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* GET /auth/interaction/:uid/signup/confirm?token= — o dono do e-mail abriu o
|
|
270
|
+
* link do cadastro passwordless. Cria a conta (e-mail já verificado: quem
|
|
271
|
+
* clicou provou a caixa de entrada) e conclui pelo fluxo de magic link, que
|
|
272
|
+
* aplica os mesmos gates do login (status, manutenção, 2º fator).
|
|
273
|
+
*
|
|
274
|
+
* Conta que já existe → o link é recusado e a pessoa volta ao login: é o que
|
|
275
|
+
* faz o link valer uma vez só, sem estado no banco.
|
|
276
|
+
*/
|
|
277
|
+
async signupConfirm(ctx) {
|
|
278
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
279
|
+
const cfg = service.config;
|
|
280
|
+
const accountStore = cfg.accountStore;
|
|
281
|
+
const uid = ctx.request.param('uid');
|
|
282
|
+
const backToLogin = `/auth/interaction/${uid}`;
|
|
283
|
+
const token = ctx.request.qs().token ?? '';
|
|
284
|
+
if (!cfg.passwordless?.signup || !supportsMagicLink(accountStore) || !token) {
|
|
285
|
+
return ctx.response.redirect(backToLogin);
|
|
286
|
+
}
|
|
287
|
+
const runtimeSettings = await getRuntimeSettings(ctx);
|
|
288
|
+
const registrationEnabled = await resolveEffectiveRegistration(cfg.registration?.enabled ?? true, runtimeSettings);
|
|
289
|
+
if (!registrationEnabled)
|
|
290
|
+
return ctx.response.redirect(backToLogin);
|
|
291
|
+
const claim = await readSignupToken(token);
|
|
292
|
+
if (!claim)
|
|
293
|
+
return ctx.response.redirect(backToLogin);
|
|
294
|
+
if (await accountStore.findByEmail(claim.email))
|
|
295
|
+
return ctx.response.redirect(backToLogin);
|
|
296
|
+
let created;
|
|
297
|
+
try {
|
|
298
|
+
created = await accountStore.create({
|
|
299
|
+
email: claim.email,
|
|
300
|
+
fullName: claim.fullName,
|
|
301
|
+
password: randomBytes(24).toString('hex'),
|
|
302
|
+
emailVerified: true,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// Dois cliques simultâneos: o segundo perde na unicidade do e-mail.
|
|
307
|
+
if (await accountStore.findByEmail(claim.email))
|
|
308
|
+
return ctx.response.redirect(backToLogin);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
const details = await service.interactions.details(ctx);
|
|
312
|
+
await cfg.audit?.record({
|
|
313
|
+
type: 'signup',
|
|
314
|
+
accountId: created.id,
|
|
315
|
+
email: claim.email,
|
|
316
|
+
ip: ctx.request.ip?.() ?? null,
|
|
317
|
+
clientId: details.params.client_id ?? null,
|
|
318
|
+
});
|
|
319
|
+
const issued = await accountStore.issueMagicLinkToken(claim.email);
|
|
320
|
+
if (!issued)
|
|
321
|
+
return ctx.response.redirect(backToLogin);
|
|
322
|
+
return ctx.response.redirect(`/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`);
|
|
323
|
+
}
|
|
260
324
|
/** GET /auth/forgot-password — tela standalone. */
|
|
261
325
|
async showForgot(ctx) {
|
|
262
326
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
@@ -1,10 +1,12 @@
|
|
|
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';
|
|
5
6
|
import { assertLoginAllowed } from '../login_attempt.js';
|
|
6
7
|
import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
|
|
7
8
|
import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
|
|
9
|
+
import { SOCIAL_PENDING_KEY } from '../social_pending.js';
|
|
8
10
|
import { resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
|
|
9
11
|
const UID_SESSION_KEY = 'authkit_social_uid';
|
|
10
12
|
/**
|
|
@@ -29,6 +31,31 @@ async function resolveGlobalAuthMethodsForUser(cfg, settings) {
|
|
|
29
31
|
configOverrides: cfg.authMethods,
|
|
30
32
|
});
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Política de 2º fator do login social para esta conta (ver
|
|
36
|
+
* `AuthSocialConfig.secondFactor`). Default `'enrolled'`. Os papéis passados à
|
|
37
|
+
* função vêm de `resolveAccountRoles` (o `resolveTokenRoles` do host, ex.: authz).
|
|
38
|
+
*/
|
|
39
|
+
async function resolveSecondFactorPolicy(cfg, account) {
|
|
40
|
+
const policy = cfg.social?.secondFactor ?? 'enrolled';
|
|
41
|
+
if (typeof policy !== 'function')
|
|
42
|
+
return policy;
|
|
43
|
+
return await policy({
|
|
44
|
+
id: account.id,
|
|
45
|
+
email: account.email,
|
|
46
|
+
roles: await resolveAccountRoles(cfg, account),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** true se a conta tem TOTP confirmado ou ao menos uma passkey. */
|
|
50
|
+
async function hasEnrolledSecondFactor(store, accountId) {
|
|
51
|
+
const mfa = (await store.getMfaState?.(accountId)) ?? { enabled: false };
|
|
52
|
+
if (mfa.totp ?? mfa.enabled)
|
|
53
|
+
return true;
|
|
54
|
+
if (!supportsPasskeys(store))
|
|
55
|
+
return false;
|
|
56
|
+
const list = await store.listPasskeys(accountId);
|
|
57
|
+
return Array.isArray(list) && list.length > 0;
|
|
58
|
+
}
|
|
32
59
|
export default class AuthSocialController {
|
|
33
60
|
/** GET /auth/:provider/redirect/:uid — guarda o uid e redireciona para o provider OAuth. */
|
|
34
61
|
async redirect(ctx) {
|
|
@@ -70,6 +97,20 @@ export default class AuthSocialController {
|
|
|
70
97
|
// 2. Senão, e-mail conhecido → acha por e-mail e LIGA a identidade (linking).
|
|
71
98
|
// 3. Senão → cria conta nova e liga a identidade.
|
|
72
99
|
let user = await store.findByProviderIdentity(provider, profile.id);
|
|
100
|
+
// E-mail que o provider declara NÃO verificado não identifica ninguém: ligar
|
|
101
|
+
// por ele entregaria a conta de quem é dono do endereço a quem só o digitou
|
|
102
|
+
// no provider. `unsupported`/ausente (provider que não informa) segue valendo.
|
|
103
|
+
const emailUnverified = profile.emailVerificationState === 'unverified';
|
|
104
|
+
if (!user && email && emailUnverified) {
|
|
105
|
+
await cfg.audit?.record({
|
|
106
|
+
type: 'login.failure',
|
|
107
|
+
email,
|
|
108
|
+
ip: ctx.request.ip?.() ?? null,
|
|
109
|
+
metadata: { stage: 'social', provider, reason: 'provider_email_unverified' },
|
|
110
|
+
});
|
|
111
|
+
ctx.session.forget(UID_SESSION_KEY);
|
|
112
|
+
return ctx.response.redirect(backToLogin);
|
|
113
|
+
}
|
|
73
114
|
if (!user && email) {
|
|
74
115
|
// Busca pela MESMA forma normalizada que o login usa. Uma conta gravada
|
|
75
116
|
// com outra grafia não é encontrada aqui e a pessoa ganharia uma SEGUNDA
|
|
@@ -95,7 +136,8 @@ export default class AuthSocialController {
|
|
|
95
136
|
email,
|
|
96
137
|
password: randomUUID(),
|
|
97
138
|
fullName: profile.name ?? null,
|
|
98
|
-
|
|
139
|
+
// Só o provider que confirma o e-mail o torna verificado aqui.
|
|
140
|
+
emailVerified: profile.emailVerificationState === 'verified',
|
|
99
141
|
});
|
|
100
142
|
await store.linkProviderIdentity({
|
|
101
143
|
accountId: created.id,
|
|
@@ -154,6 +196,25 @@ export default class AuthSocialController {
|
|
|
154
196
|
}
|
|
155
197
|
}
|
|
156
198
|
ctx.session.forget(UID_SESSION_KEY);
|
|
199
|
+
// 2º fator: o provider provou a identidade, mas quem cadastrou TOTP/passkey
|
|
200
|
+
// aqui espera que o fator valha também neste caminho. O desafio mora no
|
|
201
|
+
// interaction controller (mesmo gate de senha/magic link/OTP), então este
|
|
202
|
+
// passo só registra o login pendente e manda o browser para lá.
|
|
203
|
+
const policy = await resolveSecondFactorPolicy(cfg, user);
|
|
204
|
+
const needsGate = policy === 'required' ||
|
|
205
|
+
(policy === 'enrolled' && (!!cfg.mfa || (await hasEnrolledSecondFactor(store, user.id))));
|
|
206
|
+
if (needsGate) {
|
|
207
|
+
if (!uid)
|
|
208
|
+
return ctx.response.redirect(backToLogin);
|
|
209
|
+
const pending = {
|
|
210
|
+
accountId: user.id,
|
|
211
|
+
uid,
|
|
212
|
+
required: policy === 'required',
|
|
213
|
+
at: Date.now(),
|
|
214
|
+
};
|
|
215
|
+
ctx.session.put(SOCIAL_PENDING_KEY, pending);
|
|
216
|
+
return ctx.response.redirect(`/auth/interaction/${uid}/social/continue`);
|
|
217
|
+
}
|
|
157
218
|
// Conclui a interaction OIDC para este usuário (escreve o 303 de volta ao authorize).
|
|
158
219
|
// O cookie de interaction (path '/') sobrevive ao round-trip do provider OAuth.
|
|
159
220
|
await service.interactions.completeLogin(ctx, user.id);
|
|
@@ -488,6 +488,8 @@ export function registerAuthHost(router, opts = {}) {
|
|
|
488
488
|
router.get('/auth/otp-unlock/:token', [C.interaction, 'otpUnlock']);
|
|
489
489
|
router.get('/auth/interaction/:uid/signup', [C.registration, 'showSignup']);
|
|
490
490
|
withLogin(router.post('/auth/interaction/:uid/signup', [C.registration, 'signup']));
|
|
491
|
+
// Confirmação do cadastro passwordless: a conta nasce aqui, não no POST.
|
|
492
|
+
router.get('/auth/interaction/:uid/signup/confirm', [C.registration, 'signupConfirm']);
|
|
491
493
|
// Recuperação de senha (standalone).
|
|
492
494
|
router.get('/auth/forgot-password', [C.registration, 'showForgot']);
|
|
493
495
|
withLogin(router.post('/auth/forgot-password', [C.registration, 'forgot']));
|
|
@@ -499,6 +501,8 @@ export function registerAuthHost(router, opts = {}) {
|
|
|
499
501
|
if (social) {
|
|
500
502
|
router.get('/auth/:provider/redirect/:uid', [C.social, 'redirect']);
|
|
501
503
|
router.get('/auth/:provider/callback', [C.social, 'callback']);
|
|
504
|
+
// 2º fator da conta depois do provider (ver AuthSocialConfig.secondFactor).
|
|
505
|
+
router.get('/auth/interaction/:uid/social/continue', [C.interaction, 'socialContinue']);
|
|
502
506
|
}
|
|
503
507
|
// PAT introspection (server-to-server).
|
|
504
508
|
withIntrospection(router.post('/authkit/pat/introspect', [C.patIntrospection, 'handle']));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Mesma janela do magic link de login. */
|
|
2
|
+
export declare const SIGNUP_TOKEN_TTL = "15 minutes";
|
|
3
|
+
export interface SignupClaim {
|
|
4
|
+
email: string;
|
|
5
|
+
fullName: string | null;
|
|
6
|
+
}
|
|
7
|
+
export declare function issueSignupToken(claim: SignupClaim): Promise<string>;
|
|
8
|
+
/** Claim do token, ou `null` se adulterado, de outro propósito ou vencido. */
|
|
9
|
+
export declare function readSignupToken(token: string): Promise<SignupClaim | null>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { loadEncryptionService } from '../keys/keystore_crypto.js';
|
|
2
|
+
/**
|
|
3
|
+
* Token do link de confirmação do cadastro passwordless.
|
|
4
|
+
*
|
|
5
|
+
* O cadastro não cria a conta no POST: guarda e-mail + nome num token cifrado
|
|
6
|
+
* (APP_KEY), com prazo e propósito próprios, e a conta só nasce quando o dono do
|
|
7
|
+
* e-mail abre o link. Assim um envio que falha, ou um e-mail digitado por quem
|
|
8
|
+
* não é o dono, não deixa conta para trás nem dispara `account.created`.
|
|
9
|
+
*
|
|
10
|
+
* Sem estado no banco: a unicidade vem da própria conta — se ela já existe
|
|
11
|
+
* quando o link é aberto, o link é recusado.
|
|
12
|
+
*/
|
|
13
|
+
const PURPOSE = 'authkit_passwordless_signup';
|
|
14
|
+
/** Mesma janela do magic link de login. */
|
|
15
|
+
export const SIGNUP_TOKEN_TTL = '15 minutes';
|
|
16
|
+
async function encryption() {
|
|
17
|
+
return (await loadEncryptionService());
|
|
18
|
+
}
|
|
19
|
+
export async function issueSignupToken(claim) {
|
|
20
|
+
return (await encryption()).encrypt(claim, SIGNUP_TOKEN_TTL, PURPOSE);
|
|
21
|
+
}
|
|
22
|
+
/** Claim do token, ou `null` se adulterado, de outro propósito ou vencido. */
|
|
23
|
+
export async function readSignupToken(token) {
|
|
24
|
+
const claim = (await encryption()).decrypt(token, PURPOSE);
|
|
25
|
+
if (!claim || typeof claim.email !== 'string' || claim.email === '')
|
|
26
|
+
return null;
|
|
27
|
+
return {
|
|
28
|
+
email: claim.email,
|
|
29
|
+
fullName: typeof claim.fullName === 'string' ? claim.fullName : null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passo pendente entre o callback social e o 2º fator, compartilhado pelo
|
|
3
|
+
* `AuthSocialController` (grava) e pelo `AuthInteractionController` (consome).
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Login social que passou pelo provider mas ainda deve o 2º fator da conta.
|
|
7
|
+
* Lido (uma vez) por `AuthInteractionController#socialContinue`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SOCIAL_PENDING_KEY = "authkit_social_pending";
|
|
10
|
+
/** Validade do passo pendente: o desafio do 2º fator vem logo em seguida. */
|
|
11
|
+
export declare const SOCIAL_PENDING_TTL_MS: number;
|
|
12
|
+
export interface SocialPendingLogin {
|
|
13
|
+
accountId: string;
|
|
14
|
+
uid: string;
|
|
15
|
+
required: boolean;
|
|
16
|
+
at: number;
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passo pendente entre o callback social e o 2º fator, compartilhado pelo
|
|
3
|
+
* `AuthSocialController` (grava) e pelo `AuthInteractionController` (consome).
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Login social que passou pelo provider mas ainda deve o 2º fator da conta.
|
|
7
|
+
* Lido (uma vez) por `AuthInteractionController#socialContinue`.
|
|
8
|
+
*/
|
|
9
|
+
export const SOCIAL_PENDING_KEY = 'authkit_social_pending';
|
|
10
|
+
/** Validade do passo pendente: o desafio do 2º fator vem logo em seguida. */
|
|
11
|
+
export const SOCIAL_PENDING_TTL_MS = 5 * 60 * 1000;
|