ajout de la logique de remplacement d'adresse mail

This commit is contained in:
2026-04-04 21:43:57 +02:00
parent ea88c684ed
commit 2bcae68eed
10 changed files with 249 additions and 10 deletions

View File

@@ -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?
}

View File

@@ -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'),

View File

@@ -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.' })
})
}

View File

@@ -25,4 +25,10 @@ export const ConfirmPasswordRecoverySchema = z.object({
newPassword: passwordSchema,
})
export type ConfirmPasswordRecoveryInput = z.infer<typeof ConfirmPasswordRecoverySchema>
export type ConfirmPasswordRecoveryInput = z.infer<typeof ConfirmPasswordRecoverySchema>
export const EmailChangeRequestSchema = z.object({
newEmail: z.email({ error: 'Adresse email invalide.' }),
})
export type EmailChangeRequestInput = z.infer<typeof EmailChangeRequestSchema>

View File

@@ -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

View File

@@ -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'

View File

@@ -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<void> {
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<void> {
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<void> {
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,
},
})
}

View File

@@ -99,4 +99,100 @@ export async function sendPasswordChangeRequestEmail(
subject,
html,
})
}
const emailChangeWarningTemplates = {
fr: (url: string) => ({
subject: 'Demande de changement d\'adresse email',
html: `
<html><body>
<p>Une demande de changement d'adresse email a é effectuée sur votre compte.</p>
<p>
Si vous n'êtes pas à l'origine de cette demande, annulez-la immédiatement en cliquant sur
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
ce lien
</a>.
Ce lien est valable 24 heures.
</p>
</body></html>
`,
}),
en: (url: string) => ({
subject: 'Email address change request',
html: `
<html><body>
<p>A request to change your email address was made on your account.</p>
<p>
If you did not request this, cancel it immediately by clicking
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
this link
</a>.
This link is valid for 24 hours.
</p>
</body></html>
`,
}),
}
const emailChangeConfirmationTemplates = {
fr: (url: string) => ({
subject: 'Confirmez votre nouvelle adresse email',
html: `
<html><body>
<p>Pour finaliser le changement de votre adresse email, cliquez sur
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
ce lien
</a>.
Ce lien expire dans 15 minutes.
</p>
</body></html>
`,
}),
en: (url: string) => ({
subject: 'Confirm your new email address',
html: `
<html><body>
<p>To complete your email address change, click
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
this link
</a>.
This link expires in 15 minutes.
</p>
</body></html>
`,
}),
}
export async function sendEmailChangeWarning(
mailer: Transporter,
email: string,
displayName: string,
token: string,
lang: Lang = 'fr'
): Promise<void> {
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<void> {
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,
})
}

View File

@@ -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'

View File

@@ -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
}