brf/server/plugins/auth/routes/register.ts

181 lines
5.3 KiB
TypeScript

import _ from 'lodash'
import * as z from 'zod'
import type { RouteHandler } from 'fastify'
import config from '../../../config.ts'
import verifyEmailTemplate from '../../../templates/emails/verify_email.ts'
import sendMail from '../../../lib/send_mail.ts'
import StatusError from '../../../lib/status_error.ts'
import { generateToken, hashPassword } from '../helpers.ts'
import type { Invite } from '../../../../shared/types.db.ts'
import { sql, type Selectable } from 'kysely'
const { errors, timeouts } = config.auth
const BodySchema = z.object({
email: z.email(),
password: z.string(),
inviteEmail: z.email().optional(),
inviteToken: z.string().optional(),
// required: config.auth.requireInvite ? ['email', 'password', 'inviteEmail', 'inviteToken'] : ['email', 'password'],
})
const ResponseSchema = {
201: {
type: 'object',
properties: {
id: { type: 'integer' },
email: { type: 'string' },
password: { type: 'string' },
emailVerifiedAt: { type: 'string' },
bannedAt: { type: 'string' },
blockedAt: { type: 'string' },
loginAttempts: { type: 'integer' },
},
},
'4XX': { $ref: 'status-error' },
}
type InviteMinimal = Pick<Selectable<Invite>, 'id' | 'email' | 'createdAt' | 'consumedAt'> & { roleIds?: number[] }
const register: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async function (request, reply) {
const { db } = request.server
try {
// TODO validate and ensure same as confirm
const email = request.body.email.trim().toLowerCase()
let invite: InviteMinimal | null | undefined = null
if (request.body.inviteEmail && request.body.inviteToken) {
let latestInvite: { id: number } | null | undefined = null
;[invite, latestInvite] = await Promise.all([
db
.selectFrom('invite as i')
.innerJoin('invites_roles as ir', 'ir.inviteId', 'i.id')
.select([
'id',
'email',
'createdAt',
'consumedAt',
(eb) => eb.fn.agg<number[]>('array_agg', ['ir.roleId']).as('roleIds'),
])
.where((eb) =>
eb.and({
email: request.body.inviteEmail,
token: request.body.inviteToken,
}),
)
.groupBy('id')
.executeTakeFirst(),
db
.selectFrom('invite')
.select('id')
.where('email', '=', request.body.inviteEmail)
.orderBy('createdAt', 'desc')
.executeTakeFirst(),
])
if (!invite) {
throw new StatusError(...errors.tokenNotFound)
} else if (invite.id !== latestInvite!.id) {
throw new StatusError(...errors.tokenNotLatest)
} else if (Date.now() > new Date(invite.createdAt).getTime() + timeouts.invite) {
throw new StatusError(...errors.tokenExpired)
} else if (invite.consumedAt) {
throw new StatusError(...errors.tokenConsumed)
}
}
const admissions = await db
.selectFrom('admission as a')
.innerJoin('admissions_roles as ar', 'ar.admissionId', 'a.id')
.select(['regex', (eb) => eb.fn.agg('array_agg', ['ar.roleId']).as('roleIds')])
.groupBy('regex')
.execute()
.then((admissions) => admissions.filter((admission) => new RegExp(admission.regex).test(email)))
const roleIds: number[] = Array.from(
new Set([invite?.roleIds || [], ...admissions.map((admission) => admission.roleIds || [])].flat() as number[]),
)
if (!roleIds.length) {
throw new StatusError(...errors.notAuthorized)
}
const trx = await db.startTransaction().execute()
const user = await trx
.insertInto('user')
.values({
email,
password: await hashPassword(request.body.password),
emailVerifiedAt: invite?.email === email ? sql`now()` : null,
})
.returningAll()
.executeTakeFirstOrThrow()
await trx
.insertInto('users_roles')
.values(roleIds.map((roleId) => ({ roleId, userId: user.id })))
.execute()
if (invite) {
trx
.updateTable('invite')
.set({ consumedAt: sql`now()`, consumedById: user.id })
.execute()
}
if (!user.emailVerifiedAt) {
const token = generateToken()
await trx
.insertInto('emailToken')
.values({
userId: user.id,
email: user.email,
token,
})
.execute()
const link = `${new URL('/auth/verify-email', config.site.url)}?email=${user.email}&token=${token}`
await sendMail({
to: user.email,
subject: `Verify ${config.site.title} account`,
html: await verifyEmailTemplate({ link }).text(),
})
}
await trx.commit().execute()
const roles = await db.selectFrom('role').select(['id', 'name']).where('id', 'in', roleIds).execute()
reply.type('application/json')
return reply.status(201).send(Object.assign(_.omit(user, 'password'), { roles }))
} catch (err) {
this.log.error(err)
// @ts-ignore
if (err.code == 23505) {
const statusError = new StatusError(...errors.duplicateEmail)
return reply
.status(statusError.status || 500)
.type('application/json')
.send(statusError.toJSON())
} else {
throw err
}
}
}
export default {
handler: register,
schema: {
body: BodySchema,
resopnse: ResponseSchema,
},
}