Compare commits
4 Commits
auth
...
845ec655d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 845ec655d1 | |||
| 2bcae68eed | |||
| ea88c684ed | |||
| 94f1099083 |
24
package-lock.json
generated
24
package-lock.json
generated
@@ -16,12 +16,14 @@
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"prisma": "^7.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -1100,6 +1102,13 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
@@ -1405,9 +1414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz",
|
||||
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2061,6 +2070,15 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"prisma": "^7.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
|
||||
@@ -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,13 @@ model User {
|
||||
isConfirmed Boolean @default(false)
|
||||
isGoogleUser Boolean @default(false)
|
||||
avatar String
|
||||
createdAt DateTime @default(now())
|
||||
publicKey String?
|
||||
encryptedPrivateKey String?
|
||||
emailSwap String?
|
||||
emailSwapAt DateTime?
|
||||
scheduledDeletionAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ActionToken ActionToken[]
|
||||
UserPreference UserPreference?
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import mailerPlugin from './plugins/mailer.js'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import userRoutes from './routes/users.js'
|
||||
import errorHandler from './plugins/errorHandler.js'
|
||||
import cronJobs from './plugins/cronJobs.js'
|
||||
|
||||
export default function buildApp() {
|
||||
const app = Fastify({ logger: true })
|
||||
@@ -17,7 +18,8 @@ export default function buildApp() {
|
||||
})
|
||||
app.register(prismaPlugin)
|
||||
app.register(mailerPlugin)
|
||||
|
||||
app.register(cronJobs)
|
||||
|
||||
app.register(authRoutes, { prefix: '/api' })
|
||||
app.register(userRoutes, { prefix: '/api' })
|
||||
|
||||
|
||||
@@ -21,8 +21,14 @@ 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'),
|
||||
//acount deletion errors
|
||||
ACCOUNT_PENDING_DELETION: new AppError('ACCOUNT_PENDING_DELETION', 403, 'Account is scheduled for deletion. Check your emails to cancel.'),
|
||||
ACCOUNT_ALREADY_PENDING_DELETION: new AppError('ACCOUNT_ALREADY_PENDING_DELETION', 409, 'Account is already scheduled for deletion.'),
|
||||
//Auth errors
|
||||
UNAUTHORIZED: new AppError('UNAUTHORIZED', 401, 'Non authentifié'),
|
||||
USER_NOT_FOUND: new AppError('USER_NOT_FOUND', 404, 'Utilisateur introuvable'),
|
||||
|
||||
26
src/plugins/cronJobs.ts
Normal file
26
src/plugins/cronJobs.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import cron from 'node-cron'
|
||||
|
||||
export default fp(async (fastify: FastifyInstance) => {
|
||||
// Cleaning all nights at 2am
|
||||
cron.schedule('0 2 * * *', async () => {
|
||||
fastify.log.info('Cron: démarrage du nettoyage nocturne')
|
||||
|
||||
// Suppression des comptes dont la date de suppression est dépassée
|
||||
const deletedAccounts = await fastify.prisma.user.deleteMany({
|
||||
where: {
|
||||
scheduledDeletionAt: { lte: new Date() },
|
||||
},
|
||||
})
|
||||
fastify.log.info(`Cron: ${deletedAccounts.count} compte(s) supprimé(s)`)
|
||||
|
||||
// Nettoyage des tokens expirés
|
||||
const deletedTokens = await fastify.prisma.actionToken.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lte: new Date() },
|
||||
},
|
||||
})
|
||||
fastify.log.info(`Cron: ${deletedTokens.count} token(s) expiré(s) supprimé(s)`)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,12 @@ 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'
|
||||
import { EmailChangeRequestSchema } from '../schemas/user.schema.js'
|
||||
import { emailChangeRequest, emailChangeConfirm, emailRollback } from '../services/emailChange.service.js'
|
||||
import { scheduleAccountDeletion, cancelAccountDeletion } from '../services/accountDeletion.service.js'
|
||||
|
||||
export default async function userRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/users', async (request, reply) => {
|
||||
@@ -24,13 +27,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 +42,87 @@ 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.' })
|
||||
})
|
||||
|
||||
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.' })
|
||||
})
|
||||
|
||||
fastify.delete('/user/account', { preHandler: verifyAuth }, async (request, reply) => {
|
||||
await scheduleAccountDeletion(fastify.prisma, fastify.mailer, request.user.userId)
|
||||
return reply.status(200).send({ message: 'Votre compte sera supprimé dans 7 jours.' })
|
||||
})
|
||||
|
||||
fastify.get('/user/delete-cancel', async (request, reply) => {
|
||||
const { token } = request.query as { token?: string }
|
||||
if (!token) throw Errors.INVALID_TOKEN
|
||||
|
||||
await cancelAccountDeletion(fastify.prisma, token)
|
||||
return reply.status(200).send({ message: 'Suppression annulée, votre compte est restauré.' })
|
||||
})
|
||||
}
|
||||
@@ -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,34 @@
|
||||
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>
|
||||
|
||||
export const EmailChangeRequestSchema = z.object({
|
||||
newEmail: z.email({ error: 'Adresse email invalide.' }),
|
||||
})
|
||||
|
||||
export type EmailChangeRequestInput = z.infer<typeof EmailChangeRequestSchema>
|
||||
46
src/services/accountDeletion.service.ts
Normal file
46
src/services/accountDeletion.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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 { sendAccountDeletionWarning } from './mailing.service.js'
|
||||
|
||||
export async function scheduleAccountDeletion(
|
||||
prisma: PrismaClient,
|
||||
mailer: Transporter,
|
||||
userId: string,
|
||||
lang: 'fr' | 'en' = 'fr'
|
||||
): Promise<void> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) throw Errors.USER_NOT_FOUND
|
||||
if (user.scheduledDeletionAt) throw Errors.ACCOUNT_ALREADY_PENDING_DELETION
|
||||
|
||||
const scheduledDeletionAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { scheduledDeletionAt },
|
||||
})
|
||||
|
||||
const cancelToken = await createActionToken(prisma, userId, 'delete-cancel', 7 * 24 * 60)
|
||||
|
||||
await sendAccountDeletionWarning(mailer, user.email, user.displayName, cancelToken, lang)
|
||||
}
|
||||
|
||||
export async function cancelAccountDeletion(
|
||||
prisma: PrismaClient,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const actionToken = await consumeActionToken(prisma, token, 'delete-cancel')
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: actionToken.userId },
|
||||
select: { scheduledDeletionAt: true },
|
||||
})
|
||||
|
||||
if (!user?.scheduledDeletionAt) throw Errors.INVALID_TOKEN
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: actionToken.userId },
|
||||
data: { scheduledDeletionAt: null },
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -3,9 +3,10 @@ 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'
|
||||
|
||||
export async function registerUser(
|
||||
prisma: PrismaClient,
|
||||
@@ -25,12 +26,13 @@ export async function registerUser(
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
displayName,
|
||||
avatar: '',
|
||||
avatar: generateGravatarUrl(input.email),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
avatar:true,
|
||||
isConfirmed: true,
|
||||
createdAt: true,
|
||||
},
|
||||
@@ -57,12 +59,14 @@ export async function loginUser(prisma: PrismaClient, input: LoginInput) {
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, input.password)
|
||||
if (!valid) throw Errors.INVALID_CREDENTIALS
|
||||
if (user.scheduledDeletionAt) throw Errors.ACCOUNT_PENDING_DELETION
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar,
|
||||
isConfirmed: user.isConfirmed,
|
||||
createdAt: user.createdAt,
|
||||
tokenVersion: user.tokenVersion,
|
||||
|
||||
10
src/services/avatar.service.ts
Normal file
10
src/services/avatar.service.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
export function generateGravatarUrl(email: string, size = 200): string {
|
||||
const hash = crypto
|
||||
.createHash('md5')
|
||||
.update(email.trim().toLowerCase())
|
||||
.digest('hex')
|
||||
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`
|
||||
}
|
||||
102
src/services/emailChange.service.ts
Normal file
102
src/services/emailChange.service.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Transporter } from 'nodemailer'
|
||||
|
||||
type Lang = 'fr' | 'en'
|
||||
|
||||
const templates = {
|
||||
fr: (url: string) => ({
|
||||
subject: 'Merci de confirmer votre e-mail',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Bienvenue sur le gestionnaire de listes !</p>
|
||||
<p>
|
||||
Pour pouvoir utiliser le site, vous devez confirmer votre adresse mail sous 7 jours en cliquant sur
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
ce lien
|
||||
</a>.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
en: (url: string) => ({
|
||||
subject: 'Please confirm your email address',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Welcome to the list manager!</p>
|
||||
<p>
|
||||
To start using the site, please confirm your email address within 7 days by clicking
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
this link
|
||||
</a>.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export async function sendConfirmationMail(
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
250
src/services/mailing.service.ts
Normal file
250
src/services/mailing.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Transporter } from 'nodemailer'
|
||||
|
||||
type Lang = 'fr' | 'en'
|
||||
|
||||
const templates = {
|
||||
fr: (url: string) => ({
|
||||
subject: 'Merci de confirmer votre e-mail',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Bienvenue sur le gestionnaire de listes !</p>
|
||||
<p>
|
||||
Pour pouvoir utiliser le site, vous devez confirmer votre adresse mail sous 7 jours en cliquant sur
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
ce lien
|
||||
</a>.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
en: (url: string) => ({
|
||||
subject: 'Please confirm your email address',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Welcome to the list manager!</p>
|
||||
<p>
|
||||
To start using the site, please confirm your email address within 7 days by clicking
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
this link
|
||||
</a>.
|
||||
</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export async function sendConfirmationMail(
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
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,
|
||||
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 été 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,
|
||||
})
|
||||
}
|
||||
|
||||
const accountDeletionTemplates = {
|
||||
fr: (url: string) => ({
|
||||
subject: 'Suppression de votre compte',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Votre compte a été programmé pour être supprimé dans 7 jours.</p>
|
||||
<p>
|
||||
Si vous souhaitez annuler cette suppression, cliquez sur
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
ce lien
|
||||
</a>.
|
||||
Ce lien est valable 7 jours.
|
||||
</p>
|
||||
<p>Sans action de votre part, votre compte sera définitivement supprimé.</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
en: (url: string) => ({
|
||||
subject: 'Account deletion scheduled',
|
||||
html: `
|
||||
<html><body>
|
||||
<p>Your account has been scheduled for deletion in 7 days.</p>
|
||||
<p>
|
||||
If you wish to cancel this deletion, click
|
||||
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
|
||||
this link
|
||||
</a>.
|
||||
This link is valid for 7 days.
|
||||
</p>
|
||||
<p>If you do nothing, your account will be permanently deleted.</p>
|
||||
</body></html>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export async function sendAccountDeletionWarning(
|
||||
mailer: Transporter,
|
||||
email: string,
|
||||
displayName: string,
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
const url = `${process.env.FRONT_URL}/${lang}/delete-cancel?token=${token}`
|
||||
const { subject, html } = accountDeletionTemplates[lang](url)
|
||||
await mailer.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
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 './mailing.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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user