@dudousxd/adonis-authkit-server 0.1.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/LICENSE +21 -0
- package/README.md +137 -0
- package/build/assets/grafana/authkit-dashboard.json +118 -0
- package/build/commands/commands.json +30 -0
- package/build/commands/configure.d.ts +2 -0
- package/build/commands/configure.js +42 -0
- package/build/commands/eject.d.ts +11 -0
- package/build/commands/eject.js +96 -0
- package/build/commands/main.d.ts +12 -0
- package/build/commands/main.js +38 -0
- package/build/commands/ui_preset.d.ts +4 -0
- package/build/commands/ui_preset.js +32 -0
- package/build/database/migrations/make_authkit_oidc_table.d.ts +6 -0
- package/build/database/migrations/make_authkit_oidc_table.js +19 -0
- package/build/host/views/account/login.edge +29 -0
- package/build/host/views/account/mfa.edge +151 -0
- package/build/host/views/account/tokens.edge +70 -0
- package/build/host/views/admin/audit.edge +72 -0
- package/build/host/views/admin/clients.edge +51 -0
- package/build/host/views/admin/dashboard.edge +58 -0
- package/build/host/views/admin/users.edge +76 -0
- package/build/host/views/consent.edge +19 -0
- package/build/host/views/forgot.edge +30 -0
- package/build/host/views/login.edge +91 -0
- package/build/host/views/mfa-challenge.edge +88 -0
- package/build/host/views/reset.edge +29 -0
- package/build/host/views/signup.edge +44 -0
- package/build/host/views/verify-email.edge +16 -0
- package/build/index.d.ts +42 -0
- package/build/index.js +28 -0
- package/build/providers/authkit_server_provider.d.ts +19 -0
- package/build/providers/authkit_server_provider.js +81 -0
- package/build/src/accounts/account_store.d.ts +136 -0
- package/build/src/accounts/account_store.js +1 -0
- package/build/src/accounts/lucid_account_store.d.ts +75 -0
- package/build/src/accounts/lucid_account_store.js +396 -0
- package/build/src/adapters/adapter_contract.d.ts +18 -0
- package/build/src/adapters/adapter_contract.js +1 -0
- package/build/src/adapters/database_adapter.d.ts +15 -0
- package/build/src/adapters/database_adapter.js +63 -0
- package/build/src/adapters/factory.d.ts +30 -0
- package/build/src/adapters/factory.js +43 -0
- package/build/src/adapters/redis_adapter.d.ts +16 -0
- package/build/src/adapters/redis_adapter.js +95 -0
- package/build/src/audit/audit_sink.d.ts +54 -0
- package/build/src/audit/audit_sink.js +1 -0
- package/build/src/audit/lucid_audit_sink.d.ts +10 -0
- package/build/src/audit/lucid_audit_sink.js +60 -0
- package/build/src/controllers/oidc_callback_controller.d.ts +22 -0
- package/build/src/controllers/oidc_callback_controller.js +33 -0
- package/build/src/define_config.d.ts +261 -0
- package/build/src/define_config.js +115 -0
- package/build/src/host/account_lockout.d.ts +86 -0
- package/build/src/host/account_lockout.js +185 -0
- package/build/src/host/augmentations.d.ts +1 -0
- package/build/src/host/augmentations.js +1 -0
- package/build/src/host/branding.d.ts +17 -0
- package/build/src/host/branding.js +8 -0
- package/build/src/host/controllers/account_mfa_controller.d.ts +30 -0
- package/build/src/host/controllers/account_mfa_controller.js +157 -0
- package/build/src/host/controllers/account_session_controller.d.ts +7 -0
- package/build/src/host/controllers/account_session_controller.js +50 -0
- package/build/src/host/controllers/account_tokens_controller.d.ts +7 -0
- package/build/src/host/controllers/account_tokens_controller.js +55 -0
- package/build/src/host/controllers/admin/admin_audit_controller.d.ts +10 -0
- package/build/src/host/controllers/admin/admin_audit_controller.js +56 -0
- package/build/src/host/controllers/admin/admin_clients_controller.d.ts +10 -0
- package/build/src/host/controllers/admin/admin_clients_controller.js +24 -0
- package/build/src/host/controllers/admin/admin_dashboard_controller.d.ts +10 -0
- package/build/src/host/controllers/admin/admin_dashboard_controller.js +27 -0
- package/build/src/host/controllers/admin/admin_users_controller.d.ts +11 -0
- package/build/src/host/controllers/admin/admin_users_controller.js +53 -0
- package/build/src/host/controllers/interaction_controller.d.ts +44 -0
- package/build/src/host/controllers/interaction_controller.js +304 -0
- package/build/src/host/controllers/pat_introspection_controller.d.ts +22 -0
- package/build/src/host/controllers/pat_introspection_controller.js +46 -0
- package/build/src/host/controllers/registration_controller.d.ts +18 -0
- package/build/src/host/controllers/registration_controller.js +169 -0
- package/build/src/host/controllers/social_controller.d.ts +8 -0
- package/build/src/host/controllers/social_controller.js +82 -0
- package/build/src/host/default_mailer.d.ts +39 -0
- package/build/src/host/default_mailer.js +141 -0
- package/build/src/host/email_templates.d.ts +35 -0
- package/build/src/host/email_templates.js +66 -0
- package/build/src/host/i18n.d.ts +178 -0
- package/build/src/host/i18n.js +208 -0
- package/build/src/host/middleware/account_auth.d.ts +7 -0
- package/build/src/host/middleware/account_auth.js +11 -0
- package/build/src/host/rate_limit.d.ts +32 -0
- package/build/src/host/rate_limit.js +87 -0
- package/build/src/host/register_auth_host.d.ts +41 -0
- package/build/src/host/register_auth_host.js +133 -0
- package/build/src/host/renderers/edge_renderer.d.ts +3 -0
- package/build/src/host/renderers/edge_renderer.js +29 -0
- package/build/src/host/renderers/inertia_renderer.d.ts +5 -0
- package/build/src/host/renderers/inertia_renderer.js +26 -0
- package/build/src/host/validators.d.ts +39 -0
- package/build/src/host/validators.js +13 -0
- package/build/src/keys/jwks_manager.d.ts +6 -0
- package/build/src/keys/jwks_manager.js +11 -0
- package/build/src/mixins/with_audit_log.d.ts +19 -0
- package/build/src/mixins/with_audit_log.js +41 -0
- package/build/src/mixins/with_auth_user.d.ts +18 -0
- package/build/src/mixins/with_auth_user.js +39 -0
- package/build/src/mixins/with_credentials.d.ts +20 -0
- package/build/src/mixins/with_credentials.js +29 -0
- package/build/src/mixins/with_mfa.d.ts +31 -0
- package/build/src/mixins/with_mfa.js +39 -0
- package/build/src/mixins/with_personal_access_token.d.ts +19 -0
- package/build/src/mixins/with_personal_access_token.js +44 -0
- package/build/src/mixins/with_provider_identity.d.ts +20 -0
- package/build/src/mixins/with_provider_identity.js +32 -0
- package/build/src/mixins/with_webauthn_credential.d.ts +37 -0
- package/build/src/mixins/with_webauthn_credential.js +49 -0
- package/build/src/observability/metrics_controller.d.ts +5 -0
- package/build/src/observability/metrics_controller.js +24 -0
- package/build/src/observability/metrics_service.d.ts +2 -0
- package/build/src/observability/metrics_service.js +7 -0
- package/build/src/observability/otel_recorder.d.ts +10 -0
- package/build/src/observability/otel_recorder.js +59 -0
- package/build/src/observability/wire_provider_events.d.ts +12 -0
- package/build/src/observability/wire_provider_events.js +19 -0
- package/build/src/pat/lucid_pat_store.d.ts +6 -0
- package/build/src/pat/lucid_pat_store.js +62 -0
- package/build/src/pat/pat_store.d.ts +31 -0
- package/build/src/pat/pat_store.js +1 -0
- package/build/src/pat/pat_tokens.d.ts +4 -0
- package/build/src/pat/pat_tokens.js +9 -0
- package/build/src/provider/build_provider.d.ts +8 -0
- package/build/src/provider/build_provider.js +101 -0
- package/build/src/provider/interaction_actions.d.ts +21 -0
- package/build/src/provider/interaction_actions.js +32 -0
- package/build/src/provider/oidc_service.d.ts +17 -0
- package/build/src/provider/oidc_service.js +84 -0
- package/build/src/provider/token_exchange.d.ts +15 -0
- package/build/src/provider/token_exchange.js +72 -0
- package/build/src/register_routes.d.ts +16 -0
- package/build/src/register_routes.js +21 -0
- package/build/stubs/config/authkit.stub +29 -0
- package/build/stubs/main.d.ts +1 -0
- package/build/stubs/main.js +2 -0
- package/build/stubs/models/auth_user.stub +13 -0
- package/build/stubs/ui/edge/views/consent.edge +13 -0
- package/build/stubs/ui/edge/views/login.edge +19 -0
- package/build/stubs/ui/react/components/auth_shell.tsx +67 -0
- package/build/stubs/ui/react/pages/account/login.tsx +56 -0
- package/build/stubs/ui/react/pages/account/mfa.tsx +132 -0
- package/build/stubs/ui/react/pages/account/tokens.tsx +88 -0
- package/build/stubs/ui/react/pages/consent.tsx +39 -0
- package/build/stubs/ui/react/pages/forgot.tsx +44 -0
- package/build/stubs/ui/react/pages/login.tsx +171 -0
- package/build/stubs/ui/react/pages/mfa-challenge.tsx +72 -0
- package/build/stubs/ui/react/pages/reset.tsx +58 -0
- package/build/stubs/ui/react/pages/signup.tsx +78 -0
- package/build/stubs/ui/react/pages/verify-email.tsx +24 -0
- package/build/types.d.ts +7 -0
- package/build/types.js +1 -0
- package/package.json +108 -0
- package/stubs/config/authkit.stub +29 -0
- package/stubs/main.ts +2 -0
- package/stubs/models/auth_user.stub +13 -0
- package/stubs/ui/edge/views/consent.edge +13 -0
- package/stubs/ui/edge/views/login.edge +19 -0
- package/stubs/ui/react/components/auth_shell.tsx +67 -0
- package/stubs/ui/react/pages/account/login.tsx +56 -0
- package/stubs/ui/react/pages/account/mfa.tsx +132 -0
- package/stubs/ui/react/pages/account/tokens.tsx +88 -0
- package/stubs/ui/react/pages/consent.tsx +39 -0
- package/stubs/ui/react/pages/forgot.tsx +44 -0
- package/stubs/ui/react/pages/login.tsx +171 -0
- package/stubs/ui/react/pages/mfa-challenge.tsx +72 -0
- package/stubs/ui/react/pages/reset.tsx +58 -0
- package/stubs/ui/react/pages/signup.tsx +78 -0
- package/stubs/ui/react/pages/verify-email.tsx +24 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import '../../augmentations.js';
|
|
2
|
+
/**
|
|
3
|
+
* Dashboard do console admin: contagens-resumo (usuários, clients estáticos) e
|
|
4
|
+
* os eventos de auditoria mais recentes. Degrada graciosamente quando o sink de
|
|
5
|
+
* auditoria não suporta consulta (`list` ausente).
|
|
6
|
+
*/
|
|
7
|
+
export default class AdminDashboardController {
|
|
8
|
+
async index(ctx) {
|
|
9
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
10
|
+
const cfg = service.config;
|
|
11
|
+
const render = cfg.render;
|
|
12
|
+
// Total de usuários (página vazia só para ler o `total`).
|
|
13
|
+
const usersPage = await cfg.accountStore.listAccounts({ page: 1, limit: 1 });
|
|
14
|
+
const clientsCount = cfg.clients.length;
|
|
15
|
+
// Eventos recentes (best-effort: só se o sink suportar consulta).
|
|
16
|
+
const auditSupported = typeof cfg.audit?.list === 'function';
|
|
17
|
+
const recent = auditSupported ? await cfg.audit.list({ page: 1, limit: 5 }) : null;
|
|
18
|
+
return render(ctx, 'admin/dashboard', {
|
|
19
|
+
csrfToken: ctx.request.csrfToken,
|
|
20
|
+
usersTotal: usersPage.total,
|
|
21
|
+
clientsCount,
|
|
22
|
+
auditSupported,
|
|
23
|
+
auditTotal: recent?.total ?? 0,
|
|
24
|
+
recentEvents: recent?.data ?? [],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import '../../augmentations.js';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
/**
|
|
4
|
+
* Gestão de usuários do IdP: listagem paginada com busca por e-mail e edição das
|
|
5
|
+
* roles globais de uma conta. As roles são informadas como texto separado por
|
|
6
|
+
* vírgula no formulário e normalizadas aqui.
|
|
7
|
+
*/
|
|
8
|
+
export default class AdminUsersController {
|
|
9
|
+
index(ctx: HttpContext): Promise<any>;
|
|
10
|
+
updateRoles(ctx: HttpContext): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import '../../augmentations.js';
|
|
2
|
+
const PAGE_SIZE = 20;
|
|
3
|
+
/**
|
|
4
|
+
* Gestão de usuários do IdP: listagem paginada com busca por e-mail e edição das
|
|
5
|
+
* roles globais de uma conta. As roles são informadas como texto separado por
|
|
6
|
+
* vírgula no formulário e normalizadas aqui.
|
|
7
|
+
*/
|
|
8
|
+
export default class AdminUsersController {
|
|
9
|
+
async index(ctx) {
|
|
10
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
11
|
+
const cfg = service.config;
|
|
12
|
+
const render = cfg.render;
|
|
13
|
+
const search = ctx.request.input('search', '').trim();
|
|
14
|
+
const page = Math.max(1, Number.parseInt(ctx.request.input('page', '1'), 10) || 1);
|
|
15
|
+
const result = await cfg.accountStore.listAccounts({ search, page, limit: PAGE_SIZE });
|
|
16
|
+
const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
|
|
17
|
+
return render(ctx, 'admin/users', {
|
|
18
|
+
csrfToken: ctx.request.csrfToken,
|
|
19
|
+
search,
|
|
20
|
+
page,
|
|
21
|
+
totalPages,
|
|
22
|
+
total: result.total,
|
|
23
|
+
users: result.data.map((u) => ({
|
|
24
|
+
id: u.id,
|
|
25
|
+
email: u.email,
|
|
26
|
+
name: u.name ?? '',
|
|
27
|
+
roles: u.globalRoles ?? [],
|
|
28
|
+
rolesText: (u.globalRoles ?? []).join(', '),
|
|
29
|
+
})),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async updateRoles(ctx) {
|
|
33
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
34
|
+
const cfg = service.config;
|
|
35
|
+
const accountId = ctx.request.param('id');
|
|
36
|
+
const raw = ctx.request.input('roles', '') ?? '';
|
|
37
|
+
// Normaliza: split por vírgula, trim, remove vazios e duplicatas.
|
|
38
|
+
const roles = Array.from(new Set(raw
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((r) => r.trim())
|
|
41
|
+
.filter((r) => r.length > 0)));
|
|
42
|
+
await cfg.accountStore.setGlobalRoles(accountId, roles);
|
|
43
|
+
const search = ctx.request.input('search', '').trim();
|
|
44
|
+
const page = Math.max(1, Number.parseInt(ctx.request.input('page', '1'), 10) || 1);
|
|
45
|
+
const qs = new URLSearchParams();
|
|
46
|
+
if (search)
|
|
47
|
+
qs.set('search', search);
|
|
48
|
+
if (page > 1)
|
|
49
|
+
qs.set('page', String(page));
|
|
50
|
+
const query = qs.toString();
|
|
51
|
+
return ctx.response.redirect(`/admin/users${query ? `?${query}` : ''}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
export default class AuthInteractionController {
|
|
4
|
+
show(ctx: HttpContext): Promise<any>;
|
|
5
|
+
/**
|
|
6
|
+
* POST /auth/interaction/:uid/identifier
|
|
7
|
+
* Step 1: receive email, store in session, redirect to step 2.
|
|
8
|
+
* ENUMERATION-SAFE: always advances regardless of whether the email exists.
|
|
9
|
+
*/
|
|
10
|
+
identifier(ctx: HttpContext): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* POST /auth/interaction/:uid/login
|
|
13
|
+
* Step 2: password submit. Reads email from session (never from form).
|
|
14
|
+
*/
|
|
15
|
+
login(ctx: HttpContext): Promise<any>;
|
|
16
|
+
/**
|
|
17
|
+
* POST /auth/interaction/:uid/mfa
|
|
18
|
+
* 2º fator: lê o accountId pendente da sessão e aceita um código TOTP (`code`)
|
|
19
|
+
* OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
|
|
20
|
+
*/
|
|
21
|
+
mfaVerify(ctx: HttpContext): Promise<any>;
|
|
22
|
+
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
23
|
+
private hasPasskeys;
|
|
24
|
+
/**
|
|
25
|
+
* POST /auth/interaction/:uid/passkey/options
|
|
26
|
+
* Gera as opções de autenticação por passkey para o accountId pendente do MFA,
|
|
27
|
+
* guarda o challenge na sessão e devolve as opções JSON para o browser.
|
|
28
|
+
*/
|
|
29
|
+
passkeyOptions(ctx: HttpContext): Promise<any>;
|
|
30
|
+
/**
|
|
31
|
+
* POST /auth/interaction/:uid/passkey/verify
|
|
32
|
+
* Verifica a resposta de autenticação por passkey contra o challenge guardado;
|
|
33
|
+
* em caso de sucesso FINALIZA a interaction (303 de volta ao client — alternativa
|
|
34
|
+
* ao código TOTP). É um POST de página inteira (form), não fetch: o browser
|
|
35
|
+
* submete o JSON da assertion no campo `response` e segue o redirect normalmente.
|
|
36
|
+
*/
|
|
37
|
+
passkeyVerify(ctx: HttpContext): Promise<any>;
|
|
38
|
+
/**
|
|
39
|
+
* GET /auth/interaction/:uid/switch
|
|
40
|
+
* Clears the stored email and redirects back to step 1.
|
|
41
|
+
*/
|
|
42
|
+
switchIdentifier(ctx: HttpContext): Promise<void>;
|
|
43
|
+
consent(ctx: HttpContext): Promise<void>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import { brandFor, isFirstParty } from '../branding.js';
|
|
3
|
+
import { translate } from '../i18n.js';
|
|
4
|
+
import { createAccountLockout } from '../account_lockout.js';
|
|
5
|
+
const SESSION_KEY = 'authkit_login_email';
|
|
6
|
+
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
7
|
+
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
8
|
+
/** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
|
|
9
|
+
const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
|
|
10
|
+
export default class AuthInteractionController {
|
|
11
|
+
async show(ctx) {
|
|
12
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
13
|
+
const cfg = service.config;
|
|
14
|
+
const render = cfg.render;
|
|
15
|
+
const details = await service.interactions.details(ctx);
|
|
16
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
17
|
+
if (details.prompt.name === 'consent' && isFirstParty(cfg.branding, details.params.client_id)) {
|
|
18
|
+
// Clients first-party: auto-concede o consent (pula a tela de consent).
|
|
19
|
+
// interactions.consent monta o Grant + interactionFinished e escreve o
|
|
20
|
+
// redirect de volta para o client — opera via provider.interactionDetails,
|
|
21
|
+
// independente do metodo HTTP, entao funciona no GET show().
|
|
22
|
+
return await service.interactions.consent(ctx);
|
|
23
|
+
}
|
|
24
|
+
if (details.prompt.name !== 'login') {
|
|
25
|
+
// Consent or other prompts — unchanged
|
|
26
|
+
return render(ctx, 'consent', {
|
|
27
|
+
uid: details.uid,
|
|
28
|
+
params: details.params,
|
|
29
|
+
csrfToken: ctx.request.csrfToken,
|
|
30
|
+
brand,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
34
|
+
if (!email) {
|
|
35
|
+
// Step 1: identifier (email only)
|
|
36
|
+
return render(ctx, 'login', {
|
|
37
|
+
uid: details.uid,
|
|
38
|
+
csrfToken: ctx.request.csrfToken,
|
|
39
|
+
step: 'identifier',
|
|
40
|
+
brand,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
|
|
44
|
+
const acc = await cfg.accountStore.findByEmail(email);
|
|
45
|
+
const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
|
|
46
|
+
return render(ctx, 'login', {
|
|
47
|
+
uid: details.uid,
|
|
48
|
+
csrfToken: ctx.request.csrfToken,
|
|
49
|
+
step: 'password',
|
|
50
|
+
email,
|
|
51
|
+
account,
|
|
52
|
+
brand,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* POST /auth/interaction/:uid/identifier
|
|
57
|
+
* Step 1: receive email, store in session, redirect to step 2.
|
|
58
|
+
* ENUMERATION-SAFE: always advances regardless of whether the email exists.
|
|
59
|
+
*/
|
|
60
|
+
async identifier(ctx) {
|
|
61
|
+
const { email } = ctx.request.only(['email']);
|
|
62
|
+
ctx.session.put(SESSION_KEY, email);
|
|
63
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* POST /auth/interaction/:uid/login
|
|
67
|
+
* Step 2: password submit. Reads email from session (never from form).
|
|
68
|
+
*/
|
|
69
|
+
async login(ctx) {
|
|
70
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
71
|
+
const cfg = service.config;
|
|
72
|
+
const render = cfg.render;
|
|
73
|
+
const details = await service.interactions.details(ctx);
|
|
74
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
75
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
76
|
+
if (!email) {
|
|
77
|
+
// Session expired or tampered — send back to step 1
|
|
78
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
79
|
+
}
|
|
80
|
+
const { password } = ctx.request.only(['password']);
|
|
81
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
82
|
+
const clientId = details.params.client_id ?? null;
|
|
83
|
+
const lockout = createAccountLockout(cfg.lockout);
|
|
84
|
+
// Bloqueio progressivo (keyed por email da sessão): se travada, não verifica a senha.
|
|
85
|
+
const lock = await lockout.isLocked(email);
|
|
86
|
+
if (lock.locked) {
|
|
87
|
+
const found = await cfg.accountStore.findByEmail(email);
|
|
88
|
+
const account = found
|
|
89
|
+
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
90
|
+
: null;
|
|
91
|
+
return render(ctx, 'login', {
|
|
92
|
+
uid: ctx.request.param('uid'),
|
|
93
|
+
csrfToken: ctx.request.csrfToken,
|
|
94
|
+
step: 'password',
|
|
95
|
+
email,
|
|
96
|
+
account,
|
|
97
|
+
error: translate(cfg.messages, 'errors.account_locked', {
|
|
98
|
+
seconds: lock.retryAfterSec ?? 0,
|
|
99
|
+
}),
|
|
100
|
+
brand,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// Verificamos as credenciais ANTES de finalizar a interaction, porque com MFA
|
|
104
|
+
// ligado precisamos exigir o 2º fator e NÃO podemos chamar interactionFinished
|
|
105
|
+
// ainda. (`service.interactions.login` verifica E finaliza num passo só — por
|
|
106
|
+
// isso fazemos a verificação aqui via accountStore e finalizamos depois.)
|
|
107
|
+
const acc = await cfg.accountStore.verifyCredentials(email, password);
|
|
108
|
+
if (!acc) {
|
|
109
|
+
await cfg.audit?.record({ type: 'login.failure', email, ip, clientId });
|
|
110
|
+
await lockout.recordFailure(email, { sink: cfg.audit, ip });
|
|
111
|
+
// Re-render password step with error (keep email in session)
|
|
112
|
+
const found = await cfg.accountStore.findByEmail(email);
|
|
113
|
+
const account = found
|
|
114
|
+
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
115
|
+
: null;
|
|
116
|
+
return render(ctx, 'login', {
|
|
117
|
+
uid: ctx.request.param('uid'),
|
|
118
|
+
csrfToken: ctx.request.csrfToken,
|
|
119
|
+
step: 'password',
|
|
120
|
+
email,
|
|
121
|
+
account,
|
|
122
|
+
error: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
123
|
+
brand,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// Senha correta: limpa o contador de falhas (o lockout protege a etapa de senha).
|
|
127
|
+
await lockout.clearFailures(email);
|
|
128
|
+
// MFA gate: se a conta tem TOTP ativo, NÃO finaliza a interaction agora —
|
|
129
|
+
// guarda o accountId pendente na sessão e renderiza o desafio do 2º fator.
|
|
130
|
+
const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
|
|
131
|
+
if (mfa.enabled) {
|
|
132
|
+
ctx.session.put(MFA_PENDING_KEY, acc.id);
|
|
133
|
+
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
134
|
+
// tem ao menos uma credencial registrada.
|
|
135
|
+
const passkeyAvailable = await this.hasPasskeys(cfg, acc.id);
|
|
136
|
+
return render(ctx, 'mfa-challenge', {
|
|
137
|
+
uid: ctx.request.param('uid'),
|
|
138
|
+
csrfToken: ctx.request.csrfToken,
|
|
139
|
+
brand,
|
|
140
|
+
passkeyAvailable,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
|
|
144
|
+
await service.interactions.completeLogin(ctx, acc.id);
|
|
145
|
+
await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip, clientId });
|
|
146
|
+
// Clean up the session key after a successful login.
|
|
147
|
+
ctx.session.forget(SESSION_KEY);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* POST /auth/interaction/:uid/mfa
|
|
151
|
+
* 2º fator: lê o accountId pendente da sessão e aceita um código TOTP (`code`)
|
|
152
|
+
* OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
|
|
153
|
+
*/
|
|
154
|
+
async mfaVerify(ctx) {
|
|
155
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
156
|
+
const cfg = service.config;
|
|
157
|
+
const render = cfg.render;
|
|
158
|
+
const details = await service.interactions.details(ctx);
|
|
159
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
160
|
+
const accountId = ctx.session.get(MFA_PENDING_KEY);
|
|
161
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
162
|
+
const clientId = details.params.client_id ?? null;
|
|
163
|
+
if (!accountId) {
|
|
164
|
+
// Sessão expirou/foi adulterada — volta ao início do login.
|
|
165
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
166
|
+
}
|
|
167
|
+
const { code, recoveryCode } = ctx.request.only(['code', 'recoveryCode']);
|
|
168
|
+
let ok = false;
|
|
169
|
+
let usedRecovery = false;
|
|
170
|
+
if (recoveryCode) {
|
|
171
|
+
ok = (await cfg.accountStore.consumeRecoveryCode?.(accountId, recoveryCode)) ?? false;
|
|
172
|
+
usedRecovery = ok;
|
|
173
|
+
}
|
|
174
|
+
else if (code) {
|
|
175
|
+
ok = (await cfg.accountStore.verifyTotp?.(accountId, code)) ?? false;
|
|
176
|
+
}
|
|
177
|
+
if (!ok) {
|
|
178
|
+
await cfg.audit?.record({
|
|
179
|
+
type: 'login.failure',
|
|
180
|
+
accountId,
|
|
181
|
+
ip,
|
|
182
|
+
clientId,
|
|
183
|
+
metadata: { stage: 'mfa' },
|
|
184
|
+
});
|
|
185
|
+
return render(ctx, 'mfa-challenge', {
|
|
186
|
+
uid: ctx.request.param('uid'),
|
|
187
|
+
csrfToken: ctx.request.csrfToken,
|
|
188
|
+
error: translate(cfg.messages, 'errors.invalid_code'),
|
|
189
|
+
brand,
|
|
190
|
+
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// Sucesso no 2º fator: finaliza a interaction para o accountId pendente.
|
|
194
|
+
ctx.session.forget(MFA_PENDING_KEY);
|
|
195
|
+
ctx.session.forget(SESSION_KEY);
|
|
196
|
+
await cfg.audit?.record({
|
|
197
|
+
type: 'login.success',
|
|
198
|
+
accountId,
|
|
199
|
+
ip,
|
|
200
|
+
clientId,
|
|
201
|
+
metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
|
|
202
|
+
});
|
|
203
|
+
await service.interactions.completeLogin(ctx, accountId);
|
|
204
|
+
}
|
|
205
|
+
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
206
|
+
async hasPasskeys(cfg, accountId) {
|
|
207
|
+
if (typeof cfg.accountStore.listPasskeys !== 'function')
|
|
208
|
+
return false;
|
|
209
|
+
const list = await cfg.accountStore.listPasskeys(accountId);
|
|
210
|
+
return Array.isArray(list) && list.length > 0;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* POST /auth/interaction/:uid/passkey/options
|
|
214
|
+
* Gera as opções de autenticação por passkey para o accountId pendente do MFA,
|
|
215
|
+
* guarda o challenge na sessão e devolve as opções JSON para o browser.
|
|
216
|
+
*/
|
|
217
|
+
async passkeyOptions(ctx) {
|
|
218
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
219
|
+
const cfg = service.config;
|
|
220
|
+
const accountId = ctx.session.get(MFA_PENDING_KEY);
|
|
221
|
+
if (!accountId) {
|
|
222
|
+
return ctx.response.badRequest({ message: 'Sessão expirada' });
|
|
223
|
+
}
|
|
224
|
+
const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(accountId);
|
|
225
|
+
if (!generated) {
|
|
226
|
+
return ctx.response.notFound({ message: 'Nenhuma passkey registrada' });
|
|
227
|
+
}
|
|
228
|
+
ctx.session.put(PASSKEY_AUTH_CHALLENGE_KEY, generated.challenge);
|
|
229
|
+
return generated.options;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* POST /auth/interaction/:uid/passkey/verify
|
|
233
|
+
* Verifica a resposta de autenticação por passkey contra o challenge guardado;
|
|
234
|
+
* em caso de sucesso FINALIZA a interaction (303 de volta ao client — alternativa
|
|
235
|
+
* ao código TOTP). É um POST de página inteira (form), não fetch: o browser
|
|
236
|
+
* submete o JSON da assertion no campo `response` e segue o redirect normalmente.
|
|
237
|
+
*/
|
|
238
|
+
async passkeyVerify(ctx) {
|
|
239
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
240
|
+
const cfg = service.config;
|
|
241
|
+
const render = cfg.render;
|
|
242
|
+
const details = await service.interactions.details(ctx);
|
|
243
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
244
|
+
const accountId = ctx.session.get(MFA_PENDING_KEY);
|
|
245
|
+
const challenge = ctx.session.get(PASSKEY_AUTH_CHALLENGE_KEY);
|
|
246
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
247
|
+
const clientId = details.params.client_id ?? null;
|
|
248
|
+
if (!accountId || !challenge) {
|
|
249
|
+
// Sessão expirou/foi adulterada — volta ao início do login.
|
|
250
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
251
|
+
}
|
|
252
|
+
// A assertion vem serializada como JSON no campo `response` do form.
|
|
253
|
+
const raw = ctx.request.input('response');
|
|
254
|
+
let parsed = null;
|
|
255
|
+
try {
|
|
256
|
+
parsed = raw ? JSON.parse(raw) : null;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
parsed = null;
|
|
260
|
+
}
|
|
261
|
+
const ok = parsed
|
|
262
|
+
? ((await cfg.accountStore.verifyPasskeyAuthentication?.(accountId, parsed, challenge)) ?? false)
|
|
263
|
+
: false;
|
|
264
|
+
ctx.session.forget(PASSKEY_AUTH_CHALLENGE_KEY);
|
|
265
|
+
if (!ok) {
|
|
266
|
+
await cfg.audit?.record({
|
|
267
|
+
type: 'login.failure',
|
|
268
|
+
accountId,
|
|
269
|
+
ip,
|
|
270
|
+
clientId,
|
|
271
|
+
metadata: { stage: 'mfa', method: 'webauthn' },
|
|
272
|
+
});
|
|
273
|
+
return render(ctx, 'mfa-challenge', {
|
|
274
|
+
uid: ctx.request.param('uid'),
|
|
275
|
+
csrfToken: ctx.request.csrfToken,
|
|
276
|
+
error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
|
|
277
|
+
brand,
|
|
278
|
+
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
ctx.session.forget(MFA_PENDING_KEY);
|
|
282
|
+
ctx.session.forget(SESSION_KEY);
|
|
283
|
+
await cfg.audit?.record({
|
|
284
|
+
type: 'login.success',
|
|
285
|
+
accountId,
|
|
286
|
+
ip,
|
|
287
|
+
clientId,
|
|
288
|
+
metadata: { mfa: 'webauthn' },
|
|
289
|
+
});
|
|
290
|
+
await service.interactions.completeLogin(ctx, accountId);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* GET /auth/interaction/:uid/switch
|
|
294
|
+
* Clears the stored email and redirects back to step 1.
|
|
295
|
+
*/
|
|
296
|
+
async switchIdentifier(ctx) {
|
|
297
|
+
ctx.session.forget(SESSION_KEY);
|
|
298
|
+
return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
|
|
299
|
+
}
|
|
300
|
+
async consent(ctx) {
|
|
301
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
302
|
+
await service.interactions.consent(ctx);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
export default class PatIntrospectionController {
|
|
3
|
+
handle(ctx: HttpContext): Promise<void | {
|
|
4
|
+
active: boolean;
|
|
5
|
+
sub?: undefined;
|
|
6
|
+
email?: undefined;
|
|
7
|
+
name?: undefined;
|
|
8
|
+
roles?: undefined;
|
|
9
|
+
scopes?: undefined;
|
|
10
|
+
audience?: undefined;
|
|
11
|
+
exp?: undefined;
|
|
12
|
+
} | {
|
|
13
|
+
active: boolean;
|
|
14
|
+
sub: any;
|
|
15
|
+
email: any;
|
|
16
|
+
name: any;
|
|
17
|
+
roles: any;
|
|
18
|
+
scopes: any;
|
|
19
|
+
audience: any;
|
|
20
|
+
exp: any;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
function bearerMatches(header, expected) {
|
|
3
|
+
if (!header || !header.startsWith('Bearer '))
|
|
4
|
+
return false;
|
|
5
|
+
const provided = header.slice(7);
|
|
6
|
+
const a = Buffer.from(provided);
|
|
7
|
+
const b = Buffer.from(expected);
|
|
8
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
9
|
+
}
|
|
10
|
+
export default class PatIntrospectionController {
|
|
11
|
+
async handle(ctx) {
|
|
12
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
13
|
+
const cfg = service.config;
|
|
14
|
+
const secret = cfg.patIntrospectionSecret;
|
|
15
|
+
if (!secret || !bearerMatches(ctx.request.header('authorization'), secret)) {
|
|
16
|
+
return ctx.response.unauthorized({ error: 'invalid_client' });
|
|
17
|
+
}
|
|
18
|
+
const token = ctx.request.input('token');
|
|
19
|
+
if (!token || typeof token !== 'string') {
|
|
20
|
+
return { active: false };
|
|
21
|
+
}
|
|
22
|
+
const meta = await cfg.patStore.findActiveByToken(token);
|
|
23
|
+
if (!meta)
|
|
24
|
+
return { active: false };
|
|
25
|
+
const account = await cfg.accountStore.findById(meta.accountId);
|
|
26
|
+
if (!account)
|
|
27
|
+
return { active: false };
|
|
28
|
+
await cfg.audit?.record({
|
|
29
|
+
type: 'pat.used',
|
|
30
|
+
accountId: account.id,
|
|
31
|
+
email: account.email,
|
|
32
|
+
ip: ctx.request.ip?.() ?? null,
|
|
33
|
+
metadata: { audience: meta.audience },
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
active: true,
|
|
37
|
+
sub: account.id,
|
|
38
|
+
email: account.email,
|
|
39
|
+
name: account.name ?? null,
|
|
40
|
+
roles: account.globalRoles ?? [],
|
|
41
|
+
scopes: meta.scopes,
|
|
42
|
+
audience: meta.audience,
|
|
43
|
+
exp: meta.exp,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
export default class AuthRegistrationController {
|
|
4
|
+
/** GET /auth/interaction/:uid/signup — tela de cadastro (dentro do fluxo OIDC). */
|
|
5
|
+
showSignup(ctx: HttpContext): Promise<any>;
|
|
6
|
+
/** POST /auth/interaction/:uid/signup — cria o usuário e finaliza o login. */
|
|
7
|
+
signup(ctx: HttpContext): Promise<any>;
|
|
8
|
+
/** GET /auth/forgot-password — tela standalone. */
|
|
9
|
+
showForgot(ctx: HttpContext): Promise<any>;
|
|
10
|
+
/** POST /auth/forgot-password — gera token e (dev) loga o link. Sempre responde sucesso (não vaza emails). */
|
|
11
|
+
forgot(ctx: HttpContext): Promise<any>;
|
|
12
|
+
/** GET /auth/reset-password?token=... — tela standalone. */
|
|
13
|
+
showReset(ctx: HttpContext): Promise<any>;
|
|
14
|
+
/** POST /auth/reset-password — redefine a senha. */
|
|
15
|
+
reset(ctx: HttpContext): Promise<any>;
|
|
16
|
+
/** GET /auth/verify-email?token=... — consome o token e mostra sucesso/falha. */
|
|
17
|
+
verifyEmail(ctx: HttpContext): Promise<any>;
|
|
18
|
+
}
|