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.
- package/README.md +269 -2
- package/docker/Dockerfile +22 -0
- package/docker/Dockerfile.dockerignore +25 -0
- package/docker/compose.yaml +19 -0
- package/layers/base/app/app.vue +22 -0
- package/layers/base/app/components/AuthCheck.vue +316 -0
- package/layers/base/app/components/DataExplorer.vue +38 -0
- package/layers/base/app/components/LocaleSwitcher.vue +28 -0
- package/layers/base/app/components/TheConfirmation.vue +48 -0
- package/layers/base/app/components/TheLoading.vue +36 -0
- package/layers/base/app/components/TheNotifications.vue +71 -0
- package/layers/base/app/components/files/FilesDelete.vue +33 -0
- package/layers/base/app/components/files/FilesUpload.vue +85 -0
- package/layers/base/app/error.vue +28 -0
- package/layers/base/app/layouts/default.vue +15 -0
- package/layers/base/app/layouts/simple.vue +3 -0
- package/layers/base/app/middleware/admin.ts +15 -0
- package/layers/base/app/middleware/authenticated.ts +10 -0
- package/layers/base/app/middleware/mfa.global.ts +8 -0
- package/layers/base/app/pages/admin/logs.vue +364 -0
- package/layers/base/app/pages/signin.vue +121 -0
- package/layers/base/app/pages/user/auth.vue +509 -0
- package/layers/base/app/plugins/error.ts +21 -0
- package/layers/base/app/plugins/session.ts +22 -0
- package/layers/base/app/stores/app.ts +100 -0
- package/layers/base/app/utils/clipboard.ts +6 -0
- package/layers/base/app/utils/files.ts +95 -0
- package/layers/base/app/utils/image.ts +33 -0
- package/layers/base/nuxt.config.ts +146 -0
- package/layers/base/public/images/auth/microsoft.svg +1 -0
- package/layers/base/public/robots.txt +2 -0
- package/layers/base/server/api/admin/logs.get.ts +49 -0
- package/layers/base/server/api/auth/anonymous.post.ts +31 -0
- package/layers/base/server/api/auth/email/get.post.ts +46 -0
- package/layers/base/server/api/auth/email/verify.post.ts +46 -0
- package/layers/base/server/api/auth/index.get.ts +10 -0
- package/layers/base/server/api/auth/index.post.ts +19 -0
- package/layers/base/server/api/auth/options.post.ts +26 -0
- package/layers/base/server/api/auth/otp/get.post.ts +74 -0
- package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
- package/layers/base/server/api/auth/password.post.ts +42 -0
- package/layers/base/server/api/auth/refresh.delete.ts +4 -0
- package/layers/base/server/api/auth/refresh.post.ts +5 -0
- package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
- package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
- package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
- package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
- package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
- package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
- package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
- package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
- package/layers/base/server/api/files/[...path].get.ts +37 -0
- package/layers/base/server/api/files/chunk.post.ts +36 -0
- package/layers/base/server/api/files/copy.post.ts +22 -0
- package/layers/base/server/api/files/index.delete.ts +12 -0
- package/layers/base/server/api/files/index.get.ts +19 -0
- package/layers/base/server/api/files/index.post.ts +15 -0
- package/layers/base/server/api/log.post.ts +20 -0
- package/layers/base/server/database/defaults/access.ts +14 -0
- package/layers/base/server/database/defaults/db.ts +5 -0
- package/layers/base/server/database/defaults/relations.ts +5 -0
- package/layers/base/server/database/relations.ts +35 -0
- package/layers/base/server/database/schema.ts +66 -0
- package/layers/base/server/middleware/0.rateLimit.ts +21 -0
- package/layers/base/server/middleware/1.session.ts +14 -0
- package/layers/base/server/middleware/2.mfa.ts +34 -0
- package/layers/base/server/middleware/3.admin.ts +4 -0
- package/layers/base/server/plugins/session.ts +9 -0
- package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
- package/layers/base/server/routes/dev/email/[template].ts +15 -0
- package/layers/base/server/tasks/backup.ts +42 -0
- package/layers/base/server/tasks/clean.ts +39 -0
- package/layers/base/server/utils/access.ts +8 -0
- package/layers/base/server/utils/auth.ts +207 -0
- package/layers/base/server/utils/backup.ts +98 -0
- package/layers/base/server/utils/db.ts +3 -0
- package/layers/base/server/utils/email.ts +138 -0
- package/layers/base/server/utils/files.ts +751 -0
- package/layers/base/server/utils/ip.ts +15 -0
- package/layers/base/server/utils/log.ts +31 -0
- package/layers/base/server/utils/rateLimit.ts +69 -0
- package/layers/base/server/utils/schemas.ts +8 -0
- package/layers/base/server/utils/text.ts +4 -0
- package/layers/base/server/utils/time.ts +3 -0
- package/layers/base/shared/types/auth.d.ts +22 -0
- package/layers/base/shared/utils/logs.ts +9 -0
- package/layers/base/shared/utils/url.ts +5 -0
- package/locales/en.json +33 -0
- package/locales/fr.json +33 -0
- package/locales/pages/admin-logs/en.json +1 -0
- package/locales/pages/admin-logs/fr.json +1 -0
- package/locales/pages/index/en.json +1 -0
- package/locales/pages/index/fr.json +1 -0
- package/locales/pages/signin/en.json +5 -0
- package/locales/pages/signin/fr.json +5 -0
- package/locales/pages/user-auth/en.json +22 -0
- package/locales/pages/user-auth/fr.json +22 -0
- package/nuxt.config.ts +54 -0
- package/package.json +92 -4
- package/scripts/db/migrate.ts +9 -0
- package/scripts/db/push-server.sh +45 -0
- package/scripts/db/restore.sh +26 -0
- package/scripts/deploy.sh +34 -0
- package/scripts/env.sh +20 -0
- package/scripts/remote/switch.sh +48 -0
- package/scripts/rollback.sh +12 -0
- package/server/assets/emails/base.html +43 -0
- package/server/assets/emails/en/base.html +1 -0
- package/server/assets/emails/en/emailChange.html +4 -0
- package/server/assets/emails/en/emailChanged.html +2 -0
- package/server/assets/emails/en/otp.html +10 -0
- package/server/assets/emails/fr/base.html +1 -0
- package/server/assets/emails/fr/emailChange.html +4 -0
- package/server/assets/emails/fr/emailChanged.html +2 -0
- 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,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
|
+
}
|