@dudousxd/adonis-authkit-server 0.1.1 → 0.2.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/index.d.ts +3 -2
- package/build/index.js +2 -1
- package/build/src/accounts/account_store.d.ts +74 -17
- package/build/src/accounts/account_store.js +12 -1
- package/build/src/accounts/lucid_account_store.d.ts +12 -27
- package/build/src/accounts/lucid_account_store.js +38 -365
- package/build/src/accounts/lucid_store/core.d.ts +8 -0
- package/build/src/accounts/lucid_store/core.js +108 -0
- package/build/src/accounts/lucid_store/mfa.d.ts +8 -0
- package/build/src/accounts/lucid_store/mfa.js +77 -0
- package/build/src/accounts/lucid_store/provider_identity.d.ts +8 -0
- package/build/src/accounts/lucid_store/provider_identity.js +41 -0
- package/build/src/accounts/lucid_store/shared.d.ts +48 -0
- package/build/src/accounts/lucid_store/shared.js +15 -0
- package/build/src/accounts/lucid_store/webauthn.d.ts +8 -0
- package/build/src/accounts/lucid_store/webauthn.js +135 -0
- package/build/src/define_config.d.ts +6 -0
- package/build/src/define_config.js +20 -5
- package/build/src/host/controllers/account_mfa_controller.js +2 -1
- package/build/src/host/controllers/account_session_controller.js +10 -18
- package/build/src/host/controllers/interaction_controller.js +13 -32
- package/build/src/host/controllers/social_controller.js +7 -0
- package/build/src/host/login_attempt.d.ts +39 -0
- package/build/src/host/login_attempt.js +37 -0
- package/build/src/host/register_auth_host.d.ts +13 -0
- package/build/src/host/register_auth_host.js +9 -2
- package/build/src/mixins/json_column.d.ts +38 -0
- package/build/src/mixins/json_column.js +31 -0
- package/build/src/mixins/with_audit_log.js +2 -4
- package/build/src/mixins/with_auth_user.js +2 -4
- package/build/src/mixins/with_mfa.js +2 -6
- package/build/src/mixins/with_personal_access_token.js +2 -4
- package/build/src/mixins/with_webauthn_credential.js +6 -8
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
|
|
2
|
+
import type { AuthAccount } from '../account_store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Encripta/decripta um valor (ex.: o segredo TOTP) em repouso. Mantém a lib
|
|
5
|
+
* desacoplada do serviço de encryption do app — qualquer implementação que
|
|
6
|
+
* faça round-trip serve (em prod, normalmente o `@adonisjs/core/services/encryption`).
|
|
7
|
+
* `decrypt` retorna `null` se o valor foi adulterado/é inválido.
|
|
8
|
+
*/
|
|
9
|
+
export interface AccountSecretEncrypter {
|
|
10
|
+
encrypt(value: string): string;
|
|
11
|
+
decrypt(value: string): string | null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Funções das cerimônias WebAuthn. Espelham a assinatura do `@simplewebauthn/server`
|
|
15
|
+
* (subconjunto usado). Injetáveis via {@link LucidAccountStoreOptions.webauthnCeremonies}
|
|
16
|
+
* para testes.
|
|
17
|
+
*/
|
|
18
|
+
export interface WebauthnCeremonies {
|
|
19
|
+
generateRegistrationOptions: typeof generateRegistrationOptions;
|
|
20
|
+
verifyRegistrationResponse: typeof verifyRegistrationResponse;
|
|
21
|
+
generateAuthenticationOptions: typeof generateAuthenticationOptions;
|
|
22
|
+
verifyAuthenticationResponse: typeof verifyAuthenticationResponse;
|
|
23
|
+
}
|
|
24
|
+
/** RP (Relying Party) do WebAuthn usado nas cerimônias. */
|
|
25
|
+
export interface ResolvedRp {
|
|
26
|
+
rpName: string;
|
|
27
|
+
rpId: string;
|
|
28
|
+
origin: string | string[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Contexto compartilhado pelos builders de capacidade. Carrega o model principal,
|
|
32
|
+
* os helpers de segredo e (quando configurados) os models/parametros das capacidades.
|
|
33
|
+
*/
|
|
34
|
+
export interface LucidStoreContext {
|
|
35
|
+
Model: any;
|
|
36
|
+
mfaIssuer: string;
|
|
37
|
+
recoveryCodeCount: number;
|
|
38
|
+
/** Encripta o segredo antes de persistir (no-op sem encrypter). */
|
|
39
|
+
sealSecret(secret: string): string;
|
|
40
|
+
/** Decripta o segredo armazenado; null em falha/adulteração (no-op sem encrypter). */
|
|
41
|
+
openSecret(stored: string | null | undefined): string | null;
|
|
42
|
+
toAccount(row: any): AuthAccount;
|
|
43
|
+
}
|
|
44
|
+
export declare const sha256: (value: string) => string;
|
|
45
|
+
/** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
|
|
46
|
+
export declare function generateRecoveryCode(): string;
|
|
47
|
+
/** Comparação de hashes hex resistente a timing. */
|
|
48
|
+
export declare function hashesEqual(a: string, b: string): boolean;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
3
|
+
/** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
|
|
4
|
+
export function generateRecoveryCode() {
|
|
5
|
+
const raw = randomBytes(5).toString('hex');
|
|
6
|
+
return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
|
|
7
|
+
}
|
|
8
|
+
/** Comparação de hashes hex resistente a timing. */
|
|
9
|
+
export function hashesEqual(a, b) {
|
|
10
|
+
const ba = Buffer.from(a);
|
|
11
|
+
const bb = Buffer.from(b);
|
|
12
|
+
if (ba.length !== bb.length)
|
|
13
|
+
return false;
|
|
14
|
+
return timingSafeEqual(ba, bb);
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WebauthnCapability } from '../account_store.js';
|
|
2
|
+
import type { LucidStoreContext, ResolvedRp, WebauthnCeremonies } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Capacidade de passkeys / WebAuthn (2º fator alternativo ao TOTP). Só é montada
|
|
5
|
+
* quando o `webauthnCredentialModel` é fornecido — ausente, a capacidade inteira
|
|
6
|
+
* fica ABSENTE do store (a UI esconde a seção de passkeys).
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildWebauthn(ctx: LucidStoreContext, Credential: any, webauthn: ResolvedRp, ceremonies: WebauthnCeremonies): WebauthnCapability;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
/**
|
|
3
|
+
* Capacidade de passkeys / WebAuthn (2º fator alternativo ao TOTP). Só é montada
|
|
4
|
+
* quando o `webauthnCredentialModel` é fornecido — ausente, a capacidade inteira
|
|
5
|
+
* fica ABSENTE do store (a UI esconde a seção de passkeys).
|
|
6
|
+
*/
|
|
7
|
+
export function buildWebauthn(ctx, Credential, webauthn, ceremonies) {
|
|
8
|
+
const { Model } = ctx;
|
|
9
|
+
return {
|
|
10
|
+
async generatePasskeyRegistrationOptions(accountId) {
|
|
11
|
+
const row = await Model.find(accountId);
|
|
12
|
+
if (!row)
|
|
13
|
+
return null;
|
|
14
|
+
const existing = await Credential.query().where('accountId', accountId);
|
|
15
|
+
const options = await ceremonies.generateRegistrationOptions({
|
|
16
|
+
rpName: webauthn.rpName,
|
|
17
|
+
rpID: webauthn.rpId,
|
|
18
|
+
userName: row.email,
|
|
19
|
+
userDisplayName: row.fullName ?? row.email,
|
|
20
|
+
// Não pede attestation (privacidade); confia na verificação local.
|
|
21
|
+
attestationType: 'none',
|
|
22
|
+
// Evita registrar a mesma credencial duas vezes.
|
|
23
|
+
excludeCredentials: existing.map((c) => ({
|
|
24
|
+
id: c.id,
|
|
25
|
+
transports: (c.transports ?? undefined),
|
|
26
|
+
})),
|
|
27
|
+
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
options: options,
|
|
31
|
+
challenge: options.challenge,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
async verifyPasskeyRegistration(accountId, response, expectedChallenge) {
|
|
35
|
+
const row = await Model.find(accountId);
|
|
36
|
+
if (!row)
|
|
37
|
+
return false;
|
|
38
|
+
let verification;
|
|
39
|
+
try {
|
|
40
|
+
verification = await ceremonies.verifyRegistrationResponse({
|
|
41
|
+
response: response,
|
|
42
|
+
expectedChallenge,
|
|
43
|
+
expectedOrigin: webauthn.origin,
|
|
44
|
+
expectedRPID: webauthn.rpId,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (!verification.verified || !verification.registrationInfo)
|
|
51
|
+
return false;
|
|
52
|
+
const { credential } = verification.registrationInfo;
|
|
53
|
+
// publicKey vem como Uint8Array → armazenamos como base64url (texto).
|
|
54
|
+
const publicKey = Buffer.from(credential.publicKey).toString('base64url');
|
|
55
|
+
await Credential.create({
|
|
56
|
+
id: credential.id,
|
|
57
|
+
accountId,
|
|
58
|
+
publicKey,
|
|
59
|
+
counter: credential.counter,
|
|
60
|
+
transports: credential.transports ?? null,
|
|
61
|
+
label: null,
|
|
62
|
+
});
|
|
63
|
+
// Registrar uma passkey também habilita o MFA (2º fator presente).
|
|
64
|
+
if (!row.mfaEnabledAt) {
|
|
65
|
+
row.mfaEnabledAt = DateTime.now();
|
|
66
|
+
await row.save();
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
},
|
|
70
|
+
async generatePasskeyAuthenticationOptions(accountId) {
|
|
71
|
+
const creds = await Credential.query().where('accountId', accountId);
|
|
72
|
+
if (creds.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
const options = await ceremonies.generateAuthenticationOptions({
|
|
75
|
+
rpID: webauthn.rpId,
|
|
76
|
+
allowCredentials: creds.map((c) => ({
|
|
77
|
+
id: c.id,
|
|
78
|
+
transports: (c.transports ?? undefined),
|
|
79
|
+
})),
|
|
80
|
+
userVerification: 'preferred',
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
options: options,
|
|
84
|
+
challenge: options.challenge,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
async verifyPasskeyAuthentication(accountId, response, expectedChallenge) {
|
|
88
|
+
const resp = response;
|
|
89
|
+
// O credential id vem na resposta (base64url) → acha a credencial da conta.
|
|
90
|
+
const cred = await Credential.query()
|
|
91
|
+
.where('accountId', accountId)
|
|
92
|
+
.where('id', resp?.id ?? '')
|
|
93
|
+
.first();
|
|
94
|
+
if (!cred)
|
|
95
|
+
return false;
|
|
96
|
+
let verification;
|
|
97
|
+
try {
|
|
98
|
+
verification = await ceremonies.verifyAuthenticationResponse({
|
|
99
|
+
response: resp,
|
|
100
|
+
expectedChallenge,
|
|
101
|
+
expectedOrigin: webauthn.origin,
|
|
102
|
+
expectedRPID: webauthn.rpId,
|
|
103
|
+
credential: {
|
|
104
|
+
id: cred.id,
|
|
105
|
+
publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')),
|
|
106
|
+
counter: cred.counter,
|
|
107
|
+
transports: (cred.transports ?? undefined),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
if (!verification.verified)
|
|
115
|
+
return false;
|
|
116
|
+
// Atualiza o signature counter (anti-replay).
|
|
117
|
+
cred.counter = verification.authenticationInfo.newCounter;
|
|
118
|
+
await cred.save();
|
|
119
|
+
return true;
|
|
120
|
+
},
|
|
121
|
+
async listPasskeys(accountId) {
|
|
122
|
+
const creds = await Credential.query()
|
|
123
|
+
.where('accountId', accountId)
|
|
124
|
+
.orderBy('createdAt', 'asc');
|
|
125
|
+
return creds.map((c) => ({
|
|
126
|
+
id: c.id,
|
|
127
|
+
label: c.label ?? undefined,
|
|
128
|
+
createdAt: c.createdAt?.toISO?.() ?? String(c.createdAt ?? ''),
|
|
129
|
+
}));
|
|
130
|
+
},
|
|
131
|
+
async removePasskey(accountId, credentialId) {
|
|
132
|
+
await Credential.query().where('accountId', accountId).where('id', credentialId).delete();
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -117,6 +117,12 @@ export interface ResolvedDynamicRegistrationConfig {
|
|
|
117
117
|
initialAccessToken?: string;
|
|
118
118
|
management: boolean;
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a config de registro dinâmico e VALIDA invariantes em tempo de resolução.
|
|
122
|
+
* O Registration Management (RFC 7592) só faz sentido com o registro habilitado
|
|
123
|
+
* (RFC 7591) — `management: true` com `enabled: false` é um erro de configuração.
|
|
124
|
+
*/
|
|
125
|
+
export declare function resolveDynamicRegistration(input?: DynamicRegistrationConfigInput): ResolvedDynamicRegistrationConfig;
|
|
120
126
|
/**
|
|
121
127
|
* Console admin opt-in do IdP (B6). Quando habilitado, monta o grupo `/admin/*`
|
|
122
128
|
* (dashboard, usuários/papéis, clients, audit) atrás de um guard que exige sessão
|
|
@@ -26,6 +26,25 @@ export function resolveLockout(input) {
|
|
|
26
26
|
store: input?.store,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a config de registro dinâmico e VALIDA invariantes em tempo de resolução.
|
|
31
|
+
* O Registration Management (RFC 7592) só faz sentido com o registro habilitado
|
|
32
|
+
* (RFC 7591) — `management: true` com `enabled: false` é um erro de configuração.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveDynamicRegistration(input) {
|
|
35
|
+
const enabled = input?.enabled ?? false;
|
|
36
|
+
const management = input?.management ?? false;
|
|
37
|
+
if (management && !enabled) {
|
|
38
|
+
throw new Error('authkit: dynamicRegistration.management (RFC 7592) requer ' +
|
|
39
|
+
'dynamicRegistration.enabled: true (RFC 7591). Habilite o registro dinâmico ' +
|
|
40
|
+
'ou desligue o management.');
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
enabled,
|
|
44
|
+
initialAccessToken: input?.initialAccessToken,
|
|
45
|
+
management,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
29
48
|
export function resolveAdmin(input) {
|
|
30
49
|
return {
|
|
31
50
|
enabled: input?.enabled ?? false,
|
|
@@ -102,11 +121,7 @@ export function defineConfig(config) {
|
|
|
102
121
|
audit: config.audit,
|
|
103
122
|
mfaIssuer: config.mfaIssuer ?? 'AuthKit',
|
|
104
123
|
webauthn: resolveWebauthn(config.issuer, config.mfaIssuer ?? 'AuthKit', config.webauthn),
|
|
105
|
-
dynamicRegistration:
|
|
106
|
-
enabled: config.dynamicRegistration?.enabled ?? false,
|
|
107
|
-
initialAccessToken: config.dynamicRegistration?.initialAccessToken,
|
|
108
|
-
management: config.dynamicRegistration?.management ?? false,
|
|
109
|
-
},
|
|
124
|
+
dynamicRegistration: resolveDynamicRegistration(config.dynamicRegistration),
|
|
110
125
|
admin: resolveAdmin(config.admin),
|
|
111
126
|
messages: resolveMessages(config.i18n),
|
|
112
127
|
locale: config.i18n?.locale ?? 'pt-BR',
|
|
@@ -2,6 +2,7 @@ import '../augmentations.js';
|
|
|
2
2
|
import QRCode from 'qrcode';
|
|
3
3
|
import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
|
|
4
4
|
import { translate } from '../i18n.js';
|
|
5
|
+
import { supportsPasskeys } from '../../accounts/account_store.js';
|
|
5
6
|
/** Desafio WebAuthn pendente (registro) guardado na sessão entre begin/finish. */
|
|
6
7
|
const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';
|
|
7
8
|
/**
|
|
@@ -18,7 +19,7 @@ export default class AccountMfaController {
|
|
|
18
19
|
const state = (await cfg.accountStore.getMfaState?.(userId)) ?? { enabled: false };
|
|
19
20
|
const recoveryCodes = ctx.session.flashMessages.get('recoveryCodes');
|
|
20
21
|
// Passkeys disponíveis quando o store as suporta (model de credenciais wired).
|
|
21
|
-
const passkeysSupported =
|
|
22
|
+
const passkeysSupported = supportsPasskeys(cfg.accountStore);
|
|
22
23
|
const passkeys = passkeysSupported ? await cfg.accountStore.listPasskeys(userId) : [];
|
|
23
24
|
return render(ctx, 'account/mfa', {
|
|
24
25
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
|
|
3
3
|
import { translate } from '../i18n.js';
|
|
4
|
-
import {
|
|
4
|
+
import { attemptPasswordLogin } from '../login_attempt.js';
|
|
5
5
|
export default class AccountSessionController {
|
|
6
6
|
async show(ctx) {
|
|
7
7
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
@@ -18,27 +18,19 @@ export default class AccountSessionController {
|
|
|
18
18
|
const render = cfg.render;
|
|
19
19
|
const { email, password } = ctx.request.only(['email', 'password']);
|
|
20
20
|
const ip = ctx.request.ip?.() ?? null;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (lock.locked) {
|
|
21
|
+
// Verificação + lockout + auditoria de falha centralizados (sem clientId no console).
|
|
22
|
+
const result = await attemptPasswordLogin(cfg, { email, password, ip });
|
|
23
|
+
if (!result.ok) {
|
|
25
24
|
return render(ctx, 'account/login', {
|
|
26
25
|
csrfToken: ctx.request.csrfToken,
|
|
27
|
-
error:
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
error: result.locked
|
|
27
|
+
? translate(cfg.messages, 'errors.account_locked', {
|
|
28
|
+
seconds: result.retryAfterSec ?? 0,
|
|
29
|
+
})
|
|
30
|
+
: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
30
31
|
});
|
|
31
32
|
}
|
|
32
|
-
const acc =
|
|
33
|
-
if (!acc) {
|
|
34
|
-
await cfg.audit?.record({ type: 'login.failure', email, ip });
|
|
35
|
-
await lockout.recordFailure(email, { sink: cfg.audit, ip });
|
|
36
|
-
return render(ctx, 'account/login', {
|
|
37
|
-
csrfToken: ctx.request.csrfToken,
|
|
38
|
-
error: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
await lockout.clearFailures(email);
|
|
33
|
+
const acc = result.account;
|
|
42
34
|
ctx.session.put(ACCOUNT_SESSION_KEY, acc.id);
|
|
43
35
|
await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip });
|
|
44
36
|
return ctx.response.redirect('/account/tokens');
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { brandFor, isFirstParty } from '../branding.js';
|
|
3
3
|
import { translate } from '../i18n.js';
|
|
4
|
-
import {
|
|
4
|
+
import { attemptPasswordLogin } from '../login_attempt.js';
|
|
5
|
+
import { supportsPasskeys } from '../../accounts/account_store.js';
|
|
5
6
|
const SESSION_KEY = 'authkit_login_email';
|
|
6
7
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
7
8
|
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
@@ -80,35 +81,12 @@ export default class AuthInteractionController {
|
|
|
80
81
|
const { password } = ctx.request.only(['password']);
|
|
81
82
|
const ip = ctx.request.ip?.() ?? null;
|
|
82
83
|
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
84
|
// Verificamos as credenciais ANTES de finalizar a interaction, porque com MFA
|
|
104
85
|
// ligado precisamos exigir o 2º fator e NÃO podemos chamar interactionFinished
|
|
105
|
-
// ainda.
|
|
106
|
-
//
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
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)
|
|
86
|
+
// ainda. A sequência verificação + lockout + auditoria de falha é centralizada
|
|
87
|
+
// em attemptPasswordLogin; a renderização (lookup p/ personalização) fica aqui.
|
|
88
|
+
const result = await attemptPasswordLogin(cfg, { email, password, ip, clientId });
|
|
89
|
+
if (!result.ok) {
|
|
112
90
|
const found = await cfg.accountStore.findByEmail(email);
|
|
113
91
|
const account = found
|
|
114
92
|
? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
|
|
@@ -119,12 +97,15 @@ export default class AuthInteractionController {
|
|
|
119
97
|
step: 'password',
|
|
120
98
|
email,
|
|
121
99
|
account,
|
|
122
|
-
error:
|
|
100
|
+
error: result.locked
|
|
101
|
+
? translate(cfg.messages, 'errors.account_locked', {
|
|
102
|
+
seconds: result.retryAfterSec ?? 0,
|
|
103
|
+
})
|
|
104
|
+
: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
123
105
|
brand,
|
|
124
106
|
});
|
|
125
107
|
}
|
|
126
|
-
|
|
127
|
-
await lockout.clearFailures(email);
|
|
108
|
+
const acc = result.account;
|
|
128
109
|
// MFA gate: se a conta tem TOTP ativo, NÃO finaliza a interaction agora —
|
|
129
110
|
// guarda o accountId pendente na sessão e renderiza o desafio do 2º fator.
|
|
130
111
|
const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
|
|
@@ -204,7 +185,7 @@ export default class AuthInteractionController {
|
|
|
204
185
|
}
|
|
205
186
|
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
206
187
|
async hasPasskeys(cfg, accountId) {
|
|
207
|
-
if (
|
|
188
|
+
if (!supportsPasskeys(cfg.accountStore))
|
|
208
189
|
return false;
|
|
209
190
|
const list = await cfg.accountStore.listPasskeys(accountId);
|
|
210
191
|
return Array.isArray(list) && list.length > 0;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { supportsProviderIdentity } from '../../accounts/account_store.js';
|
|
3
4
|
const UID_SESSION_KEY = 'authkit_social_uid';
|
|
4
5
|
/**
|
|
5
6
|
* `AllyService.use()` é tipado contra a interface `SocialProviders`, que só é
|
|
@@ -37,6 +38,12 @@ export default class AuthSocialController {
|
|
|
37
38
|
const cfg = service.config;
|
|
38
39
|
const store = cfg.accountStore;
|
|
39
40
|
const email = profile.email ?? undefined;
|
|
41
|
+
// Account linking exige a capacidade de provider-identity (model wired no store).
|
|
42
|
+
// Ausente → não há como ligar a identidade; volta ao login em vez de quebrar.
|
|
43
|
+
if (!supportsProviderIdentity(store)) {
|
|
44
|
+
ctx.session.forget(UID_SESSION_KEY);
|
|
45
|
+
return ctx.response.redirect(backToLogin);
|
|
46
|
+
}
|
|
40
47
|
// Precedência do account linking:
|
|
41
48
|
// 1. Identidade de provider já ligada → loga essa conta.
|
|
42
49
|
// 2. Senão, e-mail conhecido → acha por e-mail e LIGA a identidade (linking).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AuthAccount } from '../accounts/account_store.js';
|
|
2
|
+
import type { ResolvedServerConfig } from '../define_config.js';
|
|
3
|
+
/** Entrada de uma tentativa de login por senha (keyed por email). */
|
|
4
|
+
export interface PasswordLoginInput {
|
|
5
|
+
email: string;
|
|
6
|
+
password: string;
|
|
7
|
+
/** IP da request (para auditoria + lockout). null quando indisponível. */
|
|
8
|
+
ip: string | null;
|
|
9
|
+
/**
|
|
10
|
+
* client_id da interaction OIDC, quando existir. Só é incluído no evento de
|
|
11
|
+
* auditoria nos fluxos que o têm (interaction); ausente no console de conta.
|
|
12
|
+
*/
|
|
13
|
+
clientId?: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** Resultado de {@link attemptPasswordLogin}. */
|
|
16
|
+
export type PasswordLoginResult = {
|
|
17
|
+
ok: true;
|
|
18
|
+
account: AuthAccount;
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
locked: boolean;
|
|
22
|
+
retryAfterSec?: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Sequência canônica de login por senha + bloqueio progressivo, compartilhada
|
|
26
|
+
* pelos dois fluxos que pedem senha (interaction OIDC e console de conta):
|
|
27
|
+
*
|
|
28
|
+
* 1. `lockout.isLocked(email)` — se travada, NÃO verifica a senha; devolve
|
|
29
|
+
* `{ ok: false, locked: true, retryAfterSec }` (o controller renderiza o erro).
|
|
30
|
+
* 2. `verifyCredentials(email, password)` — em falha: emite `login.failure`
|
|
31
|
+
* (com `clientId` apenas quando fornecido), registra a falha no lockout
|
|
32
|
+
* (`{ sink: cfg.audit, ip }`) e devolve `{ ok: false, locked: false }`.
|
|
33
|
+
* 3. Em sucesso: limpa o contador de falhas e devolve `{ ok: true, account }`.
|
|
34
|
+
*
|
|
35
|
+
* O evento `login.success` e a finalização da sessão/interaction ficam a cargo de
|
|
36
|
+
* cada controller (os fluxos diferem: MFA gate na interaction, sessão no console),
|
|
37
|
+
* assim como toda a renderização.
|
|
38
|
+
*/
|
|
39
|
+
export declare function attemptPasswordLogin(cfg: ResolvedServerConfig, input: PasswordLoginInput): Promise<PasswordLoginResult>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createAccountLockout } from './account_lockout.js';
|
|
2
|
+
/**
|
|
3
|
+
* Sequência canônica de login por senha + bloqueio progressivo, compartilhada
|
|
4
|
+
* pelos dois fluxos que pedem senha (interaction OIDC e console de conta):
|
|
5
|
+
*
|
|
6
|
+
* 1. `lockout.isLocked(email)` — se travada, NÃO verifica a senha; devolve
|
|
7
|
+
* `{ ok: false, locked: true, retryAfterSec }` (o controller renderiza o erro).
|
|
8
|
+
* 2. `verifyCredentials(email, password)` — em falha: emite `login.failure`
|
|
9
|
+
* (com `clientId` apenas quando fornecido), registra a falha no lockout
|
|
10
|
+
* (`{ sink: cfg.audit, ip }`) e devolve `{ ok: false, locked: false }`.
|
|
11
|
+
* 3. Em sucesso: limpa o contador de falhas e devolve `{ ok: true, account }`.
|
|
12
|
+
*
|
|
13
|
+
* O evento `login.success` e a finalização da sessão/interaction ficam a cargo de
|
|
14
|
+
* cada controller (os fluxos diferem: MFA gate na interaction, sessão no console),
|
|
15
|
+
* assim como toda a renderização.
|
|
16
|
+
*/
|
|
17
|
+
export async function attemptPasswordLogin(cfg, input) {
|
|
18
|
+
const { email, password, ip } = input;
|
|
19
|
+
const lockout = createAccountLockout(cfg.lockout);
|
|
20
|
+
// Bloqueio progressivo (keyed por email): se travada, não verifica a senha.
|
|
21
|
+
const lock = await lockout.isLocked(email);
|
|
22
|
+
if (lock.locked) {
|
|
23
|
+
return { ok: false, locked: true, retryAfterSec: lock.retryAfterSec };
|
|
24
|
+
}
|
|
25
|
+
const account = await cfg.accountStore.verifyCredentials(email, password);
|
|
26
|
+
if (!account) {
|
|
27
|
+
// `clientId` só entra no evento quando o fluxo o fornece (interaction).
|
|
28
|
+
await cfg.audit?.record(input.clientId !== undefined
|
|
29
|
+
? { type: 'login.failure', email, ip, clientId: input.clientId }
|
|
30
|
+
: { type: 'login.failure', email, ip });
|
|
31
|
+
await lockout.recordFailure(email, { sink: cfg.audit, ip });
|
|
32
|
+
return { ok: false, locked: false };
|
|
33
|
+
}
|
|
34
|
+
// Senha correta: limpa o contador de falhas (o lockout protege a etapa de senha).
|
|
35
|
+
await lockout.clearFailures(email);
|
|
36
|
+
return { ok: true, account };
|
|
37
|
+
}
|
|
@@ -3,12 +3,25 @@ import type { AuthSocialConfig, RateLimitConfigInput } from '../define_config.js
|
|
|
3
3
|
/**
|
|
4
4
|
* Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
|
|
5
5
|
* confiável do `.use()` num grupo). Exige:
|
|
6
|
+
* 0. `config.admin.enabled` ligado (senão → 404; ver nota de flag-drift abaixo);
|
|
6
7
|
* 1. sessão de conta ativa (senão → /account/login);
|
|
7
8
|
* 2. a conta logada com pelo menos UMA das `config.admin.roles` nas roles globais
|
|
8
9
|
* (senão → /account/tokens, evitando vazar a existência do /admin).
|
|
9
10
|
* As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
|
|
10
11
|
*/
|
|
11
12
|
export declare const adminGuard: (ctx: any, next: () => Promise<void>) => Promise<any>;
|
|
13
|
+
/**
|
|
14
|
+
* Opções de montagem das rotas do host-kit.
|
|
15
|
+
*
|
|
16
|
+
* NOTA (flag-drift): vários campos aqui (`social`, `rateLimit`, `admin`) ESPELHAM
|
|
17
|
+
* o config (`config/authkit.ts`) porque a decisão de MONTAR as rotas acontece em
|
|
18
|
+
* tempo de registro, antes de o config (lazy) resolver. Eles controlam apenas se
|
|
19
|
+
* as rotas existem; a fonte de verdade do COMPORTAMENTO continua sendo o config
|
|
20
|
+
* resolvido. Se um flag aqui divergir do config (ex.: `admin: true` aqui com
|
|
21
|
+
* `admin.enabled: false` no config), os guards das rotas são a rede de segurança
|
|
22
|
+
* (o `adminGuard` 404a quando `config.admin.enabled` é false). Mantenha-os em
|
|
23
|
+
* sincronia; os guards garantem que a divergência não vire um bypass.
|
|
24
|
+
*/
|
|
12
25
|
export interface AuthHostOptions {
|
|
13
26
|
/** Onde o provider OIDC é montado (default '/oidc'). Deve casar com o final do issuer. */
|
|
14
27
|
mountPath: string;
|
|
@@ -16,18 +16,25 @@ const accountGuard = async (ctx, next) => {
|
|
|
16
16
|
/**
|
|
17
17
|
* Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
|
|
18
18
|
* confiável do `.use()` num grupo). Exige:
|
|
19
|
+
* 0. `config.admin.enabled` ligado (senão → 404; ver nota de flag-drift abaixo);
|
|
19
20
|
* 1. sessão de conta ativa (senão → /account/login);
|
|
20
21
|
* 2. a conta logada com pelo menos UMA das `config.admin.roles` nas roles globais
|
|
21
22
|
* (senão → /account/tokens, evitando vazar a existência do /admin).
|
|
22
23
|
* As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
|
|
23
24
|
*/
|
|
24
25
|
export const adminGuard = async (ctx, next) => {
|
|
26
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
27
|
+
const cfg = service.config;
|
|
28
|
+
// Defesa contra flag-drift: as rotas são montadas com `admin: true` em tempo de
|
|
29
|
+
// registro, ANTES de o config resolver. Se o config tiver `admin.enabled: false`,
|
|
30
|
+
// as rotas existem mas o console deve estar desligado — 404 (não vaza a existência).
|
|
31
|
+
if (!cfg.admin.enabled) {
|
|
32
|
+
return ctx.response.notFound();
|
|
33
|
+
}
|
|
25
34
|
const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
|
|
26
35
|
if (!accountId) {
|
|
27
36
|
return ctx.response.redirect('/account/login');
|
|
28
37
|
}
|
|
29
|
-
const service = await ctx.containerResolver.make('authkit.server');
|
|
30
|
-
const cfg = service.config;
|
|
31
38
|
const allowed = cfg.admin.roles;
|
|
32
39
|
const account = await cfg.accountStore.findById(accountId);
|
|
33
40
|
const roles = account?.globalRoles ?? [];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializer canônico de coluna JSON para os mixins. Centraliza o par
|
|
3
|
+
* `prepare`/`consume` (`JSON.stringify`/`JSON.parse`) que cada mixin escrevia à
|
|
4
|
+
* mão com tratamentos de null/default ligeiramente diferentes.
|
|
5
|
+
*
|
|
6
|
+
* As opções parametrizam exatamente as variações observadas entre os mixins, de
|
|
7
|
+
* modo que o comportamento por coluna fica IDÊNTICO ao código original:
|
|
8
|
+
*
|
|
9
|
+
* - `fallback`: valor devolvido por `consume` quando a coluna é null/undefined
|
|
10
|
+
* (ex.: `[]` para `globalRoles`/`scopes`; `null` para `recoveryCodes`/`metadata`).
|
|
11
|
+
* - `emptyOnWrite`: o que `prepare` grava quando o valor é "vazio" (null/undefined,
|
|
12
|
+
* ou — quando `treatEmptyArrayAsEmpty` — um array de length 0). `'null'` grava
|
|
13
|
+
* `null` na coluna; `'serialize'` ainda serializa (ex.: `globalRoles` grava
|
|
14
|
+
* `"[]"`). Default: `'null'`.
|
|
15
|
+
* - `treatEmptyArrayAsEmpty`: quando true, um array vazio também conta como "vazio"
|
|
16
|
+
* no write (caso do `transports`, que grava null em `[]`). Default: false.
|
|
17
|
+
* - `passthroughParsed`: quando true, `consume` aceita um valor JÁ desserializado
|
|
18
|
+
* (array/objeto) e o devolve sem reparsear — alguns dialetos/drivers entregam o
|
|
19
|
+
* JSON já decodificado. Default: false.
|
|
20
|
+
*/
|
|
21
|
+
export interface JsonColumnOptions<T> {
|
|
22
|
+
/** Valor devolvido por `consume` quando a coluna está null/undefined. */
|
|
23
|
+
fallback: T;
|
|
24
|
+
/** O que `prepare` grava para valores vazios. Default: 'null'. */
|
|
25
|
+
emptyOnWrite?: 'null' | 'serialize';
|
|
26
|
+
/** Trata array vazio como "vazio" no write (grava null). Default: false. */
|
|
27
|
+
treatEmptyArrayAsEmpty?: boolean;
|
|
28
|
+
/** Aceita valor já desserializado em `consume` (passthrough). Default: false. */
|
|
29
|
+
passthroughParsed?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Devolve o par `{ prepare, consume }` para usar num `@column({...})`.
|
|
33
|
+
* `T` é o tipo lógico da coluna (ex.: `string[]` ou `Record<string, unknown>`).
|
|
34
|
+
*/
|
|
35
|
+
export declare function jsonColumn<T>(opts: JsonColumnOptions<T>): {
|
|
36
|
+
prepare: (value: T | null | undefined) => string | null;
|
|
37
|
+
consume: (value: unknown) => T;
|
|
38
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devolve o par `{ prepare, consume }` para usar num `@column({...})`.
|
|
3
|
+
* `T` é o tipo lógico da coluna (ex.: `string[]` ou `Record<string, unknown>`).
|
|
4
|
+
*/
|
|
5
|
+
export function jsonColumn(opts) {
|
|
6
|
+
const emptyOnWrite = opts.emptyOnWrite ?? 'null';
|
|
7
|
+
const treatEmptyArrayAsEmpty = opts.treatEmptyArrayAsEmpty ?? false;
|
|
8
|
+
const passthroughParsed = opts.passthroughParsed ?? false;
|
|
9
|
+
const isEmpty = (value) => {
|
|
10
|
+
if (value === null || value === undefined)
|
|
11
|
+
return true;
|
|
12
|
+
if (treatEmptyArrayAsEmpty && Array.isArray(value) && value.length === 0)
|
|
13
|
+
return true;
|
|
14
|
+
return false;
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
prepare: (value) => {
|
|
18
|
+
if (isEmpty(value)) {
|
|
19
|
+
return emptyOnWrite === 'serialize' ? JSON.stringify(opts.fallback) : null;
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(value);
|
|
22
|
+
},
|
|
23
|
+
consume: (value) => {
|
|
24
|
+
if (value === null || value === undefined)
|
|
25
|
+
return opts.fallback;
|
|
26
|
+
if (passthroughParsed && Array.isArray(value))
|
|
27
|
+
return value;
|
|
28
|
+
return JSON.parse(value);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -5,6 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
7
|
import { column } from '@adonisjs/lucid/orm';
|
|
8
|
+
import { jsonColumn } from './json_column.js';
|
|
8
9
|
export function withAuditLog() {
|
|
9
10
|
return (superclass) => {
|
|
10
11
|
class AuditLogMixin extends superclass {
|
|
@@ -28,10 +29,7 @@ export function withAuditLog() {
|
|
|
28
29
|
column()
|
|
29
30
|
], AuditLogMixin.prototype, "ip", void 0);
|
|
30
31
|
__decorate([
|
|
31
|
-
column({
|
|
32
|
-
prepare: (value) => value ? JSON.stringify(value) : null,
|
|
33
|
-
consume: (value) => (value ? JSON.parse(value) : null),
|
|
34
|
-
})
|
|
32
|
+
column(jsonColumn({ fallback: null }))
|
|
35
33
|
], AuditLogMixin.prototype, "metadata", void 0);
|
|
36
34
|
__decorate([
|
|
37
35
|
column.dateTime({ autoCreate: true })
|