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.
Files changed (35) hide show
  1. package/README.md +25 -4
  2. package/docker/Dockerfile +3 -3
  3. package/docker/compose.yaml +2 -2
  4. package/layers/base/app/components/AuthCheck.vue +89 -50
  5. package/layers/base/app/components/files/FilesUpload.vue +9 -0
  6. package/layers/base/app/pages/user/auth.vue +3 -3
  7. package/layers/base/app/stores/app.ts +4 -9
  8. package/layers/base/nuxt.config.ts +4 -3
  9. package/layers/base/server/api/admin/logs.get.ts +1 -2
  10. package/layers/base/server/api/auth/otp/get.post.ts +0 -6
  11. package/layers/base/server/api/auth/otp/verify.post.ts +37 -39
  12. package/layers/base/server/api/auth/password.post.ts +2 -7
  13. package/layers/base/server/api/auth/refresh.post.ts +2 -2
  14. package/layers/base/server/api/auth/totp/confirm.post.ts +1 -7
  15. package/layers/base/server/api/auth/totp/verify.post.ts +15 -6
  16. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +8 -2
  17. package/layers/base/server/api/files/[...path].get.ts +23 -14
  18. package/layers/base/server/api/files/chunk.post.ts +25 -4
  19. package/layers/base/server/api/files/index.get.ts +1 -3
  20. package/layers/base/server/api/files/index.post.ts +1 -1
  21. package/layers/base/server/database/defaults/db.ts +9 -1
  22. package/layers/base/server/database/relations.ts +1 -1
  23. package/layers/base/server/middleware/0.rateLimit.ts +1 -1
  24. package/layers/base/server/routes/auth/microsoft.get.ts +30 -17
  25. package/layers/base/server/tasks/clean.ts +2 -1
  26. package/layers/base/server/utils/auth.ts +27 -5
  27. package/layers/base/server/utils/backup.ts +1 -1
  28. package/layers/base/server/utils/files.ts +129 -14
  29. package/layers/base/server/utils/rateLimit.ts +7 -2
  30. package/locales/en.json +3 -0
  31. package/locales/fr.json +3 -0
  32. package/locales/pages/user-auth/en.json +0 -1
  33. package/locales/pages/user-auth/fr.json +0 -1
  34. package/nuxt.config.ts +1 -0
  35. 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-server`.
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`, merged after yours in `db.ts`, so only define relations
208
- on your own tables.
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-server": "bash node_modules/maxibase/scripts/db/push-server.sh",
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:24-alpine AS build
1
+ FROM node:26-alpine AS build
2
2
 
3
- RUN corepack enable && corepack prepare pnpm@latest --activate
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:24-alpine
15
+ FROM node:26-alpine
16
16
  WORKDIR /app
17
17
 
18
18
  RUN apk add --no-cache postgresql-client age
@@ -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
- if (!email.value) return
117
- if (options.value.hasTOTP && !totp.value) return
118
- setTimeout(() => {
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) // wait for paste event to complete
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
- async function pastePassword() {
175
+ const pasteFields = { password, totp }
176
+
177
+ async function pasteTo(field: keyof typeof pasteFields) {
166
178
  try {
167
- password.value = (await navigator.clipboard.readText()).trim()
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
- <label for="password">
201
- {{
202
- options.hasPassword && !otpRequested ? $t('authCheck.password') : $t('authCheck.code')
203
- }}
204
- </label>
205
- <div class="group flex-row">
206
- <input
207
- id="password"
208
- v-model.trim="password"
209
- :type="showPassword ? 'text' : 'password'"
210
- :disabled="isLoading"
211
- autocomplete="current-password"
212
- :pattern="
213
- otpRequested
214
- ? '[0-9]{6}'
215
- : `.{${config.public.password.min},${config.public.password.max}}`
216
- "
217
- @paste="signIn"
218
- />
219
- <button
220
- type="button"
221
- class="flex-center fg"
222
- @click="showPassword = !showPassword"
223
- >
224
- <Icon :name="showPassword ? 'lucide:eye-off' : 'lucide:eye'" />
225
- </button>
226
- <button
227
- type="button"
228
- class="flex-center fg"
229
- :title="$t('paste') as string"
230
- @click="pastePassword"
231
- >
232
- <Icon name="lucide:clipboard-paste" />
233
- </button>
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
- <input
238
- id="totp"
239
- v-model.trim="totp"
240
- type="text"
241
- :disabled="isLoading || !password"
242
- autocomplete="one-time-code"
243
- @paste="signInWithTOTP"
244
- @change="signInWithTOTP"
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 isMfaSetup.value || (await appStore.checkAuth())
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 authVerifiedAt = ref(0)
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() - authVerifiedAt.value < reverifyAfter) {
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: 'common',
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 1 * *': 'clean',
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 query = getQuery(event)
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 session = await getUserSession(event)
20
- if (session?.user?.isAnonymous) {
21
- let user: any = await getAuth(event, email).catch(() => null)
22
- if (!user) {
23
- user = {
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
- const user = await getAuth(event, email).catch((error) => {
33
- const config = useRuntimeConfig()
19
+ await verifyOTP(email, otp)
34
20
 
35
- if (error.statusCode === 404 && config.autoSignup) {
36
- return createAuth({ email })
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
- export async function verifyOTP(email: string, otp: string): Promise<void> {
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 && (await verifyPassword(hash, otp))) {
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
- if (user.totp) {
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, refreshToken } = await verifyRefreshToken(event)
3
- await revokeRefreshToken(refreshToken.token)
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 (!email || !(otp || password) || !token) {
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.password || !(await verifyPassword(user.password, password!))) {
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.totp) {
35
+ if (!user?.totp) {
30
36
  throw createError({ status: 400, message: 'TOTP is not enabled.' })
31
37
  }
32
38
 
33
- if (await verify({ secret: user.totp, token })) {
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)