@dudousxd/adonis-authkit-server 0.4.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 +9 -3
- package/build/index.js +5 -2
- 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_mfa_controller.js +6 -2
- 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 +175 -8
- package/build/src/host/default_mailer.d.ts +8 -0
- package/build/src/host/default_mailer.js +81 -19
- package/build/src/host/email_templates.d.ts +4 -0
- package/build/src/host/email_templates.js +5 -2
- package/build/src/host/i18n.d.ts +395 -11
- package/build/src/host/i18n.js +433 -12
- 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/build/src/observability/metrics_controller.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
# @dudousxd/adonis-authkit-server
|
|
2
2
|
|
|
3
|
-
Authorization Server
|
|
4
|
-
[`oidc-provider`](https://github.com/panva/node-oidc-provider).
|
|
3
|
+
OpenID Connect / OAuth2 Authorization Server (Identity Provider) for AdonisJS — an idiomatic
|
|
4
|
+
wrapper around [`oidc-provider`](https://github.com/panva/node-oidc-provider).
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- **OIDC / OAuth2 AS** — authorization code + PKCE, refresh tokens (rotated), token exchange,
|
|
9
|
+
discovery, JWKS (managed + rotatable), revocation, introspection.
|
|
10
|
+
- **MFA** — TOTP, WebAuthn passkeys, recovery codes, trusted-device skip.
|
|
11
|
+
- **Passwordless** — magic-link email login and passkey-first login.
|
|
12
|
+
- **Protocol extensions** — Device Flow (RFC 8628), DPoP (RFC 9449), PAR (RFC 9126), step-up
|
|
13
|
+
auth via `acr_values`, Dynamic Client Registration (RFC 7591/7592).
|
|
14
|
+
- **Admin console** — user CRUD (+ disable), client CRUD, sessions, audit log (opt-in,
|
|
15
|
+
role-gated).
|
|
16
|
+
- **Account console** — self-service apps/consent, security (password/email/sessions),
|
|
17
|
+
profile.
|
|
18
|
+
- **Tokens & federation** — Personal Access Tokens, admin impersonation, back-channel logout,
|
|
19
|
+
RP-initiated logout.
|
|
20
|
+
- **Hardening** — progressive account lockout, per-IP rate-limiting, audit logging with an
|
|
21
|
+
events/webhook fan-out, new-login email alerts.
|
|
22
|
+
- **Operability** — i18n (English default, pt-BR built in), OpenTelemetry metrics, the
|
|
23
|
+
`authkit:doctor` and `authkit:rotate-keys` commands.
|
|
24
|
+
|
|
25
|
+
> The remaining sections are in Portuguese pending a full translation pass.
|
|
5
26
|
|
|
6
27
|
## Instalação
|
|
7
28
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.apps.page_title') }}</title>
|
|
3
|
+
<script src="https://cdn.tailwindcss.com"></script></head>
|
|
4
|
+
<body class="min-h-screen bg-gray-100 p-4">
|
|
5
|
+
<div class="mx-auto max-w-2xl">
|
|
6
|
+
<div class="flex items-center justify-between py-6">
|
|
7
|
+
<div>
|
|
8
|
+
<div class="text-xs font-semibold uppercase tracking-widest text-gray-400">{{ t('common.brand_eyebrow') }}</div>
|
|
9
|
+
<h1 class="text-xl font-semibold text-gray-900">{{ t('account.apps.title') }}</h1>
|
|
10
|
+
<p class="mt-1 text-sm text-gray-500">{{ t('account.apps.intro') }}</p>
|
|
11
|
+
</div>
|
|
12
|
+
<form method="POST" action="/account/logout">
|
|
13
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
14
|
+
<button type="submit" class="text-sm text-gray-500 hover:underline">{{ t('account.apps.logout') }}</button>
|
|
15
|
+
</form>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<nav class="mb-6 flex gap-4 text-sm font-medium">
|
|
19
|
+
<a href="/account/tokens" class="text-gray-500 hover:underline">{{ t('account.tokens.title') }}</a>
|
|
20
|
+
<a href="/account/security" class="text-gray-500 hover:underline">{{ t('account.security.title') }}</a>
|
|
21
|
+
<a href="/account/apps" class="text-gray-900 underline">{{ t('account.apps.title') }}</a>
|
|
22
|
+
</nav>
|
|
23
|
+
|
|
24
|
+
@if(revoked)
|
|
25
|
+
<div class="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
|
|
26
|
+
<p class="text-sm font-medium text-emerald-900">{{ revoked }}</p>
|
|
27
|
+
</div>
|
|
28
|
+
@end
|
|
29
|
+
|
|
30
|
+
@if(!supported)
|
|
31
|
+
<div class="rounded-xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-black/5">
|
|
32
|
+
{{ t('account.apps.not_supported') }}
|
|
33
|
+
</div>
|
|
34
|
+
@else
|
|
35
|
+
<div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
|
|
36
|
+
@if(apps.length === 0)
|
|
37
|
+
<p class="p-6 text-sm text-gray-500">{{ t('account.apps.empty') }}</p>
|
|
38
|
+
@else
|
|
39
|
+
@each(app in apps)
|
|
40
|
+
<div class="flex items-center justify-between border-b border-gray-100 p-4 last:border-0">
|
|
41
|
+
<div>
|
|
42
|
+
<p class="text-sm font-medium text-gray-900">{{ app.name }}</p>
|
|
43
|
+
<p class="text-xs text-gray-500">{{ t('account.apps.tokens', { accessTokens: app.accessTokens, refreshTokens: app.refreshTokens }) }}</p>
|
|
44
|
+
</div>
|
|
45
|
+
<form method="POST" action="/account/apps/{{ app.clientId }}/revoke"
|
|
46
|
+
onsubmit="return confirm('{{ t('account.apps.revoke_confirm') }}')">
|
|
47
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
48
|
+
<button type="submit" class="rounded-lg border border-red-300 px-3 py-1.5 text-sm font-semibold text-red-700 hover:bg-red-50">
|
|
49
|
+
{{ t('account.apps.revoke') }}
|
|
50
|
+
</button>
|
|
51
|
+
</form>
|
|
52
|
+
</div>
|
|
53
|
+
@end
|
|
54
|
+
@end
|
|
55
|
+
</div>
|
|
56
|
+
@end
|
|
57
|
+
</div>
|
|
58
|
+
</body></html>
|
|
@@ -17,6 +17,41 @@
|
|
|
17
17
|
</form>
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
+
<nav class="mb-6 flex gap-4 text-sm font-medium">
|
|
21
|
+
<a href="/account/tokens" class="text-gray-500 hover:underline">{{ t('account.tokens.title') }}</a>
|
|
22
|
+
<a href="/account/security" class="text-gray-900 underline">{{ t('account.security.title') }}</a>
|
|
23
|
+
<a href="/account/apps" class="text-gray-500 hover:underline">{{ t('account.apps.title') }}</a>
|
|
24
|
+
</nav>
|
|
25
|
+
|
|
26
|
+
@if(profileUpdated)
|
|
27
|
+
<div class="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
|
|
28
|
+
<p class="text-sm font-medium text-emerald-900">{{ profileUpdated }}</p>
|
|
29
|
+
</div>
|
|
30
|
+
@end
|
|
31
|
+
|
|
32
|
+
@if(profileSupported)
|
|
33
|
+
<div class="mb-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
|
34
|
+
<h2 class="mb-1 text-sm font-semibold text-gray-900">{{ t('account.profile.section') }}</h2>
|
|
35
|
+
<p class="mb-4 text-xs text-gray-500">{{ t('account.profile.intro') }}</p>
|
|
36
|
+
<form method="POST" action="/account/security/profile" class="space-y-4">
|
|
37
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
38
|
+
<div>
|
|
39
|
+
<label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.profile.name_label') }}</label>
|
|
40
|
+
<input name="name" type="text" value="{{ name }}" maxlength="255"
|
|
41
|
+
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
|
|
42
|
+
</div>
|
|
43
|
+
<div>
|
|
44
|
+
<label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.profile.avatar_label') }}</label>
|
|
45
|
+
<input name="avatarUrl" type="url" value="{{ avatarUrl }}"
|
|
46
|
+
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
|
|
47
|
+
</div>
|
|
48
|
+
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
|
|
49
|
+
{{ t('account.profile.submit') }}
|
|
50
|
+
</button>
|
|
51
|
+
</form>
|
|
52
|
+
</div>
|
|
53
|
+
@end
|
|
54
|
+
|
|
20
55
|
@if(!supported)
|
|
21
56
|
<div class="rounded-xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-black/5">
|
|
22
57
|
{{ t('account.security.not_supported') }}
|
|
@@ -79,5 +114,23 @@
|
|
|
79
114
|
</form>
|
|
80
115
|
</div>
|
|
81
116
|
@end
|
|
117
|
+
|
|
118
|
+
@if(trustedDevicesEnabled)
|
|
119
|
+
@if(trustedDevicesRevoked)
|
|
120
|
+
<div class="mt-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
|
|
121
|
+
<p class="text-sm font-medium text-emerald-900">{{ trustedDevicesRevoked }}</p>
|
|
122
|
+
</div>
|
|
123
|
+
@end
|
|
124
|
+
<div class="mt-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
|
|
125
|
+
<h2 class="mb-1 text-sm font-semibold text-gray-900">{{ t('account.security.trusted_devices_section') }}</h2>
|
|
126
|
+
<p class="mb-4 text-xs text-gray-500">{{ t('account.security.trusted_devices_intro') }}</p>
|
|
127
|
+
<form method="POST" action="/account/security/trusted-devices/revoke">
|
|
128
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
129
|
+
<button type="submit" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50">
|
|
130
|
+
{{ t('account.security.trusted_devices_revoke') }}
|
|
131
|
+
</button>
|
|
132
|
+
</form>
|
|
133
|
+
</div>
|
|
134
|
+
@end
|
|
82
135
|
</div>
|
|
83
136
|
</body></html>
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
</div>
|
|
11
11
|
<div class="flex items-center gap-4">
|
|
12
12
|
<a href="/account/security" class="text-sm text-gray-500 hover:underline">{{ t('account.tokens.security') }}</a>
|
|
13
|
+
<a href="/account/apps" class="text-sm text-gray-500 hover:underline">{{ t('account.apps.title') }}</a>
|
|
13
14
|
<form method="POST" action="/account/logout">
|
|
14
15
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
15
16
|
<button type="submit" class="text-sm text-gray-500 hover:underline">{{ t('account.tokens.logout') }}</button>
|
|
@@ -21,6 +21,35 @@
|
|
|
21
21
|
<a href="/admin/audit" class="text-gray-500 hover:underline">{{ t('admin.nav.audit') }}</a>
|
|
22
22
|
</nav>
|
|
23
23
|
|
|
24
|
+
@if(error)
|
|
25
|
+
<div class="mb-4 rounded-lg border border-red-300 bg-red-50 p-3"><p class="text-sm font-medium text-red-900">{{ error }}</p></div>
|
|
26
|
+
@end
|
|
27
|
+
@if(created)
|
|
28
|
+
<div class="mb-4 rounded-lg border border-emerald-300 bg-emerald-50 p-3"><p class="text-sm font-medium text-emerald-900">{{ created }}</p></div>
|
|
29
|
+
@end
|
|
30
|
+
@if(resetSent)
|
|
31
|
+
<div class="mb-4 rounded-lg border border-blue-300 bg-blue-50 p-3"><p class="text-sm font-medium text-blue-900">{{ resetSent }}</p></div>
|
|
32
|
+
@end
|
|
33
|
+
@if(statusChanged)
|
|
34
|
+
<div class="mb-4 rounded-lg border border-amber-300 bg-amber-50 p-3"><p class="text-sm font-medium text-amber-900">{{ statusChanged }}</p></div>
|
|
35
|
+
@end
|
|
36
|
+
|
|
37
|
+
<div class="mb-6 rounded-xl bg-white p-4 shadow-sm ring-1 ring-black/5">
|
|
38
|
+
<h2 class="mb-3 text-sm font-semibold text-gray-900">{{ t('admin.users.create_section') }}</h2>
|
|
39
|
+
<form method="POST" action="/admin/users" class="grid gap-2 sm:grid-cols-4">
|
|
40
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
41
|
+
<input name="email" type="email" required placeholder="{{ t('admin.users.create_email_placeholder') }}"
|
|
42
|
+
class="rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
|
|
43
|
+
<input name="name" type="text" placeholder="{{ t('admin.users.create_name_placeholder') }}"
|
|
44
|
+
class="rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
|
|
45
|
+
<input name="password" type="password" minlength="8" placeholder="{{ t('admin.users.create_password_placeholder') }}"
|
|
46
|
+
class="rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
|
|
47
|
+
<button type="submit" class="rounded-lg bg-gray-900 px-4 text-sm font-semibold text-white">
|
|
48
|
+
{{ t('admin.users.create_submit') }}
|
|
49
|
+
</button>
|
|
50
|
+
</form>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
24
53
|
<form method="GET" action="/admin/users" class="mb-6 flex gap-2">
|
|
25
54
|
<input name="search" value="{{ search }}" placeholder="{{ t('admin.users.search_placeholder') }}"
|
|
26
55
|
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
|
|
@@ -37,12 +66,43 @@
|
|
|
37
66
|
<div class="border-b border-gray-100 p-4 last:border-0">
|
|
38
67
|
<div class="flex items-center justify-between">
|
|
39
68
|
<div>
|
|
40
|
-
<p class="text-sm font-medium text-gray-900">
|
|
69
|
+
<p class="text-sm font-medium text-gray-900">
|
|
70
|
+
{{ user.email }}
|
|
71
|
+
@if(user.disabled)
|
|
72
|
+
<span class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700">{{ t('admin.users.disabled_badge') }}</span>
|
|
73
|
+
@end
|
|
74
|
+
</p>
|
|
41
75
|
@if(user.name)
|
|
42
76
|
<p class="text-xs text-gray-500">{{ user.name }}</p>
|
|
43
77
|
@end
|
|
44
78
|
</div>
|
|
45
|
-
<
|
|
79
|
+
<div class="flex items-center gap-3 text-sm">
|
|
80
|
+
<a href="/admin/users/{{ user.id }}/sessions" class="text-gray-500 hover:underline">{{ t('admin.users.sessions') }}</a>
|
|
81
|
+
<form method="POST" action="/admin/users/{{ user.id }}/reset-password" class="inline">
|
|
82
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
83
|
+
<input type="hidden" name="search" value="{{ search }}">
|
|
84
|
+
<input type="hidden" name="page" value="{{ page }}">
|
|
85
|
+
<button type="submit" class="text-gray-500 hover:underline">{{ t('admin.users.reset_password') }}</button>
|
|
86
|
+
</form>
|
|
87
|
+
@if(statusSupported)
|
|
88
|
+
@if(user.disabled)
|
|
89
|
+
<form method="POST" action="/admin/users/{{ user.id }}/enable" class="inline">
|
|
90
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
91
|
+
<input type="hidden" name="search" value="{{ search }}">
|
|
92
|
+
<input type="hidden" name="page" value="{{ page }}">
|
|
93
|
+
<button type="submit" class="text-emerald-700 hover:underline">{{ t('admin.users.enable') }}</button>
|
|
94
|
+
</form>
|
|
95
|
+
@else
|
|
96
|
+
<form method="POST" action="/admin/users/{{ user.id }}/disable" class="inline"
|
|
97
|
+
onsubmit="return confirm('{{ t('admin.users.disable_confirm') }}')">
|
|
98
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
99
|
+
<input type="hidden" name="search" value="{{ search }}">
|
|
100
|
+
<input type="hidden" name="page" value="{{ page }}">
|
|
101
|
+
<button type="submit" class="text-red-700 hover:underline">{{ t('admin.users.disable') }}</button>
|
|
102
|
+
</form>
|
|
103
|
+
@end
|
|
104
|
+
@end
|
|
105
|
+
</div>
|
|
46
106
|
</div>
|
|
47
107
|
<form method="POST" action="/admin/users/{{ user.id }}/roles" class="mt-3 flex gap-2">
|
|
48
108
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
@@ -86,6 +86,61 @@
|
|
|
86
86
|
<a href="/auth/forgot-password" class="hover:underline">{{ t('login.forgot_password') }}</a>
|
|
87
87
|
</div>
|
|
88
88
|
</form>
|
|
89
|
+
|
|
90
|
+
{{-- Passwordless: confirmação de magic link enviado (anti-enumeração). --}}
|
|
91
|
+
@if(magicLinkSent)
|
|
92
|
+
<p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
|
|
93
|
+
@end
|
|
94
|
+
|
|
95
|
+
{{-- Passwordless: "me envie um link de login" (mesma sessão/e-mail; não pede senha). --}}
|
|
96
|
+
@if(magicLinkAvailable && !magicLinkSent)
|
|
97
|
+
<form method="POST" action="/auth/interaction/{{ uid }}/magic" class="mt-4">
|
|
98
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
99
|
+
<button type="submit"
|
|
100
|
+
class="w-full rounded-lg border border-gray-300 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50">
|
|
101
|
+
{{ t('login.magic_link_button') }}
|
|
102
|
+
</button>
|
|
103
|
+
</form>
|
|
104
|
+
@end
|
|
105
|
+
|
|
106
|
+
{{-- Passwordless: "entrar com passkey" ANTES da senha. Reusa as cerimônias
|
|
107
|
+
de passkey do MFA (begin/finish) liberadas no estágio de senha. --}}
|
|
108
|
+
@if(passkeyFirstAvailable)
|
|
109
|
+
<form id="passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify" class="mt-4">
|
|
110
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
111
|
+
<input type="hidden" name="response" id="passkey-response">
|
|
112
|
+
</form>
|
|
113
|
+
<button type="button" id="passkey-button"
|
|
114
|
+
class="mt-4 w-full rounded-lg border border-gray-300 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50">
|
|
115
|
+
{{ t('login.passkey_button') }}
|
|
116
|
+
</button>
|
|
117
|
+
<p id="passkey-error" class="mt-3 hidden text-sm text-red-600">{{ t('mfa_challenge.passkey_error') }}</p>
|
|
118
|
+
<script type="module">
|
|
119
|
+
import { startAuthentication } from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@13/dist/bundle/index.js'
|
|
120
|
+
const btn = document.getElementById('passkey-button')
|
|
121
|
+
const errEl = document.getElementById('passkey-error')
|
|
122
|
+
const csrf = document.querySelector('#passkey-form input[name="_csrf"]').value
|
|
123
|
+
btn?.addEventListener('click', async () => {
|
|
124
|
+
errEl.classList.add('hidden')
|
|
125
|
+
btn.disabled = true
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch('/auth/interaction/{{ uid }}/passkey/options', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
|
|
130
|
+
body: JSON.stringify({}),
|
|
131
|
+
})
|
|
132
|
+
if (!res.ok) throw new Error('options')
|
|
133
|
+
const optionsJSON = await res.json()
|
|
134
|
+
const assertion = await startAuthentication({ optionsJSON })
|
|
135
|
+
document.getElementById('passkey-response').value = JSON.stringify(assertion)
|
|
136
|
+
document.getElementById('passkey-form').submit()
|
|
137
|
+
} catch (e) {
|
|
138
|
+
errEl.classList.remove('hidden')
|
|
139
|
+
btn.disabled = false
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
</script>
|
|
143
|
+
@end
|
|
89
144
|
</div>
|
|
90
145
|
@end
|
|
91
146
|
</body></html>
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-center text-lg tracking-[0.4em] outline-none transition focus:border-gray-900 focus:ring-2 focus:ring-gray-900" />
|
|
25
25
|
</div>
|
|
26
26
|
|
|
27
|
+
@if(trustedDevicesEnabled)
|
|
28
|
+
<label class="mt-4 flex items-center gap-2 text-sm text-gray-600">
|
|
29
|
+
<input type="checkbox" name="trustDevice" value="on" class="h-4 w-4 rounded border-gray-300">
|
|
30
|
+
{{ t('mfa_challenge.trust_device', { days: trustedDeviceDays }) }}
|
|
31
|
+
</label>
|
|
32
|
+
@end
|
|
33
|
+
|
|
27
34
|
<button type="submit"
|
|
28
35
|
class="mt-6 w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:opacity-90">
|
|
29
36
|
{{ t('mfa_challenge.submit') }}
|
|
@@ -37,6 +44,7 @@
|
|
|
37
44
|
<form id="passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify" class="mt-4">
|
|
38
45
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
39
46
|
<input type="hidden" name="response" id="passkey-response">
|
|
47
|
+
<input type="hidden" name="trustDevice" id="passkey-trust" value="">
|
|
40
48
|
</form>
|
|
41
49
|
<button type="button" id="passkey-button"
|
|
42
50
|
class="mt-4 w-full rounded-lg border border-gray-300 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50">
|
|
@@ -83,6 +91,10 @@
|
|
|
83
91
|
const assertion = await startAuthentication({ optionsJSON })
|
|
84
92
|
// 3) Submete a assertion como POST de página inteira (segue o 303).
|
|
85
93
|
document.getElementById('passkey-response').value = JSON.stringify(assertion)
|
|
94
|
+
// Carrega a escolha de "confiar neste dispositivo" para o form da passkey.
|
|
95
|
+
const trustChk = document.querySelector('input[name="trustDevice"][type="checkbox"]')
|
|
96
|
+
const trustHidden = document.getElementById('passkey-trust')
|
|
97
|
+
if (trustHidden) trustHidden.value = trustChk && trustChk.checked ? 'on' : ''
|
|
86
98
|
document.getElementById('passkey-form').submit()
|
|
87
99
|
} catch (e) {
|
|
88
100
|
errEl.classList.remove('hidden')
|
package/build/index.d.ts
CHANGED
|
@@ -8,10 +8,14 @@ export { registerOidcRoutes } from './src/register_routes.js';
|
|
|
8
8
|
export type { AuthServerConfigInput, ResolvedServerConfig, DynamicRegistrationConfigInput, ResolvedDynamicRegistrationConfig, AdminConfigInput, ResolvedAdminConfig, } from './src/define_config.js';
|
|
9
9
|
export { resolveAdmin, resolveWebauthn, resolveDynamicRegistration } from './src/define_config.js';
|
|
10
10
|
export type { WebauthnConfigInput, ResolvedWebauthnConfig } from './src/define_config.js';
|
|
11
|
+
export { resolvePasswordless } from './src/define_config.js';
|
|
12
|
+
export type { PasswordlessConfigInput, ResolvedPasswordlessConfig, } from './src/define_config.js';
|
|
13
|
+
export { resolveTrustedDevices, isTrustedDeviceValid, buildTrustedDevicePayload, TRUSTED_DEVICE_COOKIE, } from './src/host/trusted_device.js';
|
|
14
|
+
export type { TrustedDevicesConfigInput, ResolvedTrustedDevicesConfig, TrustedDevicePayload, } from './src/host/trusted_device.js';
|
|
11
15
|
export { lucidAccountStore } from './src/accounts/lucid_account_store.js';
|
|
12
16
|
export type { LucidAccountStoreOptions, AccountSecretEncrypter, } from './src/accounts/lucid_account_store.js';
|
|
13
|
-
export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, AccountSecurityCapability, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
|
|
14
|
-
export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, } from './src/accounts/account_store.js';
|
|
17
|
+
export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, AccountSecurityCapability, MagicLinkCapability, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
|
|
18
|
+
export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsMagicLink, } from './src/accounts/account_store.js';
|
|
15
19
|
export { withProviderIdentity } from './src/mixins/with_provider_identity.js';
|
|
16
20
|
export type { ProviderIdentityRow, ProviderIdentityClass, } from './src/mixins/with_provider_identity.js';
|
|
17
21
|
export { withWebauthnCredential } from './src/mixins/with_webauthn_credential.js';
|
|
@@ -22,11 +26,13 @@ export { withPersonalAccessToken } from './src/mixins/with_personal_access_token
|
|
|
22
26
|
export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
|
|
23
27
|
export type { AuditSink, AuditEvent, AuditEventType, StoredAuditEvent, ListAuditParams, AuditPage, } from './src/audit/audit_sink.js';
|
|
24
28
|
export { withAuditLog } from './src/mixins/with_audit_log.js';
|
|
29
|
+
export { composeAuditSink, resolveEvents, buildWebhookBody, signWebhookBody, } from './src/events/dispatcher.js';
|
|
30
|
+
export type { EventsConfigInput, ResolvedEventsConfig } from './src/events/dispatcher.js';
|
|
25
31
|
export { inertiaRenderer } from './src/host/renderers/inertia_renderer.js';
|
|
26
32
|
export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
|
|
27
33
|
export { brandFor, isFirstParty } from './src/host/branding.js';
|
|
28
34
|
export type { BrandingConfig, ClientBrand } from './src/host/branding.js';
|
|
29
|
-
export { resolveMessages, translate, DEFAULT_MESSAGES, DEFAULT_LOCALE } from './src/host/i18n.js';
|
|
35
|
+
export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_MESSAGES, DEFAULT_LOCALE, } from './src/host/i18n.js';
|
|
30
36
|
export type { I18nConfig, AuthMessages } from './src/host/i18n.js';
|
|
31
37
|
export type { AuthHostRenderer, AuthSocialConfig } from './src/define_config.js';
|
|
32
38
|
export { registerAuthHost } from './src/host/register_auth_host.js';
|
package/build/index.js
CHANGED
|
@@ -6,18 +6,21 @@ export { withMfa } from './src/mixins/with_mfa.js';
|
|
|
6
6
|
export { OidcService } from './src/provider/oidc_service.js';
|
|
7
7
|
export { registerOidcRoutes } from './src/register_routes.js';
|
|
8
8
|
export { resolveAdmin, resolveWebauthn, resolveDynamicRegistration } from './src/define_config.js';
|
|
9
|
+
export { resolvePasswordless } from './src/define_config.js';
|
|
10
|
+
export { resolveTrustedDevices, isTrustedDeviceValid, buildTrustedDevicePayload, TRUSTED_DEVICE_COOKIE, } from './src/host/trusted_device.js';
|
|
9
11
|
export { lucidAccountStore } from './src/accounts/lucid_account_store.js';
|
|
10
|
-
export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, } from './src/accounts/account_store.js';
|
|
12
|
+
export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsMagicLink, } from './src/accounts/account_store.js';
|
|
11
13
|
export { withProviderIdentity } from './src/mixins/with_provider_identity.js';
|
|
12
14
|
export { withWebauthnCredential } from './src/mixins/with_webauthn_credential.js';
|
|
13
15
|
export { lucidPatStore } from './src/pat/lucid_pat_store.js';
|
|
14
16
|
export { withPersonalAccessToken } from './src/mixins/with_personal_access_token.js';
|
|
15
17
|
export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
|
|
16
18
|
export { withAuditLog } from './src/mixins/with_audit_log.js';
|
|
19
|
+
export { composeAuditSink, resolveEvents, buildWebhookBody, signWebhookBody, } from './src/events/dispatcher.js';
|
|
17
20
|
export { inertiaRenderer } from './src/host/renderers/inertia_renderer.js';
|
|
18
21
|
export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
|
|
19
22
|
export { brandFor, isFirstParty } from './src/host/branding.js';
|
|
20
|
-
export { resolveMessages, translate, DEFAULT_MESSAGES, DEFAULT_LOCALE } from './src/host/i18n.js';
|
|
23
|
+
export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_MESSAGES, DEFAULT_LOCALE, } from './src/host/i18n.js';
|
|
21
24
|
export { registerAuthHost } from './src/host/register_auth_host.js';
|
|
22
25
|
export { resolveRateLimit, resolveNotifications } from './src/define_config.js';
|
|
23
26
|
export { createAuthThrottles } from './src/host/rate_limit.js';
|
|
@@ -126,6 +126,43 @@ export interface AccountSecurityCapability {
|
|
|
126
126
|
ok: false;
|
|
127
127
|
}>;
|
|
128
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Status da conta (habilitar/desabilitar) — usado pelo console admin para
|
|
131
|
+
* suspender uma conta sem apagá-la. É uma CAPACIDADE opcional: stores sem suporte
|
|
132
|
+
* omitem os métodos e a UI esconde os botões de disable/enable. Quando suportada,
|
|
133
|
+
* os fluxos de login (interaction OIDC + console de conta) DEVEM rejeitar contas
|
|
134
|
+
* desabilitadas (ver `attemptPasswordLogin`).
|
|
135
|
+
*
|
|
136
|
+
* O store default (Lucid) implementa via uma coluna `disabled_at` (timestamp
|
|
137
|
+
* nullable) NO model — quando a coluna existe (probe em `$columnsDefinitions`); se
|
|
138
|
+
* o model não a tiver, a capacidade fica genuinamente ausente (nenhum método é
|
|
139
|
+
* montado) e a UI esconde os botões. Hosts adicionam a coluna por migração própria
|
|
140
|
+
* (mesmo padrão documentado das demais colunas opcionais).
|
|
141
|
+
*/
|
|
142
|
+
export interface AccountStatusCapability {
|
|
143
|
+
/** Desabilita a conta (impede login). No-op se a conta não existe. */
|
|
144
|
+
disableAccount(accountId: string): Promise<void>;
|
|
145
|
+
/** Reabilita a conta. No-op se a conta não existe. */
|
|
146
|
+
enableAccount(accountId: string): Promise<void>;
|
|
147
|
+
/** Indica se a conta está desabilitada (false se a conta não existe). */
|
|
148
|
+
isDisabled(accountId: string): Promise<boolean>;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Edição do perfil próprio (console de conta): nome e avatar. CAPACIDADE opcional;
|
|
152
|
+
* stores sem suporte omitem o método e a UI esconde a seção. O store default
|
|
153
|
+
* (Lucid) grava nas colunas `full_name`/`avatar_url` do model — apenas as que
|
|
154
|
+
* existirem (probe em `$columnsDefinitions`).
|
|
155
|
+
*/
|
|
156
|
+
export interface ProfileCapability {
|
|
157
|
+
/**
|
|
158
|
+
* Atualiza o perfil da conta. Campos `undefined` são deixados como estão;
|
|
159
|
+
* `null` limpa o valor. Retorna a conta atualizada ou null se inexistente.
|
|
160
|
+
*/
|
|
161
|
+
updateProfile(accountId: string, patch: {
|
|
162
|
+
name?: string | null;
|
|
163
|
+
avatarUrl?: string | null;
|
|
164
|
+
}): Promise<AuthAccount | null>;
|
|
165
|
+
}
|
|
129
166
|
/**
|
|
130
167
|
* Account linking por identidade de provider (Google, GitHub, …).
|
|
131
168
|
* `(provider, providerUserId)` é a chave estável vinda do provider OAuth — não
|
|
@@ -144,9 +181,16 @@ export interface ProviderIdentityCapability {
|
|
|
144
181
|
* flow trata a ausência como "MFA desligado".
|
|
145
182
|
*/
|
|
146
183
|
export interface MfaCapability {
|
|
147
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Estado do MFA da conta (se o desafio TOTP deve ser exigido no login).
|
|
186
|
+
* `enabledAt` (epoch ms) é o instante em que o MFA foi (re)enrolado, usado pelo
|
|
187
|
+
* mecanismo de "trusted devices": um cookie de confiança emitido ANTES desse
|
|
188
|
+
* instante é considerado inválido (re-enrolar MFA revoga a confiança). Pode ser
|
|
189
|
+
* `null`/ausente quando o MFA não está ativo ou o store não rastreia o instante.
|
|
190
|
+
*/
|
|
148
191
|
getMfaState(accountId: string): Promise<{
|
|
149
192
|
enabled: boolean;
|
|
193
|
+
enabledAt?: number | null;
|
|
150
194
|
}>;
|
|
151
195
|
/**
|
|
152
196
|
* Inicia o enrollment TOTP: gera um segredo PENDENTE (mfaEnabledAt continua
|
|
@@ -218,6 +262,34 @@ export interface WebauthnCapability {
|
|
|
218
262
|
/** Remove uma passkey (por credential id) da conta. */
|
|
219
263
|
removePasskey(accountId: string, credentialId: string): Promise<void>;
|
|
220
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Login sem senha por "magic link" — um token de uso único e curta duração
|
|
267
|
+
* enviado por e-mail. CAPACIDADE opcional: stores sem suporte omitem os métodos e
|
|
268
|
+
* a UI esconde o botão "me envie um link".
|
|
269
|
+
*
|
|
270
|
+
* O store default (Lucid) reaproveita as colunas de reset de senha
|
|
271
|
+
* (`passwordResetToken` / `passwordResetExpiresAt`) codificando o token com o
|
|
272
|
+
* prefixo `ml:` — assim NÃO exige migração nova (mesmo padrão do `ec:` da troca de
|
|
273
|
+
* e-mail). O tradeoff é que um magic link e um reset de senha pendentes não
|
|
274
|
+
* coexistem (mesma coluna); na prática são fluxos distintos no tempo. Consumir um
|
|
275
|
+
* magic link NÃO altera a senha.
|
|
276
|
+
*/
|
|
277
|
+
export interface MagicLinkCapability {
|
|
278
|
+
/**
|
|
279
|
+
* Emite um magic link para o e-mail. Retorna o token + a conta, ou null se a
|
|
280
|
+
* conta não existe (o controller SEMPRE renderiza "link enviado" para não vazar
|
|
281
|
+
* a existência de contas).
|
|
282
|
+
*/
|
|
283
|
+
issueMagicLinkToken(email: string): Promise<{
|
|
284
|
+
token: string;
|
|
285
|
+
account: AuthAccount;
|
|
286
|
+
} | null>;
|
|
287
|
+
/**
|
|
288
|
+
* Consome (single-use) um magic link. Retorna a conta autenticada ou null se o
|
|
289
|
+
* token é inválido/expirado. NÃO altera a senha.
|
|
290
|
+
*/
|
|
291
|
+
consumeMagicLinkToken(token: string): Promise<AuthAccount | null>;
|
|
292
|
+
}
|
|
221
293
|
/**
|
|
222
294
|
* Store de contas usado pela config. É o núcleo SEMPRE presente
|
|
223
295
|
* ({@link CoreAccountStore}) + as capacidades opcionais (MFA, WebAuthn, account
|
|
@@ -227,7 +299,7 @@ export interface WebauthnCapability {
|
|
|
227
299
|
* presentes-mas-lançando). Use os type guards {@link supportsMfa},
|
|
228
300
|
* {@link supportsPasskeys}, {@link supportsProviderIdentity} para estreitar.
|
|
229
301
|
*/
|
|
230
|
-
export type AccountStore = CoreAccountStore & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability>;
|
|
302
|
+
export type AccountStore = CoreAccountStore & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability>;
|
|
231
303
|
/** Type guard: o store implementa a capacidade de MFA / TOTP. */
|
|
232
304
|
export declare function supportsMfa(store: AccountStore): store is AccountStore & MfaCapability;
|
|
233
305
|
/** Type guard: o store implementa a capacidade de passkeys / WebAuthn. */
|
|
@@ -236,3 +308,9 @@ export declare function supportsPasskeys(store: AccountStore): store is AccountS
|
|
|
236
308
|
export declare function supportsProviderIdentity(store: AccountStore): store is AccountStore & ProviderIdentityCapability;
|
|
237
309
|
/** Type guard: o store implementa o self-service de segurança (senha/e-mail). */
|
|
238
310
|
export declare function supportsAccountSecurity(store: AccountStore): store is AccountStore & AccountSecurityCapability;
|
|
311
|
+
/** Type guard: o store implementa habilitar/desabilitar conta. */
|
|
312
|
+
export declare function supportsAccountStatus(store: AccountStore): store is AccountStore & AccountStatusCapability;
|
|
313
|
+
/** Type guard: o store implementa a edição de perfil (nome/avatar). */
|
|
314
|
+
export declare function supportsProfile(store: AccountStore): store is AccountStore & ProfileCapability;
|
|
315
|
+
/** Type guard: o store implementa login por magic link (passwordless). */
|
|
316
|
+
export declare function supportsMagicLink(store: AccountStore): store is AccountStore & MagicLinkCapability;
|
|
@@ -14,3 +14,15 @@ export function supportsProviderIdentity(store) {
|
|
|
14
14
|
export function supportsAccountSecurity(store) {
|
|
15
15
|
return typeof store.changePassword === 'function';
|
|
16
16
|
}
|
|
17
|
+
/** Type guard: o store implementa habilitar/desabilitar conta. */
|
|
18
|
+
export function supportsAccountStatus(store) {
|
|
19
|
+
return typeof store.disableAccount === 'function';
|
|
20
|
+
}
|
|
21
|
+
/** Type guard: o store implementa a edição de perfil (nome/avatar). */
|
|
22
|
+
export function supportsProfile(store) {
|
|
23
|
+
return typeof store.updateProfile === 'function';
|
|
24
|
+
}
|
|
25
|
+
/** Type guard: o store implementa login por magic link (passwordless). */
|
|
26
|
+
export function supportsMagicLink(store) {
|
|
27
|
+
return typeof store.issueMagicLinkToken === 'function';
|
|
28
|
+
}
|
|
@@ -3,6 +3,7 @@ import { buildCore } from './lucid_store/core.js';
|
|
|
3
3
|
import { buildMfa } from './lucid_store/mfa.js';
|
|
4
4
|
import { buildProviderIdentity } from './lucid_store/provider_identity.js';
|
|
5
5
|
import { buildWebauthn } from './lucid_store/webauthn.js';
|
|
6
|
+
import { buildStatus, buildProfile, hasColumn } from './lucid_store/status_profile.js';
|
|
6
7
|
/**
|
|
7
8
|
* Implementação default do {@link AccountStore} sobre um model Lucid composto
|
|
8
9
|
* de `withAuthUser()` + `withCredentials()` (+ opcionalmente `withMfa()`). O
|
|
@@ -53,6 +54,7 @@ export function lucidAccountStore(Model, options = {}) {
|
|
|
53
54
|
email: row.email,
|
|
54
55
|
globalRoles: row.globalRoles ?? [],
|
|
55
56
|
name: row.fullName ?? undefined,
|
|
57
|
+
avatarUrl: row.avatarUrl ?? undefined,
|
|
56
58
|
}),
|
|
57
59
|
};
|
|
58
60
|
// Núcleo + MFA são sempre presentes. Passkeys e provider-identity só entram
|
|
@@ -65,5 +67,11 @@ export function lucidAccountStore(Model, options = {}) {
|
|
|
65
67
|
...(WebauthnCredentialModel
|
|
66
68
|
? buildWebauthn(ctx, WebauthnCredentialModel, webauthn, ceremonies)
|
|
67
69
|
: {}),
|
|
70
|
+
// Status (disable/enable) só quando o model tem a coluna `disabled_at`.
|
|
71
|
+
...(hasColumn(Model, 'disabledAt') ? buildStatus(ctx) : {}),
|
|
72
|
+
// Perfil (nome/avatar) só quando o model tem ao menos uma das colunas.
|
|
73
|
+
...(hasColumn(Model, 'fullName') || hasColumn(Model, 'avatarUrl')
|
|
74
|
+
? buildProfile(ctx)
|
|
75
|
+
: {}),
|
|
68
76
|
};
|
|
69
77
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountSecurityCapability, CoreAccountStore } from '../account_store.js';
|
|
1
|
+
import type { AccountSecurityCapability, CoreAccountStore, MagicLinkCapability } from '../account_store.js';
|
|
2
2
|
import type { LucidStoreContext } from './shared.js';
|
|
3
3
|
/**
|
|
4
4
|
* Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
|
|
@@ -6,4 +6,4 @@ import type { LucidStoreContext } from './shared.js';
|
|
|
6
6
|
* (listagem paginada + roles globais) e o self-service de segurança
|
|
7
7
|
* ({@link AccountSecurityCapability}: trocar senha/e-mail).
|
|
8
8
|
*/
|
|
9
|
-
export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability;
|
|
9
|
+
export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability & MagicLinkCapability;
|
|
@@ -2,6 +2,8 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { DateTime } from 'luxon';
|
|
3
3
|
/** Prefixo do token de troca de e-mail (reaproveita a coluna emailVerificationToken). */
|
|
4
4
|
const EMAIL_CHANGE_PREFIX = 'ec:';
|
|
5
|
+
/** Prefixo do magic link (reaproveita as colunas de reset de senha). */
|
|
6
|
+
const MAGIC_LINK_PREFIX = 'ml:';
|
|
5
7
|
/**
|
|
6
8
|
* Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
|
|
7
9
|
* identidade, cadastro, reset de senha, verificação de e-mail, administração
|
|
@@ -46,6 +48,10 @@ export function buildCore(ctx) {
|
|
|
46
48
|
return { token, account: toAccount(row) };
|
|
47
49
|
},
|
|
48
50
|
async consumePasswordResetToken(token, newPassword) {
|
|
51
|
+
// Magic links (`ml:`) NÃO são tokens de reset de senha — só o fluxo de
|
|
52
|
+
// consumeMagicLinkToken pode consumi-los (não trocam senha).
|
|
53
|
+
if (token.startsWith(MAGIC_LINK_PREFIX))
|
|
54
|
+
return false;
|
|
49
55
|
const row = await Model.query().where('passwordResetToken', token).first();
|
|
50
56
|
if (!row)
|
|
51
57
|
return false;
|
|
@@ -57,6 +63,33 @@ export function buildCore(ctx) {
|
|
|
57
63
|
await row.save();
|
|
58
64
|
return true;
|
|
59
65
|
},
|
|
66
|
+
// ----- Magic link (login passwordless) -----
|
|
67
|
+
async issueMagicLinkToken(email) {
|
|
68
|
+
const row = await Model.query().where('email', email).first();
|
|
69
|
+
if (!row)
|
|
70
|
+
return null;
|
|
71
|
+
// Token `ml:<random>` nas colunas de reset (sem migração); o prefixo o
|
|
72
|
+
// distingue de um token de reset de senha. Curta duração (15 min).
|
|
73
|
+
const token = `${MAGIC_LINK_PREFIX}${randomBytes(32).toString('hex')}`;
|
|
74
|
+
row.passwordResetToken = token;
|
|
75
|
+
row.passwordResetExpiresAt = DateTime.now().plus({ minutes: 15 });
|
|
76
|
+
await row.save();
|
|
77
|
+
return { token, account: toAccount(row) };
|
|
78
|
+
},
|
|
79
|
+
async consumeMagicLinkToken(token) {
|
|
80
|
+
if (!token || !token.startsWith(MAGIC_LINK_PREFIX))
|
|
81
|
+
return null;
|
|
82
|
+
const row = await Model.query().where('passwordResetToken', token).first();
|
|
83
|
+
if (!row)
|
|
84
|
+
return null;
|
|
85
|
+
if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
|
|
86
|
+
return null;
|
|
87
|
+
// Single-use: limpa o token (NÃO altera a senha).
|
|
88
|
+
row.passwordResetToken = null;
|
|
89
|
+
row.passwordResetExpiresAt = null;
|
|
90
|
+
await row.save();
|
|
91
|
+
return toAccount(row);
|
|
92
|
+
},
|
|
60
93
|
async issueEmailVerificationToken(email) {
|
|
61
94
|
const row = await Model.query().where('email', email).first();
|
|
62
95
|
if (!row)
|