@dudousxd/adonis-authkit-server 0.2.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/client_form.edge +83 -0
- package/build/host/views/admin/clients.edge +68 -3
- 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 +29 -0
- package/build/src/adapters/database_adapter.d.ts +12 -1
- package/build/src/adapters/database_adapter.js +24 -0
- package/build/src/adapters/redis_adapter.d.ts +14 -1
- package/build/src/adapters/redis_adapter.js +35 -0
- 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.d.ts +65 -0
- package/build/src/host/admin_clients_service.js +143 -0
- 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_clients_controller.d.ts +17 -3
- package/build/src/host/controllers/admin/admin_clients_controller.js +158 -4
- 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 +80 -0
- package/build/src/host/i18n.js +86 -1
- 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 +20 -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/build/src/provider/oidc_service.d.ts +15 -0
- package/build/src/provider/oidc_service.js +27 -0
- package/package.json +2 -2
|
@@ -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;
|
|
@@ -148,6 +188,33 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
148
188
|
'admin.clients.grants': string;
|
|
149
189
|
'admin.clients.redirect_uris': string;
|
|
150
190
|
'admin.clients.dynamic_notice': string;
|
|
191
|
+
'admin.clients.static_section': string;
|
|
192
|
+
'admin.clients.dynamic_section': string;
|
|
193
|
+
'admin.clients.dynamic_empty': string;
|
|
194
|
+
'admin.clients.dynamic_not_supported': string;
|
|
195
|
+
'admin.clients.new': string;
|
|
196
|
+
'admin.clients.new_title': string;
|
|
197
|
+
'admin.clients.edit_title': string;
|
|
198
|
+
'admin.clients.edit': string;
|
|
199
|
+
'admin.clients.delete': string;
|
|
200
|
+
'admin.clients.delete_confirm': string;
|
|
201
|
+
'admin.clients.regenerate_secret': string;
|
|
202
|
+
'admin.clients.regenerate_confirm': string;
|
|
203
|
+
'admin.clients.back': string;
|
|
204
|
+
'admin.clients.cancel': string;
|
|
205
|
+
'admin.clients.save': string;
|
|
206
|
+
'admin.clients.create': string;
|
|
207
|
+
'admin.clients.secret_once_title': string;
|
|
208
|
+
'admin.clients.secret_once_notice': string;
|
|
209
|
+
'admin.clients.field_client_id': string;
|
|
210
|
+
'admin.clients.field_client_id_placeholder': string;
|
|
211
|
+
'admin.clients.field_client_id_help': string;
|
|
212
|
+
'admin.clients.field_redirect_uris': string;
|
|
213
|
+
'admin.clients.field_redirect_uris_help': string;
|
|
214
|
+
'admin.clients.field_post_logout_uris': string;
|
|
215
|
+
'admin.clients.field_post_logout_uris_help': string;
|
|
216
|
+
'admin.clients.field_grant_types': string;
|
|
217
|
+
'admin.clients.field_auth_method': string;
|
|
151
218
|
'admin.audit.page_title': string;
|
|
152
219
|
'admin.audit.title': string;
|
|
153
220
|
'admin.audit.type_placeholder': string;
|
|
@@ -158,6 +225,19 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
158
225
|
'admin.pagination.page': string;
|
|
159
226
|
'admin.pagination.prev': string;
|
|
160
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;
|
|
161
241
|
'errors.invalid_credentials': string;
|
|
162
242
|
'errors.invalid_code': string;
|
|
163
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',
|
|
@@ -153,7 +196,34 @@ export const DEFAULT_MESSAGES = {
|
|
|
153
196
|
'admin.clients.public': 'Público',
|
|
154
197
|
'admin.clients.grants': 'Grants: {grants}',
|
|
155
198
|
'admin.clients.redirect_uris': 'Redirects: {uris}',
|
|
156
|
-
'admin.clients.dynamic_notice': 'O registro dinâmico de clients está ligado — clients registrados via /reg
|
|
199
|
+
'admin.clients.dynamic_notice': 'O registro dinâmico de clients (RFC 7591) está ligado — clients registrados via /reg são persistidos no adapter e aparecem na seção dinâmica abaixo.',
|
|
200
|
+
'admin.clients.static_section': 'Clients estáticos (config)',
|
|
201
|
+
'admin.clients.dynamic_section': 'Clients dinâmicos (adapter)',
|
|
202
|
+
'admin.clients.dynamic_empty': 'Nenhum client dinâmico persistido.',
|
|
203
|
+
'admin.clients.dynamic_not_supported': 'O adapter OIDC configurado não suporta enumeração de clients — a gestão dinâmica fica indisponível.',
|
|
204
|
+
'admin.clients.new': 'Novo client',
|
|
205
|
+
'admin.clients.new_title': 'Novo client OIDC',
|
|
206
|
+
'admin.clients.edit_title': 'Editar client OIDC',
|
|
207
|
+
'admin.clients.edit': 'Editar',
|
|
208
|
+
'admin.clients.delete': 'Excluir',
|
|
209
|
+
'admin.clients.delete_confirm': 'Excluir este client? Esta ação não pode ser desfeita.',
|
|
210
|
+
'admin.clients.regenerate_secret': 'Regenerar secret',
|
|
211
|
+
'admin.clients.regenerate_confirm': 'Regenerar o secret? O secret atual deixará de funcionar imediatamente.',
|
|
212
|
+
'admin.clients.back': 'Voltar',
|
|
213
|
+
'admin.clients.cancel': 'Cancelar',
|
|
214
|
+
'admin.clients.save': 'Salvar',
|
|
215
|
+
'admin.clients.create': 'Criar client',
|
|
216
|
+
'admin.clients.secret_once_title': 'Guarde o client_secret agora',
|
|
217
|
+
'admin.clients.secret_once_notice': 'Este é o único momento em que o secret é exibido. Copie-o agora — ele não pode ser recuperado depois.',
|
|
218
|
+
'admin.clients.field_client_id': 'Client ID',
|
|
219
|
+
'admin.clients.field_client_id_placeholder': 'deixe em branco para gerar automaticamente',
|
|
220
|
+
'admin.clients.field_client_id_help': 'Opcional. Se vazio, um identificador aleatório será gerado.',
|
|
221
|
+
'admin.clients.field_redirect_uris': 'Redirect URIs',
|
|
222
|
+
'admin.clients.field_redirect_uris_help': 'Uma URI por linha.',
|
|
223
|
+
'admin.clients.field_post_logout_uris': 'Post-logout redirect URIs',
|
|
224
|
+
'admin.clients.field_post_logout_uris_help': 'Uma URI por linha (opcional).',
|
|
225
|
+
'admin.clients.field_grant_types': 'Grant types',
|
|
226
|
+
'admin.clients.field_auth_method': 'Token endpoint auth method',
|
|
157
227
|
// Console admin — auditoria.
|
|
158
228
|
'admin.audit.page_title': 'Auditoria',
|
|
159
229
|
'admin.audit.title': 'Log de auditoria',
|
|
@@ -166,6 +236,21 @@ export const DEFAULT_MESSAGES = {
|
|
|
166
236
|
'admin.pagination.page': 'Página {page} de {total}',
|
|
167
237
|
'admin.pagination.prev': 'Anterior',
|
|
168
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.',
|
|
169
254
|
// Mensagens de erro/flash produzidas pelos controllers.
|
|
170
255
|
'errors.invalid_credentials': 'Credenciais inválidas',
|
|
171
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,7 +141,18 @@ 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']);
|
|
148
|
+
// CRUD de clients OIDC (adapter-backed). `/new` ANTES de `:id` p/ não casar
|
|
149
|
+
// "new" como id; todas as escritas são POST (com _csrf na view).
|
|
150
|
+
router.get('/admin/clients/new', [C.adminClients, 'create']);
|
|
151
|
+
router.post('/admin/clients', [C.adminClients, 'store']);
|
|
152
|
+
router.get('/admin/clients/:id/edit', [C.adminClients, 'edit']);
|
|
153
|
+
router.post('/admin/clients/:id/edit', [C.adminClients, 'update']);
|
|
154
|
+
router.post('/admin/clients/:id/regenerate-secret', [C.adminClients, 'regenerateSecret']);
|
|
155
|
+
router.post('/admin/clients/:id/delete', [C.adminClients, 'destroy']);
|
|
136
156
|
router.get('/admin/audit', [C.adminAudit, 'index']);
|
|
137
157
|
})
|
|
138
158
|
.use([adminGuard]);
|
|
@@ -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
|
+
}));
|