92 lines
2.9 KiB
TypeScript
92 lines
2.9 KiB
TypeScript
import bcrypt from 'bcrypt'
|
|
import jwt from 'jsonwebtoken'
|
|
import { randomUUID } from 'crypto'
|
|
import { readBody, getHeader, setCookie } from 'h3'
|
|
import { useRuntimeConfig } from '#imports'
|
|
import { db } from '../../utils/database'
|
|
import { logEvent, logLogin } from '../../utils/activity-logger'
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const body = await readBody(event)
|
|
const token = typeof body?.token === 'string' ? body.token : ''
|
|
const password = typeof body?.password === 'string' ? body.password : ''
|
|
const display_name = typeof body?.display_name === 'string' ? body.display_name.trim() : ''
|
|
|
|
if (!token) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Token is required' })
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters' })
|
|
}
|
|
|
|
if (display_name.length < 2) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Display name must be at least 2 characters' })
|
|
}
|
|
|
|
const user = await db
|
|
.selectFrom('users')
|
|
.select(['id', 'email', 'verified', 'password', 'token_expires_at'])
|
|
.where('token_key', '=', token)
|
|
.executeTakeFirst()
|
|
|
|
if (!user || user.verified || user.password !== null) {
|
|
throw createError({ statusCode: 404, statusMessage: 'Invitation not found' })
|
|
}
|
|
|
|
const expires = user.token_expires_at ? new Date(user.token_expires_at) : null
|
|
if (!expires || expires.getTime() <= Date.now()) {
|
|
throw createError({ statusCode: 410, statusMessage: 'Invitation has expired' })
|
|
}
|
|
|
|
const hashedPassword = await bcrypt.hash(password, 12)
|
|
const newTokenKey = randomUUID()
|
|
|
|
// Idempotency / TOCTOU guard: only mutate while the row is still pending.
|
|
// A second concurrent POST loses the race and gets a clean 410.
|
|
const result = await db
|
|
.updateTable('users')
|
|
.set({
|
|
password: hashedPassword,
|
|
display_name,
|
|
verified: true,
|
|
token_key: newTokenKey,
|
|
token_expires_at: null,
|
|
updated: new Date().toISOString(),
|
|
})
|
|
.where('id', '=', user.id)
|
|
.where('password', 'is', null)
|
|
.executeTakeFirst()
|
|
|
|
if (Number(result.numUpdatedRows ?? 0) === 0) {
|
|
throw createError({ statusCode: 410, statusMessage: 'Invitation has already been accepted' })
|
|
}
|
|
|
|
const jwtToken = jwt.sign(
|
|
{ userId: user.id, email: user.email, display_name },
|
|
useRuntimeConfig().jwtSecret,
|
|
{ expiresIn: '120d' }
|
|
)
|
|
|
|
setCookie(event, 'auth-token', jwtToken, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 120
|
|
})
|
|
|
|
const userAgent = getHeader(event, 'user-agent') || undefined
|
|
|
|
await logEvent({
|
|
eventType: 'invite_accepted',
|
|
tableName: 'users',
|
|
recordId: user.id,
|
|
userId: user.id,
|
|
userAgent,
|
|
metadata: { email: user.email }
|
|
})
|
|
|
|
logLogin(user.id, userAgent, { via: 'invite_accept' })
|
|
|
|
return { success: true }
|
|
})
|