@adonis-agora/authkit-server 0.69.0 → 0.71.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/commands/commands.json +18 -0
- package/build/commands/normalize_emails.d.ts +21 -0
- package/build/commands/normalize_emails.js +106 -0
- package/build/index.d.ts +4 -3
- package/build/index.js +6 -2
- package/build/src/accounts/account_store.d.ts +65 -1
- package/build/src/accounts/account_store.js +24 -0
- package/build/src/accounts/lucid_store/core.d.ts +2 -2
- package/build/src/accounts/lucid_store/core.js +16 -0
- package/build/src/accounts/lucid_store/mfa.js +22 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +4 -0
- package/build/src/commands/import_users.js +7 -2
- package/build/src/commands/normalize_emails.d.ts +78 -0
- package/build/src/commands/normalize_emails.js +129 -0
- package/build/src/host/account_api/account_api_controller.d.ts +2 -0
- package/build/src/host/account_api/account_api_controller.js +22 -3
- package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
- package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
- package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
- package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
- package/build/src/host/account_lockout.js +7 -2
- package/build/src/host/admin_api/admin_users_service.js +11 -3
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_validators.d.ts +2 -2
- package/build/src/host/admin_validators.js +3 -2
- package/build/src/host/controllers/account_mfa_controller.js +1 -2
- package/build/src/host/controllers/account_orgs_controller.js +4 -18
- package/build/src/host/controllers/account_security_controller.js +3 -1
- package/build/src/host/controllers/account_session_controller.js +7 -5
- package/build/src/host/controllers/interaction_controller.d.ts +10 -0
- package/build/src/host/controllers/interaction_controller.js +46 -14
- package/build/src/host/controllers/registration_controller.js +15 -5
- package/build/src/host/controllers/social_controller.js +8 -1
- package/build/src/host/email_identifier.d.ts +29 -0
- package/build/src/host/email_identifier.js +31 -0
- package/build/src/host/org_policy.d.ts +26 -0
- package/build/src/host/org_policy.js +35 -0
- package/build/src/host/passkey_registration_challenge.d.ts +12 -0
- package/build/src/host/passkey_registration_challenge.js +12 -0
- package/build/src/host/register_auth_host.js +51 -0
- package/build/src/host/sudo_mode.d.ts +17 -0
- package/build/src/host/sudo_mode.js +31 -12
- package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/host/validators.d.ts +5 -5
- package/build/src/host/validators.js +17 -5
- package/package.json +2 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { supportsAccountEmailRewrite } from '../accounts/account_store.js';
|
|
2
|
+
import { normalizeEmailIdentifier } from '../host/email_identifier.js';
|
|
3
|
+
import { ADMIN_LIST_DEFAULT_SIZE } from '../pagination.js';
|
|
4
|
+
/** Comparação por code unit — mesma ordem em qualquer máquina, sem depender do ICU. */
|
|
5
|
+
function byCodeUnit(a, b) {
|
|
6
|
+
if (a === b)
|
|
7
|
+
return 0;
|
|
8
|
+
return a < b ? -1 : 1;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Migração dos endereços GRAVADOS para a forma normalizada da identidade
|
|
12
|
+
* (`normalizeEmailIdentifier`: `trim` + `toLowerCase`).
|
|
13
|
+
*
|
|
14
|
+
* POR QUE ELA EXISTE: até a v0.68 o cadastro gravava o endereço mutilado pelo
|
|
15
|
+
* `.normalizeEmail()` do VineJS (no gmail, sem pontos e sem `+tag`), e import,
|
|
16
|
+
* convite e provider social gravavam a grafia crua, com maiúsculas. O login
|
|
17
|
+
* busca UMA forma só — a normalizada. Uma conta gravada em qualquer outra grafia
|
|
18
|
+
* fica INALCANÇÁVEL, e, por o login ser à prova de enumeração, sem nenhuma
|
|
19
|
+
* mensagem de erro. Esta migração é o que reencontra essas contas.
|
|
20
|
+
*
|
|
21
|
+
* Em modo RELATÓRIO (default) não escreve nada: varre, calcula e devolve o que
|
|
22
|
+
* mudaria. Com `apply`, grava — RECUSANDO-SE a tocar em qualquer conta envolvida
|
|
23
|
+
* numa colisão (duas contas que colapsam no mesmo endereço). Nunca funde contas,
|
|
24
|
+
* nunca escolhe vencedor: as colididas saem listadas para decisão humana.
|
|
25
|
+
*
|
|
26
|
+
* A varredura acontece INTEIRA antes de qualquer escrita, de propósito: a
|
|
27
|
+
* listagem é ordenada por e-mail, e reescrever endereços no meio da paginação
|
|
28
|
+
* moveria linhas entre páginas — contas seriam puladas sem nenhum sinal.
|
|
29
|
+
*
|
|
30
|
+
* Lógica PURA quanto a CLI (recebe o store, devolve o relatório) — testável sem
|
|
31
|
+
* ace e sem banco.
|
|
32
|
+
*/
|
|
33
|
+
export async function normalizeAccountEmails(store, options = {}) {
|
|
34
|
+
const pageSize = options.pageSize ?? ADMIN_LIST_DEFAULT_SIZE;
|
|
35
|
+
const report = {
|
|
36
|
+
scanned: 0,
|
|
37
|
+
alreadyNormalized: 0,
|
|
38
|
+
changes: [],
|
|
39
|
+
applied: 0,
|
|
40
|
+
collisions: [],
|
|
41
|
+
skippedByCollision: 0,
|
|
42
|
+
unusable: [],
|
|
43
|
+
};
|
|
44
|
+
// 1) Varredura completa. Guarda só id + e-mail (strings), agrupados pela forma
|
|
45
|
+
// normalizada — é o agrupamento que revela as colisões.
|
|
46
|
+
const buckets = new Map();
|
|
47
|
+
for (let page = 1;; page++) {
|
|
48
|
+
const { data, total } = await store.listAccounts({ page, size: pageSize });
|
|
49
|
+
if (!data.length)
|
|
50
|
+
break;
|
|
51
|
+
for (const account of data) {
|
|
52
|
+
report.scanned++;
|
|
53
|
+
const normalized = normalizeEmailIdentifier(account.email);
|
|
54
|
+
const bucket = buckets.get(normalized);
|
|
55
|
+
if (bucket)
|
|
56
|
+
bucket.push({ accountId: account.id, email: account.email });
|
|
57
|
+
else
|
|
58
|
+
buckets.set(normalized, [{ accountId: account.id, email: account.email }]);
|
|
59
|
+
}
|
|
60
|
+
if (report.scanned >= total)
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
// 2) Classificação: colisão, mudança ou já normalizada.
|
|
64
|
+
const pending = [];
|
|
65
|
+
for (const [normalized, accounts] of buckets) {
|
|
66
|
+
// `normalized` vazio (coluna nula/vazia/só espaços) não é migrável. Sai
|
|
67
|
+
// LISTADO, não somado às "já normalizadas": o relatório não pode dar
|
|
68
|
+
// atestado de saúde para a linha que ele deliberadamente deixou para trás.
|
|
69
|
+
// ANTES da checagem de colisão: duas linhas em branco caem no mesmo balde
|
|
70
|
+
// `""` e sairiam como "colisão no endereço ''", que não descreve nada.
|
|
71
|
+
if (!normalized) {
|
|
72
|
+
report.unusable.push(...accounts);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (accounts.length > 1) {
|
|
76
|
+
// Duas contas distintas no mesmo endereço normalizado. NENHUMA é tocada —
|
|
77
|
+
// nem a que já está normalizada, porque a decisão (fundir? renomear? qual
|
|
78
|
+
// delas é a pessoa?) é humana e envolve as duas.
|
|
79
|
+
report.collisions.push({ email: normalized, accounts });
|
|
80
|
+
report.skippedByCollision += accounts.length;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const [account] = accounts;
|
|
84
|
+
if (account.email === normalized) {
|
|
85
|
+
report.alreadyNormalized++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
pending.push({
|
|
89
|
+
accountId: account.accountId,
|
|
90
|
+
from: account.email,
|
|
91
|
+
to: normalized,
|
|
92
|
+
applied: false,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Ordem estável (por endereço gravado) para o relatório ser diffável entre
|
|
96
|
+
// runs. Comparação por code unit, NÃO `localeCompare`: a ordem desta depende
|
|
97
|
+
// do ICU da máquina, e aí "diffável" valeria só dentro de um host.
|
|
98
|
+
pending.sort((a, b) => byCodeUnit(a.from, b.from));
|
|
99
|
+
report.collisions.sort((a, b) => byCodeUnit(a.email, b.email));
|
|
100
|
+
// `unusable` também: sem isto a lista sairia na ordem da varredura, que é a
|
|
101
|
+
// ordem do store — a mesma garantia não valeria para ela.
|
|
102
|
+
report.unusable.sort((a, b) => byCodeUnit(a.accountId, b.accountId));
|
|
103
|
+
report.changes = pending;
|
|
104
|
+
if (!options.apply)
|
|
105
|
+
return report;
|
|
106
|
+
// 3) Escrita. Capacidade probada: um store sem ela não tem como regravar o
|
|
107
|
+
// endereço, e inventar um caminho por fora do store não é da lib.
|
|
108
|
+
if (!supportsAccountEmailRewrite(store)) {
|
|
109
|
+
throw new Error('accountStore não implementa rewriteAccountEmail: a migração não tem como gravar (relatório segue funcionando).');
|
|
110
|
+
}
|
|
111
|
+
for (const change of pending) {
|
|
112
|
+
try {
|
|
113
|
+
const ok = await store.rewriteAccountEmail(change.accountId, change.to);
|
|
114
|
+
if (ok) {
|
|
115
|
+
change.applied = true;
|
|
116
|
+
report.applied++;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// O store recusou: conta sumiu ou o endereço passou a ser de OUTRA conta
|
|
120
|
+
// entre a varredura e a escrita (colisão que nasceu no meio do caminho).
|
|
121
|
+
change.error = 'o store recusou a regravação (conta inexistente ou endereço já tomado)';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
change.error = error.message;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return report;
|
|
129
|
+
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* GET /account/api/orgs/invitations → convites pendentes
|
|
29
29
|
*/
|
|
30
30
|
import '../augmentations.js';
|
|
31
|
-
import { supportsAccountSecurity, supportsLoginMethodsPreference, supportsMagicLink, supportsOrganizations, supportsPasskeys, supportsProfile, } from '../../accounts/account_store.js';
|
|
31
|
+
import { supportsAccountSecurity, supportsLoginMethodsPreference, supportsMagicLink, supportsOrganizations, supportsPasskeys, supportsProfile, supportsRecoveryCodeCount, supportsRecoveryCodeRegeneration, } from '../../accounts/account_store.js';
|
|
32
32
|
import { PasswordPolicyError } from '../../password/password_manager.js';
|
|
33
33
|
import { accountPath } from '../account_paths.js';
|
|
34
34
|
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
@@ -627,6 +627,16 @@ export default class AccountApiController {
|
|
|
627
627
|
/* fail-safe */
|
|
628
628
|
}
|
|
629
629
|
}
|
|
630
|
+
// Quantos recovery codes restam (capability-probed, fail-safe).
|
|
631
|
+
let recoveryRemaining = null;
|
|
632
|
+
if (supportsRecoveryCodeCount(cfg.accountStore)) {
|
|
633
|
+
try {
|
|
634
|
+
recoveryRemaining = await cfg.accountStore.countRecoveryCodes(userId);
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
/* fail-safe */
|
|
638
|
+
}
|
|
639
|
+
}
|
|
630
640
|
return {
|
|
631
641
|
enabled,
|
|
632
642
|
totp: { enrolled: enabled },
|
|
@@ -639,8 +649,17 @@ export default class AccountApiController {
|
|
|
639
649
|
createdAt: p.createdAt ?? null,
|
|
640
650
|
})),
|
|
641
651
|
},
|
|
642
|
-
|
|
643
|
-
|
|
652
|
+
recovery: {
|
|
653
|
+
// Os códigos em si NUNCA voltam aqui — só no instante em que são
|
|
654
|
+
// criados (`/mfa/totp/confirm` ou `/mfa/recovery-codes`), porque é só
|
|
655
|
+
// o hash deles que fica guardado.
|
|
656
|
+
available: enabled,
|
|
657
|
+
// `remaining` é `null` quando o store não sabe contar (capacidade
|
|
658
|
+
// opcional) — diferente de `0`, que significa "acabaram, gere novos".
|
|
659
|
+
remaining: recoveryRemaining,
|
|
660
|
+
// O host só mostra o botão "gerar novos códigos" se houver como.
|
|
661
|
+
regenerable: supportsRecoveryCodeRegeneration(cfg.accountStore),
|
|
662
|
+
},
|
|
644
663
|
};
|
|
645
664
|
}
|
|
646
665
|
// ─── GET /account/api/login-methods ─────────────────────────────────────
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account Self-Service JSON API — SEGUNDO FATOR (TOTP + passkeys)
|
|
3
|
+
*
|
|
4
|
+
* Espelho JSON do `AccountMfaController`, para o host que desenha a própria
|
|
5
|
+
* tela de "segundo fator" e não pode navegar o browser no meio do fluxo.
|
|
6
|
+
*
|
|
7
|
+
* Mapa de rotas (sob o `accountGuard`; mutantes sob o CSRF do shield do host):
|
|
8
|
+
* POST /account/api/mfa/totp/enroll → inicia o enrolamento (sudo)
|
|
9
|
+
* POST /account/api/mfa/totp/confirm → confirma com o código (throttled)
|
|
10
|
+
* POST /account/api/mfa/totp/disable → desliga o MFA (sudo)
|
|
11
|
+
* POST /account/api/mfa/recovery-codes → regenera os códigos (sudo)
|
|
12
|
+
* POST /account/api/mfa/passkeys/options → options da cerimônia de registro
|
|
13
|
+
* POST /account/api/mfa/passkeys/verify → verifica o attestation (sudo)
|
|
14
|
+
*
|
|
15
|
+
* Paridade de gates com o console HTML, endpoint a endpoint:
|
|
16
|
+
*
|
|
17
|
+
* | ação | console HTML | aqui |
|
|
18
|
+
* | ---------------- | ------------ | ---------------- |
|
|
19
|
+
* | enroll | sudo | sudo → 403 JSON |
|
|
20
|
+
* | confirm | sem sudo | sem sudo |
|
|
21
|
+
* | disable | sudo | sudo → 403 JSON |
|
|
22
|
+
* | passkey options | sem sudo | sem sudo |
|
|
23
|
+
* | passkey verify | sudo | sudo → 403 JSON |
|
|
24
|
+
* | recovery codes | (não existe) | sudo → 403 JSON |
|
|
25
|
+
*
|
|
26
|
+
* A única diferença é a FORMA da recusa: `requireSudo` devolve um redirect para
|
|
27
|
+
* `/account/confirm`, que numa SPA chega como uma página HTML onde ela esperava
|
|
28
|
+
* JSON. Aqui a recusa vira `403 { error: { code: 'sudo_required' } }` e a tela
|
|
29
|
+
* do host manda o usuário confirmar identidade por conta própria. Mesma
|
|
30
|
+
* política, resposta legível por máquina.
|
|
31
|
+
*
|
|
32
|
+
* `confirm` NÃO exige sudo — porque o `enroll` que criou o segredo pendente já
|
|
33
|
+
* exigiu, e pedir de novo no passo seguinte quebraria o enrolamento de quem
|
|
34
|
+
* demorou a digitar o código. É exatamente o que o console faz. O que o console
|
|
35
|
+
* não tem, e aqui existe, é o THROTTLE: o código de 6 dígitos é adivinhável, e
|
|
36
|
+
* a rota carrega o bucket de sudo (por IP) — mais apertado que o form, nunca
|
|
37
|
+
* mais frouxo.
|
|
38
|
+
*
|
|
39
|
+
* Os dois caminhos (clássico e JSON) COMPARTILHAM o slot do desafio WebAuthn
|
|
40
|
+
* (`PASSKEY_REG_CHALLENGE_KEY`), então um `options` pedido num deles pode ser
|
|
41
|
+
* finalizado pelo outro e nunca existem dois desafios vivos na mesma sessão.
|
|
42
|
+
*/
|
|
43
|
+
import '../augmentations.js';
|
|
44
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
45
|
+
export default class AccountMfaApiController {
|
|
46
|
+
#private;
|
|
47
|
+
/**
|
|
48
|
+
* Inicia o enrolamento TOTP: segredo pendente + `otpauth://` URI + o QR já
|
|
49
|
+
* renderizado como data-URL (o console renderiza server-side; aqui a tela do
|
|
50
|
+
* host recebe pronto e decide se mostra o QR, o segredo, ou os dois).
|
|
51
|
+
*/
|
|
52
|
+
enrollTotp(ctx: HttpContext): Promise<void | {
|
|
53
|
+
secret: string;
|
|
54
|
+
otpauthUri: string;
|
|
55
|
+
qrDataUrl: string;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Confirma o enrolamento com o código do app autenticador. Sucesso ativa o
|
|
59
|
+
* MFA e devolve os recovery codes — UMA vez, como no console (lá eles vão num
|
|
60
|
+
* flash; aqui, no corpo da resposta, que é o equivalente headless).
|
|
61
|
+
*/
|
|
62
|
+
confirmTotp(ctx: HttpContext): Promise<void | {
|
|
63
|
+
ok: boolean;
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
recoveryCodes: string[];
|
|
66
|
+
}>;
|
|
67
|
+
/** Desliga o MFA (TOTP + recovery codes). Exige sudo, como o console. */
|
|
68
|
+
disableTotp(ctx: HttpContext): Promise<{
|
|
69
|
+
ok: boolean;
|
|
70
|
+
enabled: boolean;
|
|
71
|
+
} | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* Regenera os recovery codes de uma conta com MFA ATIVO e devolve os novos —
|
|
74
|
+
* uma única vez. Exige sudo: o resultado é um conjunto de credenciais que
|
|
75
|
+
* contorna o segundo fator, então vale o mesmo gate do `enroll`/`disable`.
|
|
76
|
+
*
|
|
77
|
+
* Capability-probed: stores que não implementam
|
|
78
|
+
* `regenerateRecoveryCodes` respondem 422 em vez de 500.
|
|
79
|
+
*/
|
|
80
|
+
regenerateRecoveryCodes(ctx: HttpContext): Promise<void | {
|
|
81
|
+
ok: boolean;
|
|
82
|
+
recoveryCodes: string[];
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Options da cerimônia de REGISTRO de passkey, guardando o desafio na sessão.
|
|
86
|
+
* Sem sudo, igual ao endpoint clássico: quem paga o gate é o `verify`, que é
|
|
87
|
+
* onde a credencial passa a existir.
|
|
88
|
+
*/
|
|
89
|
+
passkeyRegisterOptions(ctx: HttpContext): Promise<any>;
|
|
90
|
+
/**
|
|
91
|
+
* Verifica o attestation contra o desafio da sessão e persiste a credencial.
|
|
92
|
+
*
|
|
93
|
+
* O endpoint clássico responde 302 numa navegação e `{ok:true}` num fetch;
|
|
94
|
+
* este responde SEMPRE JSON — inclusive na recusa de sudo, que lá é um
|
|
95
|
+
* redirect. É a diferença que faz a cerimônia caber numa SPA: o browser não
|
|
96
|
+
* pode navegar entre `startRegistration()` e a verificação.
|
|
97
|
+
*/
|
|
98
|
+
passkeyRegisterVerify(ctx: HttpContext): Promise<void | {
|
|
99
|
+
ok: boolean;
|
|
100
|
+
}>;
|
|
101
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account Self-Service JSON API — SEGUNDO FATOR (TOTP + passkeys)
|
|
3
|
+
*
|
|
4
|
+
* Espelho JSON do `AccountMfaController`, para o host que desenha a própria
|
|
5
|
+
* tela de "segundo fator" e não pode navegar o browser no meio do fluxo.
|
|
6
|
+
*
|
|
7
|
+
* Mapa de rotas (sob o `accountGuard`; mutantes sob o CSRF do shield do host):
|
|
8
|
+
* POST /account/api/mfa/totp/enroll → inicia o enrolamento (sudo)
|
|
9
|
+
* POST /account/api/mfa/totp/confirm → confirma com o código (throttled)
|
|
10
|
+
* POST /account/api/mfa/totp/disable → desliga o MFA (sudo)
|
|
11
|
+
* POST /account/api/mfa/recovery-codes → regenera os códigos (sudo)
|
|
12
|
+
* POST /account/api/mfa/passkeys/options → options da cerimônia de registro
|
|
13
|
+
* POST /account/api/mfa/passkeys/verify → verifica o attestation (sudo)
|
|
14
|
+
*
|
|
15
|
+
* Paridade de gates com o console HTML, endpoint a endpoint:
|
|
16
|
+
*
|
|
17
|
+
* | ação | console HTML | aqui |
|
|
18
|
+
* | ---------------- | ------------ | ---------------- |
|
|
19
|
+
* | enroll | sudo | sudo → 403 JSON |
|
|
20
|
+
* | confirm | sem sudo | sem sudo |
|
|
21
|
+
* | disable | sudo | sudo → 403 JSON |
|
|
22
|
+
* | passkey options | sem sudo | sem sudo |
|
|
23
|
+
* | passkey verify | sudo | sudo → 403 JSON |
|
|
24
|
+
* | recovery codes | (não existe) | sudo → 403 JSON |
|
|
25
|
+
*
|
|
26
|
+
* A única diferença é a FORMA da recusa: `requireSudo` devolve um redirect para
|
|
27
|
+
* `/account/confirm`, que numa SPA chega como uma página HTML onde ela esperava
|
|
28
|
+
* JSON. Aqui a recusa vira `403 { error: { code: 'sudo_required' } }` e a tela
|
|
29
|
+
* do host manda o usuário confirmar identidade por conta própria. Mesma
|
|
30
|
+
* política, resposta legível por máquina.
|
|
31
|
+
*
|
|
32
|
+
* `confirm` NÃO exige sudo — porque o `enroll` que criou o segredo pendente já
|
|
33
|
+
* exigiu, e pedir de novo no passo seguinte quebraria o enrolamento de quem
|
|
34
|
+
* demorou a digitar o código. É exatamente o que o console faz. O que o console
|
|
35
|
+
* não tem, e aqui existe, é o THROTTLE: o código de 6 dígitos é adivinhável, e
|
|
36
|
+
* a rota carrega o bucket de sudo (por IP) — mais apertado que o form, nunca
|
|
37
|
+
* mais frouxo.
|
|
38
|
+
*
|
|
39
|
+
* Os dois caminhos (clássico e JSON) COMPARTILHAM o slot do desafio WebAuthn
|
|
40
|
+
* (`PASSKEY_REG_CHALLENGE_KEY`), então um `options` pedido num deles pode ser
|
|
41
|
+
* finalizado pelo outro e nunca existem dois desafios vivos na mesma sessão.
|
|
42
|
+
*/
|
|
43
|
+
import '../augmentations.js';
|
|
44
|
+
import QRCode from 'qrcode';
|
|
45
|
+
import { supportsMfa, supportsPasskeys, supportsRecoveryCodeRegeneration, } from '../../accounts/account_store.js';
|
|
46
|
+
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
47
|
+
import { translate } from '../i18n.js';
|
|
48
|
+
import { PASSKEY_REG_CHALLENGE_KEY } from '../passkey_registration_challenge.js';
|
|
49
|
+
import { resolveRuntimeSettings } from '../runtime_settings.js';
|
|
50
|
+
import { dispatchSecurityNotice } from '../security_notice_service.js';
|
|
51
|
+
import { isSudoSatisfied } from '../sudo_mode.js';
|
|
52
|
+
/** Erro JSON padrão — mesmo envelope do `account_api_controller`. */
|
|
53
|
+
function apiErr(code, message) {
|
|
54
|
+
return { error: { code, message } };
|
|
55
|
+
}
|
|
56
|
+
export default class AccountMfaApiController {
|
|
57
|
+
// ─── POST /account/api/mfa/totp/enroll ──────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Inicia o enrolamento TOTP: segredo pendente + `otpauth://` URI + o QR já
|
|
60
|
+
* renderizado como data-URL (o console renderiza server-side; aqui a tela do
|
|
61
|
+
* host recebe pronto e decide se mostra o QR, o segredo, ou os dois).
|
|
62
|
+
*/
|
|
63
|
+
async enrollTotp(ctx) {
|
|
64
|
+
const c = await this.#mfaContext(ctx);
|
|
65
|
+
if (!c)
|
|
66
|
+
return;
|
|
67
|
+
if (!(await this.#requireSudoJson(ctx)))
|
|
68
|
+
return;
|
|
69
|
+
const started = await c.store.startTotpEnrollment(c.userId);
|
|
70
|
+
if (!started) {
|
|
71
|
+
return ctx.response
|
|
72
|
+
.status(422)
|
|
73
|
+
.send(apiErr('enroll_failed', 'Could not start TOTP enrollment.'));
|
|
74
|
+
}
|
|
75
|
+
const qrDataUrl = await QRCode.toDataURL(started.otpauthUri);
|
|
76
|
+
return { secret: started.secret, otpauthUri: started.otpauthUri, qrDataUrl };
|
|
77
|
+
}
|
|
78
|
+
// ─── POST /account/api/mfa/totp/confirm ─────────────────────────────────
|
|
79
|
+
/**
|
|
80
|
+
* Confirma o enrolamento com o código do app autenticador. Sucesso ativa o
|
|
81
|
+
* MFA e devolve os recovery codes — UMA vez, como no console (lá eles vão num
|
|
82
|
+
* flash; aqui, no corpo da resposta, que é o equivalente headless).
|
|
83
|
+
*/
|
|
84
|
+
async confirmTotp(ctx) {
|
|
85
|
+
const c = await this.#mfaContext(ctx);
|
|
86
|
+
if (!c)
|
|
87
|
+
return;
|
|
88
|
+
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
89
|
+
const result = await c.store.confirmTotpEnrollment(c.userId, code);
|
|
90
|
+
if (!result.ok) {
|
|
91
|
+
// NÃO regenera o segredo pendente: o usuário já escaneou o QR, e um
|
|
92
|
+
// segredo novo invalidaria o app autenticador dele. Mesma decisão do
|
|
93
|
+
// console — a tela pede outro código sobre o MESMO segredo.
|
|
94
|
+
return ctx.response
|
|
95
|
+
.status(422)
|
|
96
|
+
.send(apiErr('invalid_code', translate(c.cfg.messages, 'errors.invalid_code')));
|
|
97
|
+
}
|
|
98
|
+
await c.cfg.audit?.record({
|
|
99
|
+
type: 'mfa.enabled',
|
|
100
|
+
accountId: c.userId,
|
|
101
|
+
ip: ctx.request.ip?.() ?? null,
|
|
102
|
+
metadata: { method: 'totp' },
|
|
103
|
+
});
|
|
104
|
+
await this.#notice(ctx, c, 'mfa_enabled');
|
|
105
|
+
return { ok: true, enabled: true, recoveryCodes: result.recoveryCodes ?? [] };
|
|
106
|
+
}
|
|
107
|
+
// ─── POST /account/api/mfa/totp/disable ─────────────────────────────────
|
|
108
|
+
/** Desliga o MFA (TOTP + recovery codes). Exige sudo, como o console. */
|
|
109
|
+
async disableTotp(ctx) {
|
|
110
|
+
const c = await this.#mfaContext(ctx);
|
|
111
|
+
if (!c)
|
|
112
|
+
return;
|
|
113
|
+
if (!(await this.#requireSudoJson(ctx)))
|
|
114
|
+
return;
|
|
115
|
+
await c.store.disableMfa(c.userId);
|
|
116
|
+
await c.cfg.audit?.record({
|
|
117
|
+
type: 'mfa.disabled',
|
|
118
|
+
accountId: c.userId,
|
|
119
|
+
ip: ctx.request.ip?.() ?? null,
|
|
120
|
+
});
|
|
121
|
+
await this.#notice(ctx, c, 'mfa_disabled');
|
|
122
|
+
return { ok: true, enabled: false };
|
|
123
|
+
}
|
|
124
|
+
// ─── POST /account/api/mfa/recovery-codes ───────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* Regenera os recovery codes de uma conta com MFA ATIVO e devolve os novos —
|
|
127
|
+
* uma única vez. Exige sudo: o resultado é um conjunto de credenciais que
|
|
128
|
+
* contorna o segundo fator, então vale o mesmo gate do `enroll`/`disable`.
|
|
129
|
+
*
|
|
130
|
+
* Capability-probed: stores que não implementam
|
|
131
|
+
* `regenerateRecoveryCodes` respondem 422 em vez de 500.
|
|
132
|
+
*/
|
|
133
|
+
async regenerateRecoveryCodes(ctx) {
|
|
134
|
+
const c = await this.#mfaContext(ctx);
|
|
135
|
+
if (!c)
|
|
136
|
+
return;
|
|
137
|
+
if (!supportsRecoveryCodeRegeneration(c.store)) {
|
|
138
|
+
return ctx.response
|
|
139
|
+
.status(422)
|
|
140
|
+
.send(apiErr('capability_unsupported', 'Recovery code regeneration not supported.'));
|
|
141
|
+
}
|
|
142
|
+
if (!(await this.#requireSudoJson(ctx)))
|
|
143
|
+
return;
|
|
144
|
+
const codes = await c.store.regenerateRecoveryCodes(c.userId);
|
|
145
|
+
if (!codes) {
|
|
146
|
+
// Sem MFA ativo não há conjunto a regenerar — enrolar é o caminho.
|
|
147
|
+
return ctx.response.status(422).send(apiErr('mfa_not_enabled', 'MFA is not enabled.'));
|
|
148
|
+
}
|
|
149
|
+
await c.cfg.audit?.record({
|
|
150
|
+
type: 'mfa.recovery_codes_regenerated',
|
|
151
|
+
accountId: c.userId,
|
|
152
|
+
ip: ctx.request.ip?.() ?? null,
|
|
153
|
+
});
|
|
154
|
+
return { ok: true, recoveryCodes: codes };
|
|
155
|
+
}
|
|
156
|
+
// ─── POST /account/api/mfa/passkeys/options ─────────────────────────────
|
|
157
|
+
/**
|
|
158
|
+
* Options da cerimônia de REGISTRO de passkey, guardando o desafio na sessão.
|
|
159
|
+
* Sem sudo, igual ao endpoint clássico: quem paga o gate é o `verify`, que é
|
|
160
|
+
* onde a credencial passa a existir.
|
|
161
|
+
*/
|
|
162
|
+
async passkeyRegisterOptions(ctx) {
|
|
163
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
164
|
+
const cfg = service.config;
|
|
165
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
166
|
+
if (!supportsPasskeys(cfg.accountStore)) {
|
|
167
|
+
return ctx.response
|
|
168
|
+
.status(422)
|
|
169
|
+
.send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
|
|
170
|
+
}
|
|
171
|
+
const generated = await cfg.accountStore.generatePasskeyRegistrationOptions?.(userId);
|
|
172
|
+
if (!generated) {
|
|
173
|
+
return ctx.response
|
|
174
|
+
.status(422)
|
|
175
|
+
.send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
|
|
176
|
+
}
|
|
177
|
+
ctx.session.put(PASSKEY_REG_CHALLENGE_KEY, generated.challenge);
|
|
178
|
+
return generated.options;
|
|
179
|
+
}
|
|
180
|
+
// ─── POST /account/api/mfa/passkeys/verify ──────────────────────────────
|
|
181
|
+
/**
|
|
182
|
+
* Verifica o attestation contra o desafio da sessão e persiste a credencial.
|
|
183
|
+
*
|
|
184
|
+
* O endpoint clássico responde 302 numa navegação e `{ok:true}` num fetch;
|
|
185
|
+
* este responde SEMPRE JSON — inclusive na recusa de sudo, que lá é um
|
|
186
|
+
* redirect. É a diferença que faz a cerimônia caber numa SPA: o browser não
|
|
187
|
+
* pode navegar entre `startRegistration()` e a verificação.
|
|
188
|
+
*/
|
|
189
|
+
async passkeyRegisterVerify(ctx) {
|
|
190
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
191
|
+
const cfg = service.config;
|
|
192
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
193
|
+
if (!supportsPasskeys(cfg.accountStore)) {
|
|
194
|
+
return ctx.response
|
|
195
|
+
.status(422)
|
|
196
|
+
.send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
|
|
197
|
+
}
|
|
198
|
+
if (!(await this.#requireSudoJson(ctx)))
|
|
199
|
+
return;
|
|
200
|
+
const challenge = ctx.session.get(PASSKEY_REG_CHALLENGE_KEY);
|
|
201
|
+
if (!challenge) {
|
|
202
|
+
return ctx.response
|
|
203
|
+
.status(400)
|
|
204
|
+
.send(apiErr('challenge_expired', translate(cfg.messages, 'errors.challenge_expired')));
|
|
205
|
+
}
|
|
206
|
+
const body = ctx.request.input('response', ctx.request.body());
|
|
207
|
+
const ok = (await cfg.accountStore.verifyPasskeyRegistration?.(userId, body, challenge)) ?? false;
|
|
208
|
+
// Queima o desafio em QUALQUER desfecho: um attestation recusado não pode
|
|
209
|
+
// ser retentado contra o mesmo challenge.
|
|
210
|
+
ctx.session.forget(PASSKEY_REG_CHALLENGE_KEY);
|
|
211
|
+
if (!ok) {
|
|
212
|
+
return ctx.response
|
|
213
|
+
.status(400)
|
|
214
|
+
.send(apiErr('invalid_response', translate(cfg.messages, 'errors.invalid_code')));
|
|
215
|
+
}
|
|
216
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
217
|
+
await cfg.audit?.record({
|
|
218
|
+
type: 'mfa.enabled',
|
|
219
|
+
accountId: userId,
|
|
220
|
+
ip,
|
|
221
|
+
metadata: { method: 'webauthn' },
|
|
222
|
+
});
|
|
223
|
+
await cfg.audit?.record({ type: 'passkey.registered', accountId: userId, ip });
|
|
224
|
+
const account = await cfg.accountStore.findById(userId);
|
|
225
|
+
if (account) {
|
|
226
|
+
const ts = new Date().toISOString();
|
|
227
|
+
for (const kind of ['passkey_added', 'mfa_enabled']) {
|
|
228
|
+
await dispatchSecurityNotice(ctx, { account: { id: userId, email: account.email }, kind, ip, timestamp: ts }, cfg.mail, cfg.audit, cfg);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { ok: true };
|
|
232
|
+
}
|
|
233
|
+
// ─── Internos ───────────────────────────────────────────────────────────
|
|
234
|
+
/**
|
|
235
|
+
* Resolve config + store com MFA + a conta da sessão. Quando o pré-requisito
|
|
236
|
+
* falha, JÁ RESPONDE e devolve `null`.
|
|
237
|
+
*/
|
|
238
|
+
async #mfaContext(ctx) {
|
|
239
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
240
|
+
const cfg = service.config;
|
|
241
|
+
const store = cfg.accountStore;
|
|
242
|
+
if (!supportsMfa(store)) {
|
|
243
|
+
ctx.response
|
|
244
|
+
.status(422)
|
|
245
|
+
.send(apiErr('capability_unsupported', 'MFA not supported by the account store.'));
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
249
|
+
if (!userId) {
|
|
250
|
+
ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
return { cfg, store, userId };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Gate de sudo em versão JSON. A DECISÃO é a mesmíssima do console —
|
|
257
|
+
* `isSudoSatisfied`, de onde o `requireSudo` do form também tira a dele
|
|
258
|
+
* (mesma setting, mesma janela de graça, mesma vinculação à conta, mesmo
|
|
259
|
+
* fail-safe) —, só a RECUSA muda de forma: `403 sudo_required` em vez do
|
|
260
|
+
* redirect para `/account/confirm`.
|
|
261
|
+
*
|
|
262
|
+
* Chama a decisão, e não o `requireSudo`, porque aquele ESCREVE na resposta
|
|
263
|
+
* ao recusar (`response.redirect`): sobrepor um 403 depois deixaria um
|
|
264
|
+
* `Location` pendurado numa resposta que não é redirect. Devolve `false`
|
|
265
|
+
* quando já respondeu.
|
|
266
|
+
*/
|
|
267
|
+
async #requireSudoJson(ctx) {
|
|
268
|
+
const settings = await resolveRuntimeSettings(ctx);
|
|
269
|
+
if (await isSudoSatisfied(ctx, settings))
|
|
270
|
+
return true;
|
|
271
|
+
ctx.response.status(403).send(apiErr('sudo_required', 'Identity confirmation required.'));
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
/** Notificação de segurança best-effort (nunca derruba a operação). */
|
|
275
|
+
async #notice(ctx, c, kind) {
|
|
276
|
+
const account = await c.cfg.accountStore.findById(c.userId);
|
|
277
|
+
if (!account)
|
|
278
|
+
return;
|
|
279
|
+
await dispatchSecurityNotice(ctx, {
|
|
280
|
+
account: { id: c.userId, email: account.email },
|
|
281
|
+
kind: kind,
|
|
282
|
+
ip: ctx.request.ip?.() ?? null,
|
|
283
|
+
timestamp: new Date().toISOString(),
|
|
284
|
+
}, c.cfg.mail, c.cfg.audit, c.cfg);
|
|
285
|
+
}
|
|
286
|
+
}
|