diff --git a/src/app.ts b/src/app.ts index 32f1d92..21bfc92 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,11 +1,18 @@ import Fastify from 'fastify' -import prismaPlugin from './plugins/prisma' +import cookie from '@fastify/cookie' +import prismaPlugin from './plugins/prisma.js' +import mailerPlugin from './plugins/mailer.js' +import authRoutes from './routes/auth.js' import userRoutes from './routes/users.js' export default function buildApp() { const app = Fastify({ logger: true }) + app.register(cookie) app.register(prismaPlugin) + app.register(mailerPlugin) + + app.register(authRoutes, { prefix: '/api' }) app.register(userRoutes, { prefix: '/api' }) app.get('/health', async () => ({ status: 'ok' })) diff --git a/src/plugins/mailer.ts b/src/plugins/mailer.ts new file mode 100644 index 0000000..d0388fb --- /dev/null +++ b/src/plugins/mailer.ts @@ -0,0 +1,22 @@ +import fp from 'fastify-plugin' +import { FastifyInstance } from 'fastify' +import nodemailer, { Transporter } from 'nodemailer' + +declare module 'fastify' { + interface FastifyInstance { + mailer: Transporter + } +} + +export default fp(async (fastify: FastifyInstance) => { + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }) + + fastify.decorate('mailer', transporter) +}) \ No newline at end of file diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..2b2b827 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,41 @@ +import { FastifyInstance } from 'fastify' +import { ZodError } from 'zod' +import { RegisterSchema } from '../schemas/auth.schema.js' +import { registerUser } from '../services/auth.service.js' + +export default async function authRoutes(fastify: FastifyInstance) { + fastify.post('/auth/register', async (request, reply) => { + // Validation Zod + let body + try { + body = RegisterSchema.parse(request.body) + } catch (err) { + if (err instanceof ZodError) { + return reply.status(400).send({ error: 'VALIDATION_ERROR', details: err.errors }) + } + throw err + } + + // Register + try { + const { user, authToken } = await registerUser( + fastify.prisma, + fastify.mailer, + body + ) + + reply.setCookie('authToken', authToken, { + httpOnly: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7, // 7 jours en secondes + }) + + return reply.status(201).send({ user }) + } catch (err: any) { + if (err.message === 'EMAIL_TAKEN') { + return reply.status(409).send({ error: 'EMAIL_TAKEN' }) + } + throw err + } + }) +} \ No newline at end of file diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts new file mode 100644 index 0000000..47936a8 --- /dev/null +++ b/src/schemas/auth.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const RegisterSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().min(2).max(50), +}) + +export type RegisterInput = z.infer \ No newline at end of file diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..d02bd02 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,70 @@ +import argon2 from 'argon2' +import crypto from 'crypto' +import { PrismaClient } from '../generated/prisma/client.js' +import { Transporter } from 'nodemailer' +import { RegisterInput } from '../schemas/auth.schema.js' +import { generateToken, generateAuthTokenExpiry, generateActionTokenExpiry } from './token.service.js' +import { sendConfirmationMail } from './mail.service.js' + +export async function registerUser( + prisma: PrismaClient, + mailer: Transporter, + input: RegisterInput +) { + // 1. Vérif email unique + const existing = await prisma.user.findUnique({ + where: { email: input.email }, + }) + if (existing) { + throw new Error('EMAIL_TAKEN') + } + + // 2. Hash du mot de passe + const passwordHash = await argon2.hash(input.password) + + // 3. Création de l'user + const user = await prisma.user.create({ + data: { + id: crypto.randomUUID(), + email: input.email, + passwordHash, + displayName: input.displayName, + avatar: '', + }, + select: { + id: true, + email: true, + displayName: true, + isConfirmed: true, + createdAt: true, + }, + }) + + // 4. ActionToken pour confirmation mail + const confirmToken = generateToken() + await prisma.actionToken.create({ + data: { + id: crypto.randomUUID(), + userId: user.id, + token: confirmToken, + type: 'email-confirm', + expiresAt: generateActionTokenExpiry(1440), // 24h + }, + }) + + // 5. Envoi du mail + await sendConfirmationMail(mailer, user.email, confirmToken) + + // 6. AuthToken pour la persistance + const authToken = generateToken() + await prisma.authToken.create({ + data: { + id: crypto.randomUUID(), + userId: user.id, + token: authToken, + expiresAt: generateAuthTokenExpiry(), + }, + }) + + return { user, authToken } +} \ No newline at end of file diff --git a/src/services/mail.service.ts b/src/services/mail.service.ts new file mode 100644 index 0000000..999edd9 --- /dev/null +++ b/src/services/mail.service.ts @@ -0,0 +1,20 @@ +import { Transporter } from 'nodemailer' + +export async function sendConfirmationMail( + mailer: Transporter, + email: string, + token: string +): Promise { + const url = `${process.env.APP_URL}/auth/confirm-email?token=${token}` + + await mailer.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject: 'Confirmez votre adresse mail', + html: ` +

Merci de vous être inscrit !

+

Cliquez sur ce lien pour confirmer votre adresse mail (valable 24h) :

+ ${url} + `, + }) +} \ No newline at end of file diff --git a/src/services/token.service.ts b/src/services/token.service.ts new file mode 100644 index 0000000..c2c5034 --- /dev/null +++ b/src/services/token.service.ts @@ -0,0 +1,17 @@ +import crypto from 'crypto' + +export function generateToken(length = 32): string { + return crypto.randomBytes(length).toString('hex') +} + +export function generateAuthTokenExpiry(): Date { + const date = new Date() + date.setDate(date.getDate() + 7) + return date +} + +export function generateActionTokenExpiry(minutes = 1440): Date { + const date = new Date() + date.setMinutes(date.getMinutes() + minutes) + return date +} \ No newline at end of file