gestion des passwords
This commit is contained in:
@@ -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.' })
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
9
src/schemas/shared.schema.ts
Normal file
9
src/schemas/shared.schema.ts
Normal 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.' })
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
66
src/services/passwordManagement.service.ts
Normal file
66
src/services/passwordManagement.service.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user