maxibase 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +25 -0
  2. package/layers/base/app/components/AuthCheck.vue +77 -16
  3. package/layers/base/app/pages/user/auth.vue +96 -0
  4. package/layers/base/nuxt.config.ts +4 -0
  5. package/layers/base/server/api/auth/anonymous.post.ts +4 -1
  6. package/layers/base/server/api/auth/index.get.ts +1 -0
  7. package/layers/base/server/api/auth/options.post.ts +12 -9
  8. package/layers/base/server/api/auth/otp/verify.post.ts +1 -1
  9. package/layers/base/server/api/auth/recovery/generate.post.ts +13 -0
  10. package/layers/base/server/api/auth/recovery/verify.post.ts +37 -0
  11. package/layers/base/server/api/auth/totp/verify.post.ts +22 -10
  12. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +5 -0
  13. package/layers/base/server/api/auth/webauthn/register.post.ts +6 -1
  14. package/layers/base/server/database/relations.ts +10 -0
  15. package/layers/base/server/database/schema.ts +16 -0
  16. package/layers/base/server/middleware/1.session.ts +1 -0
  17. package/layers/base/server/routes/auth/microsoft.get.ts +1 -1
  18. package/layers/base/server/tasks/clean.ts +2 -1
  19. package/layers/base/server/utils/auth.ts +107 -2
  20. package/layers/base/server/utils/files.ts +14 -0
  21. package/layers/base/server/utils/recoveryCodes.ts +56 -0
  22. package/locales/en.json +3 -1
  23. package/locales/fr.json +3 -1
  24. package/locales/pages/user-auth/en.json +11 -1
  25. package/locales/pages/user-auth/fr.json +11 -1
  26. package/nuxt.config.ts +2 -0
  27. package/package.json +1 -1
  28. package/server/assets/emails/en/inactiveDeletion.html +12 -0
  29. package/server/assets/emails/en/recoveryCodeUsed.html +3 -0
  30. package/server/assets/emails/fr/inactiveDeletion.html +12 -0
  31. package/server/assets/emails/fr/recoveryCodeUsed.html +8 -0
package/README.md CHANGED
@@ -25,6 +25,31 @@ 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
+
40
+ ### Deleting inactive users
41
+
42
+ Each user's `last_active_on` is set to the day they last signed in or resumed a session. Set
43
+ `NUXT_INACTIVE_USERS_RETENTION_DAYS` and the nightly `clean` task deletes the users inactive for
44
+ longer, with everything that cascades from them and their `u/{id}` file folders, private and
45
+ public. It is `0` by default, which keeps everyone. A user whose files can't be deleted is kept
46
+ and retried the next night. A deleted user stays in the backups until they age out.
47
+
48
+ `NUXT_INACTIVE_USERS_WARNING_DAYS` (30 by default) emails users that many days before, with
49
+ their deletion date. A failed warning is retried every night but never delays the deletion,
50
+ and signing in again cancels it. When the setting is first turned on, users already past the
51
+ limit are deleted that night without a warning.
52
+
28
53
  ## Deployment
29
54
 
30
55
  ### On your server
@@ -11,6 +11,8 @@ const email = ref('')
11
11
  const password = ref('')
12
12
  const otpRequested = ref(false)
13
13
  const totp = ref('')
14
+ const recoveryCode = ref('')
15
+ const useRecoveryCode = ref(false)
14
16
  const optionsFetched = ref(false)
15
17
  const showPassword = ref(false)
16
18
  const options = ref({
@@ -67,22 +69,20 @@ async function signInWithOtp() {
67
69
  }
68
70
  }
69
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
+
70
79
  async function signInWithTOTP() {
71
80
  if (!email.value || !totp.value || !password.value) return
72
81
  isLoading.value = true
73
82
  try {
74
- const body: {
75
- email: string
76
- token: string
77
- otp?: string
78
- password?: string
79
- } = { email: email.value, token: totp.value }
80
- if (password.value.length === 6 || password.value === route.query.token) {
81
- body.otp = password.value
82
- } else body.password = password.value
83
83
  await $fetch('/api/auth/totp/verify', {
84
84
  method: 'POST',
85
- body,
85
+ body: { email: email.value, token: totp.value, ...firstFactor() },
86
86
  })
87
87
  await refreshSession()
88
88
  totp.value = ''
@@ -93,6 +93,28 @@ async function signInWithTOTP() {
93
93
  }
94
94
  }
95
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
+
96
118
  async function signInWithPassword() {
97
119
  if (!email.value || !password.value) return
98
120
  isLoading.value = true
@@ -129,7 +151,8 @@ function signIn() {
129
151
  if (!email.value || isLoading.value) return
130
152
  // paste and change bypass the browser's check, which only guards the submit
131
153
  if (!signInForm.value?.reportValidity()) return
132
- if (totp.value) signInWithTOTP()
154
+ if (useRecoveryCode.value) signInWithRecoveryCode()
155
+ else if (totp.value) signInWithTOTP()
133
156
  else if (password.value.length === 6) signInWithOtp()
134
157
  else signInWithPassword()
135
158
  }, 100)
@@ -180,7 +203,7 @@ async function getSignInOptions(email: string) {
180
203
  }
181
204
  }
182
205
 
183
- const pasteFields = { password, totp }
206
+ const pasteFields = { password, totp, recoveryCode }
184
207
 
185
208
  async function pasteTo(field: keyof typeof pasteFields) {
186
209
  try {
@@ -260,7 +283,32 @@ onBeforeUnmount(() => WebAuthnAbortService.cancelCeremony())
260
283
  </button>
261
284
  </div>
262
285
  </div>
263
- <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">
264
312
  <label for="totp">{{ $t('authCheck.authenticatorCode') }}</label>
265
313
  <div class="group flex-row">
266
314
  <input
@@ -288,7 +336,11 @@ onBeforeUnmount(() => WebAuthnAbortService.cancelCeremony())
288
336
  </div>
289
337
  <button
290
338
  type="submit"
291
- :disabled="isLoading || (!password && !otpRequested) || (options.hasTOTP && !totp)"
339
+ :disabled="
340
+ isLoading ||
341
+ (!password && !otpRequested) ||
342
+ (useRecoveryCode ? !recoveryCode : options.hasTOTP && !totp)
343
+ "
292
344
  :class="['w mt1', isLoading ? 'spin' : '']"
293
345
  >
294
346
  {{ $t('authCheck.signin') }}
@@ -297,7 +349,7 @@ onBeforeUnmount(() => WebAuthnAbortService.cancelCeremony())
297
349
  v-if="options.hasPasskey"
298
350
  type="button"
299
351
  :disabled="isLoading"
300
- class="flex-center g1 w mt1 bg bg-border"
352
+ class="flex-center g2 w mt1 bg bg-border"
301
353
  @click="signInWithPasskey(email)"
302
354
  >
303
355
  <Icon name="lucide:key-round" />
@@ -312,6 +364,16 @@ onBeforeUnmount(() => WebAuthnAbortService.cancelCeremony())
312
364
  >
313
365
  {{ $t('authCheck.getCode') }}
314
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>
315
377
  <p
316
378
  v-if="otpRequested"
317
379
  class="text-center"
@@ -349,7 +411,6 @@ onBeforeUnmount(() => WebAuthnAbortService.cancelCeremony())
349
411
  <span class="text-center muted-text my1">{{ $t('or') }}</span>
350
412
  <button
351
413
  type="button"
352
- :disabled="isLoading"
353
414
  class="provider flex-center g2 w mb1 fg b"
354
415
  style="font-weight: normal"
355
416
  @click="signInWithPasskey()"
@@ -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)
@@ -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"
@@ -74,6 +74,10 @@ export default defineNuxtConfig({
74
74
  logs: {
75
75
  retentionDays: 90,
76
76
  },
77
+ inactiveUsers: {
78
+ retentionDays: 0, // delete users inactive for this many days, 0 keeps them forever
79
+ warningDays: 30, // email users this many days before deletion, 0 deletes without warning
80
+ },
77
81
  rateLimit: {
78
82
  enabled: true,
79
83
  banMultiplier: 5, // banned after limit × multiplier requests in one window
@@ -16,7 +16,10 @@ export default defineEventHandler(async (event) => {
16
16
  })
17
17
  }
18
18
 
19
- const insertedUsers = await db.insert(auth).values({}).returning()
19
+ const insertedUsers = await db
20
+ .insert(auth)
21
+ .values({ locale: useLocaleServerMiddleware(event).current })
22
+ .returning()
20
23
 
21
24
  if (!insertedUsers[0]) {
22
25
  throw createError({
@@ -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
  })
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
26
26
  async function signUp(event: H3Event, email: string) {
27
27
  const session = await getUserSession(event)
28
28
  if (session?.user?.isAnonymous) return setAuth({ id: session.user.id, email })
29
- if (useRuntimeConfig().autoSignup) return createAuth({ email })
29
+ if (useRuntimeConfig().autoSignup) return createAuth({ email }, event)
30
30
  throw createError({ status: 404, message: 'User not found' })
31
31
  }
32
32
 
@@ -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),
@@ -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,
@@ -4,6 +4,7 @@ import {
4
4
  text,
5
5
  integer,
6
6
  timestamp,
7
+ date,
7
8
  uuid,
8
9
  boolean,
9
10
  primaryKey,
@@ -23,6 +24,9 @@ export const auth = authSchema.table('users', {
23
24
  createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
24
25
  totp: text(),
25
26
  role: appRoleEnum().notNull().default('user'),
27
+ lastActiveOn: date({ mode: 'date' }).notNull().defaultNow(),
28
+ deletionWarnedOn: date({ mode: 'date' }),
29
+ locale: text(),
26
30
  })
27
31
 
28
32
  export const refreshTokens = authSchema.table('refresh_tokens', {
@@ -49,6 +53,18 @@ export const credentials = authSchema.table(
49
53
  (table) => [primaryKey({ columns: [table.userId, table.id] })],
50
54
  )
51
55
 
56
+ export const recoveryCodes = authSchema.table(
57
+ 'recovery_codes',
58
+ {
59
+ userId: uuid()
60
+ .notNull()
61
+ .references(() => auth.id, { onDelete: 'cascade' }),
62
+ code: text().notNull(),
63
+ createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
64
+ },
65
+ (table) => [primaryKey({ columns: [table.userId, table.code] })],
66
+ )
67
+
52
68
  export const logs = authSchema.table(
53
69
  'logs',
54
70
  {
@@ -10,5 +10,6 @@ export default defineEventHandler(async (event) => {
10
10
 
11
11
  const sessionData = await setSession(event, user, false)
12
12
  session.user = sessionData?.user // to update the session on SSR
13
+ await updateLastActive(user.id)
13
14
  } catch {}
14
15
  })
@@ -15,7 +15,7 @@ async function onSuccess(event: H3Event, { tokens }: { tokens: any }) {
15
15
  const config = useRuntimeConfig()
16
16
 
17
17
  if (error.statusCode === 404 && config.autoSignup) {
18
- return createAuth({ email: claims.email })
18
+ return createAuth({ email: claims.email }, event)
19
19
  }
20
20
  throw error
21
21
  })
@@ -1,7 +1,7 @@
1
1
  export default defineTask({
2
2
  meta: {
3
3
  name: 'clean',
4
- description: 'Clean old backups, logs and unfinished uploads',
4
+ description: 'Clean old backups, logs, unfinished uploads and inactive users',
5
5
  },
6
6
  async run() {
7
7
  const config = useRuntimeConfig()
@@ -9,6 +9,7 @@ export default defineTask({
9
9
  const steps: [string, () => Promise<unknown>][] = [['backups', cleanOldBackups]]
10
10
  if (config.s3.privateBucket) steps.push(['S3 backups', cleanOldS3Backups])
11
11
  steps.push(['logs', cleanLogs], ['uploads', () => cleanTempUploads()])
12
+ if (config.inactiveUsers.retentionDays > 0) steps.push(['inactive users', cleanInactiveUsers])
12
13
 
13
14
  const results = await Promise.allSettled(steps.map(([, step]) => step()))
14
15
 
@@ -1,5 +1,5 @@
1
1
  import { auth, refreshTokens } from '#server/database/schema'
2
- import { eq, lte } from 'drizzle-orm'
2
+ import { and, eq, inArray, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm'
3
3
  import { createHash, randomBytes } from 'crypto'
4
4
  import type { H3Event } from 'h3'
5
5
 
@@ -32,11 +32,25 @@ export function requireSecondFactor(user: Pick<AuthUser, 'totp'> & { credentials
32
32
  }
33
33
  }
34
34
 
35
- export async function createAuth(user: Pick<AuthUser, 'email'>) {
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
+
48
+ export async function createAuth(user: Pick<AuthUser, 'email'>, event: H3Event) {
36
49
  const insertedUsers = await db
37
50
  .insert(auth)
38
51
  .values({
39
52
  email: user.email?.trim().toLowerCase(),
53
+ locale: useLocaleServerMiddleware(event).current,
40
54
  })
41
55
  .returning()
42
56
  if (!insertedUsers[0]) {
@@ -149,8 +163,99 @@ export function hashRefreshToken(token: string) {
149
163
  return createHash('sha256').update(token).digest('base64url')
150
164
  }
151
165
 
166
+ export async function updateLastActive(userId: string) {
167
+ await db
168
+ .update(auth)
169
+ .set({ lastActiveOn: sql`current_date` })
170
+ .where(and(eq(auth.id, userId), lt(auth.lastActiveOn, sql`current_date`)))
171
+ }
172
+
173
+ export async function cleanInactiveUsers() {
174
+ const { retentionDays, warningDays } = useRuntimeConfig().inactiveUsers
175
+ const inactive = lt(auth.lastActiveOn, sql`current_date - ${retentionDays}::int`)
176
+
177
+ const users = await db.select({ id: auth.id }).from(auth).where(inactive)
178
+ const ids: string[] = []
179
+ for (const { id } of users) {
180
+ await deleteUserFiles(id).then(
181
+ () => ids.push(id),
182
+ () => {},
183
+ )
184
+ }
185
+
186
+ if (ids.length) {
187
+ const deleted = await db
188
+ .delete(auth)
189
+ .where(and(inArray(auth.id, ids), inactive))
190
+ .returning({ id: auth.id })
191
+ await log(
192
+ `Deleted ${deleted.length} users inactive for ${retentionDays} days`,
193
+ undefined,
194
+ 'clean task',
195
+ )
196
+ }
197
+
198
+ const failedWarnings = warningDays > 0 ? await warnInactiveUsers(retentionDays, warningDays) : 0
199
+
200
+ const errors = []
201
+ if (users.length > ids.length) {
202
+ errors.push(`files of ${users.length - ids.length} users could not be deleted`)
203
+ }
204
+ if (failedWarnings) errors.push(`${failedWarnings} warnings failed to send`)
205
+ if (errors.length) throw new Error(`Inactive users: ${errors.join(', ')}`)
206
+ }
207
+
208
+ async function warnInactiveUsers(retentionDays: number, warningDays: number) {
209
+ const warnAfterDays = Math.max(retentionDays - warningDays, 0)
210
+
211
+ const users = await db
212
+ .select({
213
+ id: auth.id,
214
+ email: auth.email,
215
+ locale: auth.locale,
216
+ lastActiveOn: auth.lastActiveOn,
217
+ })
218
+ .from(auth)
219
+ .where(
220
+ and(
221
+ lt(auth.lastActiveOn, sql`current_date - ${warnAfterDays}::int`),
222
+ isNotNull(auth.email),
223
+ or(isNull(auth.deletionWarnedOn), lte(auth.deletionWarnedOn, auth.lastActiveOn)),
224
+ ),
225
+ )
226
+
227
+ let failed = 0
228
+ for (const user of users) {
229
+ // The first nightly run where lastActiveOn < current_date - retentionDays
230
+ const deletionOn = new Date(user.lastActiveOn.getTime() + daysToMilliseconds(retentionDays + 1))
231
+ const locale = user.locale ?? 'en'
232
+ const date = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(
233
+ deletionOn,
234
+ )
235
+ try {
236
+ await sendEmailTemplate('inactiveDeletion', locale, { date }, user.email!)
237
+ await db
238
+ .update(auth)
239
+ .set({ deletionWarnedOn: sql`current_date` })
240
+ .where(eq(auth.id, user.id))
241
+ } catch {
242
+ failed++
243
+ }
244
+ }
245
+
246
+ if (users.length > failed) {
247
+ await log(
248
+ `Warned ${users.length - failed} inactive users of their deletion`,
249
+ undefined,
250
+ 'clean task',
251
+ )
252
+ }
253
+ return failed
254
+ }
255
+
152
256
  export async function createRefreshToken(userId: string, event: H3Event) {
153
257
  const token = generateRefreshToken()
258
+ await updateLastActive(userId)
154
259
 
155
260
  await db.insert(refreshTokens).values({
156
261
  userId,
@@ -386,6 +386,20 @@ export async function getFile(
386
386
 
387
387
  export async function deletePath(event: H3Event, path: string, isPrivate = true) {
388
388
  await checkFileAccess(event, path)
389
+ await removePath(path, isPrivate)
390
+ }
391
+
392
+ // The u/{id} folders of the default checkFileAccess, private and public
393
+ export async function deleteUserFiles(userId: string) {
394
+ const { s3 } = useRuntimeConfig()
395
+ for (const isPrivate of [true, false]) {
396
+ if (useS3() && !(isPrivate ? s3.privateBucket : s3.publicBucket)) continue
397
+ await removePath(`u/${userId}`, isPrivate)
398
+ }
399
+ }
400
+
401
+ // No access check, for server code acting on its own
402
+ async function removePath(path: string, isPrivate: boolean) {
389
403
  path = getSecurePath(path, isPrivate)
390
404
  // An empty path resolves to the root folder, deleting it would wipe every file
391
405
  if (path === getSecurePath('', isPrivate)) throw invalidPath()
@@ -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
@@ -32,6 +32,8 @@
32
32
  "otpSent": "Code sent to {email}",
33
33
  "passkey": "Use a passkey",
34
34
  "password": "Password",
35
- "signin": "Sign In"
35
+ "recoveryCode": "Recovery code",
36
+ "signin": "Sign In",
37
+ "useRecoveryCode": "Use a recovery code"
36
38
  }
37
39
  }
package/locales/fr.json CHANGED
@@ -32,6 +32,8 @@
32
32
  "otpSent": "Code envoyé à {email}",
33
33
  "passkey": "Utiliser une passkey",
34
34
  "password": "Mot de passe",
35
- "signin": "Se connecter"
35
+ "recoveryCode": "Code de récupération",
36
+ "signin": "Se connecter",
37
+ "useRecoveryCode": "Utiliser un code de récupération"
36
38
  }
37
39
  }
@@ -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.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Nuxt layer with auth, files, logs and backups",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -0,0 +1,12 @@
1
+ <title>Your {{appName}} account will be deleted</title>
2
+ <p>
3
+ You haven't used {{appName}} for a long time, so your account and its data will be deleted on
4
+ {{date}}.
5
+ </p>
6
+ <p>To keep them, sign in before then.</p>
7
+ <a
8
+ href="{{url}}"
9
+ class="button"
10
+ >
11
+ Sign in
12
+ </a>
@@ -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,12 @@
1
+ <title>Votre compte {{appName}} va être supprimé</title>
2
+ <p>
3
+ Vous n'avez pas utilisé {{appName}} depuis longtemps, votre compte et ses données seront donc
4
+ supprimés le {{date}}.
5
+ </p>
6
+ <p>Pour les conserver, connectez-vous d'ici là.</p>
7
+ <a
8
+ href="{{url}}"
9
+ class="button"
10
+ >
11
+ Se connecter
12
+ </a>
@@ -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>