maxibase 0.0.0-stage → 1.0.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 (115) hide show
  1. package/README.md +269 -2
  2. package/docker/Dockerfile +22 -0
  3. package/docker/Dockerfile.dockerignore +25 -0
  4. package/docker/compose.yaml +19 -0
  5. package/layers/base/app/app.vue +22 -0
  6. package/layers/base/app/components/AuthCheck.vue +316 -0
  7. package/layers/base/app/components/DataExplorer.vue +38 -0
  8. package/layers/base/app/components/LocaleSwitcher.vue +28 -0
  9. package/layers/base/app/components/TheConfirmation.vue +48 -0
  10. package/layers/base/app/components/TheLoading.vue +36 -0
  11. package/layers/base/app/components/TheNotifications.vue +71 -0
  12. package/layers/base/app/components/files/FilesDelete.vue +33 -0
  13. package/layers/base/app/components/files/FilesUpload.vue +85 -0
  14. package/layers/base/app/error.vue +28 -0
  15. package/layers/base/app/layouts/default.vue +15 -0
  16. package/layers/base/app/layouts/simple.vue +3 -0
  17. package/layers/base/app/middleware/admin.ts +15 -0
  18. package/layers/base/app/middleware/authenticated.ts +10 -0
  19. package/layers/base/app/middleware/mfa.global.ts +8 -0
  20. package/layers/base/app/pages/admin/logs.vue +364 -0
  21. package/layers/base/app/pages/signin.vue +121 -0
  22. package/layers/base/app/pages/user/auth.vue +509 -0
  23. package/layers/base/app/plugins/error.ts +21 -0
  24. package/layers/base/app/plugins/session.ts +22 -0
  25. package/layers/base/app/stores/app.ts +100 -0
  26. package/layers/base/app/utils/clipboard.ts +6 -0
  27. package/layers/base/app/utils/files.ts +95 -0
  28. package/layers/base/app/utils/image.ts +33 -0
  29. package/layers/base/nuxt.config.ts +146 -0
  30. package/layers/base/public/images/auth/microsoft.svg +1 -0
  31. package/layers/base/public/robots.txt +2 -0
  32. package/layers/base/server/api/admin/logs.get.ts +49 -0
  33. package/layers/base/server/api/auth/anonymous.post.ts +31 -0
  34. package/layers/base/server/api/auth/email/get.post.ts +46 -0
  35. package/layers/base/server/api/auth/email/verify.post.ts +46 -0
  36. package/layers/base/server/api/auth/index.get.ts +10 -0
  37. package/layers/base/server/api/auth/index.post.ts +19 -0
  38. package/layers/base/server/api/auth/options.post.ts +26 -0
  39. package/layers/base/server/api/auth/otp/get.post.ts +74 -0
  40. package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
  41. package/layers/base/server/api/auth/password.post.ts +42 -0
  42. package/layers/base/server/api/auth/refresh.delete.ts +4 -0
  43. package/layers/base/server/api/auth/refresh.post.ts +5 -0
  44. package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
  45. package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
  46. package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
  47. package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
  48. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
  49. package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
  50. package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
  51. package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
  52. package/layers/base/server/api/files/[...path].get.ts +37 -0
  53. package/layers/base/server/api/files/chunk.post.ts +36 -0
  54. package/layers/base/server/api/files/copy.post.ts +22 -0
  55. package/layers/base/server/api/files/index.delete.ts +12 -0
  56. package/layers/base/server/api/files/index.get.ts +19 -0
  57. package/layers/base/server/api/files/index.post.ts +15 -0
  58. package/layers/base/server/api/log.post.ts +20 -0
  59. package/layers/base/server/database/defaults/access.ts +14 -0
  60. package/layers/base/server/database/defaults/db.ts +5 -0
  61. package/layers/base/server/database/defaults/relations.ts +5 -0
  62. package/layers/base/server/database/relations.ts +35 -0
  63. package/layers/base/server/database/schema.ts +66 -0
  64. package/layers/base/server/middleware/0.rateLimit.ts +21 -0
  65. package/layers/base/server/middleware/1.session.ts +14 -0
  66. package/layers/base/server/middleware/2.mfa.ts +34 -0
  67. package/layers/base/server/middleware/3.admin.ts +4 -0
  68. package/layers/base/server/plugins/session.ts +9 -0
  69. package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
  70. package/layers/base/server/routes/dev/email/[template].ts +15 -0
  71. package/layers/base/server/tasks/backup.ts +42 -0
  72. package/layers/base/server/tasks/clean.ts +39 -0
  73. package/layers/base/server/utils/access.ts +8 -0
  74. package/layers/base/server/utils/auth.ts +207 -0
  75. package/layers/base/server/utils/backup.ts +98 -0
  76. package/layers/base/server/utils/db.ts +3 -0
  77. package/layers/base/server/utils/email.ts +138 -0
  78. package/layers/base/server/utils/files.ts +751 -0
  79. package/layers/base/server/utils/ip.ts +15 -0
  80. package/layers/base/server/utils/log.ts +31 -0
  81. package/layers/base/server/utils/rateLimit.ts +69 -0
  82. package/layers/base/server/utils/schemas.ts +8 -0
  83. package/layers/base/server/utils/text.ts +4 -0
  84. package/layers/base/server/utils/time.ts +3 -0
  85. package/layers/base/shared/types/auth.d.ts +22 -0
  86. package/layers/base/shared/utils/logs.ts +9 -0
  87. package/layers/base/shared/utils/url.ts +5 -0
  88. package/locales/en.json +33 -0
  89. package/locales/fr.json +33 -0
  90. package/locales/pages/admin-logs/en.json +1 -0
  91. package/locales/pages/admin-logs/fr.json +1 -0
  92. package/locales/pages/index/en.json +1 -0
  93. package/locales/pages/index/fr.json +1 -0
  94. package/locales/pages/signin/en.json +5 -0
  95. package/locales/pages/signin/fr.json +5 -0
  96. package/locales/pages/user-auth/en.json +22 -0
  97. package/locales/pages/user-auth/fr.json +22 -0
  98. package/nuxt.config.ts +54 -0
  99. package/package.json +92 -4
  100. package/scripts/db/migrate.ts +9 -0
  101. package/scripts/db/push-server.sh +45 -0
  102. package/scripts/db/restore.sh +26 -0
  103. package/scripts/deploy.sh +34 -0
  104. package/scripts/env.sh +20 -0
  105. package/scripts/remote/switch.sh +48 -0
  106. package/scripts/rollback.sh +12 -0
  107. package/server/assets/emails/base.html +43 -0
  108. package/server/assets/emails/en/base.html +1 -0
  109. package/server/assets/emails/en/emailChange.html +4 -0
  110. package/server/assets/emails/en/emailChanged.html +2 -0
  111. package/server/assets/emails/en/otp.html +10 -0
  112. package/server/assets/emails/fr/base.html +1 -0
  113. package/server/assets/emails/fr/emailChange.html +4 -0
  114. package/server/assets/emails/fr/emailChanged.html +2 -0
  115. package/server/assets/emails/fr/otp.html +10 -0
@@ -0,0 +1,95 @@
1
+ export async function uploadFiles(
2
+ files: File[],
3
+ path: string,
4
+ isPrivate: boolean,
5
+ onProgress?: (progress: number) => void,
6
+ ) {
7
+ let totalSize = files.reduce((sum, f) => sum + f.size, 0)
8
+
9
+ onProgress?.(1)
10
+ let urls: string[] = []
11
+ if (totalSize <= getChunkSize()) {
12
+ const formData = new FormData()
13
+ files.forEach((file) => formData.append('file', file))
14
+ formData.append('path', path)
15
+ formData.append('isPrivate', String(isPrivate))
16
+ urls = await postFiles(formData)
17
+ } else {
18
+ let uploadedSize = 0
19
+ for (const file of files) {
20
+ if (file.size <= getChunkSize()) {
21
+ const formData = new FormData()
22
+ formData.append('file', file)
23
+ formData.append('path', path)
24
+ formData.append('isPrivate', String(isPrivate))
25
+ urls.push(...(await postFiles(formData)))
26
+ uploadedSize += file.size
27
+ const progress = (uploadedSize / totalSize) * 100
28
+ onProgress?.(Math.round(progress))
29
+ } else {
30
+ const onChunkProgress = (chunkUploaded: number) => {
31
+ uploadedSize += chunkUploaded
32
+ const progress = (uploadedSize / totalSize) * 100
33
+ onProgress?.(Math.round(progress))
34
+ }
35
+ const url = await uploadInChunks(file, path, isPrivate, onChunkProgress)
36
+ if (url) urls.push(url)
37
+ }
38
+ }
39
+ }
40
+ onProgress?.(100)
41
+ return urls
42
+ }
43
+
44
+ async function postFiles(body: FormData): Promise<string[]> {
45
+ const urls = await $fetch('/api/files', { method: 'POST', body })
46
+ return urls.filter((url): url is string => !!url)
47
+ }
48
+
49
+ export async function uploadInChunks(
50
+ file: File,
51
+ path: string,
52
+ isPrivate: boolean,
53
+ onProgress?: (progress: number) => void,
54
+ ) {
55
+ let uploadId
56
+ let parts: any[] = []
57
+ const chunkSize = getChunkSize()
58
+ const totalChunks = Math.ceil(file.size / chunkSize)
59
+ let result: any
60
+
61
+ for (let i = 0; i < totalChunks; i++) {
62
+ const start = i * chunkSize
63
+ const formData = new FormData()
64
+ formData.append('chunk', file.slice(start, start + chunkSize))
65
+ formData.append('type', file.type)
66
+ formData.append('uploadId', uploadId || '')
67
+ formData.append('parts', JSON.stringify(parts))
68
+ formData.append('chunkIndex', String(i))
69
+ formData.append('totalChunks', String(totalChunks))
70
+ formData.append('filename', file.name)
71
+ formData.append('path', path)
72
+ formData.append('isPrivate', String(isPrivate))
73
+
74
+ result = await $fetch('/api/files/chunk', { method: 'POST', body: formData })
75
+ if (i + 1 < totalChunks) {
76
+ uploadId = result.uploadId
77
+ parts = result.parts
78
+ }
79
+ onProgress?.(Math.min(chunkSize, file.size - start)) // bytes added by this chunk
80
+ }
81
+
82
+ return result
83
+ }
84
+
85
+ export function getChunkSize() {
86
+ const config = useRuntimeConfig()
87
+ return (Number(config.public.files.chunkSize) || 1) * 1024 * 1024 // 1MB
88
+ }
89
+
90
+ export function deleteFiles(path: string, isPrivate?: boolean) {
91
+ return $fetch('/api/files', {
92
+ method: 'DELETE',
93
+ body: { path, isPrivate },
94
+ })
95
+ }
@@ -0,0 +1,33 @@
1
+ export async function formatImage(
2
+ file: File,
3
+ maxHeight?: number,
4
+ format: string = 'webp',
5
+ quality: number = 0.8,
6
+ ): Promise<File> {
7
+ if (file.type === 'image/svg+xml') return file
8
+
9
+ const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
10
+
11
+ try {
12
+ const ratio = maxHeight ? Math.min(1, maxHeight / bitmap.height) : 1
13
+ if (ratio === 1 && file.type === `image/${format}`) return file
14
+
15
+ const canvas = document.createElement('canvas')
16
+ canvas.width = Math.round(bitmap.width * ratio)
17
+ canvas.height = Math.round(bitmap.height * ratio)
18
+ const ctx = canvas.getContext('2d')
19
+ if (!ctx) throw new Error('no canvas')
20
+
21
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
22
+ const blob = await new Promise<Blob | null>((resolve) =>
23
+ canvas.toBlob(resolve, `image/${format}`, quality),
24
+ )
25
+ if (!blob) throw new Error('blob failed')
26
+
27
+ return new File([blob], `${file.name.replace(/\.[^./\\]+$/, '')}.${blob.type.split('/')[1]}`, {
28
+ type: blob.type,
29
+ })
30
+ } finally {
31
+ bitmap.close()
32
+ }
33
+ }
@@ -0,0 +1,146 @@
1
+ // https://nuxt.com/docs/api/configuration/nuxt-config
2
+ import { existsSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const authTypes = { path: fileURLToPath(new URL('./shared/types/auth.d.ts', import.meta.url)) }
6
+
7
+ export default defineNuxtConfig({
8
+ routeRules: {
9
+ '/images/auth/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },
10
+ },
11
+ runtimeConfig: {
12
+ public: {
13
+ anonymousSignup: false,
14
+ password: {
15
+ min: 16,
16
+ max: 64,
17
+ },
18
+ refreshToken: {
19
+ rotateAfter: 60 * 60 * 24, // 1 day
20
+ },
21
+ recentAuth: {
22
+ maxAge: 300, // 5 minutes
23
+ },
24
+ oauth: {
25
+ microsoft: false,
26
+ },
27
+ files: {
28
+ url: '/files',
29
+ chunkSize: 5, // in MB
30
+ },
31
+ },
32
+ session: {
33
+ maxAge: 3600, // 1 hour
34
+ password: '',
35
+ unique: false,
36
+ },
37
+ oauth: {
38
+ microsoft: {
39
+ clientId: '',
40
+ clientSecret: '',
41
+ tenant: 'common',
42
+ },
43
+ },
44
+ refreshToken: {
45
+ maxAge: 60 * 60 * 24 * 30, // 30 days
46
+ },
47
+ autoSignup: false,
48
+ forceMfa: false,
49
+ db: '',
50
+ smtp: {
51
+ host: '',
52
+ port: 465,
53
+ user: '',
54
+ pass: '',
55
+ from: '',
56
+ },
57
+ filesPublicFolder: 'files/public',
58
+ filesPrivateFolder: 'files/private',
59
+ s3: {
60
+ redirect: true,
61
+ endpoint: '',
62
+ region: 'us-east-1',
63
+ publicBucket: '',
64
+ privateBucket: '',
65
+ accessKeyId: '',
66
+ secretAccessKey: '',
67
+ },
68
+ backup: {
69
+ dumpArgs: '-F c',
70
+ retentionDays: 30,
71
+ agePublicKey: '', // age public key to encrypt backups
72
+ },
73
+ logs: {
74
+ retentionDays: 90,
75
+ },
76
+ rateLimit: {
77
+ enabled: true,
78
+ banMultiplier: 5, // banned after limit × multiplier requests in one window
79
+ banSeconds: 3600,
80
+ // Number of reverse proxies in front of the app. Set to 0 when directly exposed, else
81
+ // clients can spoof x-forwarded-for and bypass every limit below.
82
+ trustedProxies: 1,
83
+ },
84
+ },
85
+ nitro: {
86
+ experimental: {
87
+ tasks: true,
88
+ },
89
+ scheduledTasks: {
90
+ '0 3 * * *': 'backup',
91
+ '0 2 1 * *': 'clean',
92
+ },
93
+ storage: {
94
+ auth: {
95
+ driver: 'memory',
96
+ },
97
+ },
98
+ serverAssets: [
99
+ { baseName: 'base', dir: fileURLToPath(new URL('../../server/assets', import.meta.url)) },
100
+ ],
101
+ },
102
+ i18n: {
103
+ locales: [{ code: 'en' }, { code: 'fr' }],
104
+ defaultLocale: 'en',
105
+ translationDir: 'locales',
106
+ localeCookie: 'user-locale',
107
+ },
108
+ auth: {
109
+ webAuthn: true,
110
+ },
111
+ compatibilityDate: '2025-07-15',
112
+ devtools: { enabled: true },
113
+ modules: [
114
+ '@nuxt/hints',
115
+ '@nuxt/icon',
116
+ 'nuxt-auth-utils',
117
+ '@pinia/nuxt',
118
+ 'nuxt-i18n-micro',
119
+ (_, nuxt) => {
120
+ // #database/{name} is the project's server/database/{name}.ts, else the layer's default
121
+ for (const name of ['db', 'relations', 'access']) {
122
+ const own = `${nuxt.options.serverDir}/database/${name}`
123
+ nuxt.options.alias[`#database/${name}`] = existsSync(`${own}.ts`)
124
+ ? own
125
+ : fileURLToPath(new URL(`./server/database/defaults/${name}`, import.meta.url))
126
+ }
127
+ },
128
+ ],
129
+ pinia: {
130
+ storesDirs: ['stores'],
131
+ },
132
+ vite: {
133
+ optimizeDeps: {
134
+ include: ['qrcode'],
135
+ },
136
+ },
137
+ hooks: {
138
+ 'prepare:types': ({ references, sharedReferences }) => {
139
+ references.push(authTypes)
140
+ sharedReferences.push(authTypes)
141
+ },
142
+ 'nitro:prepare:types': ({ references }) => {
143
+ references.push(authTypes)
144
+ },
145
+ },
146
+ })
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>
@@ -0,0 +1,2 @@
1
+ User-Agent: *
2
+ Disallow: /
@@ -0,0 +1,49 @@
1
+ import z from 'zod'
2
+
3
+ const querySchema = z.object({
4
+ from: z.iso.datetime(),
5
+ to: z.iso.datetime(),
6
+ search: z.string().optional(),
7
+ type: z.enum(logTypeValues).optional().catch(undefined),
8
+ limit: z.coerce.number().int().positive().max(2000).default(200),
9
+ })
10
+
11
+ export default defineEventHandler(async (event) => {
12
+ const query = getQuery(event)
13
+ const { from, to, search, type, limit } = querySchema.parse(query)
14
+ const text = prepareStringForSearch(search)
15
+
16
+ const rows = await db.query.logs.findMany({
17
+ where: {
18
+ time: {
19
+ gte: new Date(from),
20
+ lte: new Date(to),
21
+ },
22
+ type: type ? { eq: type } : undefined,
23
+ OR: text
24
+ ? [
25
+ { summary: { ilike: text } },
26
+ { origin: { ilike: text } },
27
+ { auth: { email: { ilike: text } } },
28
+ ]
29
+ : undefined,
30
+ },
31
+ orderBy: {
32
+ time: 'desc',
33
+ },
34
+ limit: limit + 1,
35
+ with: {
36
+ auth: {
37
+ columns: {
38
+ email: true,
39
+ },
40
+ },
41
+ },
42
+ })
43
+
44
+ return {
45
+ logs: rows.slice(0, limit),
46
+ hasMore: rows.length > limit,
47
+ limit,
48
+ }
49
+ })
@@ -0,0 +1,31 @@
1
+ import { auth } from '#server/database/schema'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const config = useRuntimeConfig()
5
+ const session = await getUserSession(event)
6
+ if (session?.user || !config.public.anonymousSignup) {
7
+ throw createError({
8
+ status: 403,
9
+ message: 'Anonymous signup not allowed',
10
+ })
11
+ }
12
+ if (config.forceMfa) {
13
+ throw createError({
14
+ status: 403,
15
+ message: 'Anonymous signup not allowed when MFA is required',
16
+ })
17
+ }
18
+
19
+ const insertedUsers = await db.insert(auth).values({}).returning()
20
+
21
+ if (!insertedUsers[0]) {
22
+ throw createError({
23
+ status: 500,
24
+ message: 'Anonymous user creation failed',
25
+ })
26
+ }
27
+
28
+ const user = insertedUsers[0]
29
+
30
+ return setSession(event, user)
31
+ })
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod'
2
+ import { generateOTP, type OTP } from '../otp/get.post'
3
+
4
+ const bodySchema = z.object({
5
+ email: emailSchema,
6
+ locale: z.string().optional(),
7
+ })
8
+
9
+ export function emailChangeKey(userId: string, email: string) {
10
+ return `change:${userId}:${email.trim().toLowerCase()}`
11
+ }
12
+
13
+ export default defineEventHandler(async (event) => {
14
+ await requireUserSession(event)
15
+ const { email, locale = 'en' } = await readValidatedBody(event, bodySchema.parse)
16
+ const user = await getAuth(event)
17
+
18
+ if (user.email === email) {
19
+ throw createError({ status: 400, message: 'This is already your email address.' })
20
+ }
21
+
22
+ const storage = useStorage('auth')
23
+ const key = emailChangeKey(user.id, email)
24
+ const record = await storage.getItem<OTP>(key)
25
+ if (record && Date.now() < record.sentAt + 60000) {
26
+ throw createError({ status: 400, message: 'Wait a minute before requesting a new code.' })
27
+ }
28
+
29
+ const otp = generateOTP()
30
+ await storage.setItem(key, {
31
+ otp: await hashPassword(otp),
32
+ attempts: 0,
33
+ sentAt: Date.now(),
34
+ })
35
+
36
+ try {
37
+ return await sendEmailTemplate('emailChange', locale, { otp }, email)
38
+ } catch (error) {
39
+ await storage.removeItem(key)
40
+ throw createError({
41
+ status: 502,
42
+ message: 'Could not send the email. Please try again.',
43
+ cause: error,
44
+ })
45
+ }
46
+ })
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod'
2
+ import { eq } from 'drizzle-orm'
3
+ import { auth } from '#server/database/schema'
4
+ import { verifyOTP } from '../otp/verify.post'
5
+ import { emailChangeKey } from './get.post'
6
+
7
+ const bodySchema = z.object({
8
+ email: emailSchema,
9
+ otp: z.string().length(6),
10
+ locale: z.string().optional(),
11
+ })
12
+
13
+ export default defineEventHandler(async (event) => {
14
+ const { email, otp, locale = 'en' } = await readValidatedBody(event, bodySchema.parse)
15
+ await requireRecentAuth(event)
16
+ const user = await getAuth(event)
17
+ const previousEmail = user.email
18
+
19
+ await verifyOTP(emailChangeKey(user.id, email), otp)
20
+
21
+ const updated = await db
22
+ .update(auth)
23
+ .set({ email })
24
+ .where(eq(auth.id, user.id))
25
+ .returning()
26
+ .catch((error: any) => {
27
+ // 23505: unique violation
28
+ if (error?.cause?.code === '23505' || error?.code === '23505') {
29
+ throw createError({ status: 409, message: 'This email address is already in use.' })
30
+ }
31
+ throw error
32
+ })
33
+
34
+ if (!updated[0]) {
35
+ throw createError({ status: 500, message: 'User update failed' })
36
+ }
37
+
38
+ await log('Email changed', { from: previousEmail, to: email }, undefined, 'security', event)
39
+
40
+ if (previousEmail) {
41
+ await sendEmailTemplate('emailChanged', locale, { email }, previousEmail).catch(() => {})
42
+ }
43
+
44
+ await revokeAllUserTokens(user.id)
45
+ await setSession(event, updated[0])
46
+ })
@@ -0,0 +1,10 @@
1
+ export default defineEventHandler(async (event) => {
2
+ await requireUserSession(event)
3
+ const user = await getAuth(event, undefined, true)
4
+ return {
5
+ credentials: user.credentials!.map(({ id, name }) => ({ id, name })),
6
+ hasPassword: !!user.password,
7
+ hasTOTP: !!user.totp,
8
+ forceMfa: useRuntimeConfig().forceMfa,
9
+ }
10
+ })
@@ -0,0 +1,19 @@
1
+ import { eq } from 'drizzle-orm/sql/expressions/conditions'
2
+ import { z } from 'zod'
3
+ import { auth } from '#server/database/schema'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { password } = await readValidatedBody(
7
+ event,
8
+ z.object({
9
+ password: passwordSchema(),
10
+ }).parse,
11
+ )
12
+ const session = await requireRecentAuth(event)
13
+ await db
14
+ .update(auth)
15
+ .set({
16
+ password: await hashPassword(password),
17
+ })
18
+ .where(eq(auth.id, session.user.id))
19
+ })
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod'
2
+
3
+ const bodySchema = z.object({
4
+ email: emailSchema,
5
+ })
6
+
7
+ export default defineEventHandler(async (event) => {
8
+ const { email } = await readValidatedBody(event, bodySchema.parse)
9
+
10
+ try {
11
+ const user = await getAuth(event, email, true)
12
+ return {
13
+ hasOTP: true,
14
+ hasPassword: !!user.password,
15
+ hasTOTP: !!user.totp,
16
+ hasPasskey: user.credentials!.length > 0,
17
+ }
18
+ } catch {
19
+ return {
20
+ hasOTP: true,
21
+ hasPassword: false,
22
+ hasTOTP: false,
23
+ hasPasskey: false,
24
+ }
25
+ }
26
+ })
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod'
2
+ import crypto from 'crypto'
3
+
4
+ const bodySchema = z.object({
5
+ email: emailSchema,
6
+ locale: z.string().optional(),
7
+ goto: z.string().optional(),
8
+ path: z.string().optional(),
9
+ })
10
+
11
+ export interface OTP {
12
+ otp: string
13
+ token?: string // magic link only, absent on flows that issue no link
14
+ attempts: number
15
+ sentAt: number
16
+ }
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ const {
20
+ email,
21
+ locale = 'en',
22
+ goto,
23
+ path = 'signin',
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
+
32
+ const storage = useStorage('auth')
33
+ const record = await storage.getItem<OTP>(email)
34
+ if (record && Date.now() < record.sentAt! + 60000) {
35
+ throw createError({ status: 400, message: 'Wait a minute before requesting a new OTP.' })
36
+ }
37
+
38
+ const baseUrl = useRuntimeConfig().public.url.replace(/\/+$/, '')
39
+ const magicLinkPath = safePath(path.startsWith('/') ? path : `/${path}`, '/signin')
40
+ const linkGoto = goto ? safePath(goto, '') : ''
41
+
42
+ const otp = generateOTP()
43
+ const token = crypto.randomBytes(32).toString('base64url')
44
+ await storage.setItem(email, {
45
+ otp: await hashPassword(otp),
46
+ attempts: 0,
47
+ sentAt: Date.now(),
48
+ token: await hashPassword(token),
49
+ })
50
+
51
+ try {
52
+ return await sendEmailTemplate(
53
+ 'otp',
54
+ locale,
55
+ {
56
+ otp,
57
+ magicLink: `${baseUrl}${magicLinkPath}?email=${encodeURIComponent(email)}&token=${token}${linkGoto ? `&goto=${encodeURIComponent(linkGoto)}` : ''}`,
58
+ },
59
+ email,
60
+ )
61
+ } catch (error) {
62
+ await storage.removeItem(email)
63
+ throw createError({
64
+ status: 502,
65
+ message: 'Could not send the email. Please try again.',
66
+ cause: error,
67
+ })
68
+ }
69
+ })
70
+
71
+ export function generateOTP(length = 6): string {
72
+ const otp = crypto.randomInt(Math.pow(10, length - 1), Math.pow(10, length))
73
+ return otp.toString()
74
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod'
2
+ import type { OTP } from './get.post'
3
+
4
+ const bodySchema = z.object({
5
+ email: emailSchema,
6
+ otp: z.string().length(6).or(z.string().min(32)),
7
+ })
8
+
9
+ export default defineEventHandler(async (event) => {
10
+ 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
+
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
+ }
31
+
32
+ const user = await getAuth(event, email).catch((error) => {
33
+ const config = useRuntimeConfig()
34
+
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)
47
+ })
48
+
49
+ const MAX_OTP_ATTEMPTS = 3
50
+
51
+ export async function verifyOTP(email: string, otp: string): Promise<void> {
52
+ const storage = useStorage('auth')
53
+ const record = await storage.getItem<OTP>(email)
54
+ if (!record) {
55
+ throw createError({ status: 400, message: 'Request a new OTP.' })
56
+ }
57
+ if (Date.now() > record.sentAt! + 5 * 60 * 1000 || record.attempts >= MAX_OTP_ATTEMPTS) {
58
+ await storage.removeItem(email)
59
+ throw createError({ status: 400, message: 'Request a new OTP.' })
60
+ }
61
+
62
+ 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)
68
+ throw createError({ status: 400, message: 'Invalid OTP.' })
69
+ }
70
+ }