gestion des passwords

This commit is contained in:
2026-04-03 23:32:46 +02:00
parent 94f1099083
commit ea88c684ed
6 changed files with 199 additions and 16 deletions

View File

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

View File

@@ -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<typeof RegisterSchema>
@@ -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<typeof LoginSchema>
export type LoginInput = z.infer<typeof LoginSchema>

View File

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

View File

@@ -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<typeof UpdateDisplayNameSchema>
export type UpdateDisplayNameInput = z.infer<typeof UpdateDisplayNameSchema>
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<typeof ChangePasswordSchema>
export const PasswordRecoveryRequestSchema = z.object({
email: z.email({ error: 'Adresse email invalide.' }),
})
export type PasswordRecoveryRequestInput = z.infer<typeof PasswordRecoveryRequestSchema>
export const ConfirmPasswordRecoverySchema = z.object({
token: z.string().min(1, { error: 'Le token est requis.' }),
newPassword: passwordSchema,
})
export type ConfirmPasswordRecoveryInput = z.infer<typeof ConfirmPasswordRecoverySchema>

View File

@@ -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: `
<html><body>
<p>Vous avez demandé une réinitialisation de votre mot de passe.</p>
<p>
Cliquez sur
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
ce lien
</a>
pour choisir un nouveau mot de passe. Ce lien expire dans 60 minutes.
</p>
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
</body></html>
`,
}),
en: (url: string) => ({
subject: 'Reset your password',
html: `
<html><body>
<p>You requested a password reset.</p>
<p>
Click
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
this link
</a>
to choose a new password. This link expires in 60 minutes.
</p>
<p>If you did not request this, please ignore this email.</p>
</body></html>
`,
}),
}
export async function sendPasswordChangeRequestEmail(
mailer: Transporter,
email: string,
token: string,
lang: Lang = 'fr'
): Promise<void> {
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,

View File

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