maxibase 1.0.0 → 1.1.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 +25 -4
- package/docker/Dockerfile +3 -3
- package/docker/compose.yaml +2 -2
- package/layers/base/app/components/AuthCheck.vue +89 -50
- package/layers/base/app/components/files/FilesUpload.vue +9 -0
- package/layers/base/app/pages/user/auth.vue +3 -3
- package/layers/base/app/stores/app.ts +4 -9
- package/layers/base/nuxt.config.ts +4 -3
- package/layers/base/server/api/admin/logs.get.ts +1 -2
- package/layers/base/server/api/auth/otp/get.post.ts +0 -6
- package/layers/base/server/api/auth/otp/verify.post.ts +37 -39
- package/layers/base/server/api/auth/password.post.ts +2 -7
- package/layers/base/server/api/auth/refresh.post.ts +2 -2
- package/layers/base/server/api/auth/totp/confirm.post.ts +1 -7
- package/layers/base/server/api/auth/totp/verify.post.ts +15 -6
- package/layers/base/server/api/auth/webauthn/authenticate.post.ts +8 -2
- package/layers/base/server/api/files/[...path].get.ts +23 -14
- package/layers/base/server/api/files/chunk.post.ts +25 -4
- package/layers/base/server/api/files/index.get.ts +1 -3
- package/layers/base/server/api/files/index.post.ts +1 -1
- package/layers/base/server/database/defaults/db.ts +9 -1
- package/layers/base/server/database/relations.ts +1 -1
- package/layers/base/server/middleware/0.rateLimit.ts +1 -1
- package/layers/base/server/routes/auth/microsoft.get.ts +30 -17
- package/layers/base/server/tasks/clean.ts +2 -1
- package/layers/base/server/utils/auth.ts +27 -5
- package/layers/base/server/utils/backup.ts +1 -1
- package/layers/base/server/utils/files.ts +129 -14
- package/layers/base/server/utils/rateLimit.ts +7 -2
- package/locales/en.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/pages/user-auth/en.json +0 -1
- package/locales/pages/user-auth/fr.json +0 -1
- package/nuxt.config.ts +1 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -10,6 +10,21 @@ Setup your env file by editing the `.env.example` file and rename it to `.env`.
|
|
|
10
10
|
|
|
11
11
|
Use the route `http://localhost:3000/dev/email/[templateId]` to see the email while you edit. You can put the locale or any parameter in the query.
|
|
12
12
|
|
|
13
|
+
### Microsoft sign-in
|
|
14
|
+
|
|
15
|
+
In your Azure app registration, go to Token configuration → Add optional claim → ID and add
|
|
16
|
+
`email` and `xms_edov`. Without them every Microsoft sign-in is refused.
|
|
17
|
+
|
|
18
|
+
A Microsoft account signs in to the user with the same email, so the email is only trusted
|
|
19
|
+
when `xms_edov` says its domain is verified in the account's tenant. Any tenant can write any
|
|
20
|
+
address in a user's `mail`, so trusting it unverified lets them sign in as anyone.
|
|
21
|
+
|
|
22
|
+
The tenant defaults to `organizations`: any work or school account, no personal ones. Set
|
|
23
|
+
`NUXT_OAUTH_MICROSOFT_TENANT=common` to also accept personal accounts.
|
|
24
|
+
|
|
25
|
+
`/auth/microsoft` signs in with the Microsoft account already signed in, link to
|
|
26
|
+
`/auth/microsoft?prompt=select_account` to let the user pick another one.
|
|
27
|
+
|
|
13
28
|
## Deployment
|
|
14
29
|
|
|
15
30
|
### On your server
|
|
@@ -39,6 +54,9 @@ location /files/ {
|
|
|
39
54
|
# Optional: Hide S3 headers for security
|
|
40
55
|
proxy_hide_header x-amz-request-id;
|
|
41
56
|
proxy_hide_header x-amz-id-2;
|
|
57
|
+
|
|
58
|
+
add_header X-Content-Type-Options nosniff always;
|
|
59
|
+
add_header Content-Security-Policy sandbox always;
|
|
42
60
|
}
|
|
43
61
|
```
|
|
44
62
|
|
|
@@ -47,6 +65,8 @@ location /files/ {
|
|
|
47
65
|
```
|
|
48
66
|
location /files/ {
|
|
49
67
|
alias /home/debian/project/volumes/files/public/;
|
|
68
|
+
add_header X-Content-Type-Options nosniff always;
|
|
69
|
+
add_header Content-Security-Policy sandbox always;
|
|
50
70
|
}
|
|
51
71
|
```
|
|
52
72
|
|
|
@@ -63,7 +83,7 @@ Each build is tagged `{utc-timestamp}-{commit}` and the last 5 stay on the serve
|
|
|
63
83
|
Roll back with `pnpm rollback` (the version before the live one), `pnpm rollback {tag}`, or
|
|
64
84
|
list what is available with `pnpm rollback --list`. Rolling back does not undo migrations.
|
|
65
85
|
|
|
66
|
-
Push your migration through SSH with `pnpm db:push-
|
|
86
|
+
Push your migration through SSH with `pnpm db:push-prod`.
|
|
67
87
|
|
|
68
88
|
In a project that uses the layer, these scripts run from the package, see [Scripts](#scripts).
|
|
69
89
|
|
|
@@ -204,8 +224,9 @@ The tables of the `auth` schema ship with the layer, in `layers/base/server/data
|
|
|
204
224
|
Your `server/database/schema.ts` re-exports them and adds your own tables, so `pnpm update maxibase`
|
|
205
225
|
brings their changes: run `pnpm db:generate` after an update. To customise one, copy it into your
|
|
206
226
|
schema, a table declared in your file takes precedence over the package's one with the same name.
|
|
207
|
-
Their relations come from `authRelations
|
|
208
|
-
|
|
227
|
+
Their relations come from `authRelations` in `layers/base/server/database/relations.ts`. To add a
|
|
228
|
+
relation to one of these tables, define the table in your `relations.ts`: your entry replaces the
|
|
229
|
+
layer's for that table, so repeat the layer's relations you still need there.
|
|
209
230
|
|
|
210
231
|
`db.ts`, `relations.ts` and `access.ts` in `server/database` are optional, the layer has a default
|
|
211
232
|
for each in `layers/base/server/database/defaults`: no relations of your own, and file access to
|
|
@@ -229,7 +250,7 @@ A project's `package.json` looks like this. Add any package your own files impor
|
|
|
229
250
|
"db:seed": "npx jiti ./server/database/seed.ts",
|
|
230
251
|
"db:generate": "drizzle-kit generate",
|
|
231
252
|
"db:push": "drizzle-kit push",
|
|
232
|
-
"db:push-
|
|
253
|
+
"db:push-prod": "bash node_modules/maxibase/scripts/db/push-server.sh",
|
|
233
254
|
"db:restore": "bash node_modules/maxibase/scripts/db/restore.sh",
|
|
234
255
|
"db:migrate": "npx jiti node_modules/maxibase/scripts/db/migrate.ts",
|
|
235
256
|
"db:studio": "drizzle-kit studio",
|
package/docker/Dockerfile
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
FROM node:
|
|
1
|
+
FROM node:26-alpine AS build
|
|
2
2
|
|
|
3
|
-
RUN
|
|
3
|
+
RUN npm install -g corepack && corepack enable
|
|
4
4
|
|
|
5
5
|
WORKDIR /app
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ COPY . ./
|
|
|
12
12
|
|
|
13
13
|
RUN pnpm build
|
|
14
14
|
|
|
15
|
-
FROM node:
|
|
15
|
+
FROM node:26-alpine
|
|
16
16
|
WORKDIR /app
|
|
17
17
|
|
|
18
18
|
RUN apk add --no-cache postgresql-client age
|
package/docker/compose.yaml
CHANGED
|
@@ -5,8 +5,8 @@ services:
|
|
|
5
5
|
env_file:
|
|
6
6
|
- .env
|
|
7
7
|
volumes:
|
|
8
|
-
- ./volumes/files/public:/app/${NUXT_FILES_PUBLIC_FOLDER:-files}
|
|
9
|
-
- ./volumes/files/private:/app/${NUXT_FILES_PRIVATE_FOLDER:-files}
|
|
8
|
+
- ./volumes/files/public:/app/${NUXT_FILES_PUBLIC_FOLDER:-files/public}
|
|
9
|
+
- ./volumes/files/private:/app/${NUXT_FILES_PRIVATE_FOLDER:-files/private}
|
|
10
10
|
- ./volumes/backups:/app/backups
|
|
11
11
|
ports:
|
|
12
12
|
- ${SERVER_PORT:-3000}:3000
|
|
@@ -112,15 +112,25 @@ function handleError(error: any) {
|
|
|
112
112
|
appStore.notify(error.data?.message || 'Bad credentials', 'error')
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
const signInForm = useTemplateRef<HTMLFormElement>('signInForm')
|
|
116
|
+
let signInTimer: ReturnType<typeof setTimeout> | undefined
|
|
117
|
+
|
|
118
|
+
watch(totp, (value) => {
|
|
119
|
+
const digits = value.replace(/\s/g, '')
|
|
120
|
+
if (digits !== value) totp.value = digits
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Paste, change and submit can fire together: wait for the pasted value, then sign in once
|
|
115
124
|
function signIn() {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
clearTimeout(signInTimer)
|
|
126
|
+
signInTimer = setTimeout(() => {
|
|
127
|
+
if (!email.value || isLoading.value) return
|
|
128
|
+
// paste and change bypass the browser's check, which only guards the submit
|
|
129
|
+
if (!signInForm.value?.reportValidity()) return
|
|
119
130
|
if (totp.value) signInWithTOTP()
|
|
120
|
-
else if (!password.value) requestOtp()
|
|
121
131
|
else if (password.value.length === 6) signInWithOtp()
|
|
122
132
|
else signInWithPassword()
|
|
123
|
-
}, 100)
|
|
133
|
+
}, 100)
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
const { authenticate } = useWebAuthn({
|
|
@@ -162,9 +172,11 @@ async function getSignInOptions(email: string) {
|
|
|
162
172
|
}
|
|
163
173
|
}
|
|
164
174
|
|
|
165
|
-
|
|
175
|
+
const pasteFields = { password, totp }
|
|
176
|
+
|
|
177
|
+
async function pasteTo(field: keyof typeof pasteFields) {
|
|
166
178
|
try {
|
|
167
|
-
|
|
179
|
+
pasteFields[field].value = (await navigator.clipboard.readText()).trim()
|
|
168
180
|
signIn()
|
|
169
181
|
} catch (e) {
|
|
170
182
|
appStore.notify($t('authCheck.clipboardPermission') as string, 'error')
|
|
@@ -188,6 +200,7 @@ onMounted(async () => {
|
|
|
188
200
|
<slot name="header"></slot>
|
|
189
201
|
<form
|
|
190
202
|
v-if="optionsFetched"
|
|
203
|
+
ref="signInForm"
|
|
191
204
|
class="flex-column g2"
|
|
192
205
|
@submit.prevent="signIn"
|
|
193
206
|
>
|
|
@@ -197,52 +210,69 @@ onMounted(async () => {
|
|
|
197
210
|
:value="email"
|
|
198
211
|
autocomplete="username email"
|
|
199
212
|
/>
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
213
|
+
<div>
|
|
214
|
+
<label for="password">
|
|
215
|
+
{{
|
|
216
|
+
options.hasPassword && !otpRequested ? $t('authCheck.password') : $t('authCheck.code')
|
|
217
|
+
}}
|
|
218
|
+
</label>
|
|
219
|
+
<div class="group flex-row">
|
|
220
|
+
<input
|
|
221
|
+
id="password"
|
|
222
|
+
v-model.trim="password"
|
|
223
|
+
:type="showPassword ? 'text' : 'password'"
|
|
224
|
+
:disabled="isLoading"
|
|
225
|
+
autocomplete="current-password"
|
|
226
|
+
:pattern="
|
|
227
|
+
otpRequested
|
|
228
|
+
? '[0-9]{6}'
|
|
229
|
+
: `[0-9]{6}|.{${config.public.password.min},${config.public.password.max}}`
|
|
230
|
+
"
|
|
231
|
+
required
|
|
232
|
+
@paste="signIn"
|
|
233
|
+
/>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
class="flex-center fg"
|
|
237
|
+
@click="showPassword = !showPassword"
|
|
238
|
+
>
|
|
239
|
+
<Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" />
|
|
240
|
+
</button>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
class="flex-center fg"
|
|
244
|
+
:title="$t('paste') as string"
|
|
245
|
+
@click="pasteTo('password')"
|
|
246
|
+
>
|
|
247
|
+
<Icon name="lucide:clipboard-paste" />
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
234
250
|
</div>
|
|
235
251
|
<div v-if="options.hasTOTP">
|
|
236
252
|
<label for="totp">{{ $t('authCheck.authenticatorCode') }}</label>
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
253
|
+
<div class="group flex-row">
|
|
254
|
+
<input
|
|
255
|
+
id="totp"
|
|
256
|
+
v-model.trim="totp"
|
|
257
|
+
type="text"
|
|
258
|
+
:disabled="isLoading || !password"
|
|
259
|
+
autocomplete="one-time-code"
|
|
260
|
+
inputmode="numeric"
|
|
261
|
+
pattern="[0-9]{6}"
|
|
262
|
+
required
|
|
263
|
+
@paste="signIn"
|
|
264
|
+
@change="signIn"
|
|
265
|
+
/>
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
class="flex-center fg"
|
|
269
|
+
:title="$t('paste') as string"
|
|
270
|
+
:disabled="isLoading || !password"
|
|
271
|
+
@click="pasteTo('totp')"
|
|
272
|
+
>
|
|
273
|
+
<Icon name="lucide:clipboard-paste" />
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
246
276
|
</div>
|
|
247
277
|
<button
|
|
248
278
|
type="submit"
|
|
@@ -251,6 +281,15 @@ onMounted(async () => {
|
|
|
251
281
|
>
|
|
252
282
|
{{ $t('authCheck.signin') }}
|
|
253
283
|
</button>
|
|
284
|
+
<button
|
|
285
|
+
v-if="options.hasPasskey"
|
|
286
|
+
type="button"
|
|
287
|
+
:disabled="isLoading"
|
|
288
|
+
class="flex-center g1 w mt1 bg bg-border"
|
|
289
|
+
@click="signInWithPasskey"
|
|
290
|
+
>
|
|
291
|
+
{{ $t('authCheck.passkey') }}
|
|
292
|
+
</button>
|
|
254
293
|
<button
|
|
255
294
|
v-if="!otpRequested"
|
|
256
295
|
type="button"
|
|
@@ -47,6 +47,15 @@ async function uploadFile(event: Event) {
|
|
|
47
47
|
appStore.notify('formatFailed', 'error', false, { files: failed.join(', ') })
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
const maxSize = Number(useRuntimeConfig().public.files.maxSize)
|
|
51
|
+
const tooLarge = files.filter((file) => file.size > maxSize * 1024 * 1024)
|
|
52
|
+
if (tooLarge.length) {
|
|
53
|
+
appStore.notify('fileTooLarge', 'error', false, {
|
|
54
|
+
files: tooLarge.map((file) => file.name).join(', '),
|
|
55
|
+
max: maxSize,
|
|
56
|
+
})
|
|
57
|
+
files = files.filter((file) => !tooLarge.includes(file))
|
|
58
|
+
}
|
|
50
59
|
if (files.length) {
|
|
51
60
|
let response = await uploadFiles(files, props.path, props.isPrivate, (p) => {
|
|
52
61
|
progress.value = p
|
|
@@ -38,7 +38,7 @@ const { $getLocale, $t } = useI18n()
|
|
|
38
38
|
const isMfaSetup = computed(() => user.value?.requiresMfaSetup === true)
|
|
39
39
|
|
|
40
40
|
async function checkAuth() {
|
|
41
|
-
return
|
|
41
|
+
return appStore.checkAuth()
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const isCurrentEmail = computed(() => {
|
|
@@ -424,14 +424,14 @@ onMounted(getAuth)
|
|
|
424
424
|
/>
|
|
425
425
|
<button
|
|
426
426
|
type="button"
|
|
427
|
-
class="bg danger-text flex"
|
|
427
|
+
class="bg danger-text flex-center"
|
|
428
428
|
:disabled="!canRemovePasskey"
|
|
429
429
|
:title="canRemovePasskey ? '' : $t('lastFactor')"
|
|
430
430
|
@click="deletePasskey(credential.id)"
|
|
431
431
|
>
|
|
432
432
|
<Icon name="lucide:trash" />
|
|
433
433
|
</button>
|
|
434
|
-
<button class="flex fg"><Icon name="lucide:save" /></button>
|
|
434
|
+
<button class="flex-center fg"><Icon name="lucide:save" /></button>
|
|
435
435
|
</div>
|
|
436
436
|
</form>
|
|
437
437
|
<button
|
|
@@ -15,7 +15,7 @@ export const useAppStore = defineStore('app', () => {
|
|
|
15
15
|
reject: (value?: any) => any
|
|
16
16
|
} | null>(null)
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const { session } = useUserSession()
|
|
19
19
|
const authPromise = ref()
|
|
20
20
|
|
|
21
21
|
function confirm(message?: string) {
|
|
@@ -32,19 +32,14 @@ export const useAppStore = defineStore('app', () => {
|
|
|
32
32
|
|
|
33
33
|
async function checkAuth(): Promise<boolean> {
|
|
34
34
|
const reverifyAfter = useRuntimeConfig().public.recentAuth.maxAge * 1000
|
|
35
|
-
if (Date.now() -
|
|
35
|
+
if (Date.now() - (session.value?.authenticatedAt ?? 0) < reverifyAfter) {
|
|
36
36
|
return true
|
|
37
37
|
}
|
|
38
38
|
return new Promise<boolean>((resolve) => {
|
|
39
39
|
authPromise.value = { resolve }
|
|
40
|
+
}).finally(() => {
|
|
41
|
+
authPromise.value = null
|
|
40
42
|
})
|
|
41
|
-
.then((verified) => {
|
|
42
|
-
if (verified) authVerifiedAt.value = Date.now()
|
|
43
|
-
return verified
|
|
44
|
-
})
|
|
45
|
-
.finally(() => {
|
|
46
|
-
authPromise.value = null
|
|
47
|
-
})
|
|
48
43
|
}
|
|
49
44
|
|
|
50
45
|
function setLoading(state: boolean) {
|
|
@@ -27,6 +27,7 @@ export default defineNuxtConfig({
|
|
|
27
27
|
files: {
|
|
28
28
|
url: '/files',
|
|
29
29
|
chunkSize: 5, // in MB
|
|
30
|
+
maxSize: 100, // in MB
|
|
30
31
|
},
|
|
31
32
|
},
|
|
32
33
|
session: {
|
|
@@ -38,7 +39,7 @@ export default defineNuxtConfig({
|
|
|
38
39
|
microsoft: {
|
|
39
40
|
clientId: '',
|
|
40
41
|
clientSecret: '',
|
|
41
|
-
tenant: '
|
|
42
|
+
tenant: 'organizations',
|
|
42
43
|
},
|
|
43
44
|
},
|
|
44
45
|
refreshToken: {
|
|
@@ -66,7 +67,7 @@ export default defineNuxtConfig({
|
|
|
66
67
|
secretAccessKey: '',
|
|
67
68
|
},
|
|
68
69
|
backup: {
|
|
69
|
-
dumpArgs: '-F c',
|
|
70
|
+
dumpArgs: '-F c --exclude-table-data=auth.refresh_tokens',
|
|
70
71
|
retentionDays: 30,
|
|
71
72
|
agePublicKey: '', // age public key to encrypt backups
|
|
72
73
|
},
|
|
@@ -88,7 +89,7 @@ export default defineNuxtConfig({
|
|
|
88
89
|
},
|
|
89
90
|
scheduledTasks: {
|
|
90
91
|
'0 3 * * *': 'backup',
|
|
91
|
-
'0 2
|
|
92
|
+
'0 2 * * *': 'clean',
|
|
92
93
|
},
|
|
93
94
|
storage: {
|
|
94
95
|
auth: {
|
|
@@ -9,8 +9,7 @@ const querySchema = z.object({
|
|
|
9
9
|
})
|
|
10
10
|
|
|
11
11
|
export default defineEventHandler(async (event) => {
|
|
12
|
-
const
|
|
13
|
-
const { from, to, search, type, limit } = querySchema.parse(query)
|
|
12
|
+
const { from, to, search, type, limit } = await getValidatedQuery(event, querySchema.parse)
|
|
14
13
|
const text = prepareStringForSearch(search)
|
|
15
14
|
|
|
16
15
|
const rows = await db.query.logs.findMany({
|
|
@@ -22,12 +22,6 @@ export default defineEventHandler(async (event) => {
|
|
|
22
22
|
goto,
|
|
23
23
|
path = 'signin',
|
|
24
24
|
} = await readValidatedBody(event, bodySchema.parse)
|
|
25
|
-
if (!email || typeof email !== 'string') {
|
|
26
|
-
throw createError({
|
|
27
|
-
status: 400,
|
|
28
|
-
message: 'Email is required',
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
25
|
|
|
32
26
|
const storage = useStorage('auth')
|
|
33
27
|
const record = await storage.getItem<OTP>(email)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
2
3
|
import type { OTP } from './get.post'
|
|
3
4
|
|
|
4
5
|
const bodySchema = z.object({
|
|
@@ -8,47 +9,44 @@ const bodySchema = z.object({
|
|
|
8
9
|
|
|
9
10
|
export default defineEventHandler(async (event) => {
|
|
10
11
|
const { email, otp } = await readValidatedBody(event, bodySchema.parse)
|
|
11
|
-
if (!email || !otp) {
|
|
12
|
-
throw createError({
|
|
13
|
-
status: 400,
|
|
14
|
-
message: 'Email and OTP are required',
|
|
15
|
-
})
|
|
16
|
-
}
|
|
17
|
-
await verifyOTP(email, otp)
|
|
18
12
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
id: session.user.id,
|
|
25
|
-
email,
|
|
26
|
-
}
|
|
27
|
-
await setAuth(user)
|
|
28
|
-
}
|
|
29
|
-
return await setSession(event, user)
|
|
30
|
-
}
|
|
13
|
+
const existing = await getAuth(event, email, true).catch((error) => {
|
|
14
|
+
if (error.statusCode !== 404) throw error
|
|
15
|
+
return null
|
|
16
|
+
})
|
|
17
|
+
if (existing) requireSecondFactor(existing)
|
|
31
18
|
|
|
32
|
-
|
|
33
|
-
const config = useRuntimeConfig()
|
|
19
|
+
await verifyOTP(email, otp)
|
|
34
20
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
throw error
|
|
39
|
-
})
|
|
40
|
-
if (user.totp) {
|
|
41
|
-
throw createError({
|
|
42
|
-
status: 401,
|
|
43
|
-
message: 'TOTP required',
|
|
44
|
-
})
|
|
45
|
-
}
|
|
46
|
-
await setSession(event, user)
|
|
21
|
+
const user = existing ?? (await signUp(event, email))
|
|
22
|
+
requireSecondFactor(user) // an anonymous account can already have a TOTP
|
|
23
|
+
return setSession(event, user)
|
|
47
24
|
})
|
|
48
25
|
|
|
26
|
+
async function signUp(event: H3Event, email: string) {
|
|
27
|
+
const session = await getUserSession(event)
|
|
28
|
+
if (session?.user?.isAnonymous) return setAuth({ id: session.user.id, email })
|
|
29
|
+
if (useRuntimeConfig().autoSignup) return createAuth({ email })
|
|
30
|
+
throw createError({ status: 404, message: 'User not found' })
|
|
31
|
+
}
|
|
32
|
+
|
|
49
33
|
const MAX_OTP_ATTEMPTS = 3
|
|
50
34
|
|
|
51
|
-
|
|
35
|
+
const verifying = new Set<string>()
|
|
36
|
+
|
|
37
|
+
export async function verifyOTP(email: string, otp: string, consume = true): Promise<void> {
|
|
38
|
+
if (verifying.has(email)) {
|
|
39
|
+
throw createError({ status: 429, message: 'Verification already in progress.' })
|
|
40
|
+
}
|
|
41
|
+
verifying.add(email)
|
|
42
|
+
try {
|
|
43
|
+
await checkOTP(email, otp, consume)
|
|
44
|
+
} finally {
|
|
45
|
+
verifying.delete(email)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function checkOTP(email: string, otp: string, consume: boolean) {
|
|
52
50
|
const storage = useStorage('auth')
|
|
53
51
|
const record = await storage.getItem<OTP>(email)
|
|
54
52
|
if (!record) {
|
|
@@ -59,12 +57,12 @@ export async function verifyOTP(email: string, otp: string): Promise<void> {
|
|
|
59
57
|
throw createError({ status: 400, message: 'Request a new OTP.' })
|
|
60
58
|
}
|
|
61
59
|
|
|
60
|
+
record.attempts++
|
|
61
|
+
await storage.setItem(email, record)
|
|
62
|
+
|
|
62
63
|
const hash = otp.length > 6 ? record.token : record.otp
|
|
63
|
-
if (hash
|
|
64
|
-
await storage.removeItem(email)
|
|
65
|
-
} else {
|
|
66
|
-
record.attempts++
|
|
67
|
-
await storage.setItem(email, record)
|
|
64
|
+
if (!hash || !(await verifyPassword(hash, otp))) {
|
|
68
65
|
throw createError({ status: 400, message: 'Invalid OTP.' })
|
|
69
66
|
}
|
|
67
|
+
if (consume) await storage.removeItem(email)
|
|
70
68
|
}
|
|
@@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|
|
16
16
|
}).parse,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
-
const user = await getAuth(event, email).catch((error: any) => {
|
|
19
|
+
const user = await getAuth(event, email, true).catch((error: any) => {
|
|
20
20
|
if (error?.statusCode === 404) return null
|
|
21
21
|
throw error
|
|
22
22
|
})
|
|
@@ -31,12 +31,7 @@ export default defineEventHandler(async (event) => {
|
|
|
31
31
|
throw badCredentials()
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
throw createError({
|
|
36
|
-
status: 401,
|
|
37
|
-
message: 'TOTP required',
|
|
38
|
-
})
|
|
39
|
-
}
|
|
34
|
+
requireSecondFactor(user)
|
|
40
35
|
|
|
41
36
|
return setSession(event, user)
|
|
42
37
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export default defineEventHandler(async (event) => {
|
|
2
|
-
const { user
|
|
3
|
-
await revokeRefreshToken(
|
|
2
|
+
const { user } = await verifyRefreshToken(event)
|
|
3
|
+
await revokeRefreshToken(getCookie(event, 'refresh_token')!) // verified above, so it is set
|
|
4
4
|
await createRefreshToken(user.id, event)
|
|
5
5
|
})
|
|
@@ -10,16 +10,10 @@ const bodySchema = z.object({
|
|
|
10
10
|
|
|
11
11
|
export default defineEventHandler(async (event) => {
|
|
12
12
|
const { secret, token } = await readValidatedBody(event, bodySchema.parse)
|
|
13
|
-
if (!secret || !token) {
|
|
14
|
-
throw createError({
|
|
15
|
-
status: 400,
|
|
16
|
-
message: 'Missing required fields',
|
|
17
|
-
})
|
|
18
|
-
}
|
|
19
13
|
|
|
20
14
|
const { user } = await requireRecentAuth(event)
|
|
21
15
|
|
|
22
|
-
if (await verify({ secret, token })) {
|
|
16
|
+
if ((await verify({ secret, token })).valid) {
|
|
23
17
|
await db.update(auth).set({ totp: secret }).where(eq(auth.id, user.id))
|
|
24
18
|
await setSession(event, user, false) // refresh requiresMfaSetup
|
|
25
19
|
} else throw createError({ status: 400, message: 'Invalid code.' })
|
|
@@ -12,25 +12,34 @@ export default defineEventHandler(async (event) => {
|
|
|
12
12
|
token: z.string().length(6),
|
|
13
13
|
}).parse,
|
|
14
14
|
)
|
|
15
|
-
if (!
|
|
15
|
+
if (!otp && !password) {
|
|
16
16
|
throw createError({
|
|
17
17
|
status: 400,
|
|
18
18
|
message: 'Missing required fields',
|
|
19
19
|
})
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const user = await getAuth(event, email)
|
|
22
|
+
const user = await getAuth(event, email).catch((error: any) => {
|
|
23
|
+
if (error?.statusCode === 404) return null
|
|
24
|
+
throw error
|
|
25
|
+
})
|
|
23
26
|
|
|
24
|
-
if (otp) await verifyOTP(email, otp)
|
|
25
|
-
else if (!user
|
|
27
|
+
if (otp) await verifyOTP(email, otp, false)
|
|
28
|
+
else if (!user?.password) {
|
|
29
|
+
await hashPassword(password!)
|
|
30
|
+
throw createError({ status: 400, message: 'Invalid password.' })
|
|
31
|
+
} else if (!(await verifyPassword(user.password, password!))) {
|
|
26
32
|
throw createError({ status: 400, message: 'Invalid password.' })
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
if (!user
|
|
35
|
+
if (!user?.totp) {
|
|
30
36
|
throw createError({ status: 400, message: 'TOTP is not enabled.' })
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
await enforceRateLimit(event, 'totp', { limit: 5, window: 900 }, user.id)
|
|
40
|
+
|
|
41
|
+
if ((await verify({ secret: user.totp, token })).valid) {
|
|
42
|
+
if (otp) await useStorage('auth').removeItem(email)
|
|
34
43
|
await setSession(event, user)
|
|
35
44
|
} else throw createError({ status: 400, message: 'Invalid TOTP.' })
|
|
36
45
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { eq } from 'drizzle-orm'
|
|
2
|
-
import { auth } from '#server/database/schema'
|
|
2
|
+
import { auth, credentials } from '#server/database/schema'
|
|
3
3
|
|
|
4
4
|
export default defineWebAuthnAuthenticateEventHandler({
|
|
5
5
|
async storeChallenge(event, challenge, attemptId) {
|
|
@@ -28,7 +28,13 @@ export default defineWebAuthnAuthenticateEventHandler({
|
|
|
28
28
|
if (!credential) throw createError({ status: 400, message: 'Credential not found' })
|
|
29
29
|
return credential as any
|
|
30
30
|
},
|
|
31
|
-
async onSuccess(event, { credential }) {
|
|
31
|
+
async onSuccess(event, { credential, authenticationInfo }) {
|
|
32
|
+
// the next assertion must report a higher counter, which exposes a cloned authenticator
|
|
33
|
+
await db
|
|
34
|
+
.update(credentials)
|
|
35
|
+
.set({ counter: authenticationInfo.newCounter })
|
|
36
|
+
.where(eq(credentials.id, credential.id))
|
|
37
|
+
|
|
32
38
|
const user = await db
|
|
33
39
|
.select()
|
|
34
40
|
.from(auth)
|