diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d55a458..2bb4f95 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,7 +13,6 @@ model ActionToken { token String @unique type String expiresAt DateTime - used Boolean @default(false) createdAt DateTime @default(now()) User User @relation(fields: [userId], references: [id], onDelete: Cascade) } @@ -28,9 +27,11 @@ model User { isConfirmed Boolean @default(false) isGoogleUser Boolean @default(false) avatar String - createdAt DateTime @default(now()) publicKey String? encryptedPrivateKey String? + emailSwap String? + emailSwapAt DateTime? + createdAt DateTime @default(now()) ActionToken ActionToken[] UserPreference UserPreference? } diff --git a/src/errors/AppError.ts b/src/errors/AppError.ts index 70b3ba1..485cf5f 100644 --- a/src/errors/AppError.ts +++ b/src/errors/AppError.ts @@ -21,8 +21,12 @@ export const Errors = { //Action token errors INVALID_TOKEN: new AppError('INVALID_TOKEN', 400, 'Invalid or already used token'), TOKEN_EXPIRED: new AppError('TOKEN_EXPIRED', 400, 'Token has expired'), + // Email confirmation errors ALREADY_CONFIRMED: new AppError('ALREADY_CONFIRMED', 400, 'User is already confirmed'), - + // Email change errors + EMAIL_ALREADY_YOURS: new AppError('EMAIL_ALREADY_YOURS', 409, 'This email is already yours'), + EMAIL_ALREADY_USED: new AppError('EMAIL_ALREADY_USED', 409, 'This email is already in use'), + //Auth errors UNAUTHORIZED: new AppError('UNAUTHORIZED', 401, 'Non authentifié'), USER_NOT_FOUND: new AppError('USER_NOT_FOUND', 404, 'Utilisateur introuvable'), diff --git a/src/routes/users.ts b/src/routes/users.ts index a956c19..d45c136 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -5,6 +5,8 @@ import { verifyAuth } from '../middleware/verifyAuth' import { UpdateDisplayNameSchema, ChangePasswordSchema, PasswordRecoveryRequestSchema, ConfirmPasswordRecoverySchema } from '../schemas/user.schema.js' import { updateDisplayName } from '../services/user.service.js' import { passwordRecoveryRequest, confirmPasswordRecovery, changePassword } from '../services/passwordManagement.service.js' +import { EmailChangeRequestSchema } from '../schemas/user.schema.js' +import { emailChangeRequest, emailChangeConfirm, emailRollback } from '../services/emailChange.service.js' export default async function userRoutes(fastify: FastifyInstance) { fastify.get('/users', async (request, reply) => { @@ -79,4 +81,34 @@ export default async function userRoutes(fastify: FastifyInstance) { return reply.status(200).send({ message: 'Mot de passe mis à jour avec succès.' }) }) + + fastify.post('/user/email-change-request', { preHandler: verifyAuth }, async (request, reply) => { + const { newEmail } = EmailChangeRequestSchema.parse(request.body) + const lang = request.headers['accept-language'] + ?.split(',')[0] + .split('-')[0] as 'fr' | 'en' + const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr' + + await emailChangeRequest(fastify.prisma, fastify.mailer, request.user.userId, newEmail, validLang) + + return reply.status(200).send({ message: 'Un email de confirmation a été envoyé à votre nouvelle adresse.' }) + }) + + fastify.get('/user/email-change-confirm', async (request, reply) => { + const { token } = request.query as { token?: string } + if (!token) throw Errors.INVALID_TOKEN + + await emailChangeConfirm(fastify.prisma, token) + + return reply.status(200).send({ message: 'Email mis à jour avec succès.' }) + }) + + fastify.get('/user/email-change-rollback', async (request, reply) => { + const { token } = request.query as { token?: string } + if (!token) throw Errors.INVALID_TOKEN + + await emailRollback(fastify.prisma, token) + + return reply.status(200).send({ message: 'Email restauré avec succès.' }) + }) } \ No newline at end of file diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index 61c6223..e5a4e25 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -25,4 +25,10 @@ export const ConfirmPasswordRecoverySchema = z.object({ newPassword: passwordSchema, }) -export type ConfirmPasswordRecoveryInput = z.infer \ No newline at end of file +export type ConfirmPasswordRecoveryInput = z.infer + +export const EmailChangeRequestSchema = z.object({ + newEmail: z.email({ error: 'Adresse email invalide.' }), +}) + +export type EmailChangeRequestInput = z.infer \ No newline at end of file diff --git a/src/services/actionToken.service.ts b/src/services/actionToken.service.ts index 80b3f4a..1d0a955 100644 --- a/src/services/actionToken.service.ts +++ b/src/services/actionToken.service.ts @@ -39,12 +39,10 @@ export async function consumeActionToken( const actionToken = await prisma.actionToken.findUnique({ where: { token } }) if (!actionToken || actionToken.type !== type) throw Errors.INVALID_TOKEN - if (actionToken.used) throw Errors.INVALID_TOKEN if (actionToken.expiresAt < new Date()) throw Errors.INVALID_TOKEN - await prisma.actionToken.update({ + await prisma.actionToken.delete({ where: { token }, - data: { used: true }, }) return actionToken diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 1de3830..6e570ea 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -3,7 +3,7 @@ import crypto from 'crypto' import { PrismaClient } from '../generated/prisma/client.js' import { Transporter } from 'nodemailer' import { RegisterInput, LoginInput } from '../schemas/auth.schema.js' -import { sendConfirmationMail } from './mail.service.js' +import { sendConfirmationMail } from './mailing.service.js' import { createActionToken } from './actionToken.service.js' import { Errors } from '../errors/AppError.js' import { generateGravatarUrl } from './avatar.service.js' diff --git a/src/services/emailChange.service.ts b/src/services/emailChange.service.ts new file mode 100644 index 0000000..6469fb7 --- /dev/null +++ b/src/services/emailChange.service.ts @@ -0,0 +1,102 @@ +import { PrismaClient } from '../generated/prisma/client.js' +import { Transporter } from 'nodemailer' +import { Errors } from '../errors/AppError.js' +import { createActionToken, consumeActionToken } from './actionToken.service.js' +import { sendEmailChangeConfirmation, sendEmailChangeWarning } from './mailing.service.js' + +export async function emailChangeRequest( + prisma: PrismaClient, + mailer: Transporter, + userId: string, + newEmail: string, + lang: 'fr' | 'en' = 'fr' +): Promise { + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) throw Errors.USER_NOT_FOUND + + if (newEmail === user.email) throw Errors.EMAIL_ALREADY_YOURS + + const emailTaken = await prisma.user.findUnique({ where: { email: newEmail } }) + if (emailTaken) throw Errors.EMAIL_ALREADY_USED + + // On stocke le nouvel email dans emailSwap, valable 15 min + await prisma.user.update({ + where: { id: userId }, + data: { emailSwap: newEmail, emailSwapAt: new Date() }, + }) + + // Token de confirmation pour la nouvelle boite (15 min) + const confirmToken = await createActionToken(prisma, userId, 'email-change-confirm', 15) + + // Token de rollback pour l'ancienne boite (24h) + const rollbackToken = await createActionToken(prisma, userId, 'email-change-rollback', 1440) + + // Mail d'avertissement à l'ancienne adresse + await sendEmailChangeWarning(mailer, user.email, user.displayName, rollbackToken, lang) + + // Mail de confirmation à la nouvelle adresse + await sendEmailChangeConfirmation(mailer, newEmail, user.displayName, confirmToken, lang) +} + +export async function emailChangeConfirm( + prisma: PrismaClient, + token: string +): Promise { + const actionToken = await consumeActionToken(prisma, token, 'email-change-confirm') + + const user = await prisma.user.findUnique({ + where: { id: actionToken.userId }, + select: { emailSwap: true, emailSwapAt: true }, + }) + + if (!user?.emailSwap || !user.emailSwapAt) throw Errors.INVALID_TOKEN + + // Vérification fenêtre 15 min + const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000) + if (user.emailSwapAt < fifteenMinutesAgo) throw Errors.TOKEN_EXPIRED + + // Permutation : le nouvel email devient l'email principal + // l'ancien email va dans emailSwap pour le rollback (24h) + const currentEmail = await prisma.user.findUnique({ + where: { id: actionToken.userId }, + select: { email: true }, + }) + + await prisma.user.update({ + where: { id: actionToken.userId }, + data: { + email: user.emailSwap, + emailSwap: currentEmail!.email, + emailSwapAt: new Date(), + }, + }) +} + +export async function emailRollback( + prisma: PrismaClient, + token: string +): Promise { + const actionToken = await consumeActionToken(prisma, token, 'email-change-rollback') + + const user = await prisma.user.findUnique({ + where: { id: actionToken.userId }, + select: { emailSwap: true, emailSwapAt: true }, + }) + + if (!user?.emailSwap || !user.emailSwapAt) throw Errors.INVALID_TOKEN + + // Vérification fenêtre 24h + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000) + if (user.emailSwapAt < twentyFourHoursAgo) throw Errors.TOKEN_EXPIRED + + // On remet l'ancien email en place et on nettoie + await prisma.user.update({ + where: { id: actionToken.userId }, + data: { + email: user.emailSwap, + emailSwap: null, + emailSwapAt: null, + }, + }) +} \ No newline at end of file diff --git a/src/services/mail.service.ts b/src/services/mailing.service.ts similarity index 50% rename from src/services/mail.service.ts rename to src/services/mailing.service.ts index 5f6d1a9..78dffd6 100644 --- a/src/services/mail.service.ts +++ b/src/services/mailing.service.ts @@ -99,4 +99,100 @@ export async function sendPasswordChangeRequestEmail( subject, html, }) +} + +const emailChangeWarningTemplates = { + fr: (url: string) => ({ + subject: 'Demande de changement d\'adresse email', + html: ` + +

Une demande de changement d'adresse email a été effectuée sur votre compte.

+

+ Si vous n'êtes pas à l'origine de cette demande, annulez-la immédiatement en cliquant sur + + ce lien + . + Ce lien est valable 24 heures. +

+ + `, + }), + en: (url: string) => ({ + subject: 'Email address change request', + html: ` + +

A request to change your email address was made on your account.

+

+ If you did not request this, cancel it immediately by clicking + + this link + . + This link is valid for 24 hours. +

+ + `, + }), +} + +const emailChangeConfirmationTemplates = { + fr: (url: string) => ({ + subject: 'Confirmez votre nouvelle adresse email', + html: ` + +

Pour finaliser le changement de votre adresse email, cliquez sur + + ce lien + . + Ce lien expire dans 15 minutes. +

+ + `, + }), + en: (url: string) => ({ + subject: 'Confirm your new email address', + html: ` + +

To complete your email address change, click + + this link + . + This link expires in 15 minutes. +

+ + `, + }), +} + +export async function sendEmailChangeWarning( + mailer: Transporter, + email: string, + displayName: string, + token: string, + lang: Lang = 'fr' +): Promise { + const url = `${process.env.FRONT_URL}/${lang}/email-rollback?token=${token}` + const { subject, html } = emailChangeWarningTemplates[lang](url) + await mailer.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject, + html, + }) +} + +export async function sendEmailChangeConfirmation( + mailer: Transporter, + email: string, + displayName: string, + token: string, + lang: Lang = 'fr' +): Promise { + const url = `${process.env.FRONT_URL}/${lang}/email-change-confirm?token=${token}` + const { subject, html } = emailChangeConfirmationTemplates[lang](url) + await mailer.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject, + html, + }) } \ No newline at end of file diff --git a/src/services/passwordManagement.service.ts b/src/services/passwordManagement.service.ts index 3346103..263b2c6 100644 --- a/src/services/passwordManagement.service.ts +++ b/src/services/passwordManagement.service.ts @@ -3,7 +3,7 @@ import { Transporter } from 'nodemailer' import { PrismaClient } from '../generated/prisma/client.js' import { Errors } from '../errors/AppError.js' import { createActionToken, consumeActionToken } from './actionToken.service.js' -import { sendPasswordChangeRequestEmail } from './mail.service.js' +import { sendPasswordChangeRequestEmail } from './mailing.service.js' import { FastifyInstance } from 'fastify' import { signAuthToken } from './authToken.service.js' diff --git a/src/services/user.service.ts b/src/services/user.service.ts index fa22dcc..6d9ffee 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -7,7 +7,7 @@ export async function confirmEmail(prisma: PrismaClient, token: string) { where: { token }, }) - if (!actionToken || actionToken.type !== 'email-confirm' || actionToken.used) { + if (!actionToken || actionToken.type !== 'email-confirm') { throw Errors.INVALID_TOKEN }