maxibase 1.4.0 → 1.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.
Files changed (34) hide show
  1. package/README.md +12 -0
  2. package/layers/base/app/components/AuthCheck.vue +126 -25
  3. package/layers/base/app/pages/signin.vue +0 -10
  4. package/layers/base/app/pages/user/auth.vue +97 -1
  5. package/layers/base/server/api/auth/index.get.ts +1 -0
  6. package/layers/base/server/api/auth/options.post.ts +12 -9
  7. package/layers/base/server/api/auth/recovery/generate.post.ts +13 -0
  8. package/layers/base/server/api/auth/recovery/verify.post.ts +37 -0
  9. package/layers/base/server/api/auth/totp/verify.post.ts +22 -10
  10. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +5 -0
  11. package/layers/base/server/api/auth/webauthn/register.post.ts +6 -1
  12. package/layers/base/server/api/files/[...path].get.ts +1 -1
  13. package/layers/base/server/api/files/chunk.post.ts +13 -9
  14. package/layers/base/server/api/files/index.post.ts +10 -11
  15. package/layers/base/server/database/defaults/access.ts +2 -2
  16. package/layers/base/server/database/relations.ts +10 -0
  17. package/layers/base/server/database/schema.ts +12 -0
  18. package/layers/base/server/middleware/2.mfa.ts +1 -1
  19. package/layers/base/server/routes/dev/email/[template].ts +2 -2
  20. package/layers/base/server/utils/access.ts +1 -1
  21. package/layers/base/server/utils/auth.ts +14 -1
  22. package/layers/base/server/utils/files.ts +25 -23
  23. package/layers/base/server/utils/ip.ts +1 -1
  24. package/layers/base/server/utils/recoveryCodes.ts +56 -0
  25. package/locales/en.json +4 -1
  26. package/locales/fr.json +4 -1
  27. package/locales/pages/signin/en.json +0 -1
  28. package/locales/pages/signin/fr.json +0 -1
  29. package/locales/pages/user-auth/en.json +11 -1
  30. package/locales/pages/user-auth/fr.json +11 -1
  31. package/nuxt.config.ts +2 -0
  32. package/package.json +1 -1
  33. package/server/assets/emails/en/recoveryCodeUsed.html +3 -0
  34. package/server/assets/emails/fr/recoveryCodeUsed.html +8 -0
package/README.md CHANGED
@@ -25,6 +25,18 @@ The tenant defaults to `organizations`: any work or school account, no personal
25
25
  `/auth/microsoft` signs in with the Microsoft account already signed in, link to
26
26
  `/auth/microsoft?prompt=select_account` to let the user pick another one.
27
27
 
28
+ ### Resetting a user's second factor
29
+
30
+ A user who lost their passkeys, authenticator app and recovery codes has no way back in. Remove
31
+ their factors in the database, they sign in with their password or an email code and, under
32
+ `NUXT_FORCE_MFA`, are asked to set up a new one:
33
+
34
+ ```sql
35
+ UPDATE auth.users SET totp = NULL WHERE email = 'user@example.com';
36
+ DELETE FROM auth.credentials WHERE user_id = (SELECT id FROM auth.users WHERE email = 'user@example.com');
37
+ DELETE FROM auth.recovery_codes WHERE user_id = (SELECT id FROM auth.users WHERE email = 'user@example.com');
38
+ ```
39
+
28
40
  ## Deployment
29
41
 
30
42
  ### On your server
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { WebAuthnAbortService } from '@simplewebauthn/browser'
3
+
2
4
  const emits = defineEmits(['authenticated', 'cancel'])
3
5
  const appStore = useAppStore()
4
6
  const { $t } = useI18n()
@@ -9,6 +11,8 @@ const email = ref('')
9
11
  const password = ref('')
10
12
  const otpRequested = ref(false)
11
13
  const totp = ref('')
14
+ const recoveryCode = ref('')
15
+ const useRecoveryCode = ref(false)
12
16
  const optionsFetched = ref(false)
13
17
  const showPassword = ref(false)
14
18
  const options = ref({
@@ -65,22 +69,20 @@ async function signInWithOtp() {
65
69
  }
66
70
  }
67
71
 
72
+ function firstFactor(): { otp: string } | { password: string } {
73
+ if (password.value.length === 6 || password.value === route.query.token) {
74
+ return { otp: password.value }
75
+ }
76
+ return { password: password.value }
77
+ }
78
+
68
79
  async function signInWithTOTP() {
69
80
  if (!email.value || !totp.value || !password.value) return
70
81
  isLoading.value = true
71
82
  try {
72
- const body: {
73
- email: string
74
- token: string
75
- otp?: string
76
- password?: string
77
- } = { email: email.value, token: totp.value }
78
- if (password.value.length === 6 || password.value === route.query.token) {
79
- body.otp = password.value
80
- } else body.password = password.value
81
83
  await $fetch('/api/auth/totp/verify', {
82
84
  method: 'POST',
83
- body,
85
+ body: { email: email.value, token: totp.value, ...firstFactor() },
84
86
  })
85
87
  await refreshSession()
86
88
  totp.value = ''
@@ -91,6 +93,28 @@ async function signInWithTOTP() {
91
93
  }
92
94
  }
93
95
 
96
+ async function signInWithRecoveryCode() {
97
+ if (!email.value || !recoveryCode.value || !password.value) return
98
+ isLoading.value = true
99
+ try {
100
+ await $fetch('/api/auth/recovery/verify', {
101
+ method: 'POST',
102
+ body: {
103
+ email: email.value,
104
+ code: recoveryCode.value,
105
+ locale: useI18n().$getLocale(),
106
+ ...firstFactor(),
107
+ },
108
+ })
109
+ await refreshSession()
110
+ recoveryCode.value = ''
111
+ } catch (e: any) {
112
+ handleError(e)
113
+ } finally {
114
+ isLoading.value = false
115
+ }
116
+ }
117
+
94
118
  async function signInWithPassword() {
95
119
  if (!email.value || !password.value) return
96
120
  isLoading.value = true
@@ -127,23 +151,30 @@ function signIn() {
127
151
  if (!email.value || isLoading.value) return
128
152
  // paste and change bypass the browser's check, which only guards the submit
129
153
  if (!signInForm.value?.reportValidity()) return
130
- if (totp.value) signInWithTOTP()
154
+ if (useRecoveryCode.value) signInWithRecoveryCode()
155
+ else if (totp.value) signInWithTOTP()
131
156
  else if (password.value.length === 6) signInWithOtp()
132
157
  else signInWithPassword()
133
158
  }, 100)
134
159
  }
135
160
 
136
- const { authenticate } = useWebAuthn({
161
+ const webAuthnEndpoints = {
137
162
  registerEndpoint: '/api/auth/webauthn/register',
138
163
  authenticateEndpoint: '/api/auth/webauthn/authenticate',
164
+ }
165
+ const { authenticate } = useWebAuthn(webAuthnEndpoints)
166
+ const { authenticate: authenticateWithAutofill, isAutofillSupported } = useWebAuthn({
167
+ ...webAuthnEndpoints,
168
+ useBrowserAutofill: true,
139
169
  })
140
170
 
141
- async function signInWithPasskey() {
171
+ async function signInWithPasskey(userName?: string, autofill = false) {
142
172
  try {
143
- await authenticate(email.value)
173
+ await (autofill ? authenticateWithAutofill() : authenticate(userName))
144
174
  await refreshSession()
145
175
  } catch (e: any) {
146
- if (!e.message.startsWith('The operation either timed out')) {
176
+ // any other passkey prompt aborts the pending autofill request
177
+ if (e.name !== 'AbortError' && !e.message.startsWith('The operation either timed out')) {
147
178
  handleError(e)
148
179
  }
149
180
  }
@@ -160,7 +191,7 @@ async function getSignInOptions(email: string) {
160
191
  password.value = route.query.token as string
161
192
  if (!options.value.hasTOTP) await signInWithOtp()
162
193
  } else if (options.value.hasPasskey) {
163
- await signInWithPasskey()
194
+ await signInWithPasskey(email)
164
195
  } else if (!options.value.hasPassword) {
165
196
  await requestOtp()
166
197
  }
@@ -172,7 +203,7 @@ async function getSignInOptions(email: string) {
172
203
  }
173
204
  }
174
205
 
175
- const pasteFields = { password, totp }
206
+ const pasteFields = { password, totp, recoveryCode }
176
207
 
177
208
  async function pasteTo(field: keyof typeof pasteFields) {
178
209
  try {
@@ -187,8 +218,12 @@ onMounted(async () => {
187
218
  email.value = user.value?.email || (route.query.email as string) || ''
188
219
  if (email.value) {
189
220
  await getSignInOptions(email.value)
221
+ } else if (await isAutofillSupported()) {
222
+ signInWithPasskey(undefined, true)
190
223
  }
191
224
  })
225
+
226
+ onBeforeUnmount(() => WebAuthnAbortService.cancelCeremony())
192
227
  </script>
193
228
 
194
229
  <template>
@@ -248,7 +283,32 @@ onMounted(async () => {
248
283
  </button>
249
284
  </div>
250
285
  </div>
251
- <div v-if="options.hasTOTP">
286
+ <div v-if="useRecoveryCode">
287
+ <label for="recovery-code">{{ $t('authCheck.recoveryCode') }}</label>
288
+ <div class="group flex-row">
289
+ <input
290
+ id="recovery-code"
291
+ v-model.trim="recoveryCode"
292
+ type="text"
293
+ :disabled="isLoading || !password"
294
+ autocomplete="off"
295
+ autocapitalize="none"
296
+ pattern="[0-9a-fA-F]{5}-?[0-9a-fA-F]{5}"
297
+ required
298
+ @paste="signIn"
299
+ />
300
+ <button
301
+ type="button"
302
+ class="flex-center fg"
303
+ :title="$t('paste') as string"
304
+ :disabled="isLoading || !password"
305
+ @click="pasteTo('recoveryCode')"
306
+ >
307
+ <Icon name="lucide:clipboard-paste" />
308
+ </button>
309
+ </div>
310
+ </div>
311
+ <div v-else-if="options.hasTOTP">
252
312
  <label for="totp">{{ $t('authCheck.authenticatorCode') }}</label>
253
313
  <div class="group flex-row">
254
314
  <input
@@ -276,7 +336,11 @@ onMounted(async () => {
276
336
  </div>
277
337
  <button
278
338
  type="submit"
279
- :disabled="isLoading || (!password && !otpRequested) || (options.hasTOTP && !totp)"
339
+ :disabled="
340
+ isLoading ||
341
+ (!password && !otpRequested) ||
342
+ (useRecoveryCode ? !recoveryCode : options.hasTOTP && !totp)
343
+ "
280
344
  :class="['w mt1', isLoading ? 'spin' : '']"
281
345
  >
282
346
  {{ $t('authCheck.signin') }}
@@ -285,9 +349,10 @@ onMounted(async () => {
285
349
  v-if="options.hasPasskey"
286
350
  type="button"
287
351
  :disabled="isLoading"
288
- class="flex-center g1 w mt1 bg bg-border"
289
- @click="signInWithPasskey"
352
+ class="flex-center g2 w mt1 bg bg-border"
353
+ @click="signInWithPasskey(email)"
290
354
  >
355
+ <Icon name="lucide:key-round" />
291
356
  {{ $t('authCheck.passkey') }}
292
357
  </button>
293
358
  <button
@@ -299,6 +364,16 @@ onMounted(async () => {
299
364
  >
300
365
  {{ $t('authCheck.getCode') }}
301
366
  </button>
367
+ <button
368
+ v-if="options.hasTOTP || options.hasPasskey"
369
+ type="button"
370
+ class="w"
371
+ style="background: none; border: none; color: inherit"
372
+ :disabled="isLoading"
373
+ @click="useRecoveryCode = !useRecoveryCode"
374
+ >
375
+ {{ useRecoveryCode ? $t('cancel') : $t('authCheck.useRecoveryCode') }}
376
+ </button>
302
377
  <p
303
378
  v-if="otpRequested"
304
379
  class="text-center"
@@ -319,7 +394,7 @@ onMounted(async () => {
319
394
  type="email"
320
395
  required
321
396
  :disabled="isLoading"
322
- autocomplete="username"
397
+ autocomplete="username webauthn"
323
398
  />
324
399
  </label>
325
400
  <button
@@ -329,10 +404,22 @@ onMounted(async () => {
329
404
  {{ $t('continue') }}
330
405
  </button>
331
406
  </form>
332
- <slot
407
+ <div
333
408
  v-if="!optionsFetched"
334
- name="footer"
335
- ></slot>
409
+ class="flex-column"
410
+ >
411
+ <span class="text-center muted-text my1">{{ $t('or') }}</span>
412
+ <button
413
+ type="button"
414
+ class="provider flex-center g2 w mb1 fg b"
415
+ style="font-weight: normal"
416
+ @click="signInWithPasskey()"
417
+ >
418
+ <Icon name="lucide:key-round" />
419
+ {{ $t('authCheck.passkey') }}
420
+ </button>
421
+ <slot name="footer"></slot>
422
+ </div>
336
423
  <button
337
424
  v-if="appStore.authPromise"
338
425
  type="button"
@@ -352,4 +439,18 @@ onMounted(async () => {
352
439
  box-sizing: border-box;
353
440
  width: min(400px, 100vw - (var(--g-padding-l) + var(--g-padding-r)) * var(--g-padding-ratio2));
354
441
  }
442
+
443
+ .provider,
444
+ :slotted(.provider) {
445
+ height: 40px;
446
+ border-radius: var(--g-border-radius);
447
+ font-size: 14px;
448
+ color: inherit;
449
+ }
450
+
451
+ .provider > :is(img, .iconify),
452
+ :slotted(.provider) > :is(img, .iconify) {
453
+ width: 20px;
454
+ height: 20px;
455
+ }
355
456
  </style>
@@ -71,7 +71,6 @@ async function signInAnonymously() {
71
71
  v-if="config.public.oauth.microsoft || config.public.anonymousSignup"
72
72
  class="flex-column"
73
73
  >
74
- <span class="text-center muted-text my1">{{ $t('or') }}</span>
75
74
  <a
76
75
  v-if="config.public.oauth.microsoft"
77
76
  href="/auth/microsoft"
@@ -80,8 +79,6 @@ async function signInAnonymously() {
80
79
  <img
81
80
  src="/images/auth/microsoft.svg"
82
81
  alt=""
83
- width="21"
84
- height="21"
85
82
  />
86
83
  {{ $t('signinWith') }} Microsoft
87
84
  </a>
@@ -111,11 +108,4 @@ async function signInAnonymously() {
111
108
  box-shadow: inset 0 0 40vh var(--g-fg);
112
109
  height: 100dvh;
113
110
  }
114
-
115
- .provider {
116
- height: 40px;
117
- border-radius: var(--g-border-radius);
118
- font-size: 14px;
119
- color: inherit;
120
- }
121
111
  </style>
@@ -12,6 +12,7 @@ type AuthInfo = {
12
12
  credentials: { id: string; name: string }[]
13
13
  hasPassword: boolean
14
14
  hasTOTP: boolean
15
+ recoveryCodes: number
15
16
  forceMfa: boolean
16
17
  }
17
18
 
@@ -19,8 +20,10 @@ const auth = ref<AuthInfo>({
19
20
  credentials: [],
20
21
  hasPassword: false,
21
22
  hasTOTP: false,
23
+ recoveryCodes: 0,
22
24
  forceMfa: false,
23
25
  })
26
+ const recoveryCodes = ref<string[]>([])
24
27
  const TOTPSecret = ref('')
25
28
  const TOTPCode = ref('')
26
29
  const password1 = ref('')
@@ -54,6 +57,7 @@ const canRemovePasskey = computed(
54
57
  () => !auth.value?.forceMfa || auth.value.hasTOTP || auth.value.credentials.length > 1,
55
58
  )
56
59
  const canDisableTOTP = computed(() => !auth.value?.forceMfa || auth.value.credentials.length > 0)
60
+ const hasSecondFactor = computed(() => auth.value.hasTOTP || auth.value.credentials.length > 0)
57
61
 
58
62
  async function getAuth() {
59
63
  try {
@@ -70,9 +74,11 @@ const { register } = useWebAuthn({
70
74
  async function registerPasskey() {
71
75
  try {
72
76
  if (!(await checkAuth())) return
77
+ const isFirstFactor = !hasSecondFactor.value
73
78
  await register({ userName: user.value!.email! })
74
79
  await getAuth()
75
80
  appStore.notify('saved', 'success')
81
+ if (isFirstFactor) await offerRecoveryCodes()
76
82
  if (isMfaSetup.value) {
77
83
  await fetchUserSession()
78
84
  if (!user.value?.requiresMfaSetup) await navigateTo('/', { replace: true })
@@ -189,6 +195,7 @@ async function confirmTOTP() {
189
195
  if (TOTPCode.value.length !== 6) return
190
196
  try {
191
197
  if (!(await checkAuth())) return
198
+ const isFirstFactor = !hasSecondFactor.value
192
199
  await $fetch('/api/auth/totp/confirm', {
193
200
  method: 'POST',
194
201
  body: { secret: TOTPSecret.value, token: TOTPCode.value },
@@ -197,6 +204,7 @@ async function confirmTOTP() {
197
204
  TOTPCode.value = ''
198
205
  auth.value.hasTOTP = true
199
206
  showTOTP.value = false
207
+ if (isFirstFactor) await offerRecoveryCodes()
200
208
  if (isMfaSetup.value) {
201
209
  await fetchUserSession()
202
210
  if (!user.value?.requiresMfaSetup) await navigateTo('/', { replace: true })
@@ -218,6 +226,38 @@ async function disableTOTP() {
218
226
  }
219
227
  }
220
228
 
229
+ async function generateRecoveryCodes() {
230
+ try {
231
+ if (auth.value.recoveryCodes && !(await appStore.confirm($t('replaceRecoveryCodes') as string)))
232
+ return
233
+ if (!(await checkAuth())) return
234
+ const response = await $fetch<{ codes: string[] }>('/api/auth/recovery/generate', {
235
+ method: 'POST',
236
+ })
237
+ recoveryCodes.value = response.codes
238
+ auth.value.recoveryCodes = response.codes.length
239
+ } catch (e: any) {
240
+ appStore.notify(e?.data?.message || e?.message, 'error')
241
+ }
242
+ }
243
+
244
+ let recoveryCodesClosed: (() => void) | undefined
245
+
246
+ function closeRecoveryCodes() {
247
+ recoveryCodes.value = []
248
+ recoveryCodesClosed?.()
249
+ }
250
+
251
+ // Resolves once the codes are closed, so the MFA setup redirect does not hide them
252
+ async function offerRecoveryCodes() {
253
+ if (auth.value.recoveryCodes) return
254
+ if (!(await appStore.confirm($t('offerRecoveryCodes') as string))) return
255
+ await generateRecoveryCodes()
256
+ if (!recoveryCodes.value.length) return
257
+ await new Promise<void>((resolve) => (recoveryCodesClosed = resolve))
258
+ recoveryCodesClosed = undefined
259
+ }
260
+
221
261
  async function deleteRefreshTokens() {
222
262
  if (!(await checkAuth())) return
223
263
  appStore.setLoading(true)
@@ -388,7 +428,7 @@ onMounted(getAuth)
388
428
  required
389
429
  />
390
430
  <button
391
- class="flex fg"
431
+ class="flex-center fg"
392
432
  type="button"
393
433
  @click="showPassword2 = !showPassword2"
394
434
  >
@@ -453,6 +493,25 @@ onMounted(getAuth)
453
493
  :title="canDisableTOTP ? '' : $t('lastFactor')"
454
494
  @click.prevent="toggleTOTP"
455
495
  />
496
+
497
+ <h3 class="mt2 mb0">{{ $t('recoveryCodes') }}</h3>
498
+ <p class="muted-text mt0">
499
+ {{ $t('recoveryCodesDescription') }}
500
+ <span
501
+ v-if="hasSecondFactor"
502
+ :class="auth.recoveryCodes < 5 ? 'danger-text' : ''"
503
+ >
504
+ {{ $t('recoveryCodesLeft', { count: auth.recoveryCodes }) }}
505
+ </span>
506
+ </p>
507
+ <button
508
+ class="flex"
509
+ :disabled="!hasSecondFactor"
510
+ :title="hasSecondFactor ? '' : $t('recoveryCodesRequireFactor')"
511
+ @click="generateRecoveryCodes"
512
+ >
513
+ {{ $t('generateRecoveryCodes') }}
514
+ </button>
456
515
  </section>
457
516
  <div class="flex-row my3">
458
517
  <button
@@ -464,6 +523,43 @@ onMounted(getAuth)
464
523
  </div>
465
524
  </template>
466
525
 
526
+ <div
527
+ v-if="recoveryCodes.length"
528
+ class="modal"
529
+ >
530
+ <div
531
+ class="p3"
532
+ style="max-width: min(400px, 90vw)"
533
+ >
534
+ <p class="mt0">{{ $t('recoveryCodesSave') }}</p>
535
+ <ul
536
+ class="text-center p0"
537
+ style="list-style: none; font-family: monospace; columns: 2"
538
+ >
539
+ <li
540
+ v-for="code in recoveryCodes"
541
+ :key="code"
542
+ >
543
+ {{ code }}
544
+ </li>
545
+ </ul>
546
+ <div class="flex-row g1">
547
+ <button
548
+ class="fg fg-border"
549
+ @click="copyToClipboard(recoveryCodes.join('\n'))"
550
+ >
551
+ {{ $t('copy') }}
552
+ </button>
553
+ <button
554
+ class="ml"
555
+ @click="closeRecoveryCodes"
556
+ >
557
+ {{ $t('recoveryCodesSaved') }}
558
+ </button>
559
+ </div>
560
+ </div>
561
+ </div>
562
+
467
563
  <div
468
564
  v-if="showTOTP && TOTPSecret"
469
565
  class="modal"
@@ -5,6 +5,7 @@ export default defineEventHandler(async (event) => {
5
5
  credentials: user.credentials!.map(({ id, name }) => ({ id, name })),
6
6
  hasPassword: !!user.password,
7
7
  hasTOTP: !!user.totp,
8
+ recoveryCodes: await countRecoveryCodes(user.id),
8
9
  forceMfa: useRuntimeConfig().forceMfa,
9
10
  }
10
11
  })
@@ -7,15 +7,11 @@ const bodySchema = z.object({
7
7
  export default defineEventHandler(async (event) => {
8
8
  const { email } = await readValidatedBody(event, bodySchema.parse)
9
9
 
10
- try {
11
- const user = await getAuth(event, email, true)
12
- return {
13
- hasOTP: true,
14
- hasPassword: !!user.password,
15
- hasTOTP: !!user.totp,
16
- hasPasskey: user.credentials!.length > 0,
17
- }
18
- } catch {
10
+ const user = await getAuth(event, email, true).catch((error: any) => {
11
+ if (error?.statusCode === 404) return null
12
+ throw error
13
+ })
14
+ if (!user) {
19
15
  return {
20
16
  hasOTP: true,
21
17
  hasPassword: false,
@@ -23,4 +19,11 @@ export default defineEventHandler(async (event) => {
23
19
  hasPasskey: false,
24
20
  }
25
21
  }
22
+
23
+ return {
24
+ hasOTP: true,
25
+ hasPassword: !!user.password,
26
+ hasTOTP: !!user.totp,
27
+ hasPasskey: user.credentials!.length > 0,
28
+ }
26
29
  })
@@ -0,0 +1,13 @@
1
+ export default defineEventHandler(async (event) => {
2
+ await requireRecentAuth(event)
3
+ const user = await getAuth(event, undefined, true)
4
+
5
+ if (!user.totp && !user.credentials!.length) {
6
+ throw createError({ status: 400, message: 'Set up a second factor first.' })
7
+ }
8
+
9
+ const codes = await generateRecoveryCodes(user.id)
10
+ await log('Recovery codes generated', undefined, undefined, 'security', event)
11
+
12
+ return { codes }
13
+ })
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod'
2
+ import { verifyFirstFactor } from '../totp/verify.post'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const {
6
+ email,
7
+ password,
8
+ otp,
9
+ code,
10
+ locale = 'en',
11
+ } = await readValidatedBody(
12
+ event,
13
+ z.object({
14
+ email: emailSchema,
15
+ password: passwordSchema().optional(),
16
+ otp: otpSchema.optional(),
17
+ code: z.string().min(10).max(16),
18
+ locale: z.string().optional(),
19
+ }).parse,
20
+ )
21
+
22
+ const user = await verifyFirstFactor(event, email, { password, otp })
23
+ if (!user) throw createError({ status: 400, message: 'Invalid recovery code.' })
24
+
25
+ await enforceRateLimit(event, 'recovery', { limit: 5, window: 900 }, user.id)
26
+
27
+ const remaining = await consumeRecoveryCode(user.id, code)
28
+ if (remaining === null) throw createError({ status: 400, message: 'Invalid recovery code.' })
29
+
30
+ if (otp) await useStorage('auth').removeItem(email)
31
+ const session = await setSession(event, user)
32
+
33
+ await log('Recovery code used', { remaining }, undefined, 'security', event)
34
+ await sendEmailTemplate('recoveryCodeUsed', locale, { remaining }, user.email!).catch(() => {})
35
+
36
+ return session
37
+ })
@@ -1,17 +1,13 @@
1
1
  import { z } from 'zod'
2
2
  import { verify } from 'otplib'
3
+ import type { H3Event } from 'h3'
3
4
  import { verifyOTP } from '../otp/verify.post'
4
5
 
5
- export default defineEventHandler(async (event) => {
6
- const { email, password, otp, token } = await readValidatedBody(
7
- event,
8
- z.object({
9
- email: emailSchema,
10
- password: passwordSchema().optional(),
11
- otp: otpSchema.optional(),
12
- token: z.string().length(6),
13
- }).parse,
14
- )
6
+ export async function verifyFirstFactor(
7
+ event: H3Event,
8
+ email: string,
9
+ { password, otp }: { password?: string; otp?: string },
10
+ ) {
15
11
  if (!otp && !password) {
16
12
  throw createError({
17
13
  status: 400,
@@ -32,6 +28,22 @@ export default defineEventHandler(async (event) => {
32
28
  throw createError({ status: 400, message: 'Invalid password.' })
33
29
  }
34
30
 
31
+ return user
32
+ }
33
+
34
+ export default defineEventHandler(async (event) => {
35
+ const { email, password, otp, token } = await readValidatedBody(
36
+ event,
37
+ z.object({
38
+ email: emailSchema,
39
+ password: passwordSchema().optional(),
40
+ otp: otpSchema.optional(),
41
+ token: z.string().length(6),
42
+ }).parse,
43
+ )
44
+
45
+ const user = await verifyFirstFactor(event, email, { password, otp })
46
+
35
47
  if (!user?.totp) {
36
48
  throw createError({ status: 400, message: 'TOTP is not enabled.' })
37
49
  }
@@ -14,6 +14,9 @@ export default defineWebAuthnAuthenticateEventHandler({
14
14
 
15
15
  return challenge as string
16
16
  },
17
+ getOptions() {
18
+ return { userVerification: passkeyUserVerification() }
19
+ },
17
20
  async allowCredentials(event, userName) {
18
21
  const user = await getAuth(event, userName, true)
19
22
  return (user as any).credentials || []
@@ -29,6 +32,8 @@ export default defineWebAuthnAuthenticateEventHandler({
29
32
  return credential as any
30
33
  },
31
34
  async onSuccess(event, { credential, authenticationInfo }) {
35
+ requireUserVerification(authenticationInfo.userVerified)
36
+
32
37
  // the next assertion must report a higher counter, which exposes a cloned authenticator
33
38
  await db
34
39
  .update(credentials)
@@ -14,6 +14,9 @@ const registerHandler = defineWebAuthnRegisterEventHandler({
14
14
 
15
15
  return challenge as string
16
16
  },
17
+ getOptions() {
18
+ return { authenticatorSelection: { userVerification: passkeyUserVerification() } }
19
+ },
17
20
  async validateUser(userBody, event) {
18
21
  const session = await requireUserSession(event)
19
22
  const parsed = z
@@ -28,7 +31,9 @@ const registerHandler = defineWebAuthnRegisterEventHandler({
28
31
 
29
32
  return parsed.data
30
33
  },
31
- async onSuccess(event, { credential }) {
34
+ async onSuccess(event, { credential, registrationInfo }) {
35
+ requireUserVerification(registrationInfo.userVerified)
36
+
32
37
  const session = await requireUserSession(event)
33
38
  await db.insert(credentials).values({
34
39
  name: new Date().toISOString().substring(0, 10),
@@ -15,7 +15,7 @@ const querySchema = z.object({
15
15
  })
16
16
 
17
17
  export default defineEventHandler(async (event) => {
18
- const path = event.context.params?.path || ''
18
+ const path = getRouterParam(event, 'path') || ''
19
19
  const {
20
20
  isPrivate = false,
21
21
  maxAge,
@@ -23,11 +23,10 @@ export default defineEventHandler(async (event) => {
23
23
  const body = await readUploadBody(event, Number(useRuntimeConfig().public.files.chunkSize) || 1)
24
24
  if (!body) throw createError({ status: 400, message: 'No data' })
25
25
 
26
- const get = (name: string) =>
27
- body
28
- .find((p) => p.name === name)
29
- ?.data.toString()
30
- .trim()
26
+ const get = (name: string) => {
27
+ const value = body.get(name)
28
+ return typeof value === 'string' ? value.trim() : undefined
29
+ }
31
30
 
32
31
  const uploadId = get('uploadId')
33
32
  const chunkIndex = Number(get('chunkIndex'))
@@ -38,9 +37,14 @@ export default defineEventHandler(async (event) => {
38
37
  const isPrivate = get('isPrivate') !== 'false'
39
38
  const parts = parseParts(get('parts'))
40
39
  const maxAge = parseMaxAge(get('maxAge'))
41
- const chunkPart = body.find((p) => p.name === 'chunk')
42
-
43
- if (!Number.isInteger(chunkIndex) || !Number.isInteger(totalChunks) || !filename || !chunkPart) {
40
+ const chunkPart = body.get('chunk')
41
+
42
+ if (
43
+ !Number.isInteger(chunkIndex) ||
44
+ !Number.isInteger(totalChunks) ||
45
+ !filename ||
46
+ !(chunkPart instanceof File)
47
+ ) {
44
48
  throw invalidChunkUpload()
45
49
  }
46
50
 
@@ -50,7 +54,7 @@ export default defineEventHandler(async (event) => {
50
54
  totalChunks,
51
55
  filename,
52
56
  type,
53
- chunk: chunkPart.data,
57
+ chunk: Buffer.from(await chunkPart.arrayBuffer()),
54
58
  path,
55
59
  isPrivate,
56
60
  parts,
@@ -4,20 +4,19 @@ export default defineEventHandler(async (event) => {
4
4
  throw createError({ status: 400, message: 'No file uploaded' })
5
5
  }
6
6
 
7
- const pathPart = body.find((p) => p.name === 'path')
8
- const isPrivatePart = body.find((p) => p.name === 'isPrivate')
9
- const isPrivate = isPrivatePart ? isPrivatePart.data.toString() === 'true' : true
10
- const path = pathPart?.data.toString().trim()
11
- const maxAge = parseMaxAge(
12
- body
13
- .find((p) => p.name === 'maxAge')
14
- ?.data.toString()
15
- .trim(),
16
- )
7
+ const get = (name: string) => {
8
+ const value = body.get(name)
9
+ return typeof value === 'string' ? value.trim() : undefined
10
+ }
11
+
12
+ const isPrivate = body.has('isPrivate') ? get('isPrivate') === 'true' : true
13
+ const path = get('path')
14
+ const maxAge = parseMaxAge(get('maxAge'))
17
15
 
18
16
  return await Promise.all(
19
17
  body
20
- .filter((p) => p.name === 'file')
18
+ .getAll('file')
19
+ .filter((file) => file instanceof File)
21
20
  .map((file) => uploadFile(event, file, path, isPrivate, maxAge)),
22
21
  )
23
22
  })
@@ -3,12 +3,12 @@ import type { H3Event } from 'h3'
3
3
  // Users reach their own folder u/{id}, admins every folder
4
4
  export async function checkFileAccess(event: H3Event, path: string) {
5
5
  const { user } = await requireUserSession(event)
6
- if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
6
+ if (!user) throw createError({ status: 401, message: 'Unauthorized' })
7
7
  const root = `u/${user.id}`
8
8
  if (path.startsWith(root) && (path[root.length] === '/' || path.length === root.length)) {
9
9
  return true
10
10
  }
11
11
  if (user.role === 'admin') return true
12
12
 
13
- throw createError({ statusCode: 403, message: 'Insufficient permissions' })
13
+ throw createError({ status: 403, message: 'Insufficient permissions' })
14
14
  }
@@ -13,6 +13,10 @@ export const authRelations = defineRelationsPart(schema, (r) => ({
13
13
  from: r.auth.id,
14
14
  to: r.credentials.userId,
15
15
  }),
16
+ recoveryCodes: r.many.recoveryCodes({
17
+ from: r.auth.id,
18
+ to: r.recoveryCodes.userId,
19
+ }),
16
20
  },
17
21
  refreshTokens: {
18
22
  auth: r.one.auth({
@@ -26,6 +30,12 @@ export const authRelations = defineRelationsPart(schema, (r) => ({
26
30
  to: r.auth.id,
27
31
  }),
28
32
  },
33
+ recoveryCodes: {
34
+ auth: r.one.auth({
35
+ from: r.recoveryCodes.userId,
36
+ to: r.auth.id,
37
+ }),
38
+ },
29
39
  logs: {
30
40
  auth: r.one.auth({
31
41
  from: r.logs.userId,
@@ -49,6 +49,18 @@ export const credentials = authSchema.table(
49
49
  (table) => [primaryKey({ columns: [table.userId, table.id] })],
50
50
  )
51
51
 
52
+ export const recoveryCodes = authSchema.table(
53
+ 'recovery_codes',
54
+ {
55
+ userId: uuid()
56
+ .notNull()
57
+ .references(() => auth.id, { onDelete: 'cascade' }),
58
+ code: text().notNull(),
59
+ createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
60
+ },
61
+ (table) => [primaryKey({ columns: [table.userId, table.code] })],
62
+ )
63
+
52
64
  export const logs = authSchema.table(
53
65
  'logs',
54
66
  {
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
28
28
 
29
29
  const path = event.path.split('?')[0]!
30
30
  if (path.startsWith('/api/auth/otp/') || path === '/api/auth/password') return // re-authentication
31
- if (ENROLLMENT_ROUTES.includes(path) && !(path === '/api/auth' && event.method !== 'GET')) return
31
+ if (ENROLLMENT_ROUTES.includes(path) && !(path === '/api/auth' && !isMethod(event, 'GET'))) return
32
32
 
33
33
  throw createError({ status: 403, message: 'MFA setup required' })
34
34
  })
@@ -1,12 +1,12 @@
1
1
  export default defineEventHandler(async (event) => {
2
2
  if (process.env.NODE_ENV === 'production') {
3
3
  throw createError({
4
- statusCode: 404,
4
+ status: 404,
5
5
  message: 'Not found',
6
6
  })
7
7
  }
8
8
 
9
- const templateId = event.context.params?.template || ''
9
+ const templateId = getRouterParam(event, 'template') || ''
10
10
  const query = getQuery(event)
11
11
  const locale = (query.locale as string) || 'en'
12
12
 
@@ -4,5 +4,5 @@ export async function isAdmin(event: H3Event) {
4
4
  const { user } = await requireUserSession(event)
5
5
  if (user.role === 'admin') return true
6
6
 
7
- throw createError({ statusCode: 403, message: 'Insufficient permissions' })
7
+ throw createError({ status: 403, message: 'Insufficient permissions' })
8
8
  }
@@ -32,6 +32,19 @@ export function requireSecondFactor(user: Pick<AuthUser, 'totp'> & { credentials
32
32
  }
33
33
  }
34
34
 
35
+ export function passkeyUserVerification(): 'required' | 'preferred' {
36
+ return useRuntimeConfig().forceMfa ? 'required' : 'preferred'
37
+ }
38
+
39
+ export function requireUserVerification(userVerified: boolean) {
40
+ if (useRuntimeConfig().forceMfa && !userVerified) {
41
+ throw createError({
42
+ status: 400,
43
+ message: 'Set a PIN on your security key to use it.',
44
+ })
45
+ }
46
+ }
47
+
35
48
  export async function createAuth(user: Pick<AuthUser, 'email'>) {
36
49
  const insertedUsers = await db
37
50
  .insert(auth)
@@ -133,7 +146,7 @@ export async function requireRecentAuth(event: H3Event) {
133
146
  if (!session.authenticatedAt || Date.now() - session.authenticatedAt > maxAge) {
134
147
  throw createError({
135
148
  status: 403,
136
- statusMessage: 'reauth_required',
149
+ statusText: 'reauth_required',
137
150
  message: 'Please verify your identity again to continue.',
138
151
  })
139
152
  }
@@ -29,8 +29,8 @@ import type { CompletedPart } from '@aws-sdk/client-s3'
29
29
  import { checkFileAccess } from '#database/access'
30
30
  import { createReadStream, createWriteStream } from 'fs'
31
31
  import { pipeline } from 'stream/promises'
32
- import type { Readable } from 'stream'
33
- import type { H3Event, MultiPartData } from 'h3'
32
+ import { Readable } from 'stream'
33
+ import type { H3Event } from 'h3'
34
34
  import { createHash } from 'crypto'
35
35
  import mime from 'mime'
36
36
 
@@ -38,33 +38,34 @@ const MB = 1024 * 1024
38
38
 
39
39
  function fileTooLarge() {
40
40
  const { maxSize } = useRuntimeConfig().public.files
41
- return createError({ statusCode: 413, message: `File too large, max ${maxSize} MB` })
41
+ return createError({ status: 413, message: `File too large, max ${maxSize} MB` })
42
42
  }
43
43
 
44
44
  export async function readUploadBody(event: H3Event, maxMB: number) {
45
45
  await requireUserSession(event)
46
46
  const length = getRequestHeader(event, 'content-length')
47
- if (!length) throw createError({ statusCode: 411, message: 'Content-Length required' })
47
+ if (!length) throw createError({ status: 411, message: 'Content-Length required' })
48
48
  if (Number(length) > (maxMB + 1) * MB) throw fileTooLarge()
49
- return readMultipartFormData(event)
49
+ return readFormData(event).catch(() => undefined)
50
50
  }
51
51
 
52
52
  export async function uploadFile(
53
53
  event: H3Event,
54
- file: MultiPartData,
54
+ file: File,
55
55
  path = 'files',
56
56
  isPrivate = true,
57
57
  maxAge?: number, // only stored on S3 objects, local files get their headers from nginx
58
58
  ) {
59
59
  await checkFileAccess(event, path)
60
- path = getUploadPath(path, isPrivate, file.filename)
60
+ path = getUploadPath(path, isPrivate, file.name)
61
+ const data = Buffer.from(await file.arrayBuffer())
61
62
 
62
63
  let url
63
64
  if (useS3()) {
64
65
  const s3Key = getS3Key(path)
65
66
  url = await uploadToS3(
66
67
  s3Key,
67
- file.data,
68
+ data,
68
69
  file.type || 'application/octet-stream',
69
70
  getCacheControl(isPrivate, maxAge),
70
71
  isPrivate,
@@ -73,7 +74,7 @@ export async function uploadFile(
73
74
  const filePath = join(process.cwd(), path)
74
75
  const folderPath = filePath.replace(/\/[^/]*$/, '')
75
76
  await mkdir(folderPath, { recursive: true })
76
- await writeFile(filePath, file.data)
77
+ await writeFile(filePath, data)
77
78
 
78
79
  url = getFileURL(path, isPrivate)
79
80
  }
@@ -149,7 +150,7 @@ export async function uploadChunk(
149
150
  },
150
151
  ) {
151
152
  if (chunkIndex < 0 || chunkIndex >= totalChunks || totalChunks < 1) {
152
- throw createError({ statusCode: 400, message: 'Invalid chunk index' })
153
+ throw createError({ status: 400, message: 'Invalid chunk index' })
153
154
  }
154
155
  const { chunkSize, maxSize } = useRuntimeConfig().public.files
155
156
  const chunkBytes = (Number(chunkSize) || 1) * MB
@@ -186,7 +187,7 @@ export async function uploadChunk(
186
187
  uploadId = crypto.randomUUID()
187
188
  parts = []
188
189
  } else if (!isUUID(uploadId)) {
189
- throw createError({ statusCode: 400, message: 'Invalid upload id' })
190
+ throw createError({ status: 400, message: 'Invalid upload id' })
190
191
  }
191
192
 
192
193
  const tmpDir = join(process.cwd(), 'files', 'temp', uploadId)
@@ -335,10 +336,10 @@ export async function getFile(
335
336
  if (response.status === 416) {
336
337
  const contentRange = response.headers.get('content-range')
337
338
  if (contentRange) setHeader(event, 'Content-Range', contentRange)
338
- throw createError({ statusCode: 416, message: 'Range not satisfiable' })
339
+ throw createError({ status: 416, message: 'Range not satisfiable' })
339
340
  }
340
- if (response.status === 404) throw createError({ statusCode: 404, message: 'File not found' })
341
- if (!response.ok) throw createError({ statusCode: 502, message: 'Failed to fetch file' })
341
+ if (response.status === 404) throw createError({ status: 404, message: 'File not found' })
342
+ if (!response.ok) throw createError({ status: 502, message: 'Failed to fetch file' })
342
343
 
343
344
  const passthrough = {
344
345
  'Content-Type': response.headers.get('content-type'),
@@ -358,7 +359,7 @@ export async function getFile(
358
359
 
359
360
  const stats = await stat(fullPath).catch(() => null)
360
361
  if (!stats?.isFile()) {
361
- throw createError({ statusCode: 404, message: 'File not found' })
362
+ throw createError({ status: 404, message: 'File not found' })
362
363
  }
363
364
 
364
365
  setHeader(event, 'Content-Type', mime.getType(fullPath) || 'application/octet-stream')
@@ -367,7 +368,7 @@ export async function getFile(
367
368
  const parsedRange = parseRangeHeader(range, stats.size)
368
369
  if (parsedRange === 'unsatisfiable') {
369
370
  setHeader(event, 'Content-Range', `bytes */${stats.size}`)
370
- throw createError({ statusCode: 416, message: 'Range not satisfiable' })
371
+ throw createError({ status: 416, message: 'Range not satisfiable' })
371
372
  }
372
373
 
373
374
  if (parsedRange) {
@@ -375,11 +376,12 @@ export async function getFile(
375
376
  setResponseStatus(event, 206)
376
377
  setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`)
377
378
  setHeader(event, 'Content-Length', end - start + 1)
378
- return createReadStream(fullPath, { start, end })
379
+ return Readable.toWeb(createReadStream(fullPath, { start, end }))
379
380
  }
380
381
 
381
382
  setHeader(event, 'Content-Length', stats.size)
382
- return createReadStream(fullPath)
383
+ // h3 v2 only streams web streams
384
+ return Readable.toWeb(createReadStream(fullPath))
383
385
  }
384
386
 
385
387
  export async function deletePath(event: H3Event, path: string, isPrivate = true) {
@@ -467,7 +469,7 @@ export async function copyFile(
467
469
  const destPath = join(process.cwd(), dest)
468
470
  const stats = await stat(srcPath).catch(() => null)
469
471
  if (!stats?.isFile()) {
470
- throw createError({ statusCode: 404, message: 'File not found' })
472
+ throw createError({ status: 404, message: 'File not found' })
471
473
  }
472
474
  await mkdir(dirname(destPath), { recursive: true })
473
475
  await fsCopyFile(srcPath, destPath)
@@ -485,7 +487,7 @@ export async function copyFiles(
485
487
  deleteSrc = false,
486
488
  ) {
487
489
  const files = await listFolder(event, src, isPrivate)
488
- if (!files.length) throw createError({ statusCode: 404, message: 'File not found' })
490
+ if (!files.length) throw createError({ status: 404, message: 'File not found' })
489
491
  const normalizedSrc = removeRoot(getSecurePath(src, isPrivate), isPrivate)
490
492
  const normalizedDest = removeRoot(getSecurePath(dest, isPrivate), isPrivate)
491
493
  return Promise.all(
@@ -519,7 +521,7 @@ export function getSecurePath(path: string, isPrivate = true) {
519
521
  }
520
522
 
521
523
  function invalidPath() {
522
- return createError({ statusCode: 400, message: 'Invalid path' })
524
+ return createError({ status: 400, message: 'Invalid path' })
523
525
  }
524
526
 
525
527
  function getUploadPath(path: string, isPrivate: boolean, filename?: string) {
@@ -583,7 +585,7 @@ export function parseMaxAge(value?: string) {
583
585
  if (!value) return undefined
584
586
  const maxAge = Number(value)
585
587
  if (!Number.isInteger(maxAge) || maxAge < 0) {
586
- throw createError({ statusCode: 400, message: 'Invalid maxAge' })
588
+ throw createError({ status: 400, message: 'Invalid maxAge' })
587
589
  }
588
590
  return maxAge
589
591
  }
@@ -788,7 +790,7 @@ export async function deleteFromS3(path: string, isPrivate = true) {
788
790
  const result = await client.send(command)
789
791
  if (result.Errors?.length) {
790
792
  throw createError({
791
- statusCode: 502,
793
+ status: 502,
792
794
  message: `Failed to delete ${result.Errors.length} file(s): ${result.Errors.map((e) => `${e.Key} (${e.Code})`).join(', ')}`,
793
795
  })
794
796
  }
@@ -11,5 +11,5 @@ export function getClientIP(event: H3Event): string {
11
11
  if (clientIP) return clientIP
12
12
  }
13
13
 
14
- return event.node.req.socket?.remoteAddress || 'unknown'
14
+ return getRequestIP(event) || 'unknown'
15
15
  }
@@ -0,0 +1,56 @@
1
+ import { and, eq } from 'drizzle-orm'
2
+ import { randomBytes } from 'crypto'
3
+ import { recoveryCodes } from '#server/database/schema'
4
+
5
+ const RECOVERY_CODE_COUNT = 10
6
+
7
+ function generateRecoveryCode() {
8
+ const code = randomBytes(5).toString('hex')
9
+ return `${code.slice(0, 5)}-${code.slice(5)}`
10
+ }
11
+
12
+ function normalizeRecoveryCode(code: string) {
13
+ return code.toLowerCase().replace(/[^0-9a-f]/g, '')
14
+ }
15
+
16
+ export async function generateRecoveryCodes(userId: string) {
17
+ const codes = Array.from({ length: RECOVERY_CODE_COUNT }, generateRecoveryCode)
18
+ const hashes = await Promise.all(codes.map((code) => hashPassword(normalizeRecoveryCode(code))))
19
+
20
+ await db.transaction(async (tx) => {
21
+ await tx.delete(recoveryCodes).where(eq(recoveryCodes.userId, userId))
22
+ await tx.insert(recoveryCodes).values(hashes.map((code) => ({ userId, code })))
23
+ })
24
+
25
+ return codes
26
+ }
27
+
28
+ export async function consumeRecoveryCode(userId: string, code: string) {
29
+ const normalized = normalizeRecoveryCode(code)
30
+ const rows = await db
31
+ .select({ code: recoveryCodes.code })
32
+ .from(recoveryCodes)
33
+ .where(eq(recoveryCodes.userId, userId))
34
+
35
+ let match: string | undefined
36
+ for (const row of rows) {
37
+ if (await verifyPassword(row.code, normalized)) {
38
+ match = row.code
39
+ break
40
+ }
41
+ }
42
+ if (!match) return null
43
+
44
+ const deleted = await db
45
+ .delete(recoveryCodes)
46
+ .where(and(eq(recoveryCodes.userId, userId), eq(recoveryCodes.code, match)))
47
+ .returning()
48
+ // a concurrent request used it first
49
+ if (!deleted.length) return null
50
+
51
+ return rows.length - 1
52
+ }
53
+
54
+ export function countRecoveryCodes(userId: string) {
55
+ return db.$count(recoveryCodes, eq(recoveryCodes.userId, userId))
56
+ }
package/locales/en.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "fileTooLarge": "Too large, max {max} MB: {files}",
16
16
  "formatFailed": "Could not format or upload {files}",
17
17
  "language": "Language | Languages",
18
+ "or": "or",
18
19
  "paste": "Paste",
19
20
  "rename": "Rename",
20
21
  "retry": "Retry",
@@ -31,6 +32,8 @@
31
32
  "otpSent": "Code sent to {email}",
32
33
  "passkey": "Use a passkey",
33
34
  "password": "Password",
34
- "signin": "Sign In"
35
+ "recoveryCode": "Recovery code",
36
+ "signin": "Sign In",
37
+ "useRecoveryCode": "Use a recovery code"
35
38
  }
36
39
  }
package/locales/fr.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "fileTooLarge": "Trop volumineux, max {max} Mo : {files}",
16
16
  "formatFailed": "Impossible de formater ou téléverser {files}",
17
17
  "language": "Langue | Langues",
18
+ "or": "ou",
18
19
  "paste": "Coller",
19
20
  "rename": "Renommer",
20
21
  "retry": "Réessayer",
@@ -31,6 +32,8 @@
31
32
  "otpSent": "Code envoyé à {email}",
32
33
  "passkey": "Utiliser une passkey",
33
34
  "password": "Mot de passe",
34
- "signin": "Se connecter"
35
+ "recoveryCode": "Code de récupération",
36
+ "signin": "Se connecter",
37
+ "useRecoveryCode": "Utiliser un code de récupération"
35
38
  }
36
39
  }
@@ -1,5 +1,4 @@
1
1
  {
2
2
  "continueAsGuest": "Continue as Guest",
3
- "or": "or",
4
3
  "signinWith": "Sign in with"
5
4
  }
@@ -1,5 +1,4 @@
1
1
  {
2
2
  "continueAsGuest": "Continuer en tant qu'invité",
3
- "or": "ou",
4
3
  "signinWith": "Se connecter avec"
5
4
  }
@@ -17,5 +17,15 @@
17
17
  "registerPasskey": "Add a passkey",
18
18
  "settings": "Settings",
19
19
  "setupTotp": "Use an authenticator app",
20
- "totpSecret": "Scan this QR code with your authenticator app or copy the code manually."
20
+ "totpSecret": "Scan this QR code with your authenticator app or copy the code manually.",
21
+ "copy": "Copy",
22
+ "generateRecoveryCodes": "Generate recovery codes",
23
+ "recoveryCodes": "Recovery codes",
24
+ "recoveryCodesDescription": "Single-use codes to sign in if you lose your passkey or authenticator app.",
25
+ "recoveryCodesLeft": "Codes left: {count}.",
26
+ "recoveryCodesRequireFactor": "Add a passkey or an authenticator app first.",
27
+ "recoveryCodesSave": "Save these codes somewhere safe, they will not be shown again. Each code works once.",
28
+ "recoveryCodesSaved": "I saved them",
29
+ "replaceRecoveryCodes": "Your current recovery codes will stop working. Generate new ones?",
30
+ "offerRecoveryCodes": "Generate recovery codes now? They let you sign in if you lose this device."
21
31
  }
@@ -17,5 +17,15 @@
17
17
  "registerPasskey": "Ajouter une passkey",
18
18
  "settings": "Paramètres",
19
19
  "setupTotp": "Utiliser une application d'authentification",
20
- "totpSecret": "Scannez ce code QR avec votre application d'authentification ou copiez le code manuellement."
20
+ "totpSecret": "Scannez ce code QR avec votre application d'authentification ou copiez le code manuellement.",
21
+ "copy": "Copier",
22
+ "generateRecoveryCodes": "Générer des codes de récupération",
23
+ "recoveryCodes": "Codes de récupération",
24
+ "recoveryCodesDescription": "Codes à usage unique pour vous connecter si vous perdez votre passkey ou votre application d'authentification.",
25
+ "recoveryCodesLeft": "Codes restants : {count}.",
26
+ "recoveryCodesRequireFactor": "Ajoutez d'abord une passkey ou une application d'authentification.",
27
+ "recoveryCodesSave": "Conservez ces codes en lieu sûr, ils ne seront plus affichés. Chaque code ne fonctionne qu'une fois.",
28
+ "recoveryCodesSaved": "Je les ai sauvegardés",
29
+ "replaceRecoveryCodes": "Vos codes de récupération actuels ne fonctionneront plus. En générer de nouveaux ?",
30
+ "offerRecoveryCodes": "Générer des codes de récupération maintenant ? Ils vous permettent de vous connecter si vous perdez cet appareil."
21
31
  }
package/nuxt.config.ts CHANGED
@@ -34,6 +34,8 @@ export default defineNuxtConfig({
34
34
  '/api/auth/email/verify': { limit: 10, window: 60 },
35
35
  '/api/auth/otp/verify': { limit: 10, window: 60 },
36
36
  '/api/auth/totp/verify': { limit: 10, window: 60 },
37
+ '/api/auth/recovery/verify': { limit: 10, window: 60 },
38
+ '/api/auth/recovery/generate': { limit: 5, window: 900 },
37
39
  '/api/auth/webauthn/authenticate': { limit: 10, window: 60 },
38
40
  '/api/auth/webauthn/register': { limit: 10, window: 60 },
39
41
  '/api/auth/anonymous': { limit: 5, window: 3600 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxibase",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Nuxt layer with auth, files, logs and backups",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -0,0 +1,3 @@
1
+ <title>Recovery code used</title>
2
+ <p>A recovery code was used to sign in to your account. Codes left: {{remaining}}.</p>
3
+ <p>If this was not you, change your password and generate new recovery codes.</p>
@@ -0,0 +1,8 @@
1
+ <title>Code de récupération utilisé</title>
2
+ <p>
3
+ Un code de récupération a été utilisé pour vous connecter à votre compte. Codes restants :
4
+ {{remaining}}.
5
+ </p>
6
+ <p>
7
+ Si ce n'était pas vous, changez votre mot de passe et générez de nouveaux codes de récupération.
8
+ </p>