@navette-js/panels 1.0.0-beta.4 → 1.0.0-beta.5
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/THIRD-PARTY-NOTICES.md +1 -0
- package/dist/navette/navette/calendar.css +5 -5
- package/dist/navette/navette/motion.css +21 -21
- package/dist/navette/navette/motion.js +6 -6
- package/dist/navette/navette/security-keys.css +11 -0
- package/dist/navette/navette/security-keys.js +72 -0
- package/lib/auth.d.ts +17 -0
- package/lib/auth.js +70 -0
- package/lib/error-pages.d.ts +9 -0
- package/lib/error-pages.js +173 -0
- package/lib/import-export.d.ts +45 -2
- package/lib/import-export.js +235 -62
- package/lib/index.d.ts +447 -74
- package/lib/index.js +1280 -267
- package/lib/multi-factor.d.ts +348 -0
- package/lib/multi-factor.js +928 -0
- package/lib/qr-code.d.ts +16 -0
- package/lib/qr-code.js +255 -0
- package/lib/throttle.d.ts +10 -0
- package/lib/throttle.js +25 -0
- package/lib/topbar.d.ts +3 -1
- package/lib/topbar.js +18 -1
- package/lib/totp.d.ts +38 -0
- package/lib/totp.js +104 -0
- package/lib/webauthn.d.ts +155 -0
- package/lib/webauthn.js +287 -0
- package/lib/xlsx.d.ts +5 -0
- package/lib/xlsx.js +103 -1
- package/package.json +9 -9
- package/views/components/tenant-menu.ejs +3 -3
- package/views/errors/page.ejs +87 -0
- package/views/layout/base.ejs +2 -0
- package/views/livewire/sidebar.ejs +29 -15
- package/views/livewire/topbar.ejs +1 -1
- package/views/pages/page.ejs +53 -35
- package/views/pages/simple.ejs +4 -0
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
// Multi-factor authentication: the provider contract, the login challenge, and two providers:
|
|
2
|
+
// authenticator apps (TOTP, with recovery codes) and security keys (WebAuthn: hardware keys and passkeys).
|
|
3
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { Action } from '@navette-js/actions';
|
|
5
|
+
import { Field, Halt, OneTimeCodeInput, Radio, Step, TextInput } from '@navette-js/forms';
|
|
6
|
+
import { Actions, Flex, Group, Html, Image, Section, Text } from '@navette-js/schemas';
|
|
7
|
+
import { HtmlString, e, js } from '@navette-js/support';
|
|
8
|
+
import { t, tc } from '@navette-js/support/i18n';
|
|
9
|
+
import { button } from '@navette-js/support/ui';
|
|
10
|
+
import { readToken, signToken } from "./auth.js";
|
|
11
|
+
import { Notification } from "./notifications.js";
|
|
12
|
+
import { qrCodeDataUri } from "./qr-code.js";
|
|
13
|
+
import { Throttle } from "./throttle.js";
|
|
14
|
+
import { generateTotpSecret, totpUri, verifyTotp } from "./totp.js";
|
|
15
|
+
import { authenticationOptions, generateChallenge, registrationOptions, verifyAuthentication, verifyRegistration, WebAuthnError } from "./webauthn.js";
|
|
16
|
+
/** In-memory store: fine for a single process. When running several, use a shared store (Redis…) with `cacheStore()`. */
|
|
17
|
+
export class MemoryCacheStore {
|
|
18
|
+
entries = new Map();
|
|
19
|
+
max;
|
|
20
|
+
constructor(max = 50_000) { this.max = max; }
|
|
21
|
+
get(key) {
|
|
22
|
+
const entry = this.entries.get(key);
|
|
23
|
+
if (!entry)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (entry.expires <= Date.now()) {
|
|
26
|
+
this.entries.delete(key);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return entry.value;
|
|
30
|
+
}
|
|
31
|
+
put(key, value, ttlSeconds) {
|
|
32
|
+
this.entries.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
|
33
|
+
if (this.entries.size > this.max) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
for (const [k, v] of this.entries)
|
|
36
|
+
if (v.expires <= now)
|
|
37
|
+
this.entries.delete(k);
|
|
38
|
+
// Still full: drop the oldest entries.
|
|
39
|
+
for (const k of this.entries.keys()) {
|
|
40
|
+
if (this.entries.size <= this.max)
|
|
41
|
+
break;
|
|
42
|
+
this.entries.delete(k);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
forget(key) { this.entries.delete(key); }
|
|
47
|
+
}
|
|
48
|
+
let defaultCacheStore = new MemoryCacheStore();
|
|
49
|
+
/** Store used by the providers that do not set their own `cacheStore()`. */
|
|
50
|
+
export const useMultiFactorCacheStore = (store) => { defaultCacheStore = store; };
|
|
51
|
+
/** The store of short-lived values shared by authentication features (multi-factor codes, email change blocks). */
|
|
52
|
+
export const getDefaultCacheStore = () => defaultCacheStore;
|
|
53
|
+
/** Runs `fn` after any other call holding the same key in this process (check-then-write sequences). */
|
|
54
|
+
const queues = new Map();
|
|
55
|
+
async function serialized(key, fn) {
|
|
56
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
57
|
+
const run = previous.catch(() => { }).then(fn);
|
|
58
|
+
queues.set(key, run);
|
|
59
|
+
try {
|
|
60
|
+
return await run;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
if (queues.get(key) === run)
|
|
64
|
+
queues.delete(key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ------------------------------------------------------------------ helpers
|
|
68
|
+
const contextOf = (livewire) => livewire?._ctx;
|
|
69
|
+
const userKey = (user) => createHash('sha256').update(String(user.id)).digest('base64url').slice(0, 22);
|
|
70
|
+
/** The signed-in user of the request the action runs in; the action stops if they changed since it was mounted. */
|
|
71
|
+
function currentUser(livewire) {
|
|
72
|
+
const user = contextOf(livewire)?.user;
|
|
73
|
+
if (!user)
|
|
74
|
+
throw new Halt();
|
|
75
|
+
return user;
|
|
76
|
+
}
|
|
77
|
+
const fail = (livewire, path, message) => { livewire.addError(path, message); throw new Halt(); };
|
|
78
|
+
/** "Current password" field: checked against the auth provider. Not saved. */
|
|
79
|
+
function currentPasswordInput(name, label, attribute) {
|
|
80
|
+
return TextInput.make(name).label(label).validationAttribute(attribute).password().revealable().autocomplete('current-password').required().dehydrated(false)
|
|
81
|
+
.rule({
|
|
82
|
+
name: 'current_password',
|
|
83
|
+
passes: async (value, ctx) => { const { panel, user } = contextOf(ctx.livewire); return Boolean(user && (await panel.getAuth().attempt(user.email, String(value)))); },
|
|
84
|
+
message: () => 'The password is incorrect.',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Link showing the recovery code field: it sets `useRecoveryCode` next to the field, in the browser state. */
|
|
88
|
+
function useRecoveryCodeLink(label, ctx) {
|
|
89
|
+
const path = [ctx.component.container.getStatePath(), 'useRecoveryCode'].filter(Boolean).join('.');
|
|
90
|
+
return Text.make(new HtmlString(`<button type="button" class="fi-link fi-size-sm fi-color fi-color-primary fi-text-color-600 dark:fi-text-color-400" x-on:click="${e(`$wire.set(${js(path)}, true)`)}"><span class="fi-link-label">${e(label)}</span></button>`));
|
|
91
|
+
}
|
|
92
|
+
/** A rate limit as a validation rule: each validation counts as an attempt. */
|
|
93
|
+
const throttleRule = (throttle, key, message) => ({
|
|
94
|
+
name: 'throttle', passes: (_value, ctx) => throttle.hit(key(ctx)) === 0, message: () => message, implicit: true,
|
|
95
|
+
});
|
|
96
|
+
const statusBadge = (enabled, enabledLabel, disabledLabel) => (enabled ? Text.make(enabledLabel).badge().color('success') : Text.make(disabledLabel).badge());
|
|
97
|
+
// ------------------------------------------------------------------ the challenge
|
|
98
|
+
/** The login challenge: enabled providers, the picker, rate limiting. */
|
|
99
|
+
export class MultiFactorChallenge {
|
|
100
|
+
/** Attempts per user and per minute. */
|
|
101
|
+
static maxAttempts = 5;
|
|
102
|
+
static throttle = null;
|
|
103
|
+
panel;
|
|
104
|
+
constructor(panel) { this.panel = panel; }
|
|
105
|
+
static make(panel) { return new this(panel); }
|
|
106
|
+
getProviders() { return this.panel.getMultiFactorAuthenticationProviders(); }
|
|
107
|
+
async getEnabledProviders(user) {
|
|
108
|
+
const enabled = [];
|
|
109
|
+
for (const provider of this.getProviders())
|
|
110
|
+
if (await provider.isEnabled(user, this.panel))
|
|
111
|
+
enabled.push(provider);
|
|
112
|
+
return enabled;
|
|
113
|
+
}
|
|
114
|
+
async hasEnabledProviders(user) {
|
|
115
|
+
for (const provider of this.getProviders())
|
|
116
|
+
if (await provider.isEnabled(user, this.panel))
|
|
117
|
+
return true;
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
async beforeChallenge(user, provider) {
|
|
121
|
+
const target = provider ?? (await this.getEnabledProviders(user))[0];
|
|
122
|
+
await target?.beforeChallenge?.(user, this.panel);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Challenge fields for these enabled providers: a picker when there are several, then each provider's fields under its id
|
|
126
|
+
* (only the picked provider's are shown and validated).
|
|
127
|
+
*/
|
|
128
|
+
getFormComponents(user, enabled) {
|
|
129
|
+
const several = enabled.length > 1;
|
|
130
|
+
const picker = several ? [Section.make().compact().secondary().schema([
|
|
131
|
+
Radio.make('provider').label(t('filament-panels::auth/pages/login.multi_factor.form.provider.label'))
|
|
132
|
+
.options(Object.fromEntries(enabled.map((provider) => [provider.getId(), provider.getLoginFormLabel()])))
|
|
133
|
+
.default(enabled[0].getId()).live().required().markAsRequired(false),
|
|
134
|
+
])] : [];
|
|
135
|
+
return [
|
|
136
|
+
...picker,
|
|
137
|
+
...enabled.map((provider) => {
|
|
138
|
+
const group = Group.make(provider.getChallengeFormComponents(user, this.panel)).statePath(provider.getId());
|
|
139
|
+
return several ? group.visible(({ get }) => (get('provider') ?? enabled[0].getId()) === provider.getId()) : group;
|
|
140
|
+
}),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
static limiter() { return (this.throttle ??= new Throttle(this.maxAttempts)); }
|
|
144
|
+
/** Records an attempt; returns 0 if allowed, otherwise the seconds to wait. */
|
|
145
|
+
hitRateLimiter(user) { return this.constructor.limiter().hit(`${this.panel.getId()}:${userKey(user)}`); }
|
|
146
|
+
clearRateLimiter(user) { this.constructor.limiter().clear(`${this.panel.getId()}:${userKey(user)}`); }
|
|
147
|
+
}
|
|
148
|
+
const ENCRYPTED_PREFIX = 'enc:v1:';
|
|
149
|
+
const secretKey = (panel) => Buffer.from(hkdfSync('sha256', panel.getAuthKey(), 'navette', 'app-authentication-secret', 32));
|
|
150
|
+
/** Encrypts a TOTP secret at rest (AES-256-GCM, with a key derived from the panel key). */
|
|
151
|
+
export function encryptSecret(panel, secret) {
|
|
152
|
+
const iv = randomBytes(12);
|
|
153
|
+
const cipher = createCipheriv('aes-256-gcm', secretKey(panel), iv);
|
|
154
|
+
const data = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
|
155
|
+
return `${ENCRYPTED_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64url')}`;
|
|
156
|
+
}
|
|
157
|
+
/** Decrypts a stored secret; a value without the prefix is returned as is (secrets stored in clear by your own code). `null` when it cannot be read. */
|
|
158
|
+
export function decryptSecret(panel, stored) {
|
|
159
|
+
if (!stored.startsWith(ENCRYPTED_PREFIX))
|
|
160
|
+
return stored;
|
|
161
|
+
try {
|
|
162
|
+
const raw = Buffer.from(stored.slice(ENCRYPTED_PREFIX.length), 'base64url');
|
|
163
|
+
const decipher = createDecipheriv('aes-256-gcm', secretKey(panel), raw.subarray(0, 12));
|
|
164
|
+
decipher.setAuthTag(raw.subarray(12, 28));
|
|
165
|
+
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const hashRecoveryCode = (code) => `sha256:${createHash('sha256').update(code.trim()).digest('hex')}`;
|
|
172
|
+
const RECOVERY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
173
|
+
const randomString = (length) => Array.from({ length }, () => RECOVERY_ALPHABET[randomInt(RECOVERY_ALPHABET.length)]).join('');
|
|
174
|
+
/**
|
|
175
|
+
* Authentication with a code from an authenticator app (Google Authenticator, Microsoft Authenticator, 1Password…):
|
|
176
|
+
* time-based one-time passwords (RFC 6238). `recoverable()` adds single-use recovery codes.
|
|
177
|
+
*/
|
|
178
|
+
export class AppAuthentication {
|
|
179
|
+
_id = 'app';
|
|
180
|
+
_recoverable = false;
|
|
181
|
+
_regenerableRecoveryCodes = true;
|
|
182
|
+
_recoveryCodeCount = 8;
|
|
183
|
+
_brandName = null;
|
|
184
|
+
_codeWindow = 1;
|
|
185
|
+
_digits = 6;
|
|
186
|
+
_period = 30;
|
|
187
|
+
_algorithm = 'sha1';
|
|
188
|
+
_store = null;
|
|
189
|
+
_cacheStore = null;
|
|
190
|
+
_confirmsPassword = true;
|
|
191
|
+
_generateRecoveryCodes = null;
|
|
192
|
+
setupThrottle = new Throttle(5);
|
|
193
|
+
managementThrottle = new Throttle(5);
|
|
194
|
+
static make() { return new this(); }
|
|
195
|
+
getId() { return this._id; }
|
|
196
|
+
/** Changes the id (to register two app providers with different settings). */
|
|
197
|
+
id(id) { this._id = id; return this; }
|
|
198
|
+
getLoginFormLabel() { return t('filament-panels::auth/multi-factor/app/provider.login_form.label'); }
|
|
199
|
+
/** Single-use recovery codes, shown once at setup, to sign in without the app. Needs `get`/`saveAppAuthenticationRecoveryCodes` on the store. */
|
|
200
|
+
recoverable(condition = true) { this._recoverable = condition; return this; }
|
|
201
|
+
isRecoverable() { return this._recoverable; }
|
|
202
|
+
/** `false`: users cannot regenerate their recovery codes. */
|
|
203
|
+
regenerableRecoveryCodes(condition = true) { this._regenerableRecoveryCodes = condition; return this; }
|
|
204
|
+
canRegenerateRecoveryCodes() { return this._regenerableRecoveryCodes; }
|
|
205
|
+
recoveryCodeCount(count) { this._recoveryCodeCount = Math.max(1, Math.min(50, Math.floor(count))); return this; }
|
|
206
|
+
getRecoveryCodeCount() { return this._recoveryCodeCount; }
|
|
207
|
+
/** Generates the recovery codes (by default, `xxxxxxxxxx-xxxxxxxxxx` from 62 characters). */
|
|
208
|
+
generateRecoveryCodesUsing(callback) { this._generateRecoveryCodes = callback; return this; }
|
|
209
|
+
/** Name shown in the app, next to the account (the panel's brand name by default). */
|
|
210
|
+
brandName(name) { this._brandName = name; return this; }
|
|
211
|
+
getBrandName(panel) { return this._brandName ?? panel.getBrandName().replace(/<[^>]*>/g, ''); }
|
|
212
|
+
/** Codes accepted before and after the current one, to allow for clock drift (1 by default: ±30 seconds). */
|
|
213
|
+
codeWindow(window) { this._codeWindow = Math.max(0, Math.min(10, Math.floor(window))); return this; }
|
|
214
|
+
getCodeWindow() { return this._codeWindow; }
|
|
215
|
+
/** Code settings. Most apps only support the defaults (6 digits, 30 seconds, SHA-1). */
|
|
216
|
+
codeOptions(options) {
|
|
217
|
+
if (options.digits)
|
|
218
|
+
this._digits = options.digits;
|
|
219
|
+
if (options.period)
|
|
220
|
+
this._period = options.period;
|
|
221
|
+
if (options.algorithm)
|
|
222
|
+
this._algorithm = options.algorithm;
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
/** Where secrets and recovery codes are stored (the panel's auth provider by default). */
|
|
226
|
+
store(store) { this._store = store; return this; }
|
|
227
|
+
/** Where used codes are remembered (replay protection). */
|
|
228
|
+
cacheStore(store) { this._cacheStore = store; return this; }
|
|
229
|
+
/** `false`: setting up and turning off do not ask for the current password (accounts without one). */
|
|
230
|
+
confirmsPassword(condition = true) { this._confirmsPassword = condition; return this; }
|
|
231
|
+
getStore(panel) {
|
|
232
|
+
const store = this._store ?? panel.getAuth();
|
|
233
|
+
if (typeof store.getAppAuthenticationSecret !== 'function' || typeof store.saveAppAuthenticationSecret !== 'function') {
|
|
234
|
+
throw new Error('App authentication: the auth provider must implement getAppAuthenticationSecret and saveAppAuthenticationSecret (knexAuthProvider does), or pass a store with store().');
|
|
235
|
+
}
|
|
236
|
+
return store;
|
|
237
|
+
}
|
|
238
|
+
codeOptionsOf() { return { digits: this._digits, period: this._period, algorithm: this._algorithm }; }
|
|
239
|
+
checkConfiguration(panel) {
|
|
240
|
+
const store = this.getStore(panel);
|
|
241
|
+
if (this._recoverable && (typeof store.getAppAuthenticationRecoveryCodes !== 'function' || typeof store.saveAppAuthenticationRecoveryCodes !== 'function')) {
|
|
242
|
+
throw new Error('App authentication: recoverable() needs getAppAuthenticationRecoveryCodes and saveAppAuthenticationRecoveryCodes on the store (knexAuthProvider has them).');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async isEnabled(user, panel) {
|
|
246
|
+
const stored = await this.getStore(panel).getAppAuthenticationSecret(user);
|
|
247
|
+
return typeof stored === 'string' && stored !== '';
|
|
248
|
+
}
|
|
249
|
+
/** The user's secret, decrypted; `null` when none is set or it cannot be decrypted (the panel key changed). */
|
|
250
|
+
async getSecret(user, panel) {
|
|
251
|
+
const stored = await this.getStore(panel).getAppAuthenticationSecret(user);
|
|
252
|
+
return typeof stored === 'string' && stored !== '' ? decryptSecret(panel, stored) : null;
|
|
253
|
+
}
|
|
254
|
+
async saveSecret(user, panel, secret) {
|
|
255
|
+
await this.getStore(panel).saveAppAuthenticationSecret(user, secret === null ? null : encryptSecret(panel, secret));
|
|
256
|
+
}
|
|
257
|
+
generateSecret() { return generateTotpSecret(20); }
|
|
258
|
+
generateRecoveryCodes() { return this._generateRecoveryCodes?.() ?? Array.from({ length: this._recoveryCodeCount }, () => `${randomString(10)}-${randomString(10)}`); }
|
|
259
|
+
/** Saves recovery codes (hashed): `null` removes them. */
|
|
260
|
+
async saveRecoveryCodes(user, panel, codes) {
|
|
261
|
+
const store = this.getStore(panel);
|
|
262
|
+
if (typeof store.saveAppAuthenticationRecoveryCodes !== 'function')
|
|
263
|
+
throw new Error('App authentication: recoverable() needs saveAppAuthenticationRecoveryCodes on the store.');
|
|
264
|
+
await store.saveAppAuthenticationRecoveryCodes(user, codes === null ? null : codes.map(hashRecoveryCode));
|
|
265
|
+
}
|
|
266
|
+
/** `otpauth://` URI of a secret, for the QR code. */
|
|
267
|
+
getUri(user, panel, secret) { return totpUri(secret, user.email, this.getBrandName(panel), this.codeOptionsOf()); }
|
|
268
|
+
/** QR code of a secret, as a `data:` URI. */
|
|
269
|
+
generateQrCodeDataUri(user, panel, secret) { return qrCodeDataUri(this.getUri(user, panel, secret), { title: t('filament-panels::auth/multi-factor/app/actions/set-up.modal.content.qr_code.alt') }); }
|
|
270
|
+
/**
|
|
271
|
+
* Checks a code. With `preventReuse` (sign-in and sensitive actions), a code is accepted once: the time step of the last
|
|
272
|
+
* accepted code is remembered, and that code and every earlier one are refused (RFC 6238 §5.2).
|
|
273
|
+
*/
|
|
274
|
+
async verifyCode(code, secret, options = {}) {
|
|
275
|
+
const verify = (after) => verifyTotp(secret, code, { ...this.codeOptionsOf(), window: this._codeWindow, after });
|
|
276
|
+
if (!options.preventReuse)
|
|
277
|
+
return verify(null) !== null;
|
|
278
|
+
const key = `navette:mfa:app:last-step:${createHash('sha256').update(secret).digest('base64url')}`;
|
|
279
|
+
const cache = this._cacheStore ?? defaultCacheStore;
|
|
280
|
+
return serialized(key, async () => {
|
|
281
|
+
const last = await cache.get(key);
|
|
282
|
+
const step = verify(typeof last === 'number' ? last : null);
|
|
283
|
+
if (step === null)
|
|
284
|
+
return false;
|
|
285
|
+
await cache.put(key, step, (this._codeWindow * 2 + 2) * this._period);
|
|
286
|
+
return true;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** Checks a recovery code and removes it: each one works once. */
|
|
290
|
+
async verifyRecoveryCode(code, user, panel) {
|
|
291
|
+
if (typeof code !== 'string' || code.trim() === '' || code.length > 200)
|
|
292
|
+
return false;
|
|
293
|
+
const store = this.getStore(panel);
|
|
294
|
+
if (typeof store.getAppAuthenticationRecoveryCodes !== 'function' || typeof store.saveAppAuthenticationRecoveryCodes !== 'function')
|
|
295
|
+
return false;
|
|
296
|
+
return serialized(`navette:mfa:app:recovery:${panel.getId()}:${userKey(user)}`, async () => {
|
|
297
|
+
const hashes = (await store.getAppAuthenticationRecoveryCodes(user)) ?? [];
|
|
298
|
+
const candidate = Buffer.from(hashRecoveryCode(code));
|
|
299
|
+
let matched = -1;
|
|
300
|
+
// Every stored hash is compared, in constant time.
|
|
301
|
+
hashes.forEach((hash, index) => { const stored = Buffer.from(String(hash)); if (stored.length === candidate.length && timingSafeEqual(stored, candidate) && matched < 0)
|
|
302
|
+
matched = index; });
|
|
303
|
+
if (matched < 0)
|
|
304
|
+
return false;
|
|
305
|
+
await store.saveAppAuthenticationRecoveryCodes(user, hashes.filter((_, index) => index !== matched));
|
|
306
|
+
return true;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
getChallengeFormComponents(user, panel) {
|
|
310
|
+
const recoverable = this._recoverable;
|
|
311
|
+
const group = 'filament-panels::auth/multi-factor/app/provider.login_form';
|
|
312
|
+
return [
|
|
313
|
+
OneTimeCodeInput.make('code').length(this._digits).label(t(`${group}.code.label`)).validationAttribute(t(`${group}.code.validation_attribute`))
|
|
314
|
+
.submitOnCompletion().autofocus()
|
|
315
|
+
.belowContent(recoverable ? (ctx) => (ctx.get('useRecoveryCode') ? null : useRecoveryCodeLink(t(`${group}.code.actions.use_recovery_code.label`), ctx)) : null)
|
|
316
|
+
.required(({ get }) => !recoverable || !get('useRecoveryCode') || !get('recoveryCode'))
|
|
317
|
+
.rule({ name: 'totp', passes: async (value) => { const secret = await this.getSecret(user, panel); return secret !== null && this.verifyCode(value, secret, { preventReuse: true }); }, message: () => t(`${group}.code.messages.invalid`) })
|
|
318
|
+
.hidden(({ get }) => recoverable && Boolean(get('useRecoveryCode'))),
|
|
319
|
+
TextInput.make('recoveryCode').label(t(`${group}.recovery_code.label`)).validationAttribute(t(`${group}.recovery_code.validation_attribute`))
|
|
320
|
+
.password().revealable().autocomplete('one-time-code').required()
|
|
321
|
+
.rule({ name: 'recovery_code', passes: (value) => this.verifyRecoveryCode(value, user, panel), message: () => t(`${group}.recovery_code.messages.invalid`) })
|
|
322
|
+
.visible(({ get }) => recoverable && Boolean(get('useRecoveryCode'))),
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
getManagementSchemaComponents(user, panel) {
|
|
326
|
+
const group = 'filament-panels::auth/multi-factor/app/provider.management_schema.actions';
|
|
327
|
+
const enabled = () => this.isEnabled(user, panel);
|
|
328
|
+
return [
|
|
329
|
+
Actions.make(this.getActions(user, panel)).key(`${this._id}-actions`)
|
|
330
|
+
.label(t(`${group}.label`)).belowContent(t(`${group}.below_content`))
|
|
331
|
+
.afterLabel(async () => statusBadge(await enabled(), t(`${group}.messages.enabled`), t(`${group}.messages.disabled`))),
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
getActions(user, panel) {
|
|
335
|
+
const enabled = () => this.isEnabled(user, panel);
|
|
336
|
+
return [
|
|
337
|
+
this.setUpAction(panel).hidden(enabled),
|
|
338
|
+
this.regenerateRecoveryCodesAction(panel).visible(async () => this._recoverable && this._regenerableRecoveryCodes && (await enabled())),
|
|
339
|
+
this.disableAction(panel).visible(enabled),
|
|
340
|
+
...(this._recoverable && this._regenerableRecoveryCodes ? [this.showNewRecoveryCodesAction(panel)] : []),
|
|
341
|
+
];
|
|
342
|
+
}
|
|
343
|
+
/** Setup modal: QR code and secret, a code from the app (and the password), then the recovery codes. */
|
|
344
|
+
setUpAction(panel) {
|
|
345
|
+
const group = 'filament-panels::auth/multi-factor/app/actions/set-up';
|
|
346
|
+
const purpose = `${panel.getId()}.mfa-app-set-up`;
|
|
347
|
+
// The secret and recovery codes travel with the mounted action, signed: the browser can show them but not change them.
|
|
348
|
+
const pending = (ctx) => {
|
|
349
|
+
const payload = readToken(panel.getAuthKey(), purpose, ctx.arguments?.pending);
|
|
350
|
+
const user = contextOf(ctx.livewire)?.user;
|
|
351
|
+
return payload && user && payload.user === userKey(user) && typeof payload.secret === 'string' ? { secret: payload.secret, codes: Array.isArray(payload.codes) ? payload.codes.map(String) : [] } : null;
|
|
352
|
+
};
|
|
353
|
+
const argumentsOf = (ctx) => ctx.livewire.mountedActions?.find?.((mounted) => mounted.name === 'setUpAppAuthentication')?.arguments ?? {};
|
|
354
|
+
const pendingFromSchema = (ctx) => pending({ arguments: argumentsOf(ctx), livewire: ctx.livewire });
|
|
355
|
+
const codeInput = OneTimeCodeInput.make('code').length(this._digits).label(t(`${group}.modal.form.code.label`)).validationAttribute(t(`${group}.modal.form.code.validation_attribute`))
|
|
356
|
+
.helperText(t(`${group}.modal.form.code.below_content`)).required()
|
|
357
|
+
.rule({ name: 'totp', passes: async (value, ctx) => { const state = pendingFromSchema(ctx); return state !== null && this.verifyCode(value, state.secret); }, message: () => t(`${group}.modal.form.code.messages.invalid`) });
|
|
358
|
+
const steps = [
|
|
359
|
+
Step.make('app')
|
|
360
|
+
.beforeValidation((ctx) => {
|
|
361
|
+
const user = currentUser(ctx.livewire);
|
|
362
|
+
if (this.setupThrottle.hit(`${panel.getId()}:${userKey(user)}`) > 0) {
|
|
363
|
+
const codePath = ctx.component.getChildSchemas()[0].getFlatComponents().find((component) => component instanceof OneTimeCodeInput)?.getStatePath();
|
|
364
|
+
if (codePath)
|
|
365
|
+
fail(ctx.livewire, codePath, t(`${group}.modal.form.code.messages.rate_limited`));
|
|
366
|
+
throw new Halt();
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
.schema([
|
|
370
|
+
Group.make([
|
|
371
|
+
Text.make(t(`${group}.modal.content.qr_code.instruction`)).color('neutral'),
|
|
372
|
+
Image.make((ctx) => { const state = pendingFromSchema(ctx); const user = contextOf(ctx.livewire).user; return state ? this.generateQrCodeDataUri(user, panel, state.secret) : ''; }, t(`${group}.modal.content.qr_code.alt`))
|
|
373
|
+
.imageHeight('12rem').alignCenter(),
|
|
374
|
+
Flex.make([
|
|
375
|
+
Text.make(t(`${group}.modal.content.text_code.instruction`)).color('neutral').grow(false),
|
|
376
|
+
Text.make((ctx) => pendingFromSchema(ctx)?.secret ?? '').fontFamily('mono').color('neutral').copyable().copyMessage(t(`${group}.modal.content.text_code.messages.copied`)).grow(false),
|
|
377
|
+
]).from('sm'),
|
|
378
|
+
]).dense(),
|
|
379
|
+
codeInput,
|
|
380
|
+
...(this._confirmsPassword ? [currentPasswordInput('password', t(`${group}.modal.form.password.label`), t(`${group}.modal.form.password.validation_attribute`))] : []),
|
|
381
|
+
]),
|
|
382
|
+
...(this._recoverable ? [Step.make('recovery').schema(this.recoveryCodesContent((ctx) => pendingFromSchema(ctx)?.codes ?? [], t(`${group}.modal.content.recovery_codes.instruction`)))] : []),
|
|
383
|
+
];
|
|
384
|
+
return Action.make('setUpAppAuthentication')
|
|
385
|
+
.label(t(`${group}.label`)).color('primary').icon('m-lock-closed').link()
|
|
386
|
+
.mountUsing((ctx) => {
|
|
387
|
+
const user = currentUser(ctx.livewire);
|
|
388
|
+
ctx.arguments.pending = signToken(panel.getAuthKey(), purpose, { secret: this.generateSecret(), ...(this._recoverable ? { codes: this.generateRecoveryCodes() } : {}), user: userKey(user) }, 60 * 30);
|
|
389
|
+
})
|
|
390
|
+
.modalWidth('lg').closeModalByClickingAway(false).closeModalByEscaping(false)
|
|
391
|
+
.modalIcon('o-lock-closed').modalIconColor('primary')
|
|
392
|
+
.modalHeading(t(`${group}.modal.heading`))
|
|
393
|
+
.modalDescription(() => new HtmlString(t(`${group}.modal.description`)
|
|
394
|
+
.replace(/<x-filament::link href="([^"]+)" target="_blank">([^<]+)<\/x-filament::link>/g, (_match, href, label) => `<a href="${e(href)}" target="_blank" rel="noopener" class="fi-link fi-size-md fi-color fi-color-primary fi-text-color-600 dark:fi-text-color-400"><span class="fi-link-label">${e(label)}</span></a>`)
|
|
395
|
+
.trim()))
|
|
396
|
+
.modalSubmitActionLabel(t(`${group}.modal.actions.submit.label`))
|
|
397
|
+
.steps(steps)
|
|
398
|
+
.modifyWizardUsing((wizard) => wizard.hiddenHeader())
|
|
399
|
+
.action(async (ctx) => {
|
|
400
|
+
const user = currentUser(ctx.livewire);
|
|
401
|
+
const state = pending(ctx);
|
|
402
|
+
// Issued for another user, or expired: nothing is saved.
|
|
403
|
+
if (!state)
|
|
404
|
+
return;
|
|
405
|
+
await this.saveSecret(user, panel, state.secret);
|
|
406
|
+
if (this._recoverable)
|
|
407
|
+
await this.saveRecoveryCodes(user, panel, state.codes);
|
|
408
|
+
Notification.make().success().title(t(`${group}.notifications.enabled.title`)).icon('o-lock-closed').send();
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/** Recovery codes, with "copy" and "download" links. */
|
|
412
|
+
recoveryCodesContent(codes, instruction) {
|
|
413
|
+
const content = 'filament-panels::auth/multi-factor/recovery-codes-modal-content';
|
|
414
|
+
return [
|
|
415
|
+
Text.make(instruction).weight('bold').color('neutral'),
|
|
416
|
+
Html.make((ctx) => `<ul class="fi-sc-unordered-list fi-size-xs">${codes(ctx).map((code) => `<li><span class="fi-sc-text fi-font-mono fi-size-xs fi-color-neutral">${e(code)}</span></li>`).join('')}</ul>`),
|
|
417
|
+
Text.make((ctx) => {
|
|
418
|
+
const text = codes(ctx).join('\n');
|
|
419
|
+
const linkClass = 'fi-link fi-size-sm fi-color fi-color-primary fi-text-color-600 dark:fi-text-color-400';
|
|
420
|
+
return new HtmlString(`<span>${e(t(`${content}.actions.0`))} <button type="button" class="${linkClass}" x-on:click="${e(`window.navigator.clipboard.writeText(${js(text)}); $tooltip(${js(t(`${content}.messages.copied`))}, { theme: $store.theme })`)}"><span class="fi-link-label">${e(t(`${content}.actions.copy.label`))}</span></button> ${e(t(`${content}.actions.1`))} <a href="${e(`data:application/octet-stream,${encodeURIComponent(text)}`)}" download="recovery-codes.txt" class="${linkClass}"><span class="fi-link-label">${e(t(`${content}.actions.download.label`))}</span></a> ${e(t(`${content}.actions.2`))}</span>`);
|
|
421
|
+
}),
|
|
422
|
+
];
|
|
423
|
+
}
|
|
424
|
+
/** Fields confirming a sensitive action: a code from the app (or a recovery code) and, optionally, the password. */
|
|
425
|
+
confirmationFields(panel, group, options) {
|
|
426
|
+
const throttleKey = (ctx) => `${panel.getId()}:${userKey(contextOf(ctx.livewire).user)}`;
|
|
427
|
+
const recoverable = options.recoveryCodes && this._recoverable;
|
|
428
|
+
return [
|
|
429
|
+
OneTimeCodeInput.make('code').length(this._digits).label(t(`${group}.modal.form.code.label`)).validationAttribute(t(`${group}.modal.form.code.validation_attribute`))
|
|
430
|
+
.required(({ get }) => !recoverable || !get('recoveryCode'))
|
|
431
|
+
.belowContent(recoverable ? (ctx) => (ctx.get('useRecoveryCode') ? null : useRecoveryCodeLink(t(`${group}.modal.form.code.actions.use_recovery_code.label`), ctx)) : null)
|
|
432
|
+
.rule(throttleRule(this.managementThrottle, throttleKey, t(`${group}.modal.form.code.messages.rate_limited`)))
|
|
433
|
+
.rule({ name: 'totp', passes: async (value, ctx) => { const user = contextOf(ctx.livewire).user; const secret = await this.getSecret(user, panel); return secret !== null && this.verifyCode(value, secret, { preventReuse: true }); }, message: () => t(`${group}.modal.form.code.messages.invalid`) })
|
|
434
|
+
.hidden(({ get }) => recoverable && Boolean(get('useRecoveryCode'))),
|
|
435
|
+
...(recoverable ? [
|
|
436
|
+
TextInput.make('recoveryCode').label(t(`${group}.modal.form.recovery_code.label`)).validationAttribute(t(`${group}.modal.form.recovery_code.validation_attribute`))
|
|
437
|
+
.password().revealable().autocomplete('one-time-code').required()
|
|
438
|
+
.rule({ name: 'recovery_code', passes: (value, ctx) => this.verifyRecoveryCode(value, contextOf(ctx.livewire).user, panel), message: () => t(`${group}.modal.form.recovery_code.messages.invalid`) })
|
|
439
|
+
.visible(({ get }) => Boolean(get('useRecoveryCode'))),
|
|
440
|
+
] : []),
|
|
441
|
+
...(this._confirmsPassword ? [currentPasswordInput('password', t(`${group}.modal.form.password.label`), t(`${group}.modal.form.password.validation_attribute`))] : []),
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
/** Turns app authentication off, after a code (or a recovery code) and the password. */
|
|
445
|
+
disableAction(panel) {
|
|
446
|
+
const group = 'filament-panels::auth/multi-factor/app/actions/disable';
|
|
447
|
+
return Action.make('disableAppAuthentication')
|
|
448
|
+
.label(t(`${group}.label`)).color('danger').icon('m-lock-open').link()
|
|
449
|
+
.modalWidth('md').modalIcon('o-lock-open').modalIconColor('danger')
|
|
450
|
+
.modalHeading(t(`${group}.modal.heading`)).modalDescription(t(`${group}.modal.description`))
|
|
451
|
+
.modalSubmitActionLabel(t(`${group}.modal.actions.submit.label`))
|
|
452
|
+
.schema(this.confirmationFields(panel, group, { recoveryCodes: true }))
|
|
453
|
+
.action(async (ctx) => {
|
|
454
|
+
const user = currentUser(ctx.livewire);
|
|
455
|
+
await this.saveSecret(user, panel, null);
|
|
456
|
+
if (this._recoverable)
|
|
457
|
+
await this.saveRecoveryCodes(user, panel, null);
|
|
458
|
+
Notification.make().success().title(t(`${group}.notifications.disabled.title`)).icon('o-lock-open').send();
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/** Replaces the recovery codes, after a code from the app and the password; the new codes are shown once. */
|
|
462
|
+
regenerateRecoveryCodesAction(panel) {
|
|
463
|
+
const group = 'filament-panels::auth/multi-factor/app/actions/regenerate-recovery-codes';
|
|
464
|
+
return Action.make('regenerateAppAuthenticationRecoveryCodes')
|
|
465
|
+
.label(t(`${group}.label`)).color('gray').icon('m-arrow-path').link()
|
|
466
|
+
.modalWidth('md').modalIcon('o-arrow-path')
|
|
467
|
+
.modalHeading(t(`${group}.modal.heading`)).modalDescription(t(`${group}.modal.description`))
|
|
468
|
+
.modalSubmitActionLabel(t(`${group}.modal.actions.submit.label`))
|
|
469
|
+
.schema(this.confirmationFields(panel, group, { recoveryCodes: false }))
|
|
470
|
+
.action(async (ctx) => {
|
|
471
|
+
const user = currentUser(ctx.livewire);
|
|
472
|
+
const codes = this.generateRecoveryCodes();
|
|
473
|
+
await this.saveRecoveryCodes(user, panel, codes);
|
|
474
|
+
Notification.make().success().title(t(`${group}.notifications.regenerated.title`)).icon('o-lock-closed').send();
|
|
475
|
+
// The new codes, shown once, in a modal opened in place of this one.
|
|
476
|
+
await ctx.livewire.replaceMountedAction('showNewAppAuthenticationRecoveryCodes', { codes: signToken(panel.getAuthKey(), `${panel.getId()}.mfa-app-recovery-codes`, { codes, user: userKey(user) }, 60 * 10) });
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/** Shows recovery codes just generated; only opened by `regenerateRecoveryCodesAction()`. */
|
|
480
|
+
showNewRecoveryCodesAction(panel) {
|
|
481
|
+
const group = 'filament-panels::auth/multi-factor/app/actions/regenerate-recovery-codes.show_new_recovery_codes.modal';
|
|
482
|
+
const codes = (ctx) => {
|
|
483
|
+
const mounted = ctx.livewire.mountedActions?.find?.((candidate) => candidate.name === 'showNewAppAuthenticationRecoveryCodes');
|
|
484
|
+
const payload = readToken(panel.getAuthKey(), `${panel.getId()}.mfa-app-recovery-codes`, mounted?.arguments?.codes);
|
|
485
|
+
const user = contextOf(ctx.livewire)?.user;
|
|
486
|
+
return payload && user && payload.user === userKey(user) && Array.isArray(payload.codes) ? payload.codes.map(String) : [];
|
|
487
|
+
};
|
|
488
|
+
return Action.make('showNewAppAuthenticationRecoveryCodes').hidden()
|
|
489
|
+
.modalHeading(t(`${group}.heading`)).modalWidth('md').modalIcon('o-lock-closed').modalIconColor('primary')
|
|
490
|
+
.closeModalByClickingAway(false).modalSubmitAction(false).modalCancelActionLabel(t(`${group}.actions.submit.label`))
|
|
491
|
+
.schema(this.recoveryCodesContent(codes, t(`${group}.description`)));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** Field running a WebAuthn ceremony in the browser; its state is the credential (JSON) the browser returns. */
|
|
495
|
+
export class SecurityKeyInput extends Field {
|
|
496
|
+
_mode = 'authenticate';
|
|
497
|
+
_optionsUsing = null;
|
|
498
|
+
_submitOnSuccess = false;
|
|
499
|
+
_autoStart = false;
|
|
500
|
+
_buttonLabel = null;
|
|
501
|
+
static make(name) { return new this(name); }
|
|
502
|
+
/** `register`: adds a key (`navigator.credentials.create()`); `authenticate`: uses one (`navigator.credentials.get()`). */
|
|
503
|
+
mode(mode) { this._mode = mode; return this; }
|
|
504
|
+
/** Options of the ceremony, asked for by the browser when the user clicks the button. */
|
|
505
|
+
optionsUsing(callback) { this._optionsUsing = callback; return this; }
|
|
506
|
+
/** Submits the form as soon as the key answered. */
|
|
507
|
+
submitOnSuccess(condition = true) { this._submitOnSuccess = condition; return this; }
|
|
508
|
+
/** Starts the ceremony when the field appears (browsers may still require a click). */
|
|
509
|
+
autoStart(condition = true) { this._autoStart = condition; return this; }
|
|
510
|
+
buttonLabel(label) { this._buttonLabel = label; return this; }
|
|
511
|
+
getExposedMethods() { return ['getPublicKeyOptions']; }
|
|
512
|
+
async getPublicKeyOptions() {
|
|
513
|
+
if (!(await this.isVisible()) || (await this.isDisabled()))
|
|
514
|
+
return null;
|
|
515
|
+
return (await this._optionsUsing?.(this)) ?? null;
|
|
516
|
+
}
|
|
517
|
+
hydrateState(value) { return typeof value === 'string' && value.length <= 100_000 ? value : null; }
|
|
518
|
+
async toHtml() {
|
|
519
|
+
const group = 'filament-panels::auth/multi-factor/security-keys/field';
|
|
520
|
+
const messages = {
|
|
521
|
+
waiting: t(`${group}.messages.waiting`), success: t(`${group}.messages.${this._mode === 'register' ? 'registered' : 'verified'}`),
|
|
522
|
+
cancelled: t(`${group}.messages.cancelled`), unsupported: t(`${group}.messages.unsupported`), failed: t(`${group}.messages.failed`),
|
|
523
|
+
};
|
|
524
|
+
const config = { mode: this._mode, key: this.getKey(), submitOnSuccess: this._submitOnSuccess, autoStart: this._autoStart, messages };
|
|
525
|
+
const label = this._buttonLabel ?? t(`${group}.actions.${this._mode === 'register' ? 'register' : 'authenticate'}.label`);
|
|
526
|
+
const control = button({ color: 'gray', icon: 'o-key', attributes: { 'x-on:click': 'start()', 'x-bind:disabled': 'isWaiting', id: this.getId(), 'aria-describedby': `${this.getId()}-status` } }, `<span class="fi-btn-label">${e(label)}</span>`);
|
|
527
|
+
return this.wrap(`<div x-data="${e(`navetteSecurityKey({ state: $wire.$entangle(${js(this.getStatePath())}, false), ...${js(config)} })`)}" class="nv-security-key">${control}<p id="${e(this.getId())}-status" class="nv-security-key-status" role="status" aria-live="polite" x-show="message" x-text="message" x-bind:class="{ 'nv-security-key-status-error': isError }" x-cloak></p></div>`, { labelAsDiv: true });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Authentication with a security key: a hardware key (YubiKey, Titan…) or a passkey stored by the device or a password
|
|
532
|
+
* manager (WebAuthn). Users can register several keys. Needs a secure context: HTTPS, or `localhost` in development.
|
|
533
|
+
*/
|
|
534
|
+
export class SecurityKeyAuthentication {
|
|
535
|
+
_id = 'security-keys';
|
|
536
|
+
_rpId = null;
|
|
537
|
+
_rpName = null;
|
|
538
|
+
_origins = null;
|
|
539
|
+
_userVerification = 'preferred';
|
|
540
|
+
_authenticatorAttachment = null;
|
|
541
|
+
_residentKey = 'discouraged';
|
|
542
|
+
_timeout = 120_000;
|
|
543
|
+
_maxKeys = 10;
|
|
544
|
+
_store = null;
|
|
545
|
+
_cacheStore = null;
|
|
546
|
+
_confirmsPassword = true;
|
|
547
|
+
throttle = new Throttle(10);
|
|
548
|
+
static make() { return new this(); }
|
|
549
|
+
getId() { return this._id; }
|
|
550
|
+
id(id) { this._id = id; return this; }
|
|
551
|
+
getLoginFormLabel() { return t('filament-panels::auth/multi-factor/security-keys/provider.login_form.label'); }
|
|
552
|
+
/** Relying party id: the domain the keys are bound to (by default, the host of `panel.appUrl()` or of the request). */
|
|
553
|
+
relyingPartyId(id) { this._rpId = id; return this; }
|
|
554
|
+
/** Name shown by the browser while registering a key (the panel's brand name by default). */
|
|
555
|
+
relyingPartyName(name) { this._rpName = name; return this; }
|
|
556
|
+
/** Origins allowed to use the keys (`https://admin.example.com`); by default, the origin of `panel.appUrl()` or of the request. */
|
|
557
|
+
origins(origins) { this._origins = origins; return this; }
|
|
558
|
+
/** `required`: the key must check a PIN or biometrics; `preferred` (default): when it can. */
|
|
559
|
+
userVerification(requirement) { this._userVerification = requirement; return this; }
|
|
560
|
+
/** Restricts registration to keys built into the device (`platform`) or to removable ones (`cross-platform`). */
|
|
561
|
+
authenticatorAttachment(attachment) { this._authenticatorAttachment = attachment; return this; }
|
|
562
|
+
/** Asks for a discoverable credential (passkey) when registering. */
|
|
563
|
+
residentKey(requirement) { this._residentKey = requirement; return this; }
|
|
564
|
+
/** Milliseconds the browser waits for the key (2 minutes by default). */
|
|
565
|
+
timeout(milliseconds) { this._timeout = Math.max(10_000, Math.min(600_000, Math.floor(milliseconds))); return this; }
|
|
566
|
+
/** Keys a user may register (10 by default). */
|
|
567
|
+
maxKeys(count) { this._maxKeys = Math.max(1, Math.floor(count)); return this; }
|
|
568
|
+
store(store) { this._store = store; return this; }
|
|
569
|
+
/** Where pending challenges are kept between the two requests of a ceremony. */
|
|
570
|
+
cacheStore(store) { this._cacheStore = store; return this; }
|
|
571
|
+
/** `false`: adding and removing keys do not ask for the current password. */
|
|
572
|
+
confirmsPassword(condition = true) { this._confirmsPassword = condition; return this; }
|
|
573
|
+
getStore(panel) {
|
|
574
|
+
const store = this._store ?? panel.getAuth();
|
|
575
|
+
for (const method of ['getSecurityKeys', 'createSecurityKey', 'updateSecurityKey', 'deleteSecurityKey']) {
|
|
576
|
+
if (typeof store[method] !== 'function')
|
|
577
|
+
throw new Error(`Security keys: the auth provider must implement ${method} (knexAuthProvider does), or pass a store with store().`);
|
|
578
|
+
}
|
|
579
|
+
return store;
|
|
580
|
+
}
|
|
581
|
+
checkConfiguration(panel) { this.getStore(panel); }
|
|
582
|
+
getKeys(user, panel) { return Promise.resolve(this.getStore(panel).getSecurityKeys(user)).then((keys) => keys ?? []); }
|
|
583
|
+
async isEnabled(user, panel) { return (await this.getKeys(user, panel)).length > 0; }
|
|
584
|
+
/** Relying party of a request: its id (domain) and allowed origins. */
|
|
585
|
+
getRelyingParty(panel, req) {
|
|
586
|
+
// The host the browser used; the scheme of `appUrl` when it names the same host (behind a TLS proxy the request says http).
|
|
587
|
+
const appUrl = panel.getAppUrl() ? new URL(panel.getAppUrl()) : null;
|
|
588
|
+
const host = req?.get?.('host') ?? null;
|
|
589
|
+
const base = host ? (appUrl && appUrl.host === host ? appUrl : new URL(`${req.protocol ?? 'http'}://${host}`)) : appUrl;
|
|
590
|
+
const id = this._rpId ?? base?.hostname ?? 'localhost';
|
|
591
|
+
return { id, name: this._rpName ?? panel.getBrandName().replace(/<[^>]*>/g, ''), origins: this._origins ?? (base ? [base.origin] : []) };
|
|
592
|
+
}
|
|
593
|
+
cache() { return this._cacheStore ?? defaultCacheStore; }
|
|
594
|
+
/** A new challenge, remembered for this user and purpose until it is used or expires. */
|
|
595
|
+
async issueChallenge(user, panel, purpose) {
|
|
596
|
+
const challenge = generateChallenge();
|
|
597
|
+
await this.cache().put(`navette:mfa:webauthn:${challenge}`, { purpose, user: userKey(user), panel: panel.getId() }, Math.ceil(this._timeout / 1000) + 60);
|
|
598
|
+
return challenge;
|
|
599
|
+
}
|
|
600
|
+
/** The challenge a response answers, if it was issued for this user and purpose; it is consumed (single use). */
|
|
601
|
+
async consumeChallenge(response, user, panel, purpose) {
|
|
602
|
+
let challenge;
|
|
603
|
+
try {
|
|
604
|
+
challenge = JSON.parse(Buffer.from(String(response?.response?.clientDataJSON ?? ''), 'base64url').toString('utf8'))?.challenge;
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
if (typeof challenge !== 'string' || !/^[A-Za-z0-9_-]{16,200}$/.test(challenge))
|
|
610
|
+
return null;
|
|
611
|
+
const key = `navette:mfa:webauthn:${challenge}`;
|
|
612
|
+
return serialized(key, async () => {
|
|
613
|
+
const entry = (await this.cache().get(key));
|
|
614
|
+
if (!entry || entry.purpose !== purpose || entry.user !== userKey(user) || entry.panel !== panel.getId())
|
|
615
|
+
return null;
|
|
616
|
+
await this.cache().forget(key);
|
|
617
|
+
return challenge;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
requestOf(field) { return contextOf(field.livewire)?.req; }
|
|
621
|
+
/** Checks an assertion from the browser against the user's keys; updates the key's counter. */
|
|
622
|
+
async verifyAssertion(value, user, panel, req) {
|
|
623
|
+
if (typeof value !== 'string')
|
|
624
|
+
return false;
|
|
625
|
+
let response;
|
|
626
|
+
try {
|
|
627
|
+
response = JSON.parse(value);
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
const keys = await this.getKeys(user, panel);
|
|
633
|
+
const key = keys.find((candidate) => candidate.credentialId === response?.id || candidate.credentialId === response?.rawId);
|
|
634
|
+
if (!key)
|
|
635
|
+
return false;
|
|
636
|
+
const challenge = await this.consumeChallenge(response, user, panel, 'authenticate');
|
|
637
|
+
if (!challenge)
|
|
638
|
+
return false;
|
|
639
|
+
const party = this.getRelyingParty(panel, req);
|
|
640
|
+
try {
|
|
641
|
+
const result = verifyAuthentication(response, { challenge, origin: party.origins, rpId: party.id, requireUserVerification: this._userVerification === 'required', credential: key });
|
|
642
|
+
await this.getStore(panel).updateSecurityKey(user, key.id, { signCount: result.signCount, lastUsedAt: new Date().toISOString() });
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
if (error instanceof WebAuthnError)
|
|
647
|
+
return false;
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
getChallengeFormComponents(user, panel) {
|
|
652
|
+
const group = 'filament-panels::auth/multi-factor/security-keys/provider.login_form';
|
|
653
|
+
return [
|
|
654
|
+
SecurityKeyInput.make('credential').mode('authenticate').label(t(`${group}.credential.label`)).validationAttribute(t(`${group}.credential.validation_attribute`))
|
|
655
|
+
.submitOnSuccess().autoStart().required()
|
|
656
|
+
.optionsUsing(async (field) => {
|
|
657
|
+
const party = this.getRelyingParty(panel, this.requestOf(field));
|
|
658
|
+
return authenticationOptions({ rpId: party.id, challenge: await this.issueChallenge(user, panel, 'authenticate'), allowCredentials: await this.getKeys(user, panel), userVerification: this._userVerification, timeout: this._timeout });
|
|
659
|
+
})
|
|
660
|
+
.rule({ name: 'security_key', passes: (value, ctx) => this.verifyAssertion(value, user, panel, contextOf(ctx.livewire)?.req), message: () => t(`${group}.credential.messages.invalid`) }),
|
|
661
|
+
];
|
|
662
|
+
}
|
|
663
|
+
getManagementSchemaComponents(user, panel) {
|
|
664
|
+
const group = 'filament-panels::auth/multi-factor/security-keys/provider.management_schema.actions';
|
|
665
|
+
const keys = () => this.getKeys(user, panel);
|
|
666
|
+
return [
|
|
667
|
+
Actions.make([this.registerAction(panel), this.removeAction(panel).visible(async () => (await keys()).length > 0)]).key(`${this._id}-actions`)
|
|
668
|
+
.label(t(`${group}.label`))
|
|
669
|
+
.afterLabel(async () => statusBadge((await keys()).length > 0, t(`${group}.messages.enabled`), t(`${group}.messages.disabled`)))
|
|
670
|
+
.aboveContent(async () => {
|
|
671
|
+
const list = await keys();
|
|
672
|
+
if (!list.length)
|
|
673
|
+
return t(`${group}.below_content`);
|
|
674
|
+
return new HtmlString(`<ul class="nv-security-keys">${list.map((key) => `<li><span class="nv-security-key-name">${e(key.name)}</span> <span class="nv-security-key-meta">${e(t(`${group}.messages.${key.lastUsedAt ? 'last_used' : 'added'}`, { date: String(key.lastUsedAt ?? key.createdAt ?? '').slice(0, 10) }))}</span></li>`).join('')}</ul>`);
|
|
675
|
+
}),
|
|
676
|
+
];
|
|
677
|
+
}
|
|
678
|
+
/** Adds a key: a name, the ceremony in the browser, and the password. */
|
|
679
|
+
registerAction(panel) {
|
|
680
|
+
const group = 'filament-panels::auth/multi-factor/security-keys/actions/register';
|
|
681
|
+
return Action.make('registerSecurityKey')
|
|
682
|
+
.label(t(`${group}.label`)).color('primary').icon('m-key').link()
|
|
683
|
+
.visible(async (ctx) => { const user = contextOf(ctx.livewire)?.user; return Boolean(user) && (await this.getKeys(user, panel)).length < this._maxKeys; })
|
|
684
|
+
.modalWidth('md').modalIcon('o-key').modalIconColor('primary')
|
|
685
|
+
.modalHeading(t(`${group}.modal.heading`)).modalDescription(t(`${group}.modal.description`))
|
|
686
|
+
.modalSubmitActionLabel(t(`${group}.modal.actions.submit.label`))
|
|
687
|
+
.closeModalByClickingAway(false)
|
|
688
|
+
.schema([
|
|
689
|
+
TextInput.make('name').label(t(`${group}.modal.form.name.label`)).placeholder(t(`${group}.modal.form.name.placeholder`)).required().maxLength(60),
|
|
690
|
+
SecurityKeyInput.make('credential').mode('register').label(t(`${group}.modal.form.credential.label`)).validationAttribute(t(`${group}.modal.form.credential.validation_attribute`)).required()
|
|
691
|
+
.optionsUsing(async (field) => {
|
|
692
|
+
const user = contextOf(field.livewire)?.user;
|
|
693
|
+
if (!user)
|
|
694
|
+
return null;
|
|
695
|
+
const party = this.getRelyingParty(panel, this.requestOf(field));
|
|
696
|
+
return registrationOptions({
|
|
697
|
+
rpId: party.id, rpName: party.name, userId: `${panel.getId()}:${userKey(user)}`, userName: user.email, userDisplayName: user.name,
|
|
698
|
+
challenge: await this.issueChallenge(user, panel, 'register'), excludeCredentials: await this.getKeys(user, panel),
|
|
699
|
+
userVerification: this._userVerification, residentKey: this._residentKey, authenticatorAttachment: this._authenticatorAttachment, timeout: this._timeout,
|
|
700
|
+
});
|
|
701
|
+
}),
|
|
702
|
+
...(this._confirmsPassword ? [currentPasswordInput('password', t(`${group}.modal.form.password.label`), t(`${group}.modal.form.password.validation_attribute`))] : []),
|
|
703
|
+
])
|
|
704
|
+
.action(async (ctx) => {
|
|
705
|
+
const user = currentUser(ctx.livewire);
|
|
706
|
+
const path = (ctx.schema?.getFlatComponents?.() ?? []).find((component) => component instanceof SecurityKeyInput)?.getStatePath() ?? 'credential';
|
|
707
|
+
const invalid = () => fail(ctx.livewire, path, t(`${group}.modal.form.credential.messages.invalid`));
|
|
708
|
+
if (this.throttle.hit(`${panel.getId()}:${userKey(user)}`) > 0)
|
|
709
|
+
fail(ctx.livewire, path, t(`${group}.modal.form.credential.messages.rate_limited`));
|
|
710
|
+
if ((await this.getKeys(user, panel)).length >= this._maxKeys)
|
|
711
|
+
return;
|
|
712
|
+
let response;
|
|
713
|
+
try {
|
|
714
|
+
response = JSON.parse(String(ctx.data.credential));
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
invalid();
|
|
718
|
+
}
|
|
719
|
+
const challenge = await this.consumeChallenge(response, user, panel, 'register');
|
|
720
|
+
if (!challenge)
|
|
721
|
+
invalid();
|
|
722
|
+
const party = this.getRelyingParty(panel, contextOf(ctx.livewire).req);
|
|
723
|
+
let credential;
|
|
724
|
+
try {
|
|
725
|
+
credential = verifyRegistration(response, { challenge: challenge, origin: party.origins, rpId: party.id, requireUserVerification: this._userVerification === 'required' });
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
if (error instanceof WebAuthnError)
|
|
729
|
+
invalid();
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
if ((await this.getKeys(user, panel)).some((key) => key.credentialId === credential.credentialId))
|
|
733
|
+
invalid();
|
|
734
|
+
await this.getStore(panel).createSecurityKey(user, {
|
|
735
|
+
name: String(ctx.data.name).trim().slice(0, 60), credentialId: credential.credentialId, publicKey: credential.publicKey, algorithm: credential.algorithm,
|
|
736
|
+
signCount: credential.signCount, transports: credential.transports,
|
|
737
|
+
});
|
|
738
|
+
Notification.make().success().title(t(`${group}.notifications.registered.title`)).icon('o-key').send();
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
/** Removes one of the user's keys, after the password. */
|
|
742
|
+
removeAction(panel) {
|
|
743
|
+
const group = 'filament-panels::auth/multi-factor/security-keys/actions/remove';
|
|
744
|
+
return Action.make('removeSecurityKey')
|
|
745
|
+
.label(t(`${group}.label`)).color('danger').icon('m-trash').link()
|
|
746
|
+
.modalWidth('md').modalIcon('o-trash').modalIconColor('danger')
|
|
747
|
+
.modalHeading(t(`${group}.modal.heading`)).modalDescription(t(`${group}.modal.description`))
|
|
748
|
+
.modalSubmitActionLabel(t(`${group}.modal.actions.submit.label`))
|
|
749
|
+
// The user's keys are read when the modal opens, then kept with the mounted action.
|
|
750
|
+
.mountUsing(async (ctx) => {
|
|
751
|
+
const user = currentUser(ctx.livewire);
|
|
752
|
+
ctx.arguments.keys = (await this.getKeys(user, panel)).map((key) => [String(key.id), String(key.name)]);
|
|
753
|
+
})
|
|
754
|
+
.schema((schema) => {
|
|
755
|
+
const mounted = schema.livewire.mountedActions?.find?.((candidate) => candidate.name === 'removeSecurityKey');
|
|
756
|
+
const options = Array.isArray(mounted?.arguments?.keys) ? mounted.arguments.keys.filter((entry) => Array.isArray(entry) && entry.length === 2).map(([id, name]) => [String(id), String(name)]) : [];
|
|
757
|
+
return schema.components([
|
|
758
|
+
Radio.make('key').label(t(`${group}.modal.form.key.label`)).required().options(options),
|
|
759
|
+
...(this._confirmsPassword ? [currentPasswordInput('password', t(`${group}.modal.form.password.label`), t(`${group}.modal.form.password.validation_attribute`))] : []),
|
|
760
|
+
]);
|
|
761
|
+
})
|
|
762
|
+
.action(async (ctx) => {
|
|
763
|
+
const user = currentUser(ctx.livewire);
|
|
764
|
+
const key = (await this.getKeys(user, panel)).find((candidate) => String(candidate.id) === String(ctx.data.key));
|
|
765
|
+
if (!key)
|
|
766
|
+
return;
|
|
767
|
+
await this.getStore(panel).deleteSecurityKey(user, key.id);
|
|
768
|
+
Notification.make().success().title(t(`${group}.notifications.removed.title`)).send();
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/** One-time code field with a "Send a new code by email" link, which asks the server for another code. */
|
|
773
|
+
export class EmailCodeInput extends OneTimeCodeInput {
|
|
774
|
+
_resend = null;
|
|
775
|
+
_resendLabel = '';
|
|
776
|
+
static make(name) { return new this(name); }
|
|
777
|
+
/** What the link does (send a new code); without it, there is no link. */
|
|
778
|
+
resendUsing(callback, label = '') {
|
|
779
|
+
this._resend = callback;
|
|
780
|
+
this._resendLabel = label;
|
|
781
|
+
if (callback)
|
|
782
|
+
this.belowContent(() => Text.make(new HtmlString(`<button type="button" class="fi-link fi-size-sm fi-color fi-color-primary fi-text-color-600 dark:fi-text-color-400" x-on:click="${e(`$wire.callSchemaComponentMethod(${js(this.getKey())}, 'resendCode', {})`)}"><span class="fi-link-label">${e(this._resendLabel)}</span></button>`)));
|
|
783
|
+
return this;
|
|
784
|
+
}
|
|
785
|
+
getExposedMethods() { return this._resend ? ['resendCode'] : []; }
|
|
786
|
+
isRenderlessMethod() { return false; }
|
|
787
|
+
async resendCode() {
|
|
788
|
+
if (!this._resend || !(await this.isVisible()) || (await this.isDisabled()))
|
|
789
|
+
return;
|
|
790
|
+
await this._resend(this);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Authentication with a 6-digit code sent to the user's email address, through the panel's `mailer()`.
|
|
795
|
+
* A code is valid for a few minutes (4 by default) and once; at most two codes are sent per minute.
|
|
796
|
+
*/
|
|
797
|
+
export class EmailAuthentication {
|
|
798
|
+
_id = 'email_code';
|
|
799
|
+
_codeExpiryMinutes = 4;
|
|
800
|
+
_generateCode = null;
|
|
801
|
+
_store = null;
|
|
802
|
+
_cacheStore = null;
|
|
803
|
+
_sendCode = null;
|
|
804
|
+
sendThrottle = new Throttle(2);
|
|
805
|
+
managementThrottle = new Throttle(5);
|
|
806
|
+
static make() { return new this(); }
|
|
807
|
+
getId() { return this._id; }
|
|
808
|
+
id(id) { this._id = id; return this; }
|
|
809
|
+
getLoginFormLabel() { return t('filament-panels::auth/multi-factor/email/provider.login_form.label'); }
|
|
810
|
+
/** Minutes a code stays valid. */
|
|
811
|
+
codeExpiryMinutes(minutes) { this._codeExpiryMinutes = Math.max(1, Math.min(60, Math.floor(minutes))); return this; }
|
|
812
|
+
getCodeExpiryMinutes() { return this._codeExpiryMinutes; }
|
|
813
|
+
/** Generates the codes (6 random digits by default). */
|
|
814
|
+
generateCodeUsing(callback) { this._generateCode = callback; return this; }
|
|
815
|
+
generateCode() { return this._generateCode?.() ?? String(randomInt(1_000_000)).padStart(6, '0'); }
|
|
816
|
+
/** Sends the code yourself (SMS gateway, your own email template…) instead of the panel's mailer. */
|
|
817
|
+
sendCodeUsing(callback) { this._sendCode = callback; return this; }
|
|
818
|
+
store(store) { this._store = store; return this; }
|
|
819
|
+
/** Where pending codes are kept until they are used or expire. */
|
|
820
|
+
cacheStore(store) { this._cacheStore = store; return this; }
|
|
821
|
+
getStore(panel) {
|
|
822
|
+
const store = this._store ?? panel.getAuth();
|
|
823
|
+
if (typeof store.hasEmailAuthentication !== 'function' || typeof store.saveEmailAuthentication !== 'function') {
|
|
824
|
+
throw new Error('Email authentication: the auth provider must implement hasEmailAuthentication and saveEmailAuthentication (knexAuthProvider does), or pass a store with store().');
|
|
825
|
+
}
|
|
826
|
+
return store;
|
|
827
|
+
}
|
|
828
|
+
checkConfiguration(panel) { this.getStore(panel); }
|
|
829
|
+
async isEnabled(user, panel) { return Boolean(await this.getStore(panel).hasEmailAuthentication(user)); }
|
|
830
|
+
async setEnabled(user, panel, enabled) { await this.getStore(panel).saveEmailAuthentication(user, enabled); }
|
|
831
|
+
cache() { return this._cacheStore ?? defaultCacheStore; }
|
|
832
|
+
codeKey(user, panel) { return `navette:mfa:email:${panel.getId()}:${this._id}:${userKey(user)}`; }
|
|
833
|
+
/** Sends a new code (the previous one stops working). Returns `false` when two codes were already sent this minute. */
|
|
834
|
+
async sendCode(user, panel) {
|
|
835
|
+
if (this.sendThrottle.hit(`${panel.getId()}:${userKey(user)}`) > 0)
|
|
836
|
+
return false;
|
|
837
|
+
const code = this.generateCode();
|
|
838
|
+
const minutes = this._codeExpiryMinutes;
|
|
839
|
+
await this.cache().put(this.codeKey(user, panel), { hash: createHash('sha256').update(code).digest('hex'), expires: Date.now() + minutes * 60_000 }, minutes * 60);
|
|
840
|
+
if (this._sendCode) {
|
|
841
|
+
await this._sendCode(user, code, minutes, panel);
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
const group = 'filament-panels::auth/multi-factor/email/notifications/verify-email-authentication';
|
|
845
|
+
await panel.sendMail({ to: user.email, subject: t(`${group}.subject`), lines: [t(`${group}.lines.0`, { code }), tc(`${group}.lines.1`, minutes, { minutes })] });
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
/** Checks a code against the last one sent; a valid code is consumed. */
|
|
849
|
+
async verifyCode(code, user, panel) {
|
|
850
|
+
if (typeof code !== 'string' || !/^\d{4,12}$/.test(code))
|
|
851
|
+
return false;
|
|
852
|
+
const key = this.codeKey(user, panel);
|
|
853
|
+
return serialized(key, async () => {
|
|
854
|
+
const pending = (await this.cache().get(key));
|
|
855
|
+
if (!pending?.hash || !pending.expires || pending.expires < Date.now())
|
|
856
|
+
return false;
|
|
857
|
+
const expected = Buffer.from(pending.hash), actual = Buffer.from(createHash('sha256').update(code).digest('hex'));
|
|
858
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
|
859
|
+
return false;
|
|
860
|
+
await this.cache().forget(key);
|
|
861
|
+
return true;
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
async beforeChallenge(user, panel) { await this.sendCode(user, panel); }
|
|
865
|
+
/** Sends a new code and tells the user (or that they must wait). */
|
|
866
|
+
async resend(user, panel, group) {
|
|
867
|
+
if (await this.sendCode(user, panel))
|
|
868
|
+
Notification.make().success().title(t(`${group}.resend.notifications.resent.title`)).send();
|
|
869
|
+
else
|
|
870
|
+
Notification.make().danger().title(t(`${group}.resend.notifications.throttled.title`)).send();
|
|
871
|
+
}
|
|
872
|
+
getChallengeFormComponents(user, panel) {
|
|
873
|
+
const group = 'filament-panels::auth/multi-factor/email/provider.login_form.code';
|
|
874
|
+
return [
|
|
875
|
+
EmailCodeInput.make('code').label(t(`${group}.label`)).validationAttribute(t(`${group}.validation_attribute`))
|
|
876
|
+
.submitOnCompletion().autofocus().required()
|
|
877
|
+
.resendUsing(() => this.resend(user, panel, `${group}.actions`), t(`${group}.actions.resend.label`))
|
|
878
|
+
.rule({ name: 'email_code', passes: (value) => this.verifyCode(value, user, panel), message: () => t(`${group}.messages.invalid`) }),
|
|
879
|
+
];
|
|
880
|
+
}
|
|
881
|
+
/** Code field of the setup and disable modals: the signed-in user's code, rate limited. */
|
|
882
|
+
modalCodeInput(panel, group) {
|
|
883
|
+
const throttleKey = (ctx) => `${panel.getId()}:${userKey(contextOf(ctx.livewire).user)}`;
|
|
884
|
+
return EmailCodeInput.make('code').label(t(`${group}.modal.form.code.label`)).validationAttribute(t(`${group}.modal.form.code.validation_attribute`)).required()
|
|
885
|
+
.resendUsing((field) => this.resend(currentUser(field.livewire), panel, `${group}.modal.form.code.actions`), t(`${group}.modal.form.code.actions.resend.label`))
|
|
886
|
+
.rule(throttleRule(this.managementThrottle, throttleKey, t(`${group}.modal.form.code.messages.rate_limited`)))
|
|
887
|
+
.rule({ name: 'email_code', passes: (value, ctx) => this.verifyCode(value, contextOf(ctx.livewire).user, panel), message: () => t(`${group}.modal.form.code.messages.invalid`) });
|
|
888
|
+
}
|
|
889
|
+
getManagementSchemaComponents(user, panel) {
|
|
890
|
+
const group = 'filament-panels::auth/multi-factor/email/provider.management_schema.actions';
|
|
891
|
+
const enabled = () => this.isEnabled(user, panel);
|
|
892
|
+
return [
|
|
893
|
+
Actions.make([this.setUpAction(panel).hidden(enabled), this.disableAction(panel).visible(enabled)]).key(`${this._id}-actions`)
|
|
894
|
+
.label(t(`${group}.label`)).belowContent(t(`${group}.below_content`))
|
|
895
|
+
.afterLabel(async () => statusBadge(await enabled(), t(`${group}.messages.enabled`), t(`${group}.messages.disabled`))),
|
|
896
|
+
];
|
|
897
|
+
}
|
|
898
|
+
/** Sends a code when the modal opens; entering it turns email codes on. */
|
|
899
|
+
setUpAction(panel) {
|
|
900
|
+
const group = 'filament-panels::auth/multi-factor/email/actions/set-up';
|
|
901
|
+
return Action.make('setUpEmailAuthentication')
|
|
902
|
+
.label(t(`${group}.label`)).color('primary').icon('m-lock-closed').link()
|
|
903
|
+
.mountUsing(async (ctx) => { await this.sendCode(currentUser(ctx.livewire), panel); })
|
|
904
|
+
.modalWidth('md').modalIcon('o-lock-closed').modalIconColor('primary').closeModalByClickingAway(false)
|
|
905
|
+
.modalHeading(t(`${group}.modal.heading`)).modalDescription(t(`${group}.modal.description`))
|
|
906
|
+
.modalSubmitActionLabel(t(`${group}.modal.actions.submit.label`))
|
|
907
|
+
.schema([this.modalCodeInput(panel, group)])
|
|
908
|
+
.action(async (ctx) => {
|
|
909
|
+
await this.setEnabled(currentUser(ctx.livewire), panel, true);
|
|
910
|
+
Notification.make().success().title(t(`${group}.notifications.enabled.title`)).icon('o-lock-closed').send();
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
/** Sends a code when the modal opens; entering it turns email codes off. */
|
|
914
|
+
disableAction(panel) {
|
|
915
|
+
const group = 'filament-panels::auth/multi-factor/email/actions/disable';
|
|
916
|
+
return Action.make('disableEmailAuthentication')
|
|
917
|
+
.label(t(`${group}.label`)).color('danger').icon('m-lock-open').link()
|
|
918
|
+
.mountUsing(async (ctx) => { await this.sendCode(currentUser(ctx.livewire), panel); })
|
|
919
|
+
.modalWidth('md').modalIcon('o-lock-open').modalIconColor('danger')
|
|
920
|
+
.modalHeading(t(`${group}.modal.heading`)).modalDescription(t(`${group}.modal.description`))
|
|
921
|
+
.modalSubmitActionLabel(t(`${group}.modal.actions.submit.label`))
|
|
922
|
+
.schema([this.modalCodeInput(panel, group)])
|
|
923
|
+
.action(async (ctx) => {
|
|
924
|
+
await this.setEnabled(currentUser(ctx.livewire), panel, false);
|
|
925
|
+
Notification.make().success().title(t(`${group}.notifications.disabled.title`)).icon('o-lock-open').send();
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
}
|