@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serviço de inspeção/revogação das SESSÕES e GRANTS ativos de uma conta,
|
|
3
|
+
* persistidos pelo oidc-provider via o MESMO `AdapterClass` (mesmo padrão do
|
|
4
|
+
* {@link AdminClientsService}). Encapsula:
|
|
5
|
+
* - a enumeração via a capacidade opcional `list` do adapter (degrada quando
|
|
6
|
+
* ausente, igual ao CRUD de clients);
|
|
7
|
+
* - a destruição das sessões + grants da conta. Destruir um grant CASCATEIA a
|
|
8
|
+
* invalidação dos tokens no oidc-provider: os consumidores de access/refresh
|
|
9
|
+
* token carregam `Grant.find(token.grantId)` e lançam `InvalidToken('grant not
|
|
10
|
+
* found')` quando o grant some (verificado em oidc-provider v9). Mesmo assim,
|
|
11
|
+
* por garantia (belt-and-braces), também destruímos as linhas de AT/RT que
|
|
12
|
+
* referenciam os grants revogados quando o adapter enumera.
|
|
13
|
+
*/
|
|
14
|
+
export class AdminSessionsService {
|
|
15
|
+
#AdapterClass;
|
|
16
|
+
constructor(oidc) {
|
|
17
|
+
this.#AdapterClass = oidc.config.AdapterClass;
|
|
18
|
+
}
|
|
19
|
+
#adapter(model) {
|
|
20
|
+
return new this.#AdapterClass(model);
|
|
21
|
+
}
|
|
22
|
+
/** Indica se o adapter suporta enumeração (capacidade opcional). */
|
|
23
|
+
get canList() {
|
|
24
|
+
return typeof this.#adapter('Session').list === 'function';
|
|
25
|
+
}
|
|
26
|
+
async #listModel(model) {
|
|
27
|
+
const adapter = this.#adapter(model);
|
|
28
|
+
if (!adapter.list)
|
|
29
|
+
return [];
|
|
30
|
+
return adapter.list();
|
|
31
|
+
}
|
|
32
|
+
/** Lista as sessões ativas da conta (vazio quando o adapter não enumera). */
|
|
33
|
+
async listSessions(accountId) {
|
|
34
|
+
const rows = await this.#listModel('Session');
|
|
35
|
+
return rows
|
|
36
|
+
.filter((r) => r.payload.accountId === accountId)
|
|
37
|
+
.map((r) => ({
|
|
38
|
+
id: r.id,
|
|
39
|
+
accountId,
|
|
40
|
+
loginTs: r.payload.loginTs,
|
|
41
|
+
amr: r.payload.amr ?? undefined,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Lista os grants da conta, com a contagem de access/refresh tokens vivos que
|
|
46
|
+
* referenciam cada grant (`payload.grantId`). As contagens são baratas (uma
|
|
47
|
+
* enumeração de cada model token), feitas só quando o adapter enumera.
|
|
48
|
+
*/
|
|
49
|
+
async listGrants(accountId) {
|
|
50
|
+
const rows = await this.#listModel('Grant');
|
|
51
|
+
const grants = rows.filter((r) => r.payload.accountId === accountId);
|
|
52
|
+
if (grants.length === 0)
|
|
53
|
+
return [];
|
|
54
|
+
const atByGrant = await this.#countByGrant('AccessToken');
|
|
55
|
+
const rtByGrant = await this.#countByGrant('RefreshToken');
|
|
56
|
+
return grants.map((g) => ({
|
|
57
|
+
id: g.id,
|
|
58
|
+
accountId,
|
|
59
|
+
clientId: g.payload.clientId,
|
|
60
|
+
accessTokens: atByGrant.get(g.id) ?? 0,
|
|
61
|
+
refreshTokens: rtByGrant.get(g.id) ?? 0,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
/** Conta artefatos de um model token agrupados por `grantId`. */
|
|
65
|
+
async #countByGrant(model) {
|
|
66
|
+
const rows = await this.#listModel(model);
|
|
67
|
+
const map = new Map();
|
|
68
|
+
for (const r of rows) {
|
|
69
|
+
const gid = r.payload.grantId;
|
|
70
|
+
if (!gid)
|
|
71
|
+
continue;
|
|
72
|
+
map.set(gid, (map.get(gid) ?? 0) + 1);
|
|
73
|
+
}
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Revoga TODAS as sessões e grants da conta. Destruir os grants já invalida os
|
|
78
|
+
* tokens (cascata via `grant not found`); ainda assim, quando o adapter enumera,
|
|
79
|
+
* destruímos explicitamente as linhas de AT/RT desses grants (belt-and-braces),
|
|
80
|
+
* deixando o store limpo. Retorna as contagens do que foi removido.
|
|
81
|
+
*/
|
|
82
|
+
async revokeAll(accountId) {
|
|
83
|
+
const sessionAdapter = this.#adapter('Session');
|
|
84
|
+
const grantAdapter = this.#adapter('Grant');
|
|
85
|
+
const sessions = await this.listSessions(accountId);
|
|
86
|
+
for (const s of sessions) {
|
|
87
|
+
await sessionAdapter.destroy(s.id);
|
|
88
|
+
}
|
|
89
|
+
const grants = await this.listGrants(accountId);
|
|
90
|
+
const grantIds = new Set(grants.map((g) => g.id));
|
|
91
|
+
let accessTokens = 0;
|
|
92
|
+
let refreshTokens = 0;
|
|
93
|
+
// Belt-and-braces: destrói as linhas de token que referenciam os grants alvo
|
|
94
|
+
// ANTES de destruir os grants (quando o adapter enumera).
|
|
95
|
+
accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
|
|
96
|
+
refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
|
|
97
|
+
for (const g of grants) {
|
|
98
|
+
// `revokeByGrantId` derruba os artefatos ligados ao grant (no Redis isso
|
|
99
|
+
// limpa a lista do grant; no DB apaga as linhas com `grant_id`); `destroy`
|
|
100
|
+
// remove o próprio artefato `Grant`.
|
|
101
|
+
await grantAdapter.revokeByGrantId(g.id);
|
|
102
|
+
await grantAdapter.destroy(g.id);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
sessions: sessions.length,
|
|
106
|
+
grants: grants.length,
|
|
107
|
+
accessTokens,
|
|
108
|
+
refreshTokens,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
|
|
112
|
+
async #destroyTokensOfGrants(model, grantIds) {
|
|
113
|
+
const adapter = this.#adapter(model);
|
|
114
|
+
if (!adapter.list)
|
|
115
|
+
return 0;
|
|
116
|
+
const rows = await adapter.list();
|
|
117
|
+
let count = 0;
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
const gid = r.payload.grantId;
|
|
120
|
+
if (gid && grantIds.has(gid)) {
|
|
121
|
+
await adapter.destroy(r.id);
|
|
122
|
+
count++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return count;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
/**
|
|
4
|
+
* Self-service de segurança da conta (console de conta): trocar a senha e o
|
|
5
|
+
* e-mail. A troca de senha exige a senha ATUAL (verifyCredentials). A troca de
|
|
6
|
+
* e-mail exige a senha atual e dispara um link de confirmação para o NOVO
|
|
7
|
+
* endereço (consumido em GET /account/email/confirm). Degrada graciosamente se o
|
|
8
|
+
* store não suportar a capacidade ({@link supportsAccountSecurity}).
|
|
9
|
+
*/
|
|
10
|
+
export default class AccountSecurityController {
|
|
11
|
+
index(ctx: HttpContext): Promise<any>;
|
|
12
|
+
changePassword(ctx: HttpContext): Promise<void>;
|
|
13
|
+
changeEmail(ctx: HttpContext): Promise<void>;
|
|
14
|
+
/** GET /account/email/confirm?token=... — consome o token e aplica o novo e-mail. */
|
|
15
|
+
confirmEmail(ctx: HttpContext): Promise<any>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
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';
|
|
5
|
+
import { sendEmailChangeConfirmationEmail } from '../default_mailer.js';
|
|
6
|
+
import { translate } from '../i18n.js';
|
|
7
|
+
/**
|
|
8
|
+
* Self-service de segurança da conta (console de conta): trocar a senha e o
|
|
9
|
+
* e-mail. A troca de senha exige a senha ATUAL (verifyCredentials). A troca de
|
|
10
|
+
* e-mail exige a senha atual e dispara um link de confirmação para o NOVO
|
|
11
|
+
* endereço (consumido em GET /account/email/confirm). Degrada graciosamente se o
|
|
12
|
+
* store não suportar a capacidade ({@link supportsAccountSecurity}).
|
|
13
|
+
*/
|
|
14
|
+
export default class AccountSecurityController {
|
|
15
|
+
async index(ctx) {
|
|
16
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
17
|
+
const cfg = service.config;
|
|
18
|
+
const render = cfg.render;
|
|
19
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
20
|
+
const account = await cfg.accountStore.findById(userId);
|
|
21
|
+
return render(ctx, 'account/security', {
|
|
22
|
+
csrfToken: ctx.request.csrfToken,
|
|
23
|
+
supported: supportsAccountSecurity(cfg.accountStore),
|
|
24
|
+
email: account?.email ?? '',
|
|
25
|
+
passwordChanged: ctx.session.flashMessages.get('passwordChanged') ?? null,
|
|
26
|
+
emailChangeRequested: ctx.session.flashMessages.get('emailChangeRequested') ?? null,
|
|
27
|
+
emailChanged: ctx.session.flashMessages.get('emailChanged') ?? null,
|
|
28
|
+
error: ctx.session.flashMessages.get('securityError') ?? null,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async changePassword(ctx) {
|
|
32
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
33
|
+
const cfg = service.config;
|
|
34
|
+
const store = cfg.accountStore;
|
|
35
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
36
|
+
if (!supportsAccountSecurity(store)) {
|
|
37
|
+
return ctx.response.redirect('/account/security');
|
|
38
|
+
}
|
|
39
|
+
const { currentPassword, newPassword } = await ctx.request.validateUsing(changePasswordValidator);
|
|
40
|
+
const account = await store.findById(userId);
|
|
41
|
+
// Confirma a senha ATUAL pelo e-mail da conta.
|
|
42
|
+
const verified = account
|
|
43
|
+
? await store.verifyCredentials(account.email, currentPassword)
|
|
44
|
+
: null;
|
|
45
|
+
if (!verified) {
|
|
46
|
+
ctx.session.flash('securityError', translate(cfg.messages, 'errors.invalid_credentials'));
|
|
47
|
+
return ctx.response.redirect('/account/security');
|
|
48
|
+
}
|
|
49
|
+
await store.changePassword(userId, newPassword);
|
|
50
|
+
await cfg.audit?.record({
|
|
51
|
+
type: 'password.changed',
|
|
52
|
+
accountId: userId,
|
|
53
|
+
ip: ctx.request.ip?.() ?? null,
|
|
54
|
+
});
|
|
55
|
+
ctx.session.flash('passwordChanged', translate(cfg.messages, 'account.security.password_changed'));
|
|
56
|
+
return ctx.response.redirect('/account/security');
|
|
57
|
+
}
|
|
58
|
+
async changeEmail(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 (!supportsAccountSecurity(store)) {
|
|
64
|
+
return ctx.response.redirect('/account/security');
|
|
65
|
+
}
|
|
66
|
+
const { currentPassword, newEmail } = await ctx.request.validateUsing(changeEmailValidator);
|
|
67
|
+
const account = await store.findById(userId);
|
|
68
|
+
const verified = account
|
|
69
|
+
? await store.verifyCredentials(account.email, currentPassword)
|
|
70
|
+
: null;
|
|
71
|
+
if (!verified) {
|
|
72
|
+
ctx.session.flash('securityError', translate(cfg.messages, 'errors.invalid_credentials'));
|
|
73
|
+
return ctx.response.redirect('/account/security');
|
|
74
|
+
}
|
|
75
|
+
const issued = await store.requestEmailChange(userId, newEmail);
|
|
76
|
+
if (!issued) {
|
|
77
|
+
ctx.session.flash('securityError', translate(cfg.messages, 'errors.email_taken'));
|
|
78
|
+
return ctx.response.redirect('/account/security');
|
|
79
|
+
}
|
|
80
|
+
await cfg.audit?.record({
|
|
81
|
+
type: 'email.change_requested',
|
|
82
|
+
accountId: userId,
|
|
83
|
+
email: newEmail,
|
|
84
|
+
ip: ctx.request.ip?.() ?? null,
|
|
85
|
+
});
|
|
86
|
+
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
87
|
+
const confirmUrl = `${origin}/account/email/confirm?token=${encodeURIComponent(issued.token)}`;
|
|
88
|
+
// Hook do config tem prioridade (override); senão usa o mailer default do host.
|
|
89
|
+
if (cfg.mail?.onEmailVerification) {
|
|
90
|
+
await cfg.mail.onEmailVerification({ email: newEmail, verifyUrl: confirmUrl, token: issued.token });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
await sendEmailChangeConfirmationEmail(ctx, { email: newEmail, confirmUrl });
|
|
94
|
+
}
|
|
95
|
+
ctx.session.flash('emailChangeRequested', translate(cfg.messages, 'account.security.email_change_requested', { email: newEmail }));
|
|
96
|
+
return ctx.response.redirect('/account/security');
|
|
97
|
+
}
|
|
98
|
+
/** GET /account/email/confirm?token=... — consome o token e aplica o novo e-mail. */
|
|
99
|
+
async confirmEmail(ctx) {
|
|
100
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
101
|
+
const cfg = service.config;
|
|
102
|
+
const store = cfg.accountStore;
|
|
103
|
+
const render = cfg.render;
|
|
104
|
+
if (!supportsAccountSecurity(store)) {
|
|
105
|
+
return render(ctx, 'account/email-confirmed', { ok: false });
|
|
106
|
+
}
|
|
107
|
+
const token = ctx.request.qs().token ?? '';
|
|
108
|
+
const result = await store.confirmEmailChange(token);
|
|
109
|
+
if (result.ok) {
|
|
110
|
+
await cfg.audit?.record({
|
|
111
|
+
type: 'email.changed',
|
|
112
|
+
accountId: result.account.id,
|
|
113
|
+
email: result.newEmail,
|
|
114
|
+
ip: ctx.request.ip?.() ?? null,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return render(ctx, 'account/email-confirmed', { ok: result.ok });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -2,6 +2,7 @@ import '../augmentations.js';
|
|
|
2
2
|
import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.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
|
export default class AccountSessionController {
|
|
6
7
|
async show(ctx) {
|
|
7
8
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
@@ -32,7 +33,7 @@ export default class AccountSessionController {
|
|
|
32
33
|
}
|
|
33
34
|
const acc = result.account;
|
|
34
35
|
ctx.session.put(ACCOUNT_SESSION_KEY, acc.id);
|
|
35
|
-
await
|
|
36
|
+
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip });
|
|
36
37
|
return ctx.response.redirect('/account/tokens');
|
|
37
38
|
}
|
|
38
39
|
async logout(ctx) {
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import '../../augmentations.js';
|
|
2
2
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* CRUD de clients OIDC no console admin. Mostra os clients ESTÁTICOS da config
|
|
5
|
+
* (somente leitura, rotulados) lado a lado com os clients DINÂMICOS persistidos
|
|
6
|
+
* no adapter (registro dinâmico/RFC 7591 + os criados aqui). Quando o adapter não
|
|
7
|
+
* suporta enumeração (`listClients`), a seção dinâmica degrada graciosamente —
|
|
8
|
+
* espelhando o padrão da tela de auditoria.
|
|
7
9
|
*/
|
|
8
10
|
export default class AdminClientsController {
|
|
9
11
|
index(ctx: HttpContext): Promise<any>;
|
|
12
|
+
/** Formulário de criação. */
|
|
13
|
+
create(ctx: HttpContext): Promise<any>;
|
|
14
|
+
/** Persiste um client novo; mostra o secret UMA vez via flash. */
|
|
15
|
+
store(ctx: HttpContext): Promise<void>;
|
|
16
|
+
/** Formulário de edição de um client persistido. */
|
|
17
|
+
edit(ctx: HttpContext): Promise<any>;
|
|
18
|
+
/** Atualiza metadata editável (NÃO o secret). */
|
|
19
|
+
update(ctx: HttpContext): Promise<void>;
|
|
20
|
+
/** Regenera o secret de um client confidencial; mostra o novo valor UMA vez. */
|
|
21
|
+
regenerateSecret(ctx: HttpContext): Promise<void>;
|
|
22
|
+
/** Remove um client persistido. */
|
|
23
|
+
destroy(ctx: HttpContext): Promise<void>;
|
|
10
24
|
}
|
|
@@ -1,24 +1,178 @@
|
|
|
1
1
|
import '../../augmentations.js';
|
|
2
|
+
import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
|
|
3
|
+
import { AdminClientsService, } from '../../admin_clients_service.js';
|
|
4
|
+
const VALID_GRANTS = ['authorization_code', 'refresh_token', 'client_credentials'];
|
|
5
|
+
const VALID_AUTH_METHODS = [
|
|
6
|
+
'client_secret_basic',
|
|
7
|
+
'client_secret_post',
|
|
8
|
+
'none',
|
|
9
|
+
];
|
|
10
|
+
/** Normaliza um textarea (1 item por linha) numa lista sem vazios nem duplicatas. */
|
|
11
|
+
function parseLines(raw) {
|
|
12
|
+
return Array.from(new Set(String(raw ?? '')
|
|
13
|
+
.split(/\r?\n/)
|
|
14
|
+
.map((l) => l.trim())
|
|
15
|
+
.filter((l) => l.length > 0)));
|
|
16
|
+
}
|
|
17
|
+
/** Lê os grants marcados no form (checkboxes); cai no default quando nenhum. */
|
|
18
|
+
function parseGrants(ctx) {
|
|
19
|
+
const raw = ctx.request.input('grant_types', []);
|
|
20
|
+
const arr = Array.isArray(raw) ? raw : [raw];
|
|
21
|
+
const filtered = arr.filter((g) => VALID_GRANTS.includes(g));
|
|
22
|
+
return filtered.length ? filtered : ['authorization_code', 'refresh_token'];
|
|
23
|
+
}
|
|
24
|
+
function parseAuthMethod(ctx) {
|
|
25
|
+
const raw = ctx.request.input('token_endpoint_auth_method', 'client_secret_basic');
|
|
26
|
+
return (VALID_AUTH_METHODS.includes(raw)
|
|
27
|
+
? raw
|
|
28
|
+
: 'client_secret_basic');
|
|
29
|
+
}
|
|
30
|
+
function readInput(ctx) {
|
|
31
|
+
return {
|
|
32
|
+
clientId: ctx.request.input('client_id', '').trim() || undefined,
|
|
33
|
+
redirectUris: parseLines(ctx.request.input('redirect_uris')),
|
|
34
|
+
postLogoutRedirectUris: parseLines(ctx.request.input('post_logout_redirect_uris')),
|
|
35
|
+
grantTypes: parseGrants(ctx),
|
|
36
|
+
tokenEndpointAuthMethod: parseAuthMethod(ctx),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
2
39
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
40
|
+
* CRUD de clients OIDC no console admin. Mostra os clients ESTÁTICOS da config
|
|
41
|
+
* (somente leitura, rotulados) lado a lado com os clients DINÂMICOS persistidos
|
|
42
|
+
* no adapter (registro dinâmico/RFC 7591 + os criados aqui). Quando o adapter não
|
|
43
|
+
* suporta enumeração (`listClients`), a seção dinâmica degrada graciosamente —
|
|
44
|
+
* espelhando o padrão da tela de auditoria.
|
|
6
45
|
*/
|
|
7
46
|
export default class AdminClientsController {
|
|
8
47
|
async index(ctx) {
|
|
9
48
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
10
49
|
const cfg = service.config;
|
|
11
50
|
const render = cfg.render;
|
|
51
|
+
const admin = new AdminClientsService(service);
|
|
52
|
+
const dynamicSupported = admin.canList;
|
|
53
|
+
const dynamicClients = dynamicSupported ? await admin.list() : [];
|
|
54
|
+
const createdSecret = ctx.session.flashMessages.get('createdClientSecret');
|
|
12
55
|
return render(ctx, 'admin/clients', {
|
|
13
56
|
csrfToken: ctx.request.csrfToken,
|
|
14
57
|
dynamicEnabled: cfg.dynamicRegistration.enabled,
|
|
15
|
-
|
|
58
|
+
dynamicSupported,
|
|
59
|
+
createdSecret: createdSecret ?? null,
|
|
60
|
+
staticClients: cfg.clients.map((c) => ({
|
|
16
61
|
clientId: c.clientId,
|
|
17
62
|
confidential: !!c.clientSecret,
|
|
18
63
|
grants: c.grants ?? ['authorization_code', 'refresh_token'],
|
|
19
64
|
redirectUris: c.redirectUris ?? [],
|
|
20
65
|
postLogoutRedirectUris: c.postLogoutRedirectUris ?? [],
|
|
21
66
|
})),
|
|
67
|
+
dynamicClients,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Formulário de criação. */
|
|
71
|
+
async create(ctx) {
|
|
72
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
73
|
+
const render = service.config.render;
|
|
74
|
+
return render(ctx, 'admin/client_form', {
|
|
75
|
+
csrfToken: ctx.request.csrfToken,
|
|
76
|
+
mode: 'create',
|
|
77
|
+
client: {
|
|
78
|
+
clientId: '',
|
|
79
|
+
redirectUris: [],
|
|
80
|
+
postLogoutRedirectUris: [],
|
|
81
|
+
grants: ['authorization_code', 'refresh_token'],
|
|
82
|
+
tokenEndpointAuthMethod: 'client_secret_basic',
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Persiste um client novo; mostra o secret UMA vez via flash. */
|
|
87
|
+
async store(ctx) {
|
|
88
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
89
|
+
const cfg = service.config;
|
|
90
|
+
const admin = new AdminClientsService(service);
|
|
91
|
+
const input = readInput(ctx);
|
|
92
|
+
const created = await admin.create(input);
|
|
93
|
+
if (created.clientSecret) {
|
|
94
|
+
ctx.session.flash('createdClientSecret', {
|
|
95
|
+
clientId: created.clientId,
|
|
96
|
+
clientSecret: created.clientSecret,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
await cfg.audit?.record({
|
|
100
|
+
type: 'client.created',
|
|
101
|
+
clientId: created.clientId,
|
|
102
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
103
|
+
ip: ctx.request.ip?.() ?? null,
|
|
104
|
+
});
|
|
105
|
+
return ctx.response.redirect('/admin/clients');
|
|
106
|
+
}
|
|
107
|
+
/** Formulário de edição de um client persistido. */
|
|
108
|
+
async edit(ctx) {
|
|
109
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
110
|
+
const render = service.config.render;
|
|
111
|
+
const admin = new AdminClientsService(service);
|
|
112
|
+
const clientId = ctx.request.param('id');
|
|
113
|
+
const client = await admin.find(clientId);
|
|
114
|
+
if (!client)
|
|
115
|
+
return ctx.response.redirect('/admin/clients');
|
|
116
|
+
return render(ctx, 'admin/client_form', {
|
|
117
|
+
csrfToken: ctx.request.csrfToken,
|
|
118
|
+
mode: 'edit',
|
|
119
|
+
client,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Atualiza metadata editável (NÃO o secret). */
|
|
123
|
+
async update(ctx) {
|
|
124
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
125
|
+
const cfg = service.config;
|
|
126
|
+
const admin = new AdminClientsService(service);
|
|
127
|
+
const clientId = ctx.request.param('id');
|
|
128
|
+
const existing = await admin.find(clientId);
|
|
129
|
+
if (!existing)
|
|
130
|
+
return ctx.response.redirect('/admin/clients');
|
|
131
|
+
const input = { ...readInput(ctx), clientId };
|
|
132
|
+
await admin.update(clientId, input);
|
|
133
|
+
await cfg.audit?.record({
|
|
134
|
+
type: 'client.updated',
|
|
135
|
+
clientId,
|
|
136
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
137
|
+
ip: ctx.request.ip?.() ?? null,
|
|
138
|
+
});
|
|
139
|
+
return ctx.response.redirect('/admin/clients');
|
|
140
|
+
}
|
|
141
|
+
/** Regenera o secret de um client confidencial; mostra o novo valor UMA vez. */
|
|
142
|
+
async regenerateSecret(ctx) {
|
|
143
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
144
|
+
const cfg = service.config;
|
|
145
|
+
const admin = new AdminClientsService(service);
|
|
146
|
+
const clientId = ctx.request.param('id');
|
|
147
|
+
try {
|
|
148
|
+
const secret = await admin.regenerateSecret(clientId);
|
|
149
|
+
ctx.session.flash('createdClientSecret', { clientId, clientSecret: secret });
|
|
150
|
+
await cfg.audit?.record({
|
|
151
|
+
type: 'client.updated',
|
|
152
|
+
clientId,
|
|
153
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
154
|
+
ip: ctx.request.ip?.() ?? null,
|
|
155
|
+
metadata: { action: 'regenerate_secret' },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// client inexistente ou public — sem secret a regenerar; volta silenciosamente.
|
|
160
|
+
}
|
|
161
|
+
return ctx.response.redirect('/admin/clients');
|
|
162
|
+
}
|
|
163
|
+
/** Remove um client persistido. */
|
|
164
|
+
async destroy(ctx) {
|
|
165
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
166
|
+
const cfg = service.config;
|
|
167
|
+
const admin = new AdminClientsService(service);
|
|
168
|
+
const clientId = ctx.request.param('id');
|
|
169
|
+
await admin.delete(clientId);
|
|
170
|
+
await cfg.audit?.record({
|
|
171
|
+
type: 'client.deleted',
|
|
172
|
+
clientId,
|
|
173
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
174
|
+
ip: ctx.request.ip?.() ?? null,
|
|
22
175
|
});
|
|
176
|
+
return ctx.response.redirect('/admin/clients');
|
|
23
177
|
}
|
|
24
178
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import '../../augmentations.js';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
/**
|
|
4
|
+
* Inspeção/revogação das sessões e grants ativos de uma conta no console admin.
|
|
5
|
+
* Lista as `Session` (logins do IdP) + os `Grant` (autorizações por client) da
|
|
6
|
+
* conta, com a contagem de access/refresh tokens por grant. Degrada graciosamente
|
|
7
|
+
* quando o adapter OIDC não enumera (`list`), espelhando o CRUD de clients.
|
|
8
|
+
*/
|
|
9
|
+
export default class AdminSessionsController {
|
|
10
|
+
/** GET /admin/users/:id/sessions — lista sessões + grants da conta. */
|
|
11
|
+
index(ctx: HttpContext): Promise<any>;
|
|
12
|
+
/** POST /admin/users/:id/revoke-sessions — destrói sessões + grants da conta. */
|
|
13
|
+
revoke(ctx: HttpContext): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import '../../augmentations.js';
|
|
2
|
+
import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
|
|
3
|
+
import { AdminSessionsService } from '../../admin_sessions_service.js';
|
|
4
|
+
/**
|
|
5
|
+
* Inspeção/revogação das sessões e grants ativos de uma conta no console admin.
|
|
6
|
+
* Lista as `Session` (logins do IdP) + os `Grant` (autorizações por client) da
|
|
7
|
+
* conta, com a contagem de access/refresh tokens por grant. Degrada graciosamente
|
|
8
|
+
* quando o adapter OIDC não enumera (`list`), espelhando o CRUD de clients.
|
|
9
|
+
*/
|
|
10
|
+
export default class AdminSessionsController {
|
|
11
|
+
/** GET /admin/users/:id/sessions — lista sessões + grants da conta. */
|
|
12
|
+
async index(ctx) {
|
|
13
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
14
|
+
const cfg = service.config;
|
|
15
|
+
const render = cfg.render;
|
|
16
|
+
const accountId = ctx.request.param('id');
|
|
17
|
+
const account = await cfg.accountStore.findById(accountId);
|
|
18
|
+
const sessions = new AdminSessionsService(service);
|
|
19
|
+
const supported = sessions.canList;
|
|
20
|
+
const sessionList = supported ? await sessions.listSessions(accountId) : [];
|
|
21
|
+
const grantList = supported ? await sessions.listGrants(accountId) : [];
|
|
22
|
+
const revoked = ctx.session.flashMessages.get('sessionsRevoked');
|
|
23
|
+
return render(ctx, 'admin/sessions', {
|
|
24
|
+
csrfToken: ctx.request.csrfToken,
|
|
25
|
+
supported,
|
|
26
|
+
accountId,
|
|
27
|
+
email: account?.email ?? '',
|
|
28
|
+
revoked: revoked ?? null,
|
|
29
|
+
sessions: sessionList.map((s) => ({
|
|
30
|
+
id: s.id,
|
|
31
|
+
loginTs: s.loginTs ? new Date(s.loginTs * 1000).toISOString() : '',
|
|
32
|
+
amr: (s.amr ?? []).join(', '),
|
|
33
|
+
})),
|
|
34
|
+
grants: grantList.map((g) => ({
|
|
35
|
+
id: g.id,
|
|
36
|
+
clientId: g.clientId ?? '',
|
|
37
|
+
accessTokens: g.accessTokens,
|
|
38
|
+
refreshTokens: g.refreshTokens,
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** POST /admin/users/:id/revoke-sessions — destrói sessões + grants da conta. */
|
|
43
|
+
async revoke(ctx) {
|
|
44
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
45
|
+
const cfg = service.config;
|
|
46
|
+
const accountId = ctx.request.param('id');
|
|
47
|
+
const sessions = new AdminSessionsService(service);
|
|
48
|
+
const result = await sessions.revokeAll(accountId);
|
|
49
|
+
await cfg.audit?.record({
|
|
50
|
+
type: 'session.revoked_all',
|
|
51
|
+
accountId,
|
|
52
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
53
|
+
ip: ctx.request.ip?.() ?? null,
|
|
54
|
+
metadata: {
|
|
55
|
+
sessions: result.sessions,
|
|
56
|
+
grants: result.grants,
|
|
57
|
+
accessTokens: result.accessTokens,
|
|
58
|
+
refreshTokens: result.refreshTokens,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
ctx.session.flash('sessionsRevoked', result);
|
|
62
|
+
return ctx.response.redirect(`/admin/users/${accountId}/sessions`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -19,6 +19,17 @@ export default class AuthInteractionController {
|
|
|
19
19
|
* OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
|
|
20
20
|
*/
|
|
21
21
|
mfaVerify(ctx: HttpContext): Promise<any>;
|
|
22
|
+
/**
|
|
23
|
+
* true se o authorize request exige MFA via acr_values (contém o mfaAcr da
|
|
24
|
+
* config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
|
|
25
|
+
*/
|
|
26
|
+
private acrRequiresMfa;
|
|
27
|
+
/**
|
|
28
|
+
* Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
|
|
29
|
+
* foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
|
|
30
|
+
* Retorna `undefined` quando não há step-up — completeLogin usa o default.
|
|
31
|
+
*/
|
|
32
|
+
private stepUpExtra;
|
|
22
33
|
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
23
34
|
private hasPasskeys;
|
|
24
35
|
/**
|