@dudousxd/adonis-authkit-server 0.5.0 → 0.7.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 +66 -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 +82 -0
- package/build/src/define_config.js +24 -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/avatar_storage.d.ts +58 -0
- package/build/src/host/avatar_storage.js +125 -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 +84 -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 +98 -0
- package/build/src/host/i18n.js +106 -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 +6 -1
|
@@ -60,4 +60,12 @@ export declare class AdminSessionsService {
|
|
|
60
60
|
* deixando o store limpo. Retorna as contagens do que foi removido.
|
|
61
61
|
*/
|
|
62
62
|
revokeAll(accountId: string): Promise<RevokeResult>;
|
|
63
|
+
/**
|
|
64
|
+
* Revoga os grants de uma conta para UM client específico (+ tokens ligados),
|
|
65
|
+
* deixando os demais clients intactos. Reaproveita a lógica de {@link revokeAll}
|
|
66
|
+
* restrita ao `clientId`. Usado pelo self-service de consentimento
|
|
67
|
+
* (/account/apps) e por qualquer revogação granular do admin. Retorna as
|
|
68
|
+
* contagens do que foi removido.
|
|
69
|
+
*/
|
|
70
|
+
revokeClientGrants(accountId: string, clientId: string): Promise<RevokeResult>;
|
|
63
71
|
}
|
|
@@ -108,6 +108,25 @@ export class AdminSessionsService {
|
|
|
108
108
|
refreshTokens,
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Revoga os grants de uma conta para UM client específico (+ tokens ligados),
|
|
113
|
+
* deixando os demais clients intactos. Reaproveita a lógica de {@link revokeAll}
|
|
114
|
+
* restrita ao `clientId`. Usado pelo self-service de consentimento
|
|
115
|
+
* (/account/apps) e por qualquer revogação granular do admin. Retorna as
|
|
116
|
+
* contagens do que foi removido.
|
|
117
|
+
*/
|
|
118
|
+
async revokeClientGrants(accountId, clientId) {
|
|
119
|
+
const grantAdapter = this.#adapter('Grant');
|
|
120
|
+
const grants = (await this.listGrants(accountId)).filter((g) => g.clientId === clientId);
|
|
121
|
+
const grantIds = new Set(grants.map((g) => g.id));
|
|
122
|
+
const accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
|
|
123
|
+
const refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
|
|
124
|
+
for (const g of grants) {
|
|
125
|
+
await grantAdapter.revokeByGrantId(g.id);
|
|
126
|
+
await grantAdapter.destroy(g.id);
|
|
127
|
+
}
|
|
128
|
+
return { sessions: 0, grants: grants.length, accessTokens, refreshTokens };
|
|
129
|
+
}
|
|
111
130
|
/** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
|
|
112
131
|
async #destroyTokensOfGrants(model, grantIds) {
|
|
113
132
|
const adapter = this.#adapter(model);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import type { ResolvedUploadsConfig } from '../define_config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Upload de avatar do host-kit usando o `@adonisjs/drive` JÁ configurado no app.
|
|
5
|
+
* Mesmo princípio do default_mailer/rate_limit: por padrão usamos a infra do
|
|
6
|
+
* host (o disk DEFAULT do drive do app), sem o dev precisar escrever nada; tudo
|
|
7
|
+
* é sobreponível via `config/authkit.ts` (`uploads.avatars`).
|
|
8
|
+
*
|
|
9
|
+
* Best-effort / fail-safe: se `@adonisjs/drive` não estiver instalado/configurado,
|
|
10
|
+
* {@link storeAvatar} retorna `null` e a feature degrada para o input de URL.
|
|
11
|
+
* Nunca lança na request por causa de drive ausente.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Service do `@adonisjs/drive` resolvido de forma preguiçosa. Tipado como `any` de
|
|
15
|
+
* propósito: a lib NÃO depende do drive em tempo de compilação (peer/opt-in).
|
|
16
|
+
*/
|
|
17
|
+
type DriveService = any;
|
|
18
|
+
/**
|
|
19
|
+
* Permite reapontar/limpar o loader do drive (usado em testes).
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export declare function __setDriveLoaderForTests(fn: (() => Promise<DriveService | null>) | undefined): void;
|
|
23
|
+
/** Erro de validação do upload (mensagem já localizada no controller). */
|
|
24
|
+
export declare class AvatarUploadError extends Error {
|
|
25
|
+
reason: 'extname' | 'size';
|
|
26
|
+
constructor(reason: 'extname' | 'size', message: string);
|
|
27
|
+
}
|
|
28
|
+
/** File de multipart mínimo que precisamos do `request.file('avatar')`. */
|
|
29
|
+
export interface UploadedAvatar {
|
|
30
|
+
extname?: string | null;
|
|
31
|
+
size?: number;
|
|
32
|
+
/** Caminho temporário (drive lê daqui para stream). */
|
|
33
|
+
tmpPath?: string | null;
|
|
34
|
+
/** Move o arquivo para um disk do drive (API v3+). */
|
|
35
|
+
moveToDisk?: (key: string, options?: {
|
|
36
|
+
disk?: string;
|
|
37
|
+
}) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Indica se o drive do app está disponível (para a view decidir mostrar o input
|
|
41
|
+
* de arquivo). Best-effort: nunca lança.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isDriveAvailable(): Promise<boolean>;
|
|
44
|
+
/**
|
|
45
|
+
* Armazena o avatar no drive do host e retorna a URL pública.
|
|
46
|
+
*
|
|
47
|
+
* - Valida extensão (jpg/jpeg/png/webp) e tamanho (≤ maxSizeMb) — lança
|
|
48
|
+
* {@link AvatarUploadError} se inválido (o controller traduz/flasha).
|
|
49
|
+
* - Usa o disk configurado em `uploads.avatars.disk` ou o disk DEFAULT do app.
|
|
50
|
+
* - Se o drive estiver ausente/não-configurado → retorna `null` (degrada para URL).
|
|
51
|
+
*
|
|
52
|
+
* Nunca lança por causa de drive ausente; só lança em validação.
|
|
53
|
+
*/
|
|
54
|
+
export declare function storeAvatar(_ctx: HttpContext, cfg: ResolvedUploadsConfig, file: UploadedAvatar, accountId: string, messages: {
|
|
55
|
+
extname: string;
|
|
56
|
+
size: string;
|
|
57
|
+
}): Promise<string | null>;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
let driveServicePromise;
|
|
10
|
+
/**
|
|
11
|
+
* Importa o service de drive do HOST de forma preguiçosa e fail-safe.
|
|
12
|
+
* Se `@adonisjs/drive` não estiver instalado, resolve `null`.
|
|
13
|
+
*/
|
|
14
|
+
async function loadDrive() {
|
|
15
|
+
if (!driveServicePromise) {
|
|
16
|
+
// Indireção via variável: o `@adonisjs/drive` é peer/opcional e pode não estar
|
|
17
|
+
// instalado na lib, então o specifier não é resolvido em build-time.
|
|
18
|
+
const specifier = '@adonisjs/drive/services/main';
|
|
19
|
+
driveServicePromise = import(__rewriteRelativeImportExtension(specifier))
|
|
20
|
+
.then((mod) => mod.default ?? null)
|
|
21
|
+
.catch(() => null);
|
|
22
|
+
}
|
|
23
|
+
return driveServicePromise;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Permite reapontar/limpar o loader do drive (usado em testes).
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
export function __setDriveLoaderForTests(fn) {
|
|
30
|
+
if (fn) {
|
|
31
|
+
driveServicePromise = fn();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
driveServicePromise = undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Extensões aceitas para o avatar (imagem raster comum). */
|
|
38
|
+
const ALLOWED_EXTNAMES = ['jpg', 'jpeg', 'png', 'webp'];
|
|
39
|
+
/** Erro de validação do upload (mensagem já localizada no controller). */
|
|
40
|
+
export class AvatarUploadError extends Error {
|
|
41
|
+
reason;
|
|
42
|
+
constructor(reason, message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
this.name = 'AvatarUploadError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Indica se o drive do app está disponível (para a view decidir mostrar o input
|
|
50
|
+
* de arquivo). Best-effort: nunca lança.
|
|
51
|
+
*/
|
|
52
|
+
export async function isDriveAvailable() {
|
|
53
|
+
return (await loadDrive()) !== null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a extensão validada do arquivo enviado.
|
|
57
|
+
* Lança {@link AvatarUploadError} se ext/size forem inválidos.
|
|
58
|
+
*/
|
|
59
|
+
function validate(file, cfg, messages) {
|
|
60
|
+
const ext = (file.extname ?? '').toLowerCase().replace(/^\./, '');
|
|
61
|
+
if (!ALLOWED_EXTNAMES.includes(ext)) {
|
|
62
|
+
throw new AvatarUploadError('extname', messages.extname);
|
|
63
|
+
}
|
|
64
|
+
const maxBytes = cfg.avatars.maxSizeMb * 1024 * 1024;
|
|
65
|
+
if (typeof file.size === 'number' && file.size > maxBytes) {
|
|
66
|
+
throw new AvatarUploadError('size', messages.size);
|
|
67
|
+
}
|
|
68
|
+
return ext;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Gera uma chave única para o avatar dentro do diretório configurado.
|
|
72
|
+
* `${directory}/${accountId}-${random}.${ext}`
|
|
73
|
+
*/
|
|
74
|
+
function buildKey(cfg, accountId, ext) {
|
|
75
|
+
const random = Math.random().toString(36).slice(2, 10);
|
|
76
|
+
return `${cfg.avatars.directory}/${accountId}-${random}.${ext}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Armazena o avatar no drive do host e retorna a URL pública.
|
|
80
|
+
*
|
|
81
|
+
* - Valida extensão (jpg/jpeg/png/webp) e tamanho (≤ maxSizeMb) — lança
|
|
82
|
+
* {@link AvatarUploadError} se inválido (o controller traduz/flasha).
|
|
83
|
+
* - Usa o disk configurado em `uploads.avatars.disk` ou o disk DEFAULT do app.
|
|
84
|
+
* - Se o drive estiver ausente/não-configurado → retorna `null` (degrada para URL).
|
|
85
|
+
*
|
|
86
|
+
* Nunca lança por causa de drive ausente; só lança em validação.
|
|
87
|
+
*/
|
|
88
|
+
export async function storeAvatar(_ctx, cfg, file, accountId, messages) {
|
|
89
|
+
const drive = await loadDrive();
|
|
90
|
+
if (!drive)
|
|
91
|
+
return null;
|
|
92
|
+
const ext = validate(file, cfg, messages);
|
|
93
|
+
// Resolve o disk: o configurado, ou o DEFAULT do drive do app.
|
|
94
|
+
let disk;
|
|
95
|
+
try {
|
|
96
|
+
disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// disk inválido/não-configurado — degrada para URL.
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (!disk)
|
|
103
|
+
return null;
|
|
104
|
+
const key = buildKey(cfg, accountId || 'account', ext);
|
|
105
|
+
// API @adonisjs/drive v3+: o file de multipart move-se direto para o disk.
|
|
106
|
+
// `moveToDisk` lê do tmpPath e usa o disk informado (ou o default da config).
|
|
107
|
+
if (typeof file.moveToDisk === 'function') {
|
|
108
|
+
await file.moveToDisk(key, cfg.avatars.disk ? { disk: cfg.avatars.disk } : undefined);
|
|
109
|
+
}
|
|
110
|
+
else if (file.tmpPath) {
|
|
111
|
+
// Fallback: lê do tmpPath e escreve via putStream no disk resolvido.
|
|
112
|
+
const fs = await import('node:fs');
|
|
113
|
+
await disk.putStream(key, fs.createReadStream(file.tmpPath));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return await disk.getUrl(key);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// disk sem getUrl público — retorna a key como referência relativa.
|
|
123
|
+
return key;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
/**
|
|
4
|
+
* Self-service de consentimento ("apps com acesso") no console de conta. Lista os
|
|
5
|
+
* Grants da própria conta agrupados por client (resolvendo o nome do client da
|
|
6
|
+
* config estática ou do payload do adapter) e permite revogar o acesso de um
|
|
7
|
+
* client (destrói os grants + AT/RT daquele client). Degrada graciosamente quando
|
|
8
|
+
* o adapter OIDC não enumera (`list`), espelhando o console admin.
|
|
9
|
+
*/
|
|
10
|
+
export default class AccountAppsController {
|
|
11
|
+
/** GET /account/apps — lista os apps com acesso (grants) da conta logada. */
|
|
12
|
+
index(ctx: HttpContext): Promise<any>;
|
|
13
|
+
/** POST /account/apps/:clientId/revoke — revoga o acesso de um client. */
|
|
14
|
+
revoke(ctx: HttpContext): Promise<void>;
|
|
15
|
+
}
|
|
@@ -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,11 @@
|
|
|
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
|
+
import { storeAvatar, isDriveAvailable, AvatarUploadError } from '../avatar_storage.js';
|
|
6
7
|
import { translate } from '../i18n.js';
|
|
8
|
+
import { TRUSTED_DEVICE_COOKIE } from '../trusted_device.js';
|
|
7
9
|
/**
|
|
8
10
|
* Self-service de segurança da conta (console de conta): trocar a senha e o
|
|
9
11
|
* e-mail. A troca de senha exige a senha ATUAL (verifyCredentials). A troca de
|
|
@@ -21,13 +23,93 @@ export default class AccountSecurityController {
|
|
|
21
23
|
return render(ctx, 'account/security', {
|
|
22
24
|
csrfToken: ctx.request.csrfToken,
|
|
23
25
|
supported: supportsAccountSecurity(cfg.accountStore),
|
|
26
|
+
profileSupported: supportsProfile(cfg.accountStore),
|
|
27
|
+
// Só mostramos o input de arquivo se o drive do app estiver disponível.
|
|
28
|
+
avatarUploadSupported: await isDriveAvailable(),
|
|
24
29
|
email: account?.email ?? '',
|
|
30
|
+
name: account?.name ?? '',
|
|
31
|
+
avatarUrl: account?.avatarUrl ?? '',
|
|
25
32
|
passwordChanged: ctx.session.flashMessages.get('passwordChanged') ?? null,
|
|
26
33
|
emailChangeRequested: ctx.session.flashMessages.get('emailChangeRequested') ?? null,
|
|
27
34
|
emailChanged: ctx.session.flashMessages.get('emailChanged') ?? null,
|
|
35
|
+
profileUpdated: ctx.session.flashMessages.get('profileUpdated') ?? null,
|
|
28
36
|
error: ctx.session.flashMessages.get('securityError') ?? null,
|
|
37
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
38
|
+
trustedDevicesRevoked: ctx.session.flashMessages.get('trustedDevicesRevoked') ?? null,
|
|
29
39
|
});
|
|
30
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* POST /account/security/trusted-devices/revoke
|
|
43
|
+
* Limpa o cookie de dispositivo confiável DESTE navegador (o MFA volta a ser
|
|
44
|
+
* exigido aqui). Revogação global por-dispositivo não existe sem estado
|
|
45
|
+
* server-side; re-enrolar o MFA invalida a confiança em TODOS os dispositivos.
|
|
46
|
+
*/
|
|
47
|
+
async revokeTrustedDevices(ctx) {
|
|
48
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
49
|
+
const cfg = service.config;
|
|
50
|
+
ctx.response.clearCookie(TRUSTED_DEVICE_COOKIE);
|
|
51
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
52
|
+
await cfg.audit?.record({
|
|
53
|
+
type: 'trusted_device.revoked',
|
|
54
|
+
accountId: userId,
|
|
55
|
+
ip: ctx.request.ip?.() ?? null,
|
|
56
|
+
});
|
|
57
|
+
ctx.session.flash('trustedDevicesRevoked', translate(cfg.messages, 'account.security.trusted_devices_revoked'));
|
|
58
|
+
return ctx.response.redirect('/account/security');
|
|
59
|
+
}
|
|
60
|
+
/** POST /account/security/profile — atualiza nome + avatar do próprio perfil. */
|
|
61
|
+
async updateProfile(ctx) {
|
|
62
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
63
|
+
const cfg = service.config;
|
|
64
|
+
const store = cfg.accountStore;
|
|
65
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
66
|
+
if (!supportsProfile(store)) {
|
|
67
|
+
return ctx.response.redirect('/account/security');
|
|
68
|
+
}
|
|
69
|
+
const { name, avatarUrl } = await ctx.request.validateUsing(updateProfileValidator);
|
|
70
|
+
// Upload de avatar via o drive do app (opt-in pela presença do arquivo).
|
|
71
|
+
// Se um arquivo for enviado e o drive estiver disponível, a URL resultante
|
|
72
|
+
// tem prioridade sobre o input de URL; senão caímos no avatarUrl (texto).
|
|
73
|
+
let resolvedAvatarUrl = avatarUrl ?? null;
|
|
74
|
+
let via = 'url';
|
|
75
|
+
const file = ctx.request.file('avatar', {
|
|
76
|
+
size: `${cfg.uploads.avatars.maxSizeMb}mb`,
|
|
77
|
+
extnames: ['jpg', 'jpeg', 'png', 'webp'],
|
|
78
|
+
});
|
|
79
|
+
if (file) {
|
|
80
|
+
try {
|
|
81
|
+
const uploadedUrl = await storeAvatar(ctx, cfg.uploads, file, userId, {
|
|
82
|
+
extname: translate(cfg.messages, 'account.profile.avatar_invalid_type'),
|
|
83
|
+
size: translate(cfg.messages, 'account.profile.avatar_too_large'),
|
|
84
|
+
});
|
|
85
|
+
if (uploadedUrl) {
|
|
86
|
+
resolvedAvatarUrl = uploadedUrl;
|
|
87
|
+
via = 'upload';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof AvatarUploadError) {
|
|
92
|
+
ctx.session.flash('securityError', error.message);
|
|
93
|
+
return ctx.response.redirect('/account/security');
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Campos ausentes no form viram string vazia (limpa o valor); enviamos null
|
|
99
|
+
// para limpar, ou o valor trimado.
|
|
100
|
+
await store.updateProfile(userId, {
|
|
101
|
+
name: name ?? null,
|
|
102
|
+
avatarUrl: resolvedAvatarUrl,
|
|
103
|
+
});
|
|
104
|
+
await cfg.audit?.record({
|
|
105
|
+
type: 'profile.updated',
|
|
106
|
+
accountId: userId,
|
|
107
|
+
ip: ctx.request.ip?.() ?? null,
|
|
108
|
+
metadata: { via },
|
|
109
|
+
});
|
|
110
|
+
ctx.session.flash('profileUpdated', translate(cfg.messages, 'account.profile.updated'));
|
|
111
|
+
return ctx.response.redirect('/account/security');
|
|
112
|
+
}
|
|
31
113
|
async changePassword(ctx) {
|
|
32
114
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
33
115
|
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,
|