@dudousxd/adonis-authkit-server 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/commands.json +28 -0
- package/build/commands/doctor.d.ts +10 -0
- package/build/commands/doctor.js +66 -0
- package/build/commands/rotate_keys.d.ts +10 -0
- package/build/commands/rotate_keys.js +53 -0
- package/build/host/views/account/email-confirmed.edge +15 -0
- package/build/host/views/account/security.edge +83 -0
- package/build/host/views/account/tokens.edge +7 -4
- package/build/host/views/admin/sessions.edge +89 -0
- package/build/host/views/admin/users.edge +1 -0
- package/build/host/views/mfa-challenge.edge +29 -23
- package/build/index.d.ts +4 -3
- package/build/index.js +2 -2
- package/build/src/accounts/account_store.d.ts +46 -1
- package/build/src/accounts/account_store.js +4 -0
- package/build/src/accounts/lucid_store/core.d.ts +5 -4
- package/build/src/accounts/lucid_store/core.js +67 -2
- package/build/src/adapters/adapter_contract.d.ts +17 -0
- package/build/src/adapters/database_adapter.d.ts +9 -5
- package/build/src/adapters/database_adapter.js +13 -6
- package/build/src/adapters/redis_adapter.d.ts +11 -5
- package/build/src/adapters/redis_adapter.js +16 -7
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/define_config.d.ts +102 -0
- package/build/src/define_config.js +46 -3
- package/build/src/doctor/checks.d.ts +51 -0
- package/build/src/doctor/checks.js +231 -0
- package/build/src/host/admin_clients_service.js +12 -5
- package/build/src/host/admin_sessions_service.d.ts +63 -0
- package/build/src/host/admin_sessions_service.js +127 -0
- package/build/src/host/controllers/account_security_controller.d.ts +16 -0
- package/build/src/host/controllers/account_security_controller.js +119 -0
- package/build/src/host/controllers/account_session_controller.js +2 -1
- package/build/src/host/controllers/admin/admin_sessions_controller.d.ts +14 -0
- package/build/src/host/controllers/admin/admin_sessions_controller.js +64 -0
- package/build/src/host/controllers/interaction_controller.d.ts +11 -0
- package/build/src/host/controllers/interaction_controller.js +49 -10
- package/build/src/host/default_mailer.d.ts +17 -0
- package/build/src/host/default_mailer.js +51 -0
- package/build/src/host/i18n.d.ts +53 -0
- package/build/src/host/i18n.js +58 -0
- package/build/src/host/login_notify.d.ts +20 -0
- package/build/src/host/login_notify.js +71 -0
- package/build/src/host/register_auth_host.js +12 -0
- package/build/src/host/validators.d.ts +32 -0
- package/build/src/host/validators.js +14 -0
- package/build/src/keys/keystore.d.ts +43 -0
- package/build/src/keys/keystore.js +74 -0
- package/build/src/provider/build_provider.js +23 -0
- package/build/src/provider/device_sources.d.ts +6 -0
- package/build/src/provider/device_sources.js +65 -0
- package/build/src/provider/interaction_actions.d.ts +6 -1
- package/build/src/provider/interaction_actions.js +9 -2
- package/package.json +2 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import '../../augmentations.js';
|
|
2
|
+
import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
|
|
3
|
+
import { AdminSessionsService } from '../../admin_sessions_service.js';
|
|
4
|
+
/**
|
|
5
|
+
* Inspeção/revogação das sessões e grants ativos de uma conta no console admin.
|
|
6
|
+
* Lista as `Session` (logins do IdP) + os `Grant` (autorizações por client) da
|
|
7
|
+
* conta, com a contagem de access/refresh tokens por grant. Degrada graciosamente
|
|
8
|
+
* quando o adapter OIDC não enumera (`list`), espelhando o CRUD de clients.
|
|
9
|
+
*/
|
|
10
|
+
export default class AdminSessionsController {
|
|
11
|
+
/** GET /admin/users/:id/sessions — lista sessões + grants da conta. */
|
|
12
|
+
async index(ctx) {
|
|
13
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
14
|
+
const cfg = service.config;
|
|
15
|
+
const render = cfg.render;
|
|
16
|
+
const accountId = ctx.request.param('id');
|
|
17
|
+
const account = await cfg.accountStore.findById(accountId);
|
|
18
|
+
const sessions = new AdminSessionsService(service);
|
|
19
|
+
const supported = sessions.canList;
|
|
20
|
+
const sessionList = supported ? await sessions.listSessions(accountId) : [];
|
|
21
|
+
const grantList = supported ? await sessions.listGrants(accountId) : [];
|
|
22
|
+
const revoked = ctx.session.flashMessages.get('sessionsRevoked');
|
|
23
|
+
return render(ctx, 'admin/sessions', {
|
|
24
|
+
csrfToken: ctx.request.csrfToken,
|
|
25
|
+
supported,
|
|
26
|
+
accountId,
|
|
27
|
+
email: account?.email ?? '',
|
|
28
|
+
revoked: revoked ?? null,
|
|
29
|
+
sessions: sessionList.map((s) => ({
|
|
30
|
+
id: s.id,
|
|
31
|
+
loginTs: s.loginTs ? new Date(s.loginTs * 1000).toISOString() : '',
|
|
32
|
+
amr: (s.amr ?? []).join(', '),
|
|
33
|
+
})),
|
|
34
|
+
grants: grantList.map((g) => ({
|
|
35
|
+
id: g.id,
|
|
36
|
+
clientId: g.clientId ?? '',
|
|
37
|
+
accessTokens: g.accessTokens,
|
|
38
|
+
refreshTokens: g.refreshTokens,
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** POST /admin/users/:id/revoke-sessions — destrói sessões + grants da conta. */
|
|
43
|
+
async revoke(ctx) {
|
|
44
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
45
|
+
const cfg = service.config;
|
|
46
|
+
const accountId = ctx.request.param('id');
|
|
47
|
+
const sessions = new AdminSessionsService(service);
|
|
48
|
+
const result = await sessions.revokeAll(accountId);
|
|
49
|
+
await cfg.audit?.record({
|
|
50
|
+
type: 'session.revoked_all',
|
|
51
|
+
accountId,
|
|
52
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
53
|
+
ip: ctx.request.ip?.() ?? null,
|
|
54
|
+
metadata: {
|
|
55
|
+
sessions: result.sessions,
|
|
56
|
+
grants: result.grants,
|
|
57
|
+
accessTokens: result.accessTokens,
|
|
58
|
+
refreshTokens: result.refreshTokens,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
ctx.session.flash('sessionsRevoked', result);
|
|
62
|
+
return ctx.response.redirect(`/admin/users/${accountId}/sessions`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -19,6 +19,17 @@ export default class AuthInteractionController {
|
|
|
19
19
|
* OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
|
|
20
20
|
*/
|
|
21
21
|
mfaVerify(ctx: HttpContext): Promise<any>;
|
|
22
|
+
/**
|
|
23
|
+
* true se o authorize request exige MFA via acr_values (contém o mfaAcr da
|
|
24
|
+
* config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
|
|
25
|
+
*/
|
|
26
|
+
private acrRequiresMfa;
|
|
27
|
+
/**
|
|
28
|
+
* Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
|
|
29
|
+
* foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
|
|
30
|
+
* Retorna `undefined` quando não há step-up — completeLogin usa o default.
|
|
31
|
+
*/
|
|
32
|
+
private stepUpExtra;
|
|
22
33
|
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
23
34
|
private hasPasskeys;
|
|
24
35
|
/**
|
|
@@ -2,6 +2,7 @@ import '../augmentations.js';
|
|
|
2
2
|
import { brandFor, isFirstParty } from '../branding.js';
|
|
3
3
|
import { translate } from '../i18n.js';
|
|
4
4
|
import { attemptPasswordLogin } from '../login_attempt.js';
|
|
5
|
+
import { notifyLoginSuccess } from '../login_notify.js';
|
|
5
6
|
import { supportsPasskeys } from '../../accounts/account_store.js';
|
|
6
7
|
const SESSION_KEY = 'authkit_login_email';
|
|
7
8
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
@@ -106,10 +107,25 @@ export default class AuthInteractionController {
|
|
|
106
107
|
});
|
|
107
108
|
}
|
|
108
109
|
const acc = result.account;
|
|
109
|
-
//
|
|
110
|
-
//
|
|
110
|
+
// Step-up auth (acr_values): o client pode EXIGIR MFA nesta requisição
|
|
111
|
+
// solicitando o `mfaAcr` em acr_values, mesmo que a conta tenha MFA opcional.
|
|
112
|
+
const mfaRequired = this.acrRequiresMfa(cfg, details);
|
|
113
|
+
// MFA gate: força o 2º fator se a conta tem TOTP ativo OU se o client exige MFA
|
|
114
|
+
// via acr. Não finaliza a interaction agora — guarda o accountId pendente.
|
|
111
115
|
const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
|
|
112
|
-
if (mfa.enabled) {
|
|
116
|
+
if (mfa.enabled || mfaRequired) {
|
|
117
|
+
if (mfaRequired && !mfa.enabled) {
|
|
118
|
+
// Client exige MFA mas a conta não tem MFA enrolado: bloqueia este login
|
|
119
|
+
// com a instrução de configurar MFA no console (não há 2º fator a desafiar).
|
|
120
|
+
return render(ctx, 'mfa-challenge', {
|
|
121
|
+
uid: ctx.request.param('uid'),
|
|
122
|
+
csrfToken: ctx.request.csrfToken,
|
|
123
|
+
brand,
|
|
124
|
+
passkeyAvailable: false,
|
|
125
|
+
error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
|
|
126
|
+
noEnrollment: true,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
113
129
|
ctx.session.put(MFA_PENDING_KEY, acc.id);
|
|
114
130
|
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
115
131
|
// tem ao menos uma credencial registrada.
|
|
@@ -123,7 +139,7 @@ export default class AuthInteractionController {
|
|
|
123
139
|
}
|
|
124
140
|
// Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
|
|
125
141
|
await service.interactions.completeLogin(ctx, acc.id);
|
|
126
|
-
await
|
|
142
|
+
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
|
|
127
143
|
// Clean up the session key after a successful login.
|
|
128
144
|
ctx.session.forget(SESSION_KEY);
|
|
129
145
|
}
|
|
@@ -174,14 +190,38 @@ export default class AuthInteractionController {
|
|
|
174
190
|
// Sucesso no 2º fator: finaliza a interaction para o accountId pendente.
|
|
175
191
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
176
192
|
ctx.session.forget(SESSION_KEY);
|
|
177
|
-
await cfg
|
|
178
|
-
type: 'login.success',
|
|
193
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
179
194
|
accountId,
|
|
180
195
|
ip,
|
|
181
196
|
clientId,
|
|
182
197
|
metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
|
|
183
198
|
});
|
|
184
|
-
|
|
199
|
+
// Step-up: um 2º fator foi de fato verificado — carimba acr/amr no id_token
|
|
200
|
+
// se o client solicitou o mfaAcr nesta requisição.
|
|
201
|
+
await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, usedRecovery ? 'recovery' : 'totp'));
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* true se o authorize request exige MFA via acr_values (contém o mfaAcr da
|
|
205
|
+
* config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
|
|
206
|
+
*/
|
|
207
|
+
acrRequiresMfa(cfg, details) {
|
|
208
|
+
const mfaAcr = cfg.stepUp?.mfaAcr;
|
|
209
|
+
if (!mfaAcr)
|
|
210
|
+
return false;
|
|
211
|
+
const raw = details?.params?.acr_values;
|
|
212
|
+
if (typeof raw !== 'string' || !raw)
|
|
213
|
+
return false;
|
|
214
|
+
return raw.split(/\s+/).includes(mfaAcr);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
|
|
218
|
+
* foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
|
|
219
|
+
* Retorna `undefined` quando não há step-up — completeLogin usa o default.
|
|
220
|
+
*/
|
|
221
|
+
stepUpExtra(cfg, details, method) {
|
|
222
|
+
if (!this.acrRequiresMfa(cfg, details))
|
|
223
|
+
return undefined;
|
|
224
|
+
return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
|
|
185
225
|
}
|
|
186
226
|
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
187
227
|
async hasPasskeys(cfg, accountId) {
|
|
@@ -261,14 +301,13 @@ export default class AuthInteractionController {
|
|
|
261
301
|
}
|
|
262
302
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
263
303
|
ctx.session.forget(SESSION_KEY);
|
|
264
|
-
await cfg
|
|
265
|
-
type: 'login.success',
|
|
304
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
266
305
|
accountId,
|
|
267
306
|
ip,
|
|
268
307
|
clientId,
|
|
269
308
|
metadata: { mfa: 'webauthn' },
|
|
270
309
|
});
|
|
271
|
-
await service.interactions.completeLogin(ctx, accountId);
|
|
310
|
+
await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn'));
|
|
272
311
|
}
|
|
273
312
|
/**
|
|
274
313
|
* GET /auth/interaction/:uid/switch
|
|
@@ -28,6 +28,23 @@ export declare function sendPasswordResetEmail(ctx: HttpContext, data: {
|
|
|
28
28
|
email: string;
|
|
29
29
|
resetUrl: string;
|
|
30
30
|
}): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
|
|
33
|
+
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sendEmailChangeConfirmationEmail(ctx: HttpContext, data: {
|
|
36
|
+
email: string;
|
|
37
|
+
confirmUrl: string;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
|
|
41
|
+
* Best-effort: no fallback (sem mail) loga o evento; nunca lança.
|
|
42
|
+
*/
|
|
43
|
+
export declare function sendNewLoginEmail(ctx: HttpContext, data: {
|
|
44
|
+
email: string;
|
|
45
|
+
ip: string;
|
|
46
|
+
when: string;
|
|
47
|
+
}): Promise<void>;
|
|
31
48
|
/**
|
|
32
49
|
* Envia o e-mail de verificação pelo mailer default do host.
|
|
33
50
|
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
@@ -115,6 +115,57 @@ export async function sendPasswordResetEmail(ctx, data) {
|
|
|
115
115
|
ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar e-mail de redefinição de senha');
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
|
|
120
|
+
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
121
|
+
*/
|
|
122
|
+
export async function sendEmailChangeConfirmationEmail(ctx, data) {
|
|
123
|
+
try {
|
|
124
|
+
const brand = resolveBrand(ctx);
|
|
125
|
+
const content = renderTransactionalEmail({
|
|
126
|
+
brand,
|
|
127
|
+
subject: 'Confirme seu novo e-mail',
|
|
128
|
+
heading: 'Confirme seu novo e-mail',
|
|
129
|
+
intro: `Recebemos um pedido para alterar o e-mail da sua conta em ${brand.appName} para este endereço. Clique no botão abaixo para confirmar a alteração. Se não foi você, ignore este e-mail — nada será alterado.`,
|
|
130
|
+
ctaLabel: 'Confirmar novo e-mail',
|
|
131
|
+
ctaUrl: data.confirmUrl,
|
|
132
|
+
footnote: 'Por segurança, este link expira em breve e só pode ser usado uma vez.',
|
|
133
|
+
});
|
|
134
|
+
const sent = await sendEmail(ctx, data.email, content);
|
|
135
|
+
if (!sent) {
|
|
136
|
+
ctx.logger.info({ confirmUrl: data.confirmUrl, email: data.email }, 'authkit: link de confirmação de troca de e-mail (dev — @adonisjs/mail ausente)');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar confirmação de troca de e-mail');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
|
|
145
|
+
* Best-effort: no fallback (sem mail) loga o evento; nunca lança.
|
|
146
|
+
*/
|
|
147
|
+
export async function sendNewLoginEmail(ctx, data) {
|
|
148
|
+
try {
|
|
149
|
+
const brand = resolveBrand(ctx);
|
|
150
|
+
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
151
|
+
const content = renderTransactionalEmail({
|
|
152
|
+
brand,
|
|
153
|
+
subject: 'Novo acesso à sua conta',
|
|
154
|
+
heading: 'Novo acesso à sua conta',
|
|
155
|
+
intro: `Detectamos um novo acesso à sua conta em ${brand.appName} a partir do IP ${data.ip} em ${data.when}. Se foi você, nenhuma ação é necessária. Se não reconhece este acesso, altere sua senha imediatamente.`,
|
|
156
|
+
ctaLabel: 'Acessar minha conta',
|
|
157
|
+
ctaUrl: `${origin}/account/security`,
|
|
158
|
+
footnote: 'Você está recebendo este alerta porque um login foi feito de um endereço de IP não visto antes.',
|
|
159
|
+
});
|
|
160
|
+
const sent = await sendEmail(ctx, data.email, content);
|
|
161
|
+
if (!sent) {
|
|
162
|
+
ctx.logger.info({ ip: data.ip, email: data.email }, 'authkit: alerta de novo acesso (dev — @adonisjs/mail ausente)');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar alerta de novo acesso');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
118
169
|
/**
|
|
119
170
|
* Envia o e-mail de verificação pelo mailer default do host.
|
|
120
171
|
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
package/build/src/host/i18n.d.ts
CHANGED
|
@@ -90,6 +90,7 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
90
90
|
'account.tokens.page_title': string;
|
|
91
91
|
'account.tokens.title': string;
|
|
92
92
|
'account.tokens.logout': string;
|
|
93
|
+
'account.tokens.security': string;
|
|
93
94
|
'account.tokens.created_notice': string;
|
|
94
95
|
'account.tokens.name_placeholder': string;
|
|
95
96
|
'account.tokens.create': string;
|
|
@@ -100,6 +101,28 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
100
101
|
'account.tokens.scopes': string;
|
|
101
102
|
'account.tokens.audience': string;
|
|
102
103
|
'account.tokens.revoke': string;
|
|
104
|
+
'account.security.page_title': string;
|
|
105
|
+
'account.security.title': string;
|
|
106
|
+
'account.security.logout': string;
|
|
107
|
+
'account.security.current_email': string;
|
|
108
|
+
'account.security.not_supported': string;
|
|
109
|
+
'account.security.password_section': string;
|
|
110
|
+
'account.security.current_password_label': string;
|
|
111
|
+
'account.security.new_password_label': string;
|
|
112
|
+
'account.security.change_password_submit': string;
|
|
113
|
+
'account.security.password_changed': string;
|
|
114
|
+
'account.security.email_section': string;
|
|
115
|
+
'account.security.email_intro': string;
|
|
116
|
+
'account.security.new_email_label': string;
|
|
117
|
+
'account.security.email_password_label': string;
|
|
118
|
+
'account.security.change_email_submit': string;
|
|
119
|
+
'account.security.email_change_requested': string;
|
|
120
|
+
'account.security.email_changed': string;
|
|
121
|
+
'account.email_confirmed.page_title': string;
|
|
122
|
+
'account.email_confirmed.ok_title': string;
|
|
123
|
+
'account.email_confirmed.ok_body': string;
|
|
124
|
+
'account.email_confirmed.invalid_title': string;
|
|
125
|
+
'account.email_confirmed.invalid_body': string;
|
|
103
126
|
'account.mfa.page_title': string;
|
|
104
127
|
'account.mfa.title': string;
|
|
105
128
|
'account.mfa.logout': string;
|
|
@@ -140,6 +163,23 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
140
163
|
'admin.users.empty': string;
|
|
141
164
|
'admin.users.roles_placeholder': string;
|
|
142
165
|
'admin.users.save_roles': string;
|
|
166
|
+
'admin.users.sessions': string;
|
|
167
|
+
'admin.sessions.page_title': string;
|
|
168
|
+
'admin.sessions.title': string;
|
|
169
|
+
'admin.sessions.account': string;
|
|
170
|
+
'admin.sessions.back': string;
|
|
171
|
+
'admin.sessions.not_supported': string;
|
|
172
|
+
'admin.sessions.revoked_notice': string;
|
|
173
|
+
'admin.sessions.sessions_section': string;
|
|
174
|
+
'admin.sessions.sessions_empty': string;
|
|
175
|
+
'admin.sessions.session_login_ts': string;
|
|
176
|
+
'admin.sessions.session_amr': string;
|
|
177
|
+
'admin.sessions.grants_section': string;
|
|
178
|
+
'admin.sessions.grants_empty': string;
|
|
179
|
+
'admin.sessions.grant_client': string;
|
|
180
|
+
'admin.sessions.grant_tokens': string;
|
|
181
|
+
'admin.sessions.revoke_all': string;
|
|
182
|
+
'admin.sessions.revoke_confirm': string;
|
|
143
183
|
'admin.clients.page_title': string;
|
|
144
184
|
'admin.clients.title': string;
|
|
145
185
|
'admin.clients.empty': string;
|
|
@@ -185,6 +225,19 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
185
225
|
'admin.pagination.page': string;
|
|
186
226
|
'admin.pagination.prev': string;
|
|
187
227
|
'admin.pagination.next': string;
|
|
228
|
+
'device.input.title': string;
|
|
229
|
+
'device.input.intro': string;
|
|
230
|
+
'device.input.submit': string;
|
|
231
|
+
'device.input.error_invalid': string;
|
|
232
|
+
'device.input.error_aborted': string;
|
|
233
|
+
'device.input.error_generic': string;
|
|
234
|
+
'device.confirm.title': string;
|
|
235
|
+
'device.confirm.body': string;
|
|
236
|
+
'device.confirm.submit': string;
|
|
237
|
+
'device.confirm.abort': string;
|
|
238
|
+
'device.success.title': string;
|
|
239
|
+
'device.success.body': string;
|
|
240
|
+
'mfa_challenge.required_no_enrollment': string;
|
|
188
241
|
'errors.invalid_credentials': string;
|
|
189
242
|
'errors.invalid_code': string;
|
|
190
243
|
'errors.email_taken': string;
|
package/build/src/host/i18n.js
CHANGED
|
@@ -90,6 +90,7 @@ export const DEFAULT_MESSAGES = {
|
|
|
90
90
|
'account.tokens.page_title': 'Tokens de acesso',
|
|
91
91
|
'account.tokens.title': 'Tokens de acesso',
|
|
92
92
|
'account.tokens.logout': 'Sair',
|
|
93
|
+
'account.tokens.security': 'Segurança',
|
|
93
94
|
'account.tokens.created_notice': 'Token criado — copie agora, não será mostrado de novo:',
|
|
94
95
|
'account.tokens.name_placeholder': 'Nome do token (ex.: CI deploy)',
|
|
95
96
|
'account.tokens.create': 'Criar',
|
|
@@ -100,6 +101,30 @@ export const DEFAULT_MESSAGES = {
|
|
|
100
101
|
'account.tokens.scopes': 'Escopos: {scopes}',
|
|
101
102
|
'account.tokens.audience': 'Audiência: {audience}',
|
|
102
103
|
'account.tokens.revoke': 'Revogar',
|
|
104
|
+
// Console de conta — segurança (account/security): senha + e-mail.
|
|
105
|
+
'account.security.page_title': 'Segurança da conta',
|
|
106
|
+
'account.security.title': 'Segurança da conta',
|
|
107
|
+
'account.security.logout': 'Sair',
|
|
108
|
+
'account.security.current_email': 'E-mail atual: {email}',
|
|
109
|
+
'account.security.not_supported': 'A troca de senha e e-mail não está disponível nesta instalação.',
|
|
110
|
+
'account.security.password_section': 'Trocar senha',
|
|
111
|
+
'account.security.current_password_label': 'Senha atual',
|
|
112
|
+
'account.security.new_password_label': 'Nova senha',
|
|
113
|
+
'account.security.change_password_submit': 'Trocar senha',
|
|
114
|
+
'account.security.password_changed': 'Senha alterada com sucesso.',
|
|
115
|
+
'account.security.email_section': 'Trocar e-mail',
|
|
116
|
+
'account.security.email_intro': 'Enviaremos um link de confirmação para o novo endereço. A troca só é aplicada após a confirmação.',
|
|
117
|
+
'account.security.new_email_label': 'Novo e-mail',
|
|
118
|
+
'account.security.email_password_label': 'Senha atual',
|
|
119
|
+
'account.security.change_email_submit': 'Solicitar troca de e-mail',
|
|
120
|
+
'account.security.email_change_requested': 'Enviamos um link de confirmação para {email}. Clique nele para concluir a troca.',
|
|
121
|
+
'account.security.email_changed': 'E-mail alterado com sucesso.',
|
|
122
|
+
// Confirmação de troca de e-mail (account/email-confirmed).
|
|
123
|
+
'account.email_confirmed.page_title': 'Confirmação de e-mail',
|
|
124
|
+
'account.email_confirmed.ok_title': 'E-mail alterado',
|
|
125
|
+
'account.email_confirmed.ok_body': 'Seu novo e-mail foi confirmado e já está ativo.',
|
|
126
|
+
'account.email_confirmed.invalid_title': 'Link inválido',
|
|
127
|
+
'account.email_confirmed.invalid_body': 'O link de confirmação é inválido ou já foi utilizado.',
|
|
103
128
|
// Console de conta — MFA (account/mfa).
|
|
104
129
|
'account.mfa.page_title': 'Verificação em duas etapas',
|
|
105
130
|
'account.mfa.title': 'Verificação em duas etapas',
|
|
@@ -145,6 +170,24 @@ export const DEFAULT_MESSAGES = {
|
|
|
145
170
|
'admin.users.empty': 'Nenhum usuário encontrado.',
|
|
146
171
|
'admin.users.roles_placeholder': 'Papéis (separados por vírgula)',
|
|
147
172
|
'admin.users.save_roles': 'Salvar papéis',
|
|
173
|
+
'admin.users.sessions': 'Sessões',
|
|
174
|
+
// Console admin — sessões/grants ativos de uma conta.
|
|
175
|
+
'admin.sessions.page_title': 'Sessões ativas',
|
|
176
|
+
'admin.sessions.title': 'Sessões ativas',
|
|
177
|
+
'admin.sessions.account': 'Conta: {email}',
|
|
178
|
+
'admin.sessions.back': 'Voltar para usuários',
|
|
179
|
+
'admin.sessions.not_supported': 'O adapter OIDC configurado não suporta enumeração — a inspeção de sessões fica indisponível.',
|
|
180
|
+
'admin.sessions.revoked_notice': 'Revogado: {sessions} sessão(ões), {grants} grant(s), {accessTokens} access token(s), {refreshTokens} refresh token(s).',
|
|
181
|
+
'admin.sessions.sessions_section': 'Sessões (login no IdP)',
|
|
182
|
+
'admin.sessions.sessions_empty': 'Nenhuma sessão ativa.',
|
|
183
|
+
'admin.sessions.session_login_ts': 'Login: {date}',
|
|
184
|
+
'admin.sessions.session_amr': 'Métodos: {amr}',
|
|
185
|
+
'admin.sessions.grants_section': 'Grants (autorizações por client)',
|
|
186
|
+
'admin.sessions.grants_empty': 'Nenhum grant ativo.',
|
|
187
|
+
'admin.sessions.grant_client': 'Client: {clientId}',
|
|
188
|
+
'admin.sessions.grant_tokens': '{accessTokens} access · {refreshTokens} refresh',
|
|
189
|
+
'admin.sessions.revoke_all': 'Revogar todas as sessões e grants',
|
|
190
|
+
'admin.sessions.revoke_confirm': 'Revogar todas as sessões e grants desta conta? O usuário precisará entrar novamente e os tokens emitidos deixarão de funcionar.',
|
|
148
191
|
// Console admin — clients.
|
|
149
192
|
'admin.clients.page_title': 'Clients OAuth',
|
|
150
193
|
'admin.clients.title': 'Clients OAuth',
|
|
@@ -193,6 +236,21 @@ export const DEFAULT_MESSAGES = {
|
|
|
193
236
|
'admin.pagination.page': 'Página {page} de {total}',
|
|
194
237
|
'admin.pagination.prev': 'Anterior',
|
|
195
238
|
'admin.pagination.next': 'Próxima',
|
|
239
|
+
// Device Authorization Grant (RFC 8628) — telas servidas pelo oidc-provider.
|
|
240
|
+
'device.input.title': 'Entrar no dispositivo',
|
|
241
|
+
'device.input.intro': 'Digite o código exibido no seu dispositivo.',
|
|
242
|
+
'device.input.submit': 'Continuar',
|
|
243
|
+
'device.input.error_invalid': 'O código informado está incorreto. Tente novamente.',
|
|
244
|
+
'device.input.error_aborted': 'A solicitação de login foi interrompida.',
|
|
245
|
+
'device.input.error_generic': 'Ocorreu um erro ao processar sua solicitação.',
|
|
246
|
+
'device.confirm.title': 'Confirmar dispositivo',
|
|
247
|
+
'device.confirm.body': 'O código abaixo deve estar sendo exibido no seu dispositivo. Confirme apenas se reconhecê-lo.',
|
|
248
|
+
'device.confirm.submit': 'Continuar',
|
|
249
|
+
'device.confirm.abort': 'Cancelar',
|
|
250
|
+
'device.success.title': 'Login concluído',
|
|
251
|
+
'device.success.body': 'Login realizado com sucesso. Você já pode voltar ao dispositivo.',
|
|
252
|
+
// Step-up auth (acr_values): cliente exige MFA mas a conta não tem MFA enrolado.
|
|
253
|
+
'mfa_challenge.required_no_enrollment': 'Este cliente exige verificação em duas etapas. Configure o MFA no console da sua conta para continuar.',
|
|
196
254
|
// Mensagens de erro/flash produzidas pelos controllers.
|
|
197
255
|
'errors.invalid_credentials': 'Credenciais inválidas',
|
|
198
256
|
'errors.invalid_code': 'Código inválido',
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import type { ResolvedServerConfig } from '../define_config.js';
|
|
3
|
+
/** Dados de um login bem-sucedido a auditar/notificar. */
|
|
4
|
+
export interface LoginSuccessInput {
|
|
5
|
+
accountId: string;
|
|
6
|
+
email?: string | null;
|
|
7
|
+
ip?: string | null;
|
|
8
|
+
clientId?: string | null;
|
|
9
|
+
/** Metadata extra a anexar ao evento login.success (ex.: { mfa: 'totp' }). */
|
|
10
|
+
metadata?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Centraliza o pós-login bem-sucedido: registra o evento `login.success` e dispara
|
|
14
|
+
* (best-effort) o alerta de NOVO acesso quando o IP nunca foi visto para a conta.
|
|
15
|
+
*
|
|
16
|
+
* É fire-and-forget e FAIL-SAFE: a notificação roda DEPOIS do audit e qualquer erro
|
|
17
|
+
* é engolido — NUNCA bloqueia nem lança no caminho do login. Substitui as chamadas
|
|
18
|
+
* `cfg.audit?.record({ type: 'login.success', ... })` espalhadas pelos controllers.
|
|
19
|
+
*/
|
|
20
|
+
export declare function notifyLoginSuccess(ctx: HttpContext, cfg: ResolvedServerConfig, input: LoginSuccessInput): Promise<void>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { sendNewLoginEmail } from './default_mailer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Centraliza o pós-login bem-sucedido: registra o evento `login.success` e dispara
|
|
4
|
+
* (best-effort) o alerta de NOVO acesso quando o IP nunca foi visto para a conta.
|
|
5
|
+
*
|
|
6
|
+
* É fire-and-forget e FAIL-SAFE: a notificação roda DEPOIS do audit e qualquer erro
|
|
7
|
+
* é engolido — NUNCA bloqueia nem lança no caminho do login. Substitui as chamadas
|
|
8
|
+
* `cfg.audit?.record({ type: 'login.success', ... })` espalhadas pelos controllers.
|
|
9
|
+
*/
|
|
10
|
+
export async function notifyLoginSuccess(ctx, cfg, input) {
|
|
11
|
+
const { accountId, email, ip, clientId, metadata } = input;
|
|
12
|
+
// 1) Audit do login.success (mesmo formato de antes).
|
|
13
|
+
await cfg.audit?.record({
|
|
14
|
+
type: 'login.success',
|
|
15
|
+
accountId,
|
|
16
|
+
email: email ?? null,
|
|
17
|
+
ip: ip ?? null,
|
|
18
|
+
clientId: clientId ?? null,
|
|
19
|
+
metadata,
|
|
20
|
+
});
|
|
21
|
+
// 2) Alerta de novo acesso (opt-out via notifications.newLoginEmail: false).
|
|
22
|
+
if (!cfg.notifications.newLoginEmail)
|
|
23
|
+
return;
|
|
24
|
+
// Fire-and-forget: nunca propaga erro pro caminho do login.
|
|
25
|
+
void (async () => {
|
|
26
|
+
// Resolve o e-mail quando o caller não o forneceu (ex.: fluxo de MFA só tem o
|
|
27
|
+
// accountId em escopo). Best-effort.
|
|
28
|
+
let resolvedEmail = email ?? null;
|
|
29
|
+
if (!resolvedEmail) {
|
|
30
|
+
resolvedEmail = (await cfg.accountStore.findById(accountId))?.email ?? null;
|
|
31
|
+
}
|
|
32
|
+
await maybeNotifyNewLogin(ctx, cfg, { accountId, email: resolvedEmail, ip: ip ?? null });
|
|
33
|
+
})().catch((error) => {
|
|
34
|
+
ctx.logger.error({ err: error, accountId }, 'authkit: falha no alerta de novo acesso');
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Verifica se o IP é novo para a conta (consultando o audit sink por
|
|
39
|
+
* `login.success` do subject) e, se for, envia o e-mail de alerta + audita
|
|
40
|
+
* `login.new_ip_notified`. Degrada para no-op quando: sem IP, sem e-mail, sem
|
|
41
|
+
* sink consultável (`list`), ou já houve um login.success deste IP antes.
|
|
42
|
+
*/
|
|
43
|
+
async function maybeNotifyNewLogin(ctx, cfg, data) {
|
|
44
|
+
const { accountId, email, ip } = data;
|
|
45
|
+
if (!ip || !email)
|
|
46
|
+
return;
|
|
47
|
+
// Sem consulta do histórico não dá pra decidir se o IP é novo → no-op.
|
|
48
|
+
if (typeof cfg.audit?.list !== 'function')
|
|
49
|
+
return;
|
|
50
|
+
// Lê o histórico de login.success do subject. O evento ATUAL já foi gravado por
|
|
51
|
+
// notifyLoginSuccess, então um IP visto antes aparece com count >= 2 para o IP.
|
|
52
|
+
// Buscamos uma página ampla e contamos as ocorrências deste IP.
|
|
53
|
+
const page = await cfg.audit.list({
|
|
54
|
+
type: 'login.success',
|
|
55
|
+
subject: accountId,
|
|
56
|
+
page: 1,
|
|
57
|
+
limit: 200,
|
|
58
|
+
});
|
|
59
|
+
const sameIpCount = page.data.filter((e) => e.ip === ip).length;
|
|
60
|
+
// > 1 significa que já havia um login.success deste IP antes do atual → não é novo.
|
|
61
|
+
if (sameIpCount > 1)
|
|
62
|
+
return;
|
|
63
|
+
const when = new Date().toISOString();
|
|
64
|
+
await sendNewLoginEmail(ctx, { email, ip, when });
|
|
65
|
+
await cfg.audit?.record({
|
|
66
|
+
type: 'login.new_ip_notified',
|
|
67
|
+
accountId,
|
|
68
|
+
email,
|
|
69
|
+
ip,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -52,9 +52,11 @@ const C = {
|
|
|
52
52
|
patIntrospection: () => import('./controllers/pat_introspection_controller.js'),
|
|
53
53
|
accountSession: () => import('./controllers/account_session_controller.js'),
|
|
54
54
|
accountTokens: () => import('./controllers/account_tokens_controller.js'),
|
|
55
|
+
accountSecurity: () => import('./controllers/account_security_controller.js'),
|
|
55
56
|
accountMfa: () => import('./controllers/account_mfa_controller.js'),
|
|
56
57
|
adminDashboard: () => import('./controllers/admin/admin_dashboard_controller.js'),
|
|
57
58
|
adminUsers: () => import('./controllers/admin/admin_users_controller.js'),
|
|
59
|
+
adminSessions: () => import('./controllers/admin/admin_sessions_controller.js'),
|
|
58
60
|
adminClients: () => import('./controllers/admin/admin_clients_controller.js'),
|
|
59
61
|
adminAudit: () => import('./controllers/admin/admin_audit_controller.js'),
|
|
60
62
|
};
|
|
@@ -108,12 +110,19 @@ export function registerAuthHost(router, opts) {
|
|
|
108
110
|
router.get('/account/login', [C.accountSession, 'show']);
|
|
109
111
|
router.post('/account/login', [C.accountSession, 'login']);
|
|
110
112
|
router.post('/account/logout', [C.accountSession, 'logout']);
|
|
113
|
+
// Confirmação de troca de e-mail (standalone, GET-only — consome o token do link;
|
|
114
|
+
// pode ser aberta em outro dispositivo, então NÃO exige sessão).
|
|
115
|
+
router.get('/account/email/confirm', [C.accountSecurity, 'confirmEmail']);
|
|
111
116
|
// Rotas de tokens protegidas por AccountAuthMiddleware (redireciona para /account/login se não autenticado).
|
|
112
117
|
router
|
|
113
118
|
.group(() => {
|
|
114
119
|
router.get('/account/tokens', [C.accountTokens, 'index']);
|
|
115
120
|
router.post('/account/tokens', [C.accountTokens, 'store']);
|
|
116
121
|
router.post('/account/tokens/:id/revoke', [C.accountTokens, 'destroy']);
|
|
122
|
+
// Segurança da conta: trocar senha + solicitar troca de e-mail.
|
|
123
|
+
router.get('/account/security', [C.accountSecurity, 'index']);
|
|
124
|
+
router.post('/account/security/password', [C.accountSecurity, 'changePassword']);
|
|
125
|
+
router.post('/account/security/email', [C.accountSecurity, 'changeEmail']);
|
|
117
126
|
// MFA / TOTP (enrollment, confirmação, disable).
|
|
118
127
|
router.get('/account/mfa', [C.accountMfa, 'index']);
|
|
119
128
|
router.post('/account/mfa/enroll', [C.accountMfa, 'enroll']);
|
|
@@ -132,6 +141,9 @@ export function registerAuthHost(router, opts) {
|
|
|
132
141
|
router.get('/admin', [C.adminDashboard, 'index']);
|
|
133
142
|
router.get('/admin/users', [C.adminUsers, 'index']);
|
|
134
143
|
router.post('/admin/users/:id/roles', [C.adminUsers, 'updateRoles']);
|
|
144
|
+
// Sessões/grants ativos da conta + revogação em massa.
|
|
145
|
+
router.get('/admin/users/:id/sessions', [C.adminSessions, 'index']);
|
|
146
|
+
router.post('/admin/users/:id/revoke-sessions', [C.adminSessions, 'revoke']);
|
|
135
147
|
router.get('/admin/clients', [C.adminClients, 'index']);
|
|
136
148
|
// CRUD de clients OIDC (adapter-backed). `/new` ANTES de `:id` p/ não casar
|
|
137
149
|
// "new" como id; todas as escritas são POST (com _csrf na view).
|
|
@@ -37,3 +37,35 @@ export declare const resetPasswordValidator: import("@vinejs/vine").VineValidato
|
|
|
37
37
|
password: string;
|
|
38
38
|
token: string;
|
|
39
39
|
}>, Record<string, any> | undefined>;
|
|
40
|
+
/**
|
|
41
|
+
* Troca de senha no console de conta. A regra da nova senha espelha o
|
|
42
|
+
* signupValidator (min 8, max 255); a senha atual é confirmada à parte via
|
|
43
|
+
* verifyCredentials.
|
|
44
|
+
*/
|
|
45
|
+
export declare const changePasswordValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
|
|
46
|
+
currentPassword: import("@vinejs/vine").VineString;
|
|
47
|
+
newPassword: import("@vinejs/vine").VineString;
|
|
48
|
+
}, {
|
|
49
|
+
currentPassword: string;
|
|
50
|
+
newPassword: string;
|
|
51
|
+
}, {
|
|
52
|
+
currentPassword: string;
|
|
53
|
+
newPassword: string;
|
|
54
|
+
}, {
|
|
55
|
+
currentPassword: string;
|
|
56
|
+
newPassword: string;
|
|
57
|
+
}>, Record<string, any> | undefined>;
|
|
58
|
+
/** Troca de e-mail no console de conta: senha atual + o novo e-mail. */
|
|
59
|
+
export declare const changeEmailValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
|
|
60
|
+
currentPassword: import("@vinejs/vine").VineString;
|
|
61
|
+
newEmail: import("@vinejs/vine").VineString;
|
|
62
|
+
}, {
|
|
63
|
+
newEmail: string;
|
|
64
|
+
currentPassword: string;
|
|
65
|
+
}, {
|
|
66
|
+
newEmail: string;
|
|
67
|
+
currentPassword: string;
|
|
68
|
+
}, {
|
|
69
|
+
newEmail: string;
|
|
70
|
+
currentPassword: string;
|
|
71
|
+
}>, Record<string, any> | undefined>;
|
|
@@ -11,3 +11,17 @@ export const resetPasswordValidator = vine.compile(vine.object({
|
|
|
11
11
|
token: vine.string().trim().minLength(1),
|
|
12
12
|
password: vine.string().minLength(8).maxLength(255),
|
|
13
13
|
}));
|
|
14
|
+
/**
|
|
15
|
+
* Troca de senha no console de conta. A regra da nova senha espelha o
|
|
16
|
+
* signupValidator (min 8, max 255); a senha atual é confirmada à parte via
|
|
17
|
+
* verifyCredentials.
|
|
18
|
+
*/
|
|
19
|
+
export const changePasswordValidator = vine.compile(vine.object({
|
|
20
|
+
currentPassword: vine.string().minLength(1),
|
|
21
|
+
newPassword: vine.string().minLength(8).maxLength(255),
|
|
22
|
+
}));
|
|
23
|
+
/** Troca de e-mail no console de conta: senha atual + o novo e-mail. */
|
|
24
|
+
export const changeEmailValidator = vine.compile(vine.object({
|
|
25
|
+
currentPassword: vine.string().minLength(1),
|
|
26
|
+
newEmail: vine.string().trim().email().normalizeEmail(),
|
|
27
|
+
}));
|