@dudousxd/adonis-authkit-server 0.5.0 → 0.6.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/README.md +23 -2
- package/build/host/views/account/apps.edge +58 -0
- package/build/host/views/account/security.edge +53 -0
- package/build/host/views/account/tokens.edge +1 -0
- package/build/host/views/admin/users.edge +62 -2
- package/build/host/views/login.edge +55 -0
- package/build/host/views/mfa-challenge.edge +12 -0
- package/build/index.d.ts +8 -2
- package/build/index.js +4 -1
- package/build/src/accounts/account_store.d.ts +80 -2
- package/build/src/accounts/account_store.js +12 -0
- package/build/src/accounts/lucid_account_store.js +8 -0
- package/build/src/accounts/lucid_store/core.d.ts +2 -2
- package/build/src/accounts/lucid_store/core.js +33 -0
- package/build/src/accounts/lucid_store/mfa.js +4 -1
- package/build/src/accounts/lucid_store/status_profile.d.ts +21 -0
- package/build/src/accounts/lucid_store/status_profile.js +66 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/define_config.d.ts +53 -0
- package/build/src/define_config.js +14 -1
- package/build/src/doctor/checks.js +32 -32
- package/build/src/events/dispatcher.d.ts +45 -0
- package/build/src/events/dispatcher.js +92 -0
- package/build/src/host/admin_sessions_service.d.ts +8 -0
- package/build/src/host/admin_sessions_service.js +19 -0
- package/build/src/host/controllers/account_apps_controller.d.ts +15 -0
- package/build/src/host/controllers/account_apps_controller.js +61 -0
- package/build/src/host/controllers/account_security_controller.d.ts +9 -0
- package/build/src/host/controllers/account_security_controller.js +52 -2
- package/build/src/host/controllers/account_session_controller.js +3 -1
- package/build/src/host/controllers/admin/admin_users_controller.d.ts +13 -0
- package/build/src/host/controllers/admin/admin_users_controller.js +133 -0
- package/build/src/host/controllers/interaction_controller.d.ts +32 -0
- package/build/src/host/controllers/interaction_controller.js +169 -6
- package/build/src/host/default_mailer.d.ts +8 -0
- package/build/src/host/default_mailer.js +28 -0
- package/build/src/host/i18n.d.ts +90 -0
- package/build/src/host/i18n.js +98 -0
- package/build/src/host/login_attempt.d.ts +1 -0
- package/build/src/host/login_attempt.js +11 -0
- package/build/src/host/register_auth_host.js +18 -1
- package/build/src/host/trusted_device.d.ts +61 -0
- package/build/src/host/trusted_device.js +65 -0
- package/build/src/host/validators.d.ts +35 -0
- package/build/src/host/validators.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
|
|
3
|
+
import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
4
|
+
/**
|
|
5
|
+
* Self-service de consentimento ("apps com acesso") no console de conta. Lista os
|
|
6
|
+
* Grants da própria conta agrupados por client (resolvendo o nome do client da
|
|
7
|
+
* config estática ou do payload do adapter) e permite revogar o acesso de um
|
|
8
|
+
* client (destrói os grants + AT/RT daquele client). Degrada graciosamente quando
|
|
9
|
+
* o adapter OIDC não enumera (`list`), espelhando o console admin.
|
|
10
|
+
*/
|
|
11
|
+
export default class AccountAppsController {
|
|
12
|
+
/** GET /account/apps — lista os apps com acesso (grants) da conta logada. */
|
|
13
|
+
async index(ctx) {
|
|
14
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
15
|
+
const cfg = service.config;
|
|
16
|
+
const render = cfg.render;
|
|
17
|
+
const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
18
|
+
const sessions = new AdminSessionsService(service);
|
|
19
|
+
const supported = sessions.canList;
|
|
20
|
+
const grantList = supported ? await sessions.listGrants(accountId) : [];
|
|
21
|
+
// Resolve o nome amigável do client: clientId é o fallback (config estática não
|
|
22
|
+
// carrega um display name).
|
|
23
|
+
const nameOf = (clientId) => clientId ?? '';
|
|
24
|
+
const revoked = ctx.session.flashMessages.get('appRevoked');
|
|
25
|
+
return render(ctx, 'account/apps', {
|
|
26
|
+
csrfToken: ctx.request.csrfToken,
|
|
27
|
+
supported,
|
|
28
|
+
revoked: revoked ?? null,
|
|
29
|
+
apps: grantList
|
|
30
|
+
.filter((g) => !!g.clientId)
|
|
31
|
+
.map((g) => ({
|
|
32
|
+
clientId: g.clientId,
|
|
33
|
+
name: nameOf(g.clientId),
|
|
34
|
+
accessTokens: g.accessTokens,
|
|
35
|
+
refreshTokens: g.refreshTokens,
|
|
36
|
+
})),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** POST /account/apps/:clientId/revoke — revoga o acesso de um client. */
|
|
40
|
+
async revoke(ctx) {
|
|
41
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
42
|
+
const cfg = service.config;
|
|
43
|
+
const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
44
|
+
const clientId = ctx.request.param('clientId');
|
|
45
|
+
const sessions = new AdminSessionsService(service);
|
|
46
|
+
const result = await sessions.revokeClientGrants(accountId, clientId);
|
|
47
|
+
await cfg.audit?.record({
|
|
48
|
+
type: 'grant.revoked_by_user',
|
|
49
|
+
accountId,
|
|
50
|
+
clientId,
|
|
51
|
+
ip: ctx.request.ip?.() ?? null,
|
|
52
|
+
metadata: {
|
|
53
|
+
grants: result.grants,
|
|
54
|
+
accessTokens: result.accessTokens,
|
|
55
|
+
refreshTokens: result.refreshTokens,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
ctx.session.flash('appRevoked', cfg.messages['account.apps.revoked'] ?? 'account.apps.revoked');
|
|
59
|
+
return ctx.response.redirect('/account/apps');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -9,6 +9,15 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
9
9
|
*/
|
|
10
10
|
export default class AccountSecurityController {
|
|
11
11
|
index(ctx: HttpContext): Promise<any>;
|
|
12
|
+
/**
|
|
13
|
+
* POST /account/security/trusted-devices/revoke
|
|
14
|
+
* Limpa o cookie de dispositivo confiável DESTE navegador (o MFA volta a ser
|
|
15
|
+
* exigido aqui). Revogação global por-dispositivo não existe sem estado
|
|
16
|
+
* server-side; re-enrolar o MFA invalida a confiança em TODOS os dispositivos.
|
|
17
|
+
*/
|
|
18
|
+
revokeTrustedDevices(ctx: HttpContext): Promise<void>;
|
|
19
|
+
/** POST /account/security/profile — atualiza nome + avatar do próprio perfil. */
|
|
20
|
+
updateProfile(ctx: HttpContext): Promise<void>;
|
|
12
21
|
changePassword(ctx: HttpContext): Promise<void>;
|
|
13
22
|
changeEmail(ctx: HttpContext): Promise<void>;
|
|
14
23
|
/** GET /account/email/confirm?token=... — consome o token e aplica o novo e-mail. */
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
|
|
3
|
-
import { supportsAccountSecurity } from '../../accounts/account_store.js';
|
|
4
|
-
import { changePasswordValidator, changeEmailValidator } from '../validators.js';
|
|
3
|
+
import { supportsAccountSecurity, supportsProfile } from '../../accounts/account_store.js';
|
|
4
|
+
import { changePasswordValidator, changeEmailValidator, updateProfileValidator } from '../validators.js';
|
|
5
5
|
import { sendEmailChangeConfirmationEmail } from '../default_mailer.js';
|
|
6
6
|
import { translate } from '../i18n.js';
|
|
7
|
+
import { TRUSTED_DEVICE_COOKIE } from '../trusted_device.js';
|
|
7
8
|
/**
|
|
8
9
|
* Self-service de segurança da conta (console de conta): trocar a senha e o
|
|
9
10
|
* e-mail. A troca de senha exige a senha ATUAL (verifyCredentials). A troca de
|
|
@@ -21,13 +22,62 @@ export default class AccountSecurityController {
|
|
|
21
22
|
return render(ctx, 'account/security', {
|
|
22
23
|
csrfToken: ctx.request.csrfToken,
|
|
23
24
|
supported: supportsAccountSecurity(cfg.accountStore),
|
|
25
|
+
profileSupported: supportsProfile(cfg.accountStore),
|
|
24
26
|
email: account?.email ?? '',
|
|
27
|
+
name: account?.name ?? '',
|
|
28
|
+
avatarUrl: account?.avatarUrl ?? '',
|
|
25
29
|
passwordChanged: ctx.session.flashMessages.get('passwordChanged') ?? null,
|
|
26
30
|
emailChangeRequested: ctx.session.flashMessages.get('emailChangeRequested') ?? null,
|
|
27
31
|
emailChanged: ctx.session.flashMessages.get('emailChanged') ?? null,
|
|
32
|
+
profileUpdated: ctx.session.flashMessages.get('profileUpdated') ?? null,
|
|
28
33
|
error: ctx.session.flashMessages.get('securityError') ?? null,
|
|
34
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
35
|
+
trustedDevicesRevoked: ctx.session.flashMessages.get('trustedDevicesRevoked') ?? null,
|
|
29
36
|
});
|
|
30
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* POST /account/security/trusted-devices/revoke
|
|
40
|
+
* Limpa o cookie de dispositivo confiável DESTE navegador (o MFA volta a ser
|
|
41
|
+
* exigido aqui). Revogação global por-dispositivo não existe sem estado
|
|
42
|
+
* server-side; re-enrolar o MFA invalida a confiança em TODOS os dispositivos.
|
|
43
|
+
*/
|
|
44
|
+
async revokeTrustedDevices(ctx) {
|
|
45
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
46
|
+
const cfg = service.config;
|
|
47
|
+
ctx.response.clearCookie(TRUSTED_DEVICE_COOKIE);
|
|
48
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
49
|
+
await cfg.audit?.record({
|
|
50
|
+
type: 'trusted_device.revoked',
|
|
51
|
+
accountId: userId,
|
|
52
|
+
ip: ctx.request.ip?.() ?? null,
|
|
53
|
+
});
|
|
54
|
+
ctx.session.flash('trustedDevicesRevoked', translate(cfg.messages, 'account.security.trusted_devices_revoked'));
|
|
55
|
+
return ctx.response.redirect('/account/security');
|
|
56
|
+
}
|
|
57
|
+
/** POST /account/security/profile — atualiza nome + avatar do próprio perfil. */
|
|
58
|
+
async updateProfile(ctx) {
|
|
59
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
60
|
+
const cfg = service.config;
|
|
61
|
+
const store = cfg.accountStore;
|
|
62
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
63
|
+
if (!supportsProfile(store)) {
|
|
64
|
+
return ctx.response.redirect('/account/security');
|
|
65
|
+
}
|
|
66
|
+
const { name, avatarUrl } = await ctx.request.validateUsing(updateProfileValidator);
|
|
67
|
+
// Campos ausentes no form viram string vazia (limpa o valor); enviamos null
|
|
68
|
+
// para limpar, ou o valor trimado.
|
|
69
|
+
await store.updateProfile(userId, {
|
|
70
|
+
name: name ?? null,
|
|
71
|
+
avatarUrl: avatarUrl ?? null,
|
|
72
|
+
});
|
|
73
|
+
await cfg.audit?.record({
|
|
74
|
+
type: 'profile.updated',
|
|
75
|
+
accountId: userId,
|
|
76
|
+
ip: ctx.request.ip?.() ?? null,
|
|
77
|
+
});
|
|
78
|
+
ctx.session.flash('profileUpdated', translate(cfg.messages, 'account.profile.updated'));
|
|
79
|
+
return ctx.response.redirect('/account/security');
|
|
80
|
+
}
|
|
31
81
|
async changePassword(ctx) {
|
|
32
82
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
33
83
|
const cfg = service.config;
|
|
@@ -28,7 +28,9 @@ export default class AccountSessionController {
|
|
|
28
28
|
? translate(cfg.messages, 'errors.account_locked', {
|
|
29
29
|
seconds: result.retryAfterSec ?? 0,
|
|
30
30
|
})
|
|
31
|
-
:
|
|
31
|
+
: result.disabled
|
|
32
|
+
? translate(cfg.messages, 'errors.account_disabled')
|
|
33
|
+
: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
36
|
const acc = result.account;
|
|
@@ -6,6 +6,19 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
6
6
|
* vírgula no formulário e normalizadas aqui.
|
|
7
7
|
*/
|
|
8
8
|
export default class AdminUsersController {
|
|
9
|
+
#private;
|
|
9
10
|
index(ctx: HttpContext): Promise<any>;
|
|
11
|
+
/**
|
|
12
|
+
* POST /admin/users — cria uma conta. Se `password` for informado, a conta já
|
|
13
|
+
* nasce com senha. Senão, emite um token de reset e envia o e-mail (o usuário
|
|
14
|
+
* define a própria senha) — fluxo "create + invite". Audita `user.created`.
|
|
15
|
+
*/
|
|
16
|
+
store(ctx: HttpContext): Promise<void>;
|
|
17
|
+
/** POST /admin/users/:id/reset-password — emite token de reset + envia e-mail. */
|
|
18
|
+
resetPassword(ctx: HttpContext): Promise<void>;
|
|
19
|
+
/** POST /admin/users/:id/disable — desabilita a conta (bloqueia login). */
|
|
20
|
+
disable(ctx: HttpContext): Promise<void>;
|
|
21
|
+
/** POST /admin/users/:id/enable — reabilita a conta. */
|
|
22
|
+
enable(ctx: HttpContext): Promise<void>;
|
|
10
23
|
updateRoles(ctx: HttpContext): Promise<void>;
|
|
11
24
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import '../../augmentations.js';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { supportsAccountStatus } from '../../../accounts/account_store.js';
|
|
4
|
+
import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
|
|
5
|
+
import { adminCreateUserValidator } from '../../validators.js';
|
|
6
|
+
import { sendPasswordResetEmail } from '../../default_mailer.js';
|
|
2
7
|
const PAGE_SIZE = 20;
|
|
3
8
|
/**
|
|
4
9
|
* Gestão de usuários do IdP: listagem paginada com busca por e-mail e edição das
|
|
@@ -14,21 +19,149 @@ export default class AdminUsersController {
|
|
|
14
19
|
const page = Math.max(1, Number.parseInt(ctx.request.input('page', '1'), 10) || 1);
|
|
15
20
|
const result = await cfg.accountStore.listAccounts({ search, page, limit: PAGE_SIZE });
|
|
16
21
|
const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
|
|
22
|
+
const store = cfg.accountStore;
|
|
23
|
+
const statusSupported = supportsAccountStatus(store);
|
|
24
|
+
// Resolve o estado de disabled por conta (só quando suportado).
|
|
25
|
+
const disabledMap = new Map();
|
|
26
|
+
if (statusSupported) {
|
|
27
|
+
for (const u of result.data) {
|
|
28
|
+
disabledMap.set(u.id, await store.isDisabled(u.id));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
17
31
|
return render(ctx, 'admin/users', {
|
|
18
32
|
csrfToken: ctx.request.csrfToken,
|
|
19
33
|
search,
|
|
20
34
|
page,
|
|
21
35
|
totalPages,
|
|
22
36
|
total: result.total,
|
|
37
|
+
statusSupported,
|
|
38
|
+
created: ctx.session.flashMessages.get('userCreated') ?? null,
|
|
39
|
+
resetSent: ctx.session.flashMessages.get('resetSent') ?? null,
|
|
40
|
+
statusChanged: ctx.session.flashMessages.get('statusChanged') ?? null,
|
|
41
|
+
error: ctx.session.flashMessages.get('usersError') ?? null,
|
|
23
42
|
users: result.data.map((u) => ({
|
|
24
43
|
id: u.id,
|
|
25
44
|
email: u.email,
|
|
26
45
|
name: u.name ?? '',
|
|
27
46
|
roles: u.globalRoles ?? [],
|
|
28
47
|
rolesText: (u.globalRoles ?? []).join(', '),
|
|
48
|
+
disabled: disabledMap.get(u.id) ?? false,
|
|
29
49
|
})),
|
|
30
50
|
});
|
|
31
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* POST /admin/users — cria uma conta. Se `password` for informado, a conta já
|
|
54
|
+
* nasce com senha. Senão, emite um token de reset e envia o e-mail (o usuário
|
|
55
|
+
* define a própria senha) — fluxo "create + invite". Audita `user.created`.
|
|
56
|
+
*/
|
|
57
|
+
async store(ctx) {
|
|
58
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
59
|
+
const cfg = service.config;
|
|
60
|
+
const store = cfg.accountStore;
|
|
61
|
+
const actorId = ctx.session.get(ACCOUNT_SESSION_KEY) ?? null;
|
|
62
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
63
|
+
const { email, name, password } = await ctx.request.validateUsing(adminCreateUserValidator);
|
|
64
|
+
const existing = await store.findByEmail(email);
|
|
65
|
+
if (existing) {
|
|
66
|
+
ctx.session.flash('usersError', cfg.messages['errors.email_taken'] ?? 'errors.email_taken');
|
|
67
|
+
return ctx.response.redirect('/admin/users');
|
|
68
|
+
}
|
|
69
|
+
// Sem senha informada: cria com uma senha aleatória forte (descartável) e
|
|
70
|
+
// dispara o fluxo de reset para o usuário definir a sua.
|
|
71
|
+
const initialPassword = password ?? randomBytes(24).toString('hex');
|
|
72
|
+
const account = await store.create({ email, password: initialPassword, fullName: name ?? null });
|
|
73
|
+
await cfg.audit?.record({
|
|
74
|
+
type: 'user.created',
|
|
75
|
+
accountId: account.id,
|
|
76
|
+
email,
|
|
77
|
+
actorId,
|
|
78
|
+
ip,
|
|
79
|
+
metadata: { invited: !password },
|
|
80
|
+
});
|
|
81
|
+
if (!password) {
|
|
82
|
+
await this.#sendResetEmail(ctx, cfg, email);
|
|
83
|
+
}
|
|
84
|
+
ctx.session.flash('userCreated', cfg.messages['admin.users.created'] ?? 'admin.users.created');
|
|
85
|
+
return ctx.response.redirect('/admin/users');
|
|
86
|
+
}
|
|
87
|
+
/** POST /admin/users/:id/reset-password — emite token de reset + envia e-mail. */
|
|
88
|
+
async resetPassword(ctx) {
|
|
89
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
90
|
+
const cfg = service.config;
|
|
91
|
+
const store = cfg.accountStore;
|
|
92
|
+
const actorId = ctx.session.get(ACCOUNT_SESSION_KEY) ?? null;
|
|
93
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
94
|
+
const accountId = ctx.request.param('id');
|
|
95
|
+
const account = await store.findById(accountId);
|
|
96
|
+
if (account) {
|
|
97
|
+
await this.#sendResetEmail(ctx, cfg, account.email);
|
|
98
|
+
await cfg.audit?.record({
|
|
99
|
+
type: 'user.password_reset_sent',
|
|
100
|
+
accountId,
|
|
101
|
+
email: account.email,
|
|
102
|
+
actorId,
|
|
103
|
+
ip,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
ctx.session.flash('resetSent', cfg.messages['admin.users.reset_sent'] ?? 'admin.users.reset_sent');
|
|
107
|
+
return this.#redirectBack(ctx);
|
|
108
|
+
}
|
|
109
|
+
/** POST /admin/users/:id/disable — desabilita a conta (bloqueia login). */
|
|
110
|
+
async disable(ctx) {
|
|
111
|
+
return this.#toggleStatus(ctx, true);
|
|
112
|
+
}
|
|
113
|
+
/** POST /admin/users/:id/enable — reabilita a conta. */
|
|
114
|
+
async enable(ctx) {
|
|
115
|
+
return this.#toggleStatus(ctx, false);
|
|
116
|
+
}
|
|
117
|
+
async #toggleStatus(ctx, disable) {
|
|
118
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
119
|
+
const cfg = service.config;
|
|
120
|
+
const store = cfg.accountStore;
|
|
121
|
+
const actorId = ctx.session.get(ACCOUNT_SESSION_KEY) ?? null;
|
|
122
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
123
|
+
const accountId = ctx.request.param('id');
|
|
124
|
+
if (supportsAccountStatus(store)) {
|
|
125
|
+
if (disable)
|
|
126
|
+
await store.disableAccount(accountId);
|
|
127
|
+
else
|
|
128
|
+
await store.enableAccount(accountId);
|
|
129
|
+
await cfg.audit?.record({
|
|
130
|
+
type: disable ? 'user.disabled' : 'user.enabled',
|
|
131
|
+
accountId,
|
|
132
|
+
actorId,
|
|
133
|
+
ip,
|
|
134
|
+
});
|
|
135
|
+
ctx.session.flash('statusChanged', cfg.messages[disable ? 'admin.users.disabled' : 'admin.users.enabled'] ??
|
|
136
|
+
(disable ? 'admin.users.disabled' : 'admin.users.enabled'));
|
|
137
|
+
}
|
|
138
|
+
return this.#redirectBack(ctx);
|
|
139
|
+
}
|
|
140
|
+
/** Emite o token de reset e dispara o e-mail (hook do config tem prioridade). */
|
|
141
|
+
async #sendResetEmail(ctx, cfg, email) {
|
|
142
|
+
const issued = await cfg.accountStore.issuePasswordResetToken(email);
|
|
143
|
+
if (!issued)
|
|
144
|
+
return;
|
|
145
|
+
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
146
|
+
const resetUrl = `${origin}/auth/reset-password?token=${encodeURIComponent(issued.token)}`;
|
|
147
|
+
if (cfg.mail?.onPasswordReset) {
|
|
148
|
+
await cfg.mail.onPasswordReset({ email, resetUrl, token: issued.token });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
await sendPasswordResetEmail(ctx, { email, resetUrl });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
#redirectBack(ctx) {
|
|
155
|
+
const search = ctx.request.input('search', '').trim();
|
|
156
|
+
const page = Math.max(1, Number.parseInt(ctx.request.input('page', '1'), 10) || 1);
|
|
157
|
+
const qs = new URLSearchParams();
|
|
158
|
+
if (search)
|
|
159
|
+
qs.set('search', search);
|
|
160
|
+
if (page > 1)
|
|
161
|
+
qs.set('page', String(page));
|
|
162
|
+
const query = qs.toString();
|
|
163
|
+
return ctx.response.redirect(`/admin/users${query ? `?${query}` : ''}`);
|
|
164
|
+
}
|
|
32
165
|
async updateRoles(ctx) {
|
|
33
166
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
34
167
|
const cfg = service.config;
|
|
@@ -32,6 +32,38 @@ export default class AuthInteractionController {
|
|
|
32
32
|
private stepUpExtra;
|
|
33
33
|
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
34
34
|
private hasPasskeys;
|
|
35
|
+
/**
|
|
36
|
+
* Lê o cookie de dispositivo confiável (encriptado, appKey-backed) e valida que
|
|
37
|
+
* pertence a `accountId`, não expirou e é posterior ao último (re)enrollment de
|
|
38
|
+
* MFA. Step-up NÃO chama isto (força sempre o MFA). Best-effort: qualquer erro de
|
|
39
|
+
* leitura → não confiável.
|
|
40
|
+
*/
|
|
41
|
+
private checkTrustedDevice;
|
|
42
|
+
/**
|
|
43
|
+
* Se o checkbox "confiar neste dispositivo" foi marcado E o mecanismo está ligado,
|
|
44
|
+
* grava o cookie encriptado de confiança para a conta (skip MFA por N dias).
|
|
45
|
+
*/
|
|
46
|
+
private maybeTrustDevice;
|
|
47
|
+
/**
|
|
48
|
+
* accountId para uma cerimônia de passkey no login. Prioriza o accountId pendente
|
|
49
|
+
* do MFA (passkey como 2º fator). Quando ausente e o passkey-first está ligado,
|
|
50
|
+
* resolve a conta pelo e-mail guardado na sessão (passkey ANTES da senha) — só se
|
|
51
|
+
* a conta existe E tem ao menos uma passkey.
|
|
52
|
+
*/
|
|
53
|
+
private resolvePasskeyAccountId;
|
|
54
|
+
/**
|
|
55
|
+
* POST /auth/interaction/:uid/magic
|
|
56
|
+
* Magic link: lê o e-mail da sessão (passwordless.magicLink ligado), emite um
|
|
57
|
+
* token de uso único e dispara o e-mail. SEMPRE renderiza "link enviado",
|
|
58
|
+
* independentemente de a conta existir (anti-enumeração). Throttled como o login.
|
|
59
|
+
*/
|
|
60
|
+
magicLinkRequest(ctx: HttpContext): Promise<any>;
|
|
61
|
+
/**
|
|
62
|
+
* GET /auth/interaction/:uid/magic?token=...
|
|
63
|
+
* Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
|
|
64
|
+
* inválido/expirado volta ao início do login.
|
|
65
|
+
*/
|
|
66
|
+
magicLinkConsume(ctx: HttpContext): Promise<void>;
|
|
35
67
|
/**
|
|
36
68
|
* POST /auth/interaction/:uid/passkey/options
|
|
37
69
|
* Gera as opções de autenticação por passkey para o accountId pendente do MFA,
|
|
@@ -3,7 +3,9 @@ import { brandFor, isFirstParty } from '../branding.js';
|
|
|
3
3
|
import { translate } from '../i18n.js';
|
|
4
4
|
import { attemptPasswordLogin } from '../login_attempt.js';
|
|
5
5
|
import { notifyLoginSuccess } from '../login_notify.js';
|
|
6
|
-
import { supportsPasskeys } from '../../accounts/account_store.js';
|
|
6
|
+
import { supportsPasskeys, supportsMagicLink } from '../../accounts/account_store.js';
|
|
7
|
+
import { sendMagicLinkEmail } from '../default_mailer.js';
|
|
8
|
+
import { TRUSTED_DEVICE_COOKIE, buildTrustedDevicePayload, isTrustedDeviceValid, } from '../trusted_device.js';
|
|
7
9
|
const SESSION_KEY = 'authkit_login_email';
|
|
8
10
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
9
11
|
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
@@ -45,6 +47,10 @@ export default class AuthInteractionController {
|
|
|
45
47
|
// Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
|
|
46
48
|
const acc = await cfg.accountStore.findByEmail(email);
|
|
47
49
|
const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
|
|
50
|
+
// Passwordless: magic link disponível se ligado E o store suporta. Passkey-first
|
|
51
|
+
// disponível se ligado, o store suporta E a conta tem ao menos uma passkey.
|
|
52
|
+
const magicLinkAvailable = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
|
|
53
|
+
const passkeyFirstAvailable = cfg.passwordless.passkeyFirst && !!acc && (await this.hasPasskeys(cfg, acc.id));
|
|
48
54
|
return render(ctx, 'login', {
|
|
49
55
|
uid: details.uid,
|
|
50
56
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -52,6 +58,8 @@ export default class AuthInteractionController {
|
|
|
52
58
|
email,
|
|
53
59
|
account,
|
|
54
60
|
brand,
|
|
61
|
+
magicLinkAvailable,
|
|
62
|
+
passkeyFirstAvailable,
|
|
55
63
|
});
|
|
56
64
|
}
|
|
57
65
|
/**
|
|
@@ -102,7 +110,9 @@ export default class AuthInteractionController {
|
|
|
102
110
|
? translate(cfg.messages, 'errors.account_locked', {
|
|
103
111
|
seconds: result.retryAfterSec ?? 0,
|
|
104
112
|
})
|
|
105
|
-
:
|
|
113
|
+
: result.disabled
|
|
114
|
+
? translate(cfg.messages, 'errors.account_disabled')
|
|
115
|
+
: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
106
116
|
brand,
|
|
107
117
|
});
|
|
108
118
|
}
|
|
@@ -126,6 +136,18 @@ export default class AuthInteractionController {
|
|
|
126
136
|
noEnrollment: true,
|
|
127
137
|
});
|
|
128
138
|
}
|
|
139
|
+
// Trusted device: se o mecanismo está ligado, a conta JÁ tem MFA enrolado e
|
|
140
|
+
// o request NÃO é um step-up (que sempre força o MFA), um cookie de confiança
|
|
141
|
+
// válido para ESTA conta pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
|
|
142
|
+
if (cfg.trustedDevices.enabled && mfa.enabled && !mfaRequired) {
|
|
143
|
+
const trusted = await this.checkTrustedDevice(ctx, acc.id, mfa.enabledAt ?? null);
|
|
144
|
+
if (trusted) {
|
|
145
|
+
await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
|
|
146
|
+
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
|
|
147
|
+
ctx.session.forget(SESSION_KEY);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
129
151
|
ctx.session.put(MFA_PENDING_KEY, acc.id);
|
|
130
152
|
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
131
153
|
// tem ao menos uma credencial registrada.
|
|
@@ -135,6 +157,8 @@ export default class AuthInteractionController {
|
|
|
135
157
|
csrfToken: ctx.request.csrfToken,
|
|
136
158
|
brand,
|
|
137
159
|
passkeyAvailable,
|
|
160
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
161
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
138
162
|
});
|
|
139
163
|
}
|
|
140
164
|
// Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
|
|
@@ -185,9 +209,13 @@ export default class AuthInteractionController {
|
|
|
185
209
|
error: translate(cfg.messages, 'errors.invalid_code'),
|
|
186
210
|
brand,
|
|
187
211
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
212
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
213
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
188
214
|
});
|
|
189
215
|
}
|
|
190
|
-
// Sucesso no 2º fator:
|
|
216
|
+
// Sucesso no 2º fator: opcionalmente confia neste dispositivo (checkbox).
|
|
217
|
+
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
218
|
+
// Finaliza a interaction para o accountId pendente.
|
|
191
219
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
192
220
|
ctx.session.forget(SESSION_KEY);
|
|
193
221
|
await notifyLoginSuccess(ctx, cfg, {
|
|
@@ -230,6 +258,135 @@ export default class AuthInteractionController {
|
|
|
230
258
|
const list = await cfg.accountStore.listPasskeys(accountId);
|
|
231
259
|
return Array.isArray(list) && list.length > 0;
|
|
232
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Lê o cookie de dispositivo confiável (encriptado, appKey-backed) e valida que
|
|
263
|
+
* pertence a `accountId`, não expirou e é posterior ao último (re)enrollment de
|
|
264
|
+
* MFA. Step-up NÃO chama isto (força sempre o MFA). Best-effort: qualquer erro de
|
|
265
|
+
* leitura → não confiável.
|
|
266
|
+
*/
|
|
267
|
+
async checkTrustedDevice(ctx, accountId, mfaEnabledAt) {
|
|
268
|
+
try {
|
|
269
|
+
const payload = ctx.request.encryptedCookie(TRUSTED_DEVICE_COOKIE);
|
|
270
|
+
return isTrustedDeviceValid(payload, { accountId, mfaEnabledAt });
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Se o checkbox "confiar neste dispositivo" foi marcado E o mecanismo está ligado,
|
|
278
|
+
* grava o cookie encriptado de confiança para a conta (skip MFA por N dias).
|
|
279
|
+
*/
|
|
280
|
+
async maybeTrustDevice(ctx, cfg, accountId) {
|
|
281
|
+
if (!cfg.trustedDevices.enabled)
|
|
282
|
+
return;
|
|
283
|
+
const checked = ctx.request.input('trustDevice');
|
|
284
|
+
// Checkbox HTML: presente ('on'/'true'/'1') = marcado.
|
|
285
|
+
const on = checked === 'on' || checked === 'true' || checked === '1' || checked === true;
|
|
286
|
+
if (!on)
|
|
287
|
+
return;
|
|
288
|
+
const payload = buildTrustedDevicePayload(accountId, cfg.trustedDevices);
|
|
289
|
+
ctx.response.encryptedCookie(TRUSTED_DEVICE_COOKIE, payload, {
|
|
290
|
+
httpOnly: true,
|
|
291
|
+
sameSite: 'lax',
|
|
292
|
+
maxAge: cfg.trustedDevices.days * 24 * 60 * 60,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* accountId para uma cerimônia de passkey no login. Prioriza o accountId pendente
|
|
297
|
+
* do MFA (passkey como 2º fator). Quando ausente e o passkey-first está ligado,
|
|
298
|
+
* resolve a conta pelo e-mail guardado na sessão (passkey ANTES da senha) — só se
|
|
299
|
+
* a conta existe E tem ao menos uma passkey.
|
|
300
|
+
*/
|
|
301
|
+
async resolvePasskeyAccountId(ctx, cfg) {
|
|
302
|
+
const pending = ctx.session.get(MFA_PENDING_KEY);
|
|
303
|
+
if (pending)
|
|
304
|
+
return pending;
|
|
305
|
+
if (!cfg.passwordless?.passkeyFirst)
|
|
306
|
+
return undefined;
|
|
307
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
308
|
+
if (!email)
|
|
309
|
+
return undefined;
|
|
310
|
+
const acc = await cfg.accountStore.findByEmail(email);
|
|
311
|
+
if (!acc)
|
|
312
|
+
return undefined;
|
|
313
|
+
return (await this.hasPasskeys(cfg, acc.id)) ? acc.id : undefined;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* POST /auth/interaction/:uid/magic
|
|
317
|
+
* Magic link: lê o e-mail da sessão (passwordless.magicLink ligado), emite um
|
|
318
|
+
* token de uso único e dispara o e-mail. SEMPRE renderiza "link enviado",
|
|
319
|
+
* independentemente de a conta existir (anti-enumeração). Throttled como o login.
|
|
320
|
+
*/
|
|
321
|
+
async magicLinkRequest(ctx) {
|
|
322
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
323
|
+
const cfg = service.config;
|
|
324
|
+
const render = cfg.render;
|
|
325
|
+
const details = await service.interactions.details(ctx);
|
|
326
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
327
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
328
|
+
const uid = ctx.request.param('uid');
|
|
329
|
+
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
|
|
330
|
+
const issued = await cfg.accountStore.issueMagicLinkToken(email);
|
|
331
|
+
if (issued) {
|
|
332
|
+
await cfg.audit?.record({
|
|
333
|
+
type: 'login.magic_link_sent',
|
|
334
|
+
accountId: issued.account.id,
|
|
335
|
+
email,
|
|
336
|
+
ip: ctx.request.ip?.() ?? null,
|
|
337
|
+
clientId: details.params.client_id ?? null,
|
|
338
|
+
});
|
|
339
|
+
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
340
|
+
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
341
|
+
if (cfg.mail?.onMagicLink) {
|
|
342
|
+
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token });
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
await sendMagicLinkEmail(ctx, { email, magicUrl });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Resposta uniforme (não vaza existência de conta).
|
|
350
|
+
return render(ctx, 'login', {
|
|
351
|
+
uid,
|
|
352
|
+
csrfToken: ctx.request.csrfToken,
|
|
353
|
+
step: 'password',
|
|
354
|
+
email,
|
|
355
|
+
account: null,
|
|
356
|
+
brand,
|
|
357
|
+
magicLinkSent: true,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* GET /auth/interaction/:uid/magic?token=...
|
|
362
|
+
* Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
|
|
363
|
+
* inválido/expirado volta ao início do login.
|
|
364
|
+
*/
|
|
365
|
+
async magicLinkConsume(ctx) {
|
|
366
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
367
|
+
const cfg = service.config;
|
|
368
|
+
const uid = ctx.request.param('uid');
|
|
369
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
370
|
+
const clientId = (await service.interactions.details(ctx)).params.client_id;
|
|
371
|
+
const token = ctx.request.qs().token ?? '';
|
|
372
|
+
if (!cfg.passwordless.magicLink || !supportsMagicLink(cfg.accountStore) || !token) {
|
|
373
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
374
|
+
}
|
|
375
|
+
const acc = await cfg.accountStore.consumeMagicLinkToken(token);
|
|
376
|
+
if (!acc) {
|
|
377
|
+
await cfg.audit?.record({ type: 'login.failure', ip, clientId, metadata: { stage: 'magic_link' } });
|
|
378
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
379
|
+
}
|
|
380
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
381
|
+
accountId: acc.id,
|
|
382
|
+
email: acc.email,
|
|
383
|
+
ip,
|
|
384
|
+
clientId: clientId ?? null,
|
|
385
|
+
metadata: { method: 'magic_link' },
|
|
386
|
+
});
|
|
387
|
+
ctx.session.forget(SESSION_KEY);
|
|
388
|
+
await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
|
|
389
|
+
}
|
|
233
390
|
/**
|
|
234
391
|
* POST /auth/interaction/:uid/passkey/options
|
|
235
392
|
* Gera as opções de autenticação por passkey para o accountId pendente do MFA,
|
|
@@ -238,7 +395,7 @@ export default class AuthInteractionController {
|
|
|
238
395
|
async passkeyOptions(ctx) {
|
|
239
396
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
240
397
|
const cfg = service.config;
|
|
241
|
-
const accountId =
|
|
398
|
+
const accountId = await this.resolvePasskeyAccountId(ctx, cfg);
|
|
242
399
|
if (!accountId) {
|
|
243
400
|
return ctx.response.badRequest({
|
|
244
401
|
message: translate(cfg.messages, 'errors.session_expired'),
|
|
@@ -266,7 +423,7 @@ export default class AuthInteractionController {
|
|
|
266
423
|
const render = cfg.render;
|
|
267
424
|
const details = await service.interactions.details(ctx);
|
|
268
425
|
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
269
|
-
const accountId =
|
|
426
|
+
const accountId = await this.resolvePasskeyAccountId(ctx, cfg);
|
|
270
427
|
const challenge = ctx.session.get(PASSKEY_AUTH_CHALLENGE_KEY);
|
|
271
428
|
const ip = ctx.request.ip?.() ?? null;
|
|
272
429
|
const clientId = details.params.client_id ?? null;
|
|
@@ -301,8 +458,12 @@ export default class AuthInteractionController {
|
|
|
301
458
|
error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
|
|
302
459
|
brand,
|
|
303
460
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
461
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
462
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
304
463
|
});
|
|
305
464
|
}
|
|
465
|
+
// Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
|
|
466
|
+
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
306
467
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
307
468
|
ctx.session.forget(SESSION_KEY);
|
|
308
469
|
await notifyLoginSuccess(ctx, cfg, {
|
|
@@ -311,7 +472,9 @@ export default class AuthInteractionController {
|
|
|
311
472
|
clientId,
|
|
312
473
|
metadata: { mfa: 'webauthn' },
|
|
313
474
|
});
|
|
314
|
-
|
|
475
|
+
// Step-up carimba acr/amr quando solicitado; senão a passkey conta como o fator
|
|
476
|
+
// forte do login (amr `['webauthn']`) — vale tanto p/ MFA quanto passkey-first.
|
|
477
|
+
await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] });
|
|
315
478
|
}
|
|
316
479
|
/**
|
|
317
480
|
* GET /auth/interaction/:uid/switch
|
|
@@ -45,6 +45,14 @@ export declare function sendNewLoginEmail(ctx: HttpContext, data: {
|
|
|
45
45
|
ip: string;
|
|
46
46
|
when: string;
|
|
47
47
|
}): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Envia o e-mail de magic link (login passwordless) pelo mailer default do host.
|
|
50
|
+
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
51
|
+
*/
|
|
52
|
+
export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
|
|
53
|
+
email: string;
|
|
54
|
+
magicUrl: string;
|
|
55
|
+
}): Promise<void>;
|
|
48
56
|
/**
|
|
49
57
|
* Envia o e-mail de verificação pelo mailer default do host.
|
|
50
58
|
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|