From ea88c684ed6fb257a43be9ffd4e9e79749f43b6e Mon Sep 17 00:00:00 2001 From: Raffi Date: Fri, 3 Apr 2026 23:32:46 +0200 Subject: [PATCH] gestion des passwords --- src/routes/users.ts | 54 +++++++++++++++--- src/schemas/auth.schema.ts | 12 ++-- src/schemas/shared.schema.ts | 9 +++ src/schemas/user.schema.ts | 23 +++++++- src/services/mail.service.ts | 51 +++++++++++++++++ src/services/passwordManagement.service.ts | 66 ++++++++++++++++++++++ 6 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 src/schemas/shared.schema.ts create mode 100644 src/services/passwordManagement.service.ts diff --git a/src/routes/users.ts b/src/routes/users.ts index f4bb5af..a956c19 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -2,9 +2,9 @@ import { FastifyInstance } from 'fastify' import { Errors } from '../errors/AppError' import { confirmEmail } from '../services/user.service' import { verifyAuth } from '../middleware/verifyAuth' -import { UpdateDisplayNameSchema } from '../schemas/user.schema.js' +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' export default async function userRoutes(fastify: FastifyInstance) { fastify.get('/users', async (request, reply) => { @@ -24,13 +24,13 @@ export default async function userRoutes(fastify: FastifyInstance) { }) fastify.get('/user/confirm', async (request, reply) => { - const { token } = request.query as { token?: string } + const { token } = request.query as { token?: string } - if (!token) { - throw Errors.INVALID_TOKEN - } + if (!token) { + throw Errors.INVALID_TOKEN + } - const result = await confirmEmail(fastify.prisma, token) + const result = await confirmEmail(fastify.prisma, token) return reply.status(200).send(result) }) @@ -39,4 +39,44 @@ export default async function userRoutes(fastify: FastifyInstance) { const user = await updateDisplayName(fastify.prisma, request.user.userId, body) return reply.status(200).send({ user }) }) + + fastify.post('/user/pwd-recovery-request', async (request, reply) => { + const { email } = PasswordRecoveryRequestSchema.parse(request.body) + if (!email) throw Errors.VALIDATION_ERROR + + const lang = request.headers['accept-language'] + ?.split(',')[0] + .split('-')[0] as 'fr' | 'en' + const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr' + + await passwordRecoveryRequest(fastify.prisma, fastify.mailer, email, validLang) + + return reply.status(200).send({ + message: 'Si cet email est enregistré, vous recevrez un lien de réinitialisation.', + }) + }) + + fastify.post('/user/pwd-recovery', async (request, reply) => { + const { token, newPassword } = ConfirmPasswordRecoverySchema.parse(request.body) + if (!token || !newPassword) throw Errors.VALIDATION_ERROR + await confirmPasswordRecovery(fastify.prisma, token, newPassword) + + return reply.status(200).send({ message: 'Mot de passe mis à jour avec succès.' }) + }) + + fastify.patch('/user/pwd-change', { preHandler: verifyAuth }, async (request, reply) => { + const { oldPassword, newPassword } = ChangePasswordSchema.parse(request.body) + if (!oldPassword || !newPassword) throw Errors.VALIDATION_ERROR + + const newToken = await changePassword(fastify.prisma, fastify, request.user.userId, oldPassword, newPassword) + + reply.setCookie('authToken', newToken, { + httpOnly: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7, + path: '/', + }) + + return reply.status(200).send({ message: 'Mot de passe mis à jour avec succès.' }) + }) } \ No newline at end of file diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 85cb5af..4b4aed3 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -1,14 +1,9 @@ import { z } from 'zod' +import { passwordSchema } from './shared.schema.js' export const RegisterSchema = z.object({ email: z.email({ error: 'Adresse email invalide.' }), - password: z - .string() - .regex(/^.{8,22}$/, { error: 'Le mot de passe doit contenir entre 8 et 22 caractères.' }) - .regex(/[^A-Za-z0-9]/, { error: 'Le mot de passe doit contenir au moins un caractère spécial.' }) - .regex(/[A-Z]/, { error: 'Le mot de passe doit contenir au moins une majuscule.' }) - .regex(/[a-z]/, { error: 'Le mot de passe doit contenir au moins une minuscule.' }) - .regex(/\d/, { error: 'Le mot de passe doit contenir au moins un chiffre.' }), + password: passwordSchema }) export type RegisterInput = z.infer @@ -18,4 +13,5 @@ export const LoginSchema = z.object({ password: z.string().min(1, { error: 'Le mot de passe est requis.' }), }) -export type LoginInput = z.infer \ No newline at end of file +export type LoginInput = z.infer + diff --git a/src/schemas/shared.schema.ts b/src/schemas/shared.schema.ts new file mode 100644 index 0000000..cf0fc3b --- /dev/null +++ b/src/schemas/shared.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const passwordSchema = z + .string() + .regex(/^.{8,22}$/, { error: 'Le mot de passe doit contenir entre 8 et 22 caractères.' }) + .regex(/[^A-Za-z0-9]/, { error: 'Le mot de passe doit contenir au moins un caractère spécial.' }) + .regex(/[A-Z]/, { error: 'Le mot de passe doit contenir au moins une majuscule.' }) + .regex(/[a-z]/, { error: 'Le mot de passe doit contenir au moins une minuscule.' }) + .regex(/\d/, { error: 'Le mot de passe doit contenir au moins un chiffre.' }) \ No newline at end of file diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index db10236..61c6223 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -1,7 +1,28 @@ import { z } from 'zod' +import { passwordSchema } from './shared.schema' export const UpdateDisplayNameSchema = z.object({ displayName: z.string().min(2).max(32), }) -export type UpdateDisplayNameInput = z.infer \ No newline at end of file +export type UpdateDisplayNameInput = z.infer + +export const ChangePasswordSchema = z.object({ + oldPassword: z.string().min(1, { error: 'L\'ancien mot de passe est requis.' }), + newPassword: passwordSchema, +}) + +export type ChangePasswordInput = z.infer + +export const PasswordRecoveryRequestSchema = z.object({ + email: z.email({ error: 'Adresse email invalide.' }), +}) + +export type PasswordRecoveryRequestInput = z.infer + +export const ConfirmPasswordRecoverySchema = z.object({ + token: z.string().min(1, { error: 'Le token est requis.' }), + newPassword: passwordSchema, +}) + +export type ConfirmPasswordRecoveryInput = z.infer \ No newline at end of file diff --git a/src/services/mail.service.ts b/src/services/mail.service.ts index dfbc33b..5f6d1a9 100644 --- a/src/services/mail.service.ts +++ b/src/services/mail.service.ts @@ -42,6 +42,57 @@ export async function sendConfirmationMail( const url = `${process.env.FRONT_URL}/${lang}/confirm?token=${token}` const { subject, html } = templates[lang](url) + await mailer.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject, + html, + }) +} + +const passwordChangeTemplates = { + fr: (url: string) => ({ + subject: 'Réinitialisation de votre mot de passe', + html: ` + +

Vous avez demandé une réinitialisation de votre mot de passe.

+

+ Cliquez sur + + ce lien + + pour choisir un nouveau mot de passe. Ce lien expire dans 60 minutes. +

+

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

+ + `, + }), + en: (url: string) => ({ + subject: 'Reset your password', + html: ` + +

You requested a password reset.

+

+ Click + + this link + + to choose a new password. This link expires in 60 minutes. +

+

If you did not request this, please ignore this email.

+ + `, + }), +} + +export async function sendPasswordChangeRequestEmail( + mailer: Transporter, + email: string, + token: string, + lang: Lang = 'fr' +): Promise { + const url = `${process.env.FRONT_URL}/${lang}/reset-pwd?token=${token}` + const { subject, html } = passwordChangeTemplates[lang](url) await mailer.sendMail({ from: process.env.MAIL_FROM, to: email, diff --git a/src/services/passwordManagement.service.ts b/src/services/passwordManagement.service.ts new file mode 100644 index 0000000..3346103 --- /dev/null +++ b/src/services/passwordManagement.service.ts @@ -0,0 +1,66 @@ +import argon2 from 'argon2' +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 { FastifyInstance } from 'fastify' +import { signAuthToken } from './authToken.service.js' + +export async function passwordRecoveryRequest( + prisma: PrismaClient, + mailer: Transporter, + email: string, + lang: 'fr' | 'en' = 'fr' + ): Promise { + const user = await prisma.user.findUnique({ where: { email } }) + + if (!user) return // Réponse neutre + + const token = await createActionToken(prisma, user.id, 'password-change', 60) + + await sendPasswordChangeRequestEmail(mailer, user.email, token, lang) +} + +export async function confirmPasswordRecovery( + prisma: PrismaClient, + token: string, + newPassword: string + ): Promise { + const actionToken = await consumeActionToken(prisma, token, 'password-change') + + const passwordHash = await argon2.hash(newPassword) + + await prisma.user.update({ + where: { id: actionToken.userId }, + data: { passwordHash }, + }) +} + +export async function changePassword( + prisma: PrismaClient, + fastify: FastifyInstance, + userId: string, + oldPassword: string, + newPassword: string +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordHash: true, tokenVersion: true }, + }) + + if (!user) throw Errors.USER_NOT_FOUND + + const isValid = await argon2.verify(user.passwordHash, oldPassword) + if (!isValid) throw Errors.INVALID_CREDENTIALS + + const passwordHash = await argon2.hash(newPassword) + const newTokenVersion = user.tokenVersion + 1 + + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash, tokenVersion: newTokenVersion }, + }) + + return signAuthToken(fastify, { userId, tokenVersion: newTokenVersion }) +} \ No newline at end of file