@adonis-agora/authkit-server 0.44.0 → 0.46.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/ui_preset.js +15 -1
- package/build/host/views/account/confirm.edge +118 -52
- package/build/host/views/account/mfa.edge +1 -1
- package/build/host/views/login.edge +2 -4
- package/build/host/views/mfa-challenge.edge +1 -1
- package/build/host/views/partials/styles.edge +1 -1
- package/build/index.d.ts +5 -1
- package/build/index.js +15 -1
- package/build/src/define_config.d.ts +71 -3
- package/build/src/define_config.js +11 -0
- package/build/src/host/account_api/account_api_controller.js +2 -2
- package/build/src/host/account_deletion_ops.d.ts +2 -2
- package/build/src/host/account_deletion_ops.js +3 -3
- package/build/src/host/account_deletion_service.js +1 -1
- package/build/src/host/assets/webauthn.js +2 -0
- package/build/src/host/avatar_storage.d.ts +40 -14
- package/build/src/host/avatar_storage.js +221 -72
- package/build/src/host/controllers/account_confirm_controller.d.ts +11 -14
- package/build/src/host/controllers/account_confirm_controller.js +42 -130
- package/build/src/host/controllers/account_security_controller.js +3 -3
- package/build/src/host/controllers/account_session_controller.js +20 -4
- package/build/src/host/controllers/webauthn_asset_controller.d.ts +22 -0
- package/build/src/host/controllers/webauthn_asset_controller.js +66 -0
- package/build/src/host/durable/account_deletion_workflow.js +1 -1
- package/build/src/host/i18n.d.ts +14 -0
- package/build/src/host/i18n.js +16 -0
- package/build/src/host/impersonation_session.js +15 -0
- package/build/src/host/rate_limit.d.ts +10 -0
- package/build/src/host/rate_limit.js +6 -0
- package/build/src/host/register_auth_host.d.ts +19 -0
- package/build/src/host/register_auth_host.js +72 -4
- package/build/src/host/sudo/index.d.ts +47 -0
- package/build/src/host/sudo/index.js +41 -0
- package/build/src/host/sudo/methods/magic_link.d.ts +43 -0
- package/build/src/host/sudo/methods/magic_link.js +174 -0
- package/build/src/host/sudo/methods/oidc_step_up.d.ts +68 -0
- package/build/src/host/sudo/methods/oidc_step_up.js +78 -0
- package/build/src/host/sudo/methods/passkey.d.ts +28 -0
- package/build/src/host/sudo/methods/passkey.js +139 -0
- package/build/src/host/sudo/methods/password.d.ts +19 -0
- package/build/src/host/sudo/methods/password.js +93 -0
- package/build/src/host/sudo/runtime.d.ts +141 -0
- package/build/src/host/sudo/runtime.js +327 -0
- package/build/src/host/sudo/types.d.ts +93 -0
- package/build/src/host/sudo/types.js +1 -0
- package/build/src/host/sudo_mode.d.ts +116 -6
- package/build/src/host/sudo_mode.js +133 -7
- package/package.json +10 -2
- package/build/stubs/ui/edge/views/consent.edge +0 -13
- package/build/stubs/ui/edge/views/login.edge +0 -19
- package/stubs/ui/edge/views/consent.edge +0 -13
- package/stubs/ui/edge/views/login.edge +0 -19
|
@@ -1,144 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sudo mode — tela de confirmação de identidade (/account/confirm).
|
|
3
3
|
*
|
|
4
|
-
* GET
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* POST /account/confirm/passkey → verifica a resposta da passkey e marca o sudo.
|
|
4
|
+
* O GET lista os métodos DISPONÍVEIS para a conta (SPI `SudoMethod`); a
|
|
5
|
+
* verificação de cada um vive no próprio método, nas rotas que ele registra.
|
|
6
|
+
* Este controller não verifica credencial nem chama `markSudo`.
|
|
8
7
|
*
|
|
9
8
|
* A tela está atrás do `accountGuard` (requer sessão de conta ativa).
|
|
10
|
-
* Após confirmação, redireciona para `return_to` (validado) ou para `/account/tokens`.
|
|
11
9
|
*/
|
|
12
10
|
import '../augmentations.js';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const CONFIRM_PASSKEY_CHALLENGE_KEY = 'authkit_confirm_passkey_challenge';
|
|
11
|
+
import { resolveAvailableMethods, configuredSudoMethods, isSudoMethodMounted, sudoContextFrom, LAST_METHOD_SESSION_KEY, } from '../sudo/runtime.js';
|
|
12
|
+
/**
|
|
13
|
+
* Reexport de compatibilidade. O construtor canônico do `SudoContext` vive em
|
|
14
|
+
* `sudo/runtime.ts` (é runtime do SPI, não detalhe da tela); este caminho
|
|
15
|
+
* antigo segue valendo para quem já o importava.
|
|
16
|
+
*/
|
|
17
|
+
export { sudoContextFrom };
|
|
21
18
|
export default class AccountConfirmController {
|
|
22
19
|
async show(ctx) {
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
20
|
+
const c = await sudoContextFrom(ctx);
|
|
21
|
+
const available = await resolveAvailableMethods(c, configuredSudoMethods(c.cfg));
|
|
22
|
+
const methods = await Promise.all(available.map(async (m) => ({ id: m.id, ...(await m.describe(c)) })));
|
|
23
|
+
if (!methods.length) {
|
|
24
|
+
// Nenhum método disponível é erro de CONFIGURAÇÃO do host, não usuário
|
|
25
|
+
// preso: a tela informa e o log aponta o problema.
|
|
26
|
+
;
|
|
27
|
+
ctx.logger?.error({ accountId: c.accountId }, 'authkit: nenhum método de sudo disponível para a conta — verifique config.sudo.methods');
|
|
28
|
+
}
|
|
29
|
+
// FLAG-DRIFT: `config.sudo.methods` decide o que a tela OFERECE, mas as
|
|
30
|
+
// rotas são montadas em tempo de registro, por `registerAuthHost`. Se as
|
|
31
|
+
// duas listas divergirem, a tela mostra um método cujo endpoint não existe
|
|
32
|
+
// — falha silenciosa e confusa. Avisa alto, uma vez por render.
|
|
33
|
+
//
|
|
34
|
+
// Só o caso de config EXPLÍCITA chega aqui divergindo: sem config,
|
|
35
|
+
// `configuredSudoMethods` devolve a própria lista montada.
|
|
36
|
+
for (const m of methods) {
|
|
37
|
+
if (!isSudoMethodMounted(m.id)) {
|
|
38
|
+
;
|
|
39
|
+
ctx.logger?.warn({ method: m.id }, `authkit: método de sudo "${m.id}" está em config.sudo.methods mas não teve ` +
|
|
40
|
+
'rotas montadas por registerAuthHost — a tela vai oferecer uma opção cujo ' +
|
|
41
|
+
'endpoint não existe');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return c.cfg.render(ctx, 'account/confirm', {
|
|
40
45
|
csrfToken: ctx.request.csrfToken,
|
|
41
|
-
returnTo,
|
|
46
|
+
returnTo: c.returnTo,
|
|
42
47
|
error: ctx.session.flashMessages.get('confirmError') ?? null,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const cfg = service.config;
|
|
50
|
-
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
51
|
-
const account = await cfg.accountStore.findById(userId);
|
|
52
|
-
const rawReturnTo = ctx.request.input?.('return_to');
|
|
53
|
-
const returnTo = validateReturnTo(rawReturnTo);
|
|
54
|
-
const { password } = ctx.request.only(['password']);
|
|
55
|
-
if (!password || !account) {
|
|
56
|
-
ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.error'));
|
|
57
|
-
return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
|
|
58
|
-
}
|
|
59
|
-
const verified = await cfg.accountStore.verifyCredentials(account.email, password);
|
|
60
|
-
if (!verified) {
|
|
61
|
-
ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.error'));
|
|
62
|
-
return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
|
|
63
|
-
}
|
|
64
|
-
// Confirmado: marca o sudo na sessão.
|
|
65
|
-
markSudo(ctx);
|
|
66
|
-
await cfg.audit?.record({
|
|
67
|
-
type: 'sudo.confirmed',
|
|
68
|
-
accountId: userId,
|
|
69
|
-
ip: ctx.request.ip?.() ?? null,
|
|
70
|
-
metadata: { method: 'password' },
|
|
71
|
-
});
|
|
72
|
-
return ctx.response.redirect(returnTo ?? accountHome(cfg));
|
|
73
|
-
}
|
|
74
|
-
async passkeyOptions(ctx) {
|
|
75
|
-
const service = await ctx.containerResolver.make('authkit.server');
|
|
76
|
-
const cfg = service.config;
|
|
77
|
-
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
78
|
-
const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(userId);
|
|
79
|
-
if (!generated) {
|
|
80
|
-
return ctx.response.notFound({
|
|
81
|
-
message: translate(cfg.messages, 'errors.no_passkey_registered'),
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
ctx.session.put(CONFIRM_PASSKEY_CHALLENGE_KEY, generated.challenge);
|
|
85
|
-
return generated.options;
|
|
86
|
-
}
|
|
87
|
-
async passkeyConfirm(ctx) {
|
|
88
|
-
const service = await ctx.containerResolver.make('authkit.server');
|
|
89
|
-
const cfg = service.config;
|
|
90
|
-
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
91
|
-
const challenge = ctx.session.get(CONFIRM_PASSKEY_CHALLENGE_KEY);
|
|
92
|
-
const rawReturnTo = ctx.request.input?.('return_to');
|
|
93
|
-
const returnTo = validateReturnTo(rawReturnTo);
|
|
94
|
-
if (!challenge) {
|
|
95
|
-
ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.passkey_error'));
|
|
96
|
-
return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
|
|
97
|
-
}
|
|
98
|
-
const raw = ctx.request.input('response');
|
|
99
|
-
let parsed = null;
|
|
100
|
-
try {
|
|
101
|
-
parsed = raw ? JSON.parse(raw) : null;
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
parsed = null;
|
|
105
|
-
}
|
|
106
|
-
const ok = parsed
|
|
107
|
-
? ((await cfg.accountStore.verifyPasskeyAuthentication?.(userId, parsed, challenge)) ?? false)
|
|
108
|
-
: false;
|
|
109
|
-
ctx.session.forget(CONFIRM_PASSKEY_CHALLENGE_KEY);
|
|
110
|
-
if (!ok) {
|
|
111
|
-
ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.passkey_error'));
|
|
112
|
-
return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
|
|
113
|
-
}
|
|
114
|
-
// Passkey confirmada: marca o sudo.
|
|
115
|
-
markSudo(ctx);
|
|
116
|
-
await cfg.audit?.record({
|
|
117
|
-
type: 'sudo.confirmed',
|
|
118
|
-
accountId: userId,
|
|
119
|
-
ip: ctx.request.ip?.() ?? null,
|
|
120
|
-
metadata: { method: 'passkey' },
|
|
48
|
+
// `confirmNotice` é flashado por `magic_link.ts` ao enviar o link (já
|
|
49
|
+
// traduzido, mesmo padrão de `confirmError`). Sem repassar aqui, quem
|
|
50
|
+
// pede o link volta para a tela sem nenhum feedback de que o e-mail saiu.
|
|
51
|
+
notice: ctx.session.flashMessages.get('confirmNotice') ?? null,
|
|
52
|
+
methods,
|
|
53
|
+
preferredId: ctx.session.get(LAST_METHOD_SESSION_KEY) ?? null,
|
|
121
54
|
});
|
|
122
|
-
return ctx.response.redirect(returnTo ?? accountHome(cfg));
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Verifica se a conta é "passwordless" (sem hash de senha definido).
|
|
126
|
-
* Fail-safe: retorna `false` quando não é possível determinar.
|
|
127
|
-
*/
|
|
128
|
-
async isPasswordless(cfg, accountId) {
|
|
129
|
-
try {
|
|
130
|
-
// Se o store expõe __getRawRow, verificamos se o hash de senha está vazio.
|
|
131
|
-
const row = await cfg.accountStore.__getRawRow?.(accountId);
|
|
132
|
-
if (row) {
|
|
133
|
-
const pw = row.password;
|
|
134
|
-
// Hash vazio ou nulo → passwordless.
|
|
135
|
-
if (!pw || pw === '')
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
143
55
|
}
|
|
144
56
|
}
|
|
@@ -3,7 +3,7 @@ import { ACCOUNT_SESSION_KEY } from "../middleware/account_auth.js";
|
|
|
3
3
|
import { supportsAccountSecurity, supportsAccountDeletion, supportsProfile, supportsPasswordHistory, } from "../../accounts/account_store.js";
|
|
4
4
|
import { changePasswordValidator, changeEmailValidator, deleteAccountValidator, updateProfileValidator, } from "../validators.js";
|
|
5
5
|
import { sendEmailChangeConfirmationEmail, sendEmailChangeNoticeEmail, sendEmailChangedCompletedEmail, } from "../default_mailer.js";
|
|
6
|
-
import { storeAvatar,
|
|
6
|
+
import { storeAvatar, isAvatarUploadSupported, AvatarUploadError, } from "../avatar_storage.js";
|
|
7
7
|
import { translate } from "../i18n.js";
|
|
8
8
|
import { TRUSTED_DEVICE_COOKIE } from "../trusted_device.js";
|
|
9
9
|
import { AccountDeletionService } from "../account_deletion_service.js";
|
|
@@ -67,8 +67,8 @@ export default class AccountSecurityController {
|
|
|
67
67
|
csrfToken: ctx.request.csrfToken,
|
|
68
68
|
supported: supportsAccountSecurity(cfg.accountStore),
|
|
69
69
|
profileSupported: supportsProfile(cfg.accountStore),
|
|
70
|
-
// Só mostramos o input de arquivo se
|
|
71
|
-
avatarUploadSupported: await
|
|
70
|
+
// Só mostramos o input de arquivo se ALGUM backend (drive OU media) puder armazenar.
|
|
71
|
+
avatarUploadSupported: await isAvatarUploadSupported(cfg.uploads),
|
|
72
72
|
email: account?.email ?? "",
|
|
73
73
|
name: account?.name ?? "",
|
|
74
74
|
avatarUrl: account?.avatarUrl ?? "",
|
|
@@ -108,10 +108,26 @@ export default class AccountSessionController {
|
|
|
108
108
|
const cfg = service.config;
|
|
109
109
|
// M6: não basta `forget(ACCOUNT_SESSION_KEY)` — sobravam na sessão
|
|
110
110
|
// `authkit_sudo_at` (sudo), `authkit_last_seen` e qualquer outro estado
|
|
111
|
-
// sensível, com o session id INALTERADO. Regenerar
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// (
|
|
111
|
+
// sensível, com o session id INALTERADO. Regenerar troca o id do cookie,
|
|
112
|
+
// o que invalida o identificador antigo.
|
|
113
|
+
//
|
|
114
|
+
// ATENÇÃO ao que `regenerate()` NÃO faz: ele NÃO descarta os dados. O
|
|
115
|
+
// AdonisJS MIGRA o conteúdo da sessão para o id novo (é o mesmo
|
|
116
|
+
// comportamento descrito no M5 do login, e é por isso que o estado de
|
|
117
|
+
// pré-login sobrevive lá). Quem apaga estado aqui é o `forget` explícito —
|
|
118
|
+
// não o `regenerate`.
|
|
119
|
+
//
|
|
120
|
+
// Consequência de projeto: TODO dado sensível guardado na sessão precisa
|
|
121
|
+
// ser VINCULADO À CONTA que o gravou (ex.: `accountId` no pendente do
|
|
122
|
+
// magic link de sudo, na vinculação do challenge de passkey e na marca de
|
|
123
|
+
// sudo — `SUDO_ACCOUNT_SESSION_KEY`). Confiar que ele "some no logout" é
|
|
124
|
+
// falso — num navegador compartilhado ele sobrevive ao logout de A e ao
|
|
125
|
+
// login de B.
|
|
126
|
+
//
|
|
127
|
+
// A marca de sudo era a EXCEÇÃO à própria regra declarada aqui: só o
|
|
128
|
+
// timestamp, sem dono. Passou a ser vinculada — por isso este `forget`
|
|
129
|
+
// continua sendo só do `ACCOUNT_SESSION_KEY`: trocada a conta, a marca de
|
|
130
|
+
// sudo remanescente já não vale para ninguém.
|
|
115
131
|
ctx.session.forget(ACCOUNT_SESSION_KEY);
|
|
116
132
|
await ctx.session.regenerate();
|
|
117
133
|
// Opt-in: espelha o logout no guard de @adonisjs/auth (ver adonisAuth em define_config.ts).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/**
|
|
3
|
+
* GET /authkit/assets/webauthn.js
|
|
4
|
+
*
|
|
5
|
+
* Serve o `@simplewebauthn/browser` a partir do próprio host, substituindo o
|
|
6
|
+
* import de `cdn.jsdelivr.net` que as views de login/MFA/confirm faziam.
|
|
7
|
+
*
|
|
8
|
+
* SEM AUTENTICAÇÃO, e é intencional: é asset estático necessário na tela de
|
|
9
|
+
* login, ou seja, antes de existir qualquer sessão. Também não pode viver sob
|
|
10
|
+
* o prefixo do console admin (que é opt-in) — `login.edge` e
|
|
11
|
+
* `mfa-challenge.edge` precisam do script mesmo num host sem console.
|
|
12
|
+
*/
|
|
13
|
+
export default class WebauthnAssetController {
|
|
14
|
+
handle(ctx: HttpContext): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Limpa o cache do bundle. Existe para os testes conseguirem exercitar tanto o
|
|
18
|
+
* caminho feliz quanto o 404 no mesmo processo.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export declare function resetWebauthnAssetCache(): void;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
/**
|
|
3
|
+
* URL do bundle ESM do `@simplewebauthn/browser` empacotado por
|
|
4
|
+
* `scripts/build_webauthn.mjs`.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ RESOLVIDO VIA `import.meta.url`, NUNCA relativo ao cwd. Os apps que
|
|
7
|
+
* consomem este pacote rodam `pnpm deploy --legacy`, que remonta a árvore de
|
|
8
|
+
* `node_modules` num diretório novo — um caminho relativo ao cwd do host
|
|
9
|
+
* apontaria para o lugar errado e a rota daria 404 em produção. Ancorado no
|
|
10
|
+
* módulo, o caminho segue o pacote para onde quer que ele seja copiado.
|
|
11
|
+
*
|
|
12
|
+
* Funciona nos dois layouts porque o bundle é commitado em `src/host/assets/`
|
|
13
|
+
* e copiado para `build/src/host/assets/` pelo script `build`:
|
|
14
|
+
* dev → src/host/controllers/… → src/host/assets/webauthn.js
|
|
15
|
+
* build → build/src/host/controllers/… → build/src/host/assets/webauthn.js
|
|
16
|
+
*/
|
|
17
|
+
const BUNDLE_URL = new URL('../assets/webauthn.js', import.meta.url);
|
|
18
|
+
/**
|
|
19
|
+
* Cache do conteúdo do bundle. É imutável por versão do pacote — ler do disco
|
|
20
|
+
* a cada request de tela de login não compra nada.
|
|
21
|
+
*
|
|
22
|
+
* `null` = ainda não lido. `false` = lido e ausente (404 memoizado); sem isso
|
|
23
|
+
* um bundle faltando viraria um `readFile` que falha por request.
|
|
24
|
+
*/
|
|
25
|
+
let cached = null;
|
|
26
|
+
/**
|
|
27
|
+
* GET /authkit/assets/webauthn.js
|
|
28
|
+
*
|
|
29
|
+
* Serve o `@simplewebauthn/browser` a partir do próprio host, substituindo o
|
|
30
|
+
* import de `cdn.jsdelivr.net` que as views de login/MFA/confirm faziam.
|
|
31
|
+
*
|
|
32
|
+
* SEM AUTENTICAÇÃO, e é intencional: é asset estático necessário na tela de
|
|
33
|
+
* login, ou seja, antes de existir qualquer sessão. Também não pode viver sob
|
|
34
|
+
* o prefixo do console admin (que é opt-in) — `login.edge` e
|
|
35
|
+
* `mfa-challenge.edge` precisam do script mesmo num host sem console.
|
|
36
|
+
*/
|
|
37
|
+
export default class WebauthnAssetController {
|
|
38
|
+
async handle(ctx) {
|
|
39
|
+
if (cached === null) {
|
|
40
|
+
try {
|
|
41
|
+
cached = await readFile(BUNDLE_URL);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
cached = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (cached === false) {
|
|
48
|
+
// 404 limpo: o bundle não foi gerado (`node scripts/build_webauthn.mjs`).
|
|
49
|
+
// A tela degrada para os demais fatores em vez de estourar 500.
|
|
50
|
+
return ctx.response.notFound();
|
|
51
|
+
}
|
|
52
|
+
return ctx.response
|
|
53
|
+
.type('text/javascript')
|
|
54
|
+
.header('Cache-Control', 'public, max-age=31536000, immutable')
|
|
55
|
+
.send(cached);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Limpa o cache do bundle. Existe para os testes conseguirem exercitar tanto o
|
|
60
|
+
* caminho feliz quanto o 404 no mesmo processo.
|
|
61
|
+
*
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
export function resetWebauthnAssetCache() {
|
|
65
|
+
cached = null;
|
|
66
|
+
}
|
|
@@ -75,7 +75,7 @@ export function defineAccountDeletionWorkflow(deps) {
|
|
|
75
75
|
result.orgMemberships = orgResult.orgMemberships;
|
|
76
76
|
result.orgInvitations = orgResult.orgInvitations;
|
|
77
77
|
// 7) Avatar no drive.
|
|
78
|
-
result.avatarDeleted = (await ctx.step("delete.avatar", async () => deleteAccountAvatar((await deps.oidc()).config, snapshot.avatarUrl))).avatarDeleted;
|
|
78
|
+
result.avatarDeleted = (await ctx.step("delete.avatar", async () => deleteAccountAvatar((await deps.oidc()).config, accountId, snapshot.avatarUrl))).avatarDeleted;
|
|
79
79
|
// 8) Anonimiza o histórico de audit.
|
|
80
80
|
result.auditAnonymized = (await ctx.step("anonymize.audit", async () => anonymizeAudit((await deps.oidc()).config, accountId))).auditAnonymized;
|
|
81
81
|
// 9) Deleta a linha da conta (ÚLTIMA etapa, forward-only).
|
package/build/src/host/i18n.d.ts
CHANGED
|
@@ -560,6 +560,13 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
560
560
|
"account.confirm.error": string;
|
|
561
561
|
"account.confirm.passkey_error": string;
|
|
562
562
|
"account.confirm.passwordless_notice": string;
|
|
563
|
+
"account.confirm.method.password": string;
|
|
564
|
+
"account.confirm.method.passkey": string;
|
|
565
|
+
"account.confirm.method.magic_link": string;
|
|
566
|
+
"account.confirm.method.oidc_step_up": string;
|
|
567
|
+
"account.confirm.magic_link_sent": string;
|
|
568
|
+
"account.confirm.no_methods": string;
|
|
569
|
+
"account.confirm.preferred_badge": string;
|
|
563
570
|
"admin.settings.sudo_mode_section": string;
|
|
564
571
|
"admin.settings.sudo_mode_intro": string;
|
|
565
572
|
"admin.settings.sudo_mode_from_config": string;
|
|
@@ -1242,6 +1249,13 @@ export declare const PT_BR_MESSAGES: {
|
|
|
1242
1249
|
"account.confirm.error": string;
|
|
1243
1250
|
"account.confirm.passkey_error": string;
|
|
1244
1251
|
"account.confirm.passwordless_notice": string;
|
|
1252
|
+
"account.confirm.method.password": string;
|
|
1253
|
+
"account.confirm.method.passkey": string;
|
|
1254
|
+
"account.confirm.method.magic_link": string;
|
|
1255
|
+
"account.confirm.method.oidc_step_up": string;
|
|
1256
|
+
"account.confirm.magic_link_sent": string;
|
|
1257
|
+
"account.confirm.no_methods": string;
|
|
1258
|
+
"account.confirm.preferred_badge": string;
|
|
1245
1259
|
"admin.settings.sudo_mode_section": string;
|
|
1246
1260
|
"admin.settings.sudo_mode_intro": string;
|
|
1247
1261
|
"admin.settings.sudo_mode_from_config": string;
|
package/build/src/host/i18n.js
CHANGED
|
@@ -605,6 +605,14 @@ export const DEFAULT_MESSAGES = {
|
|
|
605
605
|
"account.confirm.error": "Incorrect password.",
|
|
606
606
|
"account.confirm.passkey_error": "Could not authenticate with the passkey. Please try again.",
|
|
607
607
|
"account.confirm.passwordless_notice": "This account does not have a password. Please add a passkey to use sudo-protected features.",
|
|
608
|
+
// Rótulos dos métodos do SPI de sudo (account/confirm.edge, um bloco por método disponível).
|
|
609
|
+
"account.confirm.method.password": "Confirm with your password",
|
|
610
|
+
"account.confirm.method.passkey": "Confirm with a passkey",
|
|
611
|
+
"account.confirm.method.magic_link": "Email me a confirmation link",
|
|
612
|
+
"account.confirm.method.oidc_step_up": "Sign in again to confirm",
|
|
613
|
+
"account.confirm.magic_link_sent": "We sent a confirmation link to your email. It expires in 5 minutes.",
|
|
614
|
+
"account.confirm.no_methods": "No confirmation method is available for this account. Contact support.",
|
|
615
|
+
"account.confirm.preferred_badge": "Used last time",
|
|
608
616
|
// Admin settings — sudo_mode card.
|
|
609
617
|
"admin.settings.sudo_mode_section": "Sudo mode (identity confirmation)",
|
|
610
618
|
"admin.settings.sudo_mode_intro": "When enabled, sensitive actions (password change, email change, account deletion, MFA/passkey management, PAT creation/revocation) require the user to confirm their password. The confirmation is valid for a configurable grace period.",
|
|
@@ -1359,6 +1367,14 @@ export const PT_BR_MESSAGES = {
|
|
|
1359
1367
|
"account.confirm.error": "Senha incorreta.",
|
|
1360
1368
|
"account.confirm.passkey_error": "Não foi possível autenticar com a passkey. Tente novamente.",
|
|
1361
1369
|
"account.confirm.passwordless_notice": "Esta conta não possui senha. Adicione uma passkey para usar funcionalidades protegidas.",
|
|
1370
|
+
// Rótulos dos métodos do SPI de sudo (account/confirm.edge, um bloco por método disponível).
|
|
1371
|
+
"account.confirm.method.password": "Confirmar com a senha",
|
|
1372
|
+
"account.confirm.method.passkey": "Confirmar com passkey",
|
|
1373
|
+
"account.confirm.method.magic_link": "Receber link de confirmação por e-mail",
|
|
1374
|
+
"account.confirm.method.oidc_step_up": "Entrar de novo para confirmar",
|
|
1375
|
+
"account.confirm.magic_link_sent": "Enviamos um link de confirmação para o seu e-mail. Ele expira em 5 minutos.",
|
|
1376
|
+
"account.confirm.no_methods": "Nenhum método de confirmação está disponível para esta conta. Fale com o suporte.",
|
|
1377
|
+
"account.confirm.preferred_badge": "Usado da última vez",
|
|
1362
1378
|
// Admin settings — sudo_mode card (pt-BR).
|
|
1363
1379
|
"admin.settings.sudo_mode_section": "Modo sudo (confirmação de identidade)",
|
|
1364
1380
|
"admin.settings.sudo_mode_intro": "Quando habilitado, ações sensíveis (troca de senha, troca de e-mail, exclusão de conta, gerência de MFA/passkey, criação/revogação de PAT) exigem que o usuário confirme sua senha. A confirmação é válida por um período de graça configurável.",
|
|
@@ -98,6 +98,14 @@ export async function startImpersonation(ctx, params) {
|
|
|
98
98
|
// Anti-fixation: rotaciona o id da sessão (mantém os dados) antes de gravar a
|
|
99
99
|
// nova identidade. Mesmo padrão do consumidor real no RP.
|
|
100
100
|
await ctx.session.regenerate();
|
|
101
|
+
// ESCALAÇÃO DE PRIVILÉGIO (fechada por vinculação): trocar a conta aqui NÃO
|
|
102
|
+
// pode carregar junto o sudo que o admin confirmou sobre a PRÓPRIA conta —
|
|
103
|
+
// senão ele entraria personificando já com a graça aberta sobre a conta
|
|
104
|
+
// alheia (exportar/excluir dados, MFA, PATs). Não limpamos a marca aqui: ela
|
|
105
|
+
// é vinculada à conta que a confirmou (`SUDO_ACCOUNT_SESSION_KEY`), então
|
|
106
|
+
// `isSudoActive` a recusa sozinho assim que `ACCOUNT_SESSION_KEY` muda. A
|
|
107
|
+
// garantia é estrutural — vale para qualquer troca de conta futura, sem
|
|
108
|
+
// depender de um `forget` lembrado em cada nova transição.
|
|
101
109
|
ctx.session.put(IMPERSONATOR_SESSION_KEY, impersonatorId);
|
|
102
110
|
ctx.session.put(ACCOUNT_SESSION_KEY, params.targetId);
|
|
103
111
|
}
|
|
@@ -122,6 +130,13 @@ export async function stopImpersonation(ctx) {
|
|
|
122
130
|
if (!impersonatorId)
|
|
123
131
|
return;
|
|
124
132
|
await ctx.session.regenerate();
|
|
133
|
+
// Simétrico ao `startImpersonation`: o sudo obtido ENQUANTO personificava
|
|
134
|
+
// ficaria valendo sobre a conta do admin ao voltar. A vinculação corta isso —
|
|
135
|
+
// aquela marca aponta para a conta personificada e morre com a volta.
|
|
136
|
+
//
|
|
137
|
+
// Nota: se o admin tinha sudo sobre a própria conta ANTES de personificar e a
|
|
138
|
+
// graça ainda não venceu, ele volta valendo. Correto e intencional: é a
|
|
139
|
+
// confirmação dele, sobre a conta dele, dentro da janela dele.
|
|
125
140
|
ctx.session.put(ACCOUNT_SESSION_KEY, impersonatorId);
|
|
126
141
|
ctx.session.forget(IMPERSONATOR_SESSION_KEY);
|
|
127
142
|
ctx.session.forget(ADMIN_ACCESS_TOKEN_SESSION_KEY);
|
|
@@ -20,6 +20,16 @@ export interface AuthThrottles {
|
|
|
20
20
|
* de tentativas vindas do MESMO IP independentemente de qual key foi usada (M8).
|
|
21
21
|
*/
|
|
22
22
|
adminIp: ThrottleMiddleware;
|
|
23
|
+
/**
|
|
24
|
+
* Throttle das rotas dos métodos de sudo (`/account/confirm/*`), keyed por IP
|
|
25
|
+
* como o `login` — e com os MESMOS limites — mas em bucket PRÓPRIO.
|
|
26
|
+
*
|
|
27
|
+
* A separação é o ponto: `login` mede um anônimo tentando adivinhar
|
|
28
|
+
* credenciais, `sudo` mede um usuário já autenticado reprovando a própria
|
|
29
|
+
* identidade. Ver `ResolvedRateLimitConfig.sudo` para o porquê de os dois
|
|
30
|
+
* orçamentos não poderem se consumir.
|
|
31
|
+
*/
|
|
32
|
+
sudo: ThrottleMiddleware;
|
|
23
33
|
}
|
|
24
34
|
/**
|
|
25
35
|
* Service do `@adonisjs/limiter` resolvido de forma preguiçosa. Tipado como `any`
|
|
@@ -90,5 +90,11 @@ export function createAuthThrottles(config) {
|
|
|
90
90
|
adminIp: buildThrottle('authkit_admin_ip', config.adminIp, config.store, (ctx) => {
|
|
91
91
|
return `admin-ip:${ctx.request.ip?.() ?? 'unknown'}`;
|
|
92
92
|
}),
|
|
93
|
+
// Rotas dos métodos de sudo: keyed por IP (default do HttpLimiter), igual ao
|
|
94
|
+
// login. O que separa os dois é o NOME do bucket — o limiter namespaceia a
|
|
95
|
+
// contagem por nome, então `authkit_login` e `authkit_sudo` nunca somam,
|
|
96
|
+
// mesmo vindo do mesmo IP. Sem `usingKey` próprio de propósito: inventar uma
|
|
97
|
+
// key aqui seria mudar o EIXO da contagem, e o eixo certo continua sendo o IP.
|
|
98
|
+
sudo: buildThrottle('authkit_sudo', config.sudo, config.store),
|
|
93
99
|
};
|
|
94
100
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Router } from '@adonisjs/core/http';
|
|
2
2
|
import type { AuthSocialConfig, RateLimitConfigInput } from '../define_config.js';
|
|
3
|
+
import type { SudoMethod } from './sudo/types.js';
|
|
3
4
|
/** Chave da sessão Adonis que registra o timestamp da última atividade (idle timeout). */
|
|
4
5
|
export declare const ACCOUNT_LAST_SEEN_KEY = "authkit_last_seen";
|
|
5
6
|
/**
|
|
@@ -89,6 +90,24 @@ export interface AuthHostOptions {
|
|
|
89
90
|
adminApi?: boolean | {
|
|
90
91
|
prefix?: string;
|
|
91
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Métodos de sudo cujas rotas devem ser montadas. Necessário aqui (e não só
|
|
95
|
+
* no config) porque a decisão de MONTAR rotas acontece em tempo de registro,
|
|
96
|
+
* antes de o config lazy resolver — mesma razão de `social`/`admin`/`rateLimit`.
|
|
97
|
+
* Espelhe o `sudo.methods` de config/authkit.ts.
|
|
98
|
+
*
|
|
99
|
+
* SUBSTITUI os defaults, não acrescenta: a lista é do host. Quem quer manter
|
|
100
|
+
* senha/passkey ao lado do método novo os inclui explicitamente
|
|
101
|
+
* (`[sudoMethods.password(), sudoMethods.passkey(), meuMetodo()]`).
|
|
102
|
+
*
|
|
103
|
+
* Sem esta opção, `config.sudo.methods` conseguiria OFERECER um método na
|
|
104
|
+
* tela mas nunca montar seu endpoint — a opção aparece e dá 404. Falha
|
|
105
|
+
* fechada, mas é a promessa do SPI pela metade; `magicLink()` em particular
|
|
106
|
+
* não teria como ser alcançado em runtime.
|
|
107
|
+
*
|
|
108
|
+
* Ausente → `[password(), passkey()]`.
|
|
109
|
+
*/
|
|
110
|
+
sudoMethods?: SudoMethod[];
|
|
92
111
|
}
|
|
93
112
|
/**
|
|
94
113
|
* Monta todas as rotas do host-kit do Authorization Server numa chamada.
|
|
@@ -8,6 +8,19 @@ import { setAdminPrefix, normalizeAdminPrefix, setAdminApiPrefix, normalizeAdmin
|
|
|
8
8
|
import { resolveRuntimeSettings } from './runtime_settings.js';
|
|
9
9
|
import { resolveEffectiveSessionPolicy } from './runtime_toggles.js';
|
|
10
10
|
import { getAuthHostConfig } from './auth_host_config.js';
|
|
11
|
+
import { completeSudo, fail, guardSudoRoutes, sudoContextFrom, setMountedSudoMethods, } from './sudo/runtime.js';
|
|
12
|
+
import { password as sudoPassword } from './sudo/methods/password.js';
|
|
13
|
+
import { passkey as sudoPasskey } from './sudo/methods/passkey.js';
|
|
14
|
+
/**
|
|
15
|
+
* Métodos de sudo montados quando o host não passa `sudoMethods` —
|
|
16
|
+
* comportamento histórico (senha + passkey).
|
|
17
|
+
*
|
|
18
|
+
* PONTO ÚNICO. A tela não tem mais uma cópia desta lista: sem
|
|
19
|
+
* `config.sudo.methods`, `configuredSudoMethods` cai no que FOI MONTADO, ou
|
|
20
|
+
* seja, no resultado do `??` abaixo. Duas listas de default é como os dois
|
|
21
|
+
* lados divergiam.
|
|
22
|
+
*/
|
|
23
|
+
const SUDO_METHOD_DEFAULTS = [sudoPassword(), sudoPasskey()];
|
|
11
24
|
/** Chave da sessão Adonis que registra o timestamp da última atividade (idle timeout). */
|
|
12
25
|
export const ACCOUNT_LAST_SEEN_KEY = 'authkit_last_seen';
|
|
13
26
|
/**
|
|
@@ -138,6 +151,7 @@ const C = {
|
|
|
138
151
|
accountMfa: () => import('./controllers/account_mfa_controller.js'),
|
|
139
152
|
accountOrgs: () => import('./controllers/account_orgs_controller.js'),
|
|
140
153
|
accountConfirm: () => import('./controllers/account_confirm_controller.js'),
|
|
154
|
+
webauthnAsset: () => import('./controllers/webauthn_asset_controller.js'),
|
|
141
155
|
// Console React JSON API (session-authed, under {prefix}/api/*).
|
|
142
156
|
consoleShell: () => import('./admin_console/admin_shell_controller.js'),
|
|
143
157
|
consoleOverview: () => import('./admin_console/console_overview_controller.js'),
|
|
@@ -188,6 +202,25 @@ export function registerAuthHost(router, opts = {}) {
|
|
|
188
202
|
if (throttles)
|
|
189
203
|
route.use([throttles.introspection]);
|
|
190
204
|
};
|
|
205
|
+
// Bucket PRÓPRIO das rotas de sudo. Antes elas levavam o `withLogin`, o que
|
|
206
|
+
// funcionava mas somava dois orçamentos que medem coisas diferentes: login é
|
|
207
|
+
// um anônimo adivinhando credenciais, sudo é um usuário JÁ autenticado
|
|
208
|
+
// reprovando a própria identidade. Ver `ResolvedRateLimitConfig.sudo`.
|
|
209
|
+
const withSudo = (route) => {
|
|
210
|
+
if (throttles)
|
|
211
|
+
route.use([throttles.sudo]);
|
|
212
|
+
};
|
|
213
|
+
// ─── Assets estáticos do host-kit (públicos, sem autenticação) ─────────────
|
|
214
|
+
// Bundle do @simplewebauthn/browser servido pelo próprio app, no lugar do
|
|
215
|
+
// import de CDN público que as views de login/MFA/confirm faziam.
|
|
216
|
+
//
|
|
217
|
+
// Path FIXO e no topo, de propósito:
|
|
218
|
+
// • não pode viver sob o prefixo do console admin (`admin` é opt-in) —
|
|
219
|
+
// login.edge e mfa-challenge.edge precisam do script em qualquer host;
|
|
220
|
+
// • sem guard, porque é carregado NA tela de login, antes de haver sessão;
|
|
221
|
+
// • registrado ANTES do wildcard `${mount}/*` para que nenhum mountPath
|
|
222
|
+
// agressivo (ex.: '/') consiga engolir o asset e quebrar o login.
|
|
223
|
+
router.get('/authkit/assets/webauthn.js', [C.webauthnAsset]).as('authkit.assets.webauthn');
|
|
191
224
|
// Provider OIDC (wildcard + root) — o que registerOidcRoutes fazia.
|
|
192
225
|
router.any(`${mount}/*`, [C.oidc]).as('authkit.oidc.wildcard');
|
|
193
226
|
router.any(mount, [C.oidc]).as('authkit.oidc.root');
|
|
@@ -269,11 +302,46 @@ export function registerAuthHost(router, opts = {}) {
|
|
|
269
302
|
router.post('/account/mfa/passkeys/options', [C.accountMfa, 'passkeyRegisterOptions']);
|
|
270
303
|
router.post('/account/mfa/passkeys/verify', [C.accountMfa, 'passkeyRegisterVerify']);
|
|
271
304
|
router.post('/account/mfa/passkeys/:id/remove', [C.accountMfa, 'passkeyRemove']);
|
|
272
|
-
// Sudo mode (confirm identity): GET
|
|
305
|
+
// Sudo mode (confirm identity): o GET lista os métodos; cada método
|
|
306
|
+
// registra suas próprias rotas de verificação (SPI `SudoMethod`).
|
|
273
307
|
router.get('/account/confirm', [C.accountConfirm, 'show']);
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
308
|
+
// Rotas próprias dos métodos de sudo — DENTRO do grupo com `accountGuard`.
|
|
309
|
+
// O guard não é só "tem sessão": ele roda `checkAndRefreshIdle`, que apaga
|
|
310
|
+
// a sessão vencida por idle e refresca `authkit_last_seen`. Fora do grupo,
|
|
311
|
+
// uma sessão já vencida (ainda não colhida) podia postar a senha correta e
|
|
312
|
+
// receber `markSudo` — e as rotas de sudo não refrescavam o last-seen.
|
|
313
|
+
// Nenhum método built-in é alcançável por GET vindo de e-mail: o token de
|
|
314
|
+
// sudo por magic link vive na PRÓPRIA sessão, então o usuário precisa
|
|
315
|
+
// estar logado no mesmo navegador de qualquer forma. Um método que
|
|
316
|
+
// genuinamente não puder ficar sob o guard precisa de uma decisão
|
|
317
|
+
// explícita, não de mover todos para fora.
|
|
318
|
+
const helpers = {
|
|
319
|
+
contextFrom: sudoContextFrom,
|
|
320
|
+
completeSudo,
|
|
321
|
+
fail,
|
|
322
|
+
};
|
|
323
|
+
const sudoMethodsToMount = opts?.sudoMethods ?? SUDO_METHOD_DEFAULTS;
|
|
324
|
+
for (const method of sudoMethodsToMount) {
|
|
325
|
+
// `guardSudoRoutes` embrulha os handlers que o método registrar, para
|
|
326
|
+
// que `config.sudo.methods` os desabilite de fato mesmo que o método
|
|
327
|
+
// não tenha checado nada por dentro. Ver o docblock lá.
|
|
328
|
+
//
|
|
329
|
+
// `withSudo` vai junto: TODA rota de um método de sudo leva o throttle
|
|
330
|
+
// do bucket de SUDO (no-op sem rate-limit). Não é adorno — o POST que
|
|
331
|
+
// emite o magic link de sudo dispara um e-mail por chamada, e o
|
|
332
|
+
// `accountGuard` sozinho só exige uma sessão viva, que o abusador tem.
|
|
333
|
+
// Aplicar aqui, no wrapper, cobre também os métodos customizados, que
|
|
334
|
+
// não teriam como pedir throttle pelo `SudoRouteHelpers`.
|
|
335
|
+
//
|
|
336
|
+
// Bucket próprio, não o de login: mesmos limites, contagem separada —
|
|
337
|
+
// errar a senha na tela de confirmação não pode gastar o orçamento de
|
|
338
|
+
// login do IP, nem vice-versa.
|
|
339
|
+
method.register?.(guardSudoRoutes(router, method.id, helpers, withSudo), helpers);
|
|
340
|
+
}
|
|
341
|
+
// A lista montada é a fonte de verdade dos DOIS lados quando o host não
|
|
342
|
+
// configura `config.sudo.methods`: a tela oferece exatamente isto, e os
|
|
343
|
+
// handlers aceitam exatamente isto.
|
|
344
|
+
setMountedSudoMethods(sudoMethodsToMount);
|
|
277
345
|
// Organizations (multi-tenancy) — sempre montadas; controller retorna 404/403 sem tabelas.
|
|
278
346
|
router.get('/account/orgs', [C.accountOrgs, 'index']);
|
|
279
347
|
router.post('/account/orgs', [C.accountOrgs, 'store']);
|