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.
- package/README.md +12 -0
- package/layers/base/app/components/AuthCheck.vue +126 -25
- package/layers/base/app/pages/signin.vue +0 -10
- package/layers/base/app/pages/user/auth.vue +97 -1
- package/layers/base/server/api/auth/index.get.ts +1 -0
- package/layers/base/server/api/auth/options.post.ts +12 -9
- package/layers/base/server/api/auth/recovery/generate.post.ts +13 -0
- package/layers/base/server/api/auth/recovery/verify.post.ts +37 -0
- package/layers/base/server/api/auth/totp/verify.post.ts +22 -10
- package/layers/base/server/api/auth/webauthn/authenticate.post.ts +5 -0
- package/layers/base/server/api/auth/webauthn/register.post.ts +6 -1
- package/layers/base/server/api/files/[...path].get.ts +1 -1
- package/layers/base/server/api/files/chunk.post.ts +13 -9
- package/layers/base/server/api/files/index.post.ts +10 -11
- package/layers/base/server/database/defaults/access.ts +2 -2
- package/layers/base/server/database/relations.ts +10 -0
- package/layers/base/server/database/schema.ts +12 -0
- package/layers/base/server/middleware/2.mfa.ts +1 -1
- package/layers/base/server/routes/dev/email/[template].ts +2 -2
- package/layers/base/server/utils/access.ts +1 -1
- package/layers/base/server/utils/auth.ts +14 -1
- package/layers/base/server/utils/files.ts +25 -23
- package/layers/base/server/utils/ip.ts +1 -1
- package/layers/base/server/utils/recoveryCodes.ts +56 -0
- package/locales/en.json +4 -1
- package/locales/fr.json +4 -1
- package/locales/pages/signin/en.json +0 -1
- package/locales/pages/signin/fr.json +0 -1
- package/locales/pages/user-auth/en.json +11 -1
- package/locales/pages/user-auth/fr.json +11 -1
- package/nuxt.config.ts +2 -0
- package/package.json +1 -1
- package/server/assets/emails/en/recoveryCodeUsed.html +3 -0
- 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 (
|
|
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
|
|
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(
|
|
173
|
+
await (autofill ? authenticateWithAutofill() : authenticate(userName))
|
|
144
174
|
await refreshSession()
|
|
145
175
|
} catch (e: any) {
|
|
146
|
-
|
|
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="
|
|
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="
|
|
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
|
|
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
|
-
<
|
|
407
|
+
<div
|
|
333
408
|
v-if="!optionsFetched"
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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),
|
|
@@ -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
|
-
|
|
29
|
-
|
|
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.
|
|
42
|
-
|
|
43
|
-
if (
|
|
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.
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
.
|
|
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({
|
|
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({
|
|
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
|
|
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
|
-
|
|
4
|
+
status: 404,
|
|
5
5
|
message: 'Not found',
|
|
6
6
|
})
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
const templateId = event
|
|
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({
|
|
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
|
-
|
|
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
|
|
33
|
-
import type { H3Event
|
|
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({
|
|
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({
|
|
47
|
+
if (!length) throw createError({ status: 411, message: 'Content-Length required' })
|
|
48
48
|
if (Number(length) > (maxMB + 1) * MB) throw fileTooLarge()
|
|
49
|
-
return
|
|
49
|
+
return readFormData(event).catch(() => undefined)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export async function uploadFile(
|
|
53
53
|
event: H3Event,
|
|
54
|
-
file:
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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({
|
|
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({
|
|
339
|
+
throw createError({ status: 416, message: 'Range not satisfiable' })
|
|
339
340
|
}
|
|
340
|
-
if (response.status === 404) throw createError({
|
|
341
|
-
if (!response.ok) throw createError({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
35
|
+
"recoveryCode": "Code de récupération",
|
|
36
|
+
"signin": "Se connecter",
|
|
37
|
+
"useRecoveryCode": "Utiliser un code de récupération"
|
|
35
38
|
}
|
|
36
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
|
@@ -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>
|