Compare commits

14 Commits

24 changed files with 1319 additions and 21 deletions

213
package-lock.json generated
View File

@@ -9,17 +9,21 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/jwt": "^10.0.0",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"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",
@@ -548,6 +552,26 @@
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^1.0.0",
"fastify-plugin": "^5.0.0"
}
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
@@ -599,6 +623,29 @@
],
"license": "MIT"
},
"node_modules/@fastify/jwt": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
"integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.2.0",
"@lukeed/ms": "^2.0.2",
"fast-jwt": "^6.0.2",
"fastify-plugin": "^5.0.1",
"steed": "^1.1.3"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -658,6 +705,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -1046,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",
@@ -1133,6 +1196,18 @@
"node": ">=16.17.0"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1185,6 +1260,12 @@
"better-result": "bin/cli.mjs"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -1333,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"
},
@@ -1378,6 +1459,15 @@
"url": "https://dotenvx.com"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -1520,6 +1610,21 @@
"rfdc": "^1.2.0"
}
},
"node_modules/fast-jwt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
@@ -1545,6 +1650,18 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastfall": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
"license": "MIT",
"dependencies": {
"reusify": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fastify": {
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
@@ -1594,6 +1711,16 @@
],
"license": "MIT"
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4",
"xtend": "^4.0.2"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -1603,6 +1730,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fastseries": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.0",
"xtend": "^4.0.0"
}
},
"node_modules/find-my-way": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
@@ -1752,6 +1889,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
@@ -1869,6 +2012,21 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/mysql2": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
@@ -1912,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",
@@ -1964,6 +2131,12 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@@ -2425,6 +2598,26 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
@@ -2460,7 +2653,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/scheduler": {
@@ -2587,6 +2779,19 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
"license": "MIT",
"dependencies": {
"fastfall": "^1.5.0",
"fastparallel": "^2.2.0",
"fastq": "^1.3.0",
"fastseries": "^1.7.0",
"reusify": "^1.0.0"
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",

View File

@@ -16,17 +16,21 @@
"author": "Raffi",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/jwt": "^10.0.0",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"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",

View File

@@ -13,32 +13,36 @@ 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)
}
model AuthToken {
id String @id
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
revoked Boolean @default(false)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id
email String @unique
id String @id
email String @unique
passwordHash String
displayName String
isConfirmed Boolean @default(false)
isGoogleUser Boolean @default(false)
tokenVersion Int @default(0)
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[]
AuthToken AuthToken[]
UserPreference UserPreference?
}
model UserPreference {
id String @id
userId String @unique
language String @default("fr")
theme String @default("light")
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

View File

@@ -1,11 +1,26 @@
import Fastify from 'fastify'
import prismaPlugin from './plugins/prisma'
import cookie from '@fastify/cookie'
import jwt from '@fastify/jwt'
import prismaPlugin from './plugins/prisma.js'
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 })
app.register(errorHandler)
app.register(cookie)
app.register(jwt, {
secret: process.env.JWT_SECRET!,
})
app.register(prismaPlugin)
app.register(mailerPlugin)
app.register(cronJobs)
app.register(authRoutes, { prefix: '/api' })
app.register(userRoutes, { prefix: '/api' })
app.get('/health', async () => ({ status: 'ok' }))

35
src/errors/AppError.ts Normal file
View File

@@ -0,0 +1,35 @@
export class AppError extends Error {
constructor(
public readonly code: string,
public readonly statusCode: number,
message: string
) {
super(message)
this.name = 'AppError'
}
}
// Erreurs prédéfinies
export const Errors = {
// registration errors
EMAIL_TAKEN: new AppError('EMAIL_TAKEN', 409, 'Cette adresse email est déjà utilisée.'),
PASSWORD_TOO_WEAK: new AppError('PASSWORD_TOO_WEAK', 400, 'Le mot de passe doit contenir au moins 8 caractères.'),
INVALID_CREDENTIALS: new AppError('INVALID_CREDENTIALS', 401, 'Email ou mot de passe incorrect.'),
VALIDATION_ERROR: (message: string) => new AppError('VALIDATION_ERROR', 400, message),
//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'),
}

View File

@@ -0,0 +1,19 @@
import { FastifyRequest, FastifyReply } from 'fastify'
import { verifyAuthToken } from '../services/authToken.service.js'
import { Errors } from '../errors/AppError.js'
export async function verifyAuth(request: FastifyRequest, reply: FastifyReply) {
const token = request.cookies['authToken']
console.log('token reçu:', token)
if (!token) throw Errors.UNAUTHORIZED
try {
const payload = await verifyAuthToken(request.server, request.server.prisma, token)
console.log('payload:', payload)
request.user = payload
} catch (e) {
console.log('erreur:', e)
throw Errors.UNAUTHORIZED
}
}

26
src/plugins/cronJobs.ts Normal file
View 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)`)
})
})

View File

@@ -0,0 +1,34 @@
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'
import { ZodError } from 'zod'
import { AppError } from '../errors/AppError.js'
export default fp(async (fastify: FastifyInstance) => {
fastify.setErrorHandler((error, request, reply) => {
// Erreur Zod
if (error instanceof ZodError) {
return reply.status(400).send({
error: 'VALIDATION_ERROR',
details: error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
})
}
// Erreur métier
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: error.code,
message: error.message,
})
}
// Erreur inconnue
fastify.log.error(error)
return reply.status(500).send({
error: 'INTERNAL_ERROR',
message: 'Une erreur interne est survenue.',
})
})
})

22
src/plugins/mailer.ts Normal file
View File

@@ -0,0 +1,22 @@
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'
import nodemailer, { Transporter } from 'nodemailer'
declare module 'fastify' {
interface FastifyInstance {
mailer: Transporter
}
}
export default fp(async (fastify: FastifyInstance) => {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
fastify.decorate('mailer', transporter)
})

50
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,50 @@
import { FastifyInstance } from 'fastify'
import { RegisterSchema, LoginSchema } from '../schemas/auth.schema.js'
import { registerUser, loginUser, logoutUser } from '../services/auth.service.js'
import { signAuthToken } from '../services/authToken.service.js'
import { verifyAuth } from '../middleware/verifyAuth.js'
export default async function authRoutes(fastify: FastifyInstance) {
fastify.post('/auth/register', async (request, reply) => {
const body = RegisterSchema.parse(request.body)
const lang = request.headers['accept-language']
?.split(',')[0]
.split('-')[0] as 'fr' | 'en'
const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr'
const { user } = await registerUser(fastify.prisma, fastify.mailer, body, validLang)
const token = signAuthToken(fastify, { userId: user.id, tokenVersion: 0 })
reply.setCookie('authToken', token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return reply.status(201).send({ user })
})
fastify.post('/auth/login', async (request, reply) => {
const body = LoginSchema.parse(request.body)
const { user } = await loginUser(fastify.prisma, body)
const token = signAuthToken(fastify, { userId: user.id, tokenVersion: user.tokenVersion })
reply.setCookie('authToken', token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return reply.status(200).send({ user })
})
fastify.post('/auth/logout', { preHandler: verifyAuth }, async (request, reply) => {
await logoutUser(fastify.prisma, request.user.userId)
reply.clearCookie('authToken', { path: '/' })
return reply.status(200).send({ message: 'Déconnecté avec succès' })
})
}

View File

@@ -1,4 +1,13 @@
import { FastifyInstance } from 'fastify'
import { Errors } from '../errors/AppError'
import { confirmEmail } from '../services/user.service'
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'
import { scheduleAccountDeletion, cancelAccountDeletion } from '../services/accountDeletion.service.js'
export default async function userRoutes(fastify: FastifyInstance) {
fastify.get('/users', async (request, reply) => {
@@ -16,4 +25,104 @@ export default async function userRoutes(fastify: FastifyInstance) {
return users
})
fastify.get('/user/confirm', async (request, reply) => {
const { token } = request.query as { token?: string }
if (!token) {
throw Errors.INVALID_TOKEN
}
const result = await confirmEmail(fastify.prisma, token)
return reply.status(200).send(result)
})
fastify.patch('/user/display-name', { preHandler: verifyAuth }, async (request, reply) => {
const body = UpdateDisplayNameSchema.parse(request.body)
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é.' })
})
}

View File

@@ -0,0 +1,17 @@
import { z } from 'zod'
import { passwordSchema } from './shared.schema.js'
export const RegisterSchema = z.object({
email: z.email({ error: 'Adresse email invalide.' }),
password: passwordSchema
})
export type RegisterInput = z.infer<typeof RegisterSchema>
export const LoginSchema = z.object({
email: z.email({ error: 'Adresse email invalide.' }),
password: z.string().min(1, { error: 'Le mot de passe est requis.' }),
})
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

@@ -0,0 +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 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>

View 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 },
})
}

View File

@@ -0,0 +1,49 @@
import crypto from 'crypto'
import { PrismaClient } from '../generated/prisma/client.js'
import { Errors } from '../errors/AppError.js'
function generateToken(length = 32): string {
return crypto.randomBytes(length).toString('hex')
}
function generateActionTokenExpiry(minutes = 1440): Date {
const date = new Date()
date.setMinutes(date.getMinutes() + minutes)
return date
}
export async function createActionToken(
prisma: PrismaClient,
userId: string,
type: string,
expiryMinutes = 1440
): Promise<string> {
const token = generateToken()
await prisma.actionToken.create({
data: {
id: crypto.randomUUID(),
userId,
token,
type,
expiresAt: generateActionTokenExpiry(expiryMinutes),
},
})
return token
}
export async function consumeActionToken(
prisma: PrismaClient,
token: string,
type: string
) {
const actionToken = await prisma.actionToken.findUnique({ where: { token } })
if (!actionToken || actionToken.type !== type) throw Errors.INVALID_TOKEN
if (actionToken.expiresAt < new Date()) throw Errors.INVALID_TOKEN
await prisma.actionToken.delete({
where: { token },
})
return actionToken
}

View File

@@ -0,0 +1,82 @@
import argon2 from 'argon2'
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 './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,
mailer: Transporter,
input: RegisterInput,
lang: 'fr' | 'en' = 'fr'
) {
const existing = await prisma.user.findUnique({ where: { email: input.email } })
if (existing) throw Errors.EMAIL_TAKEN
const passwordHash = await argon2.hash(input.password)
const displayName = input.email.split('@')[0]
const user = await prisma.user.create({
data: {
id: crypto.randomUUID(),
email: input.email,
passwordHash,
displayName,
avatar: generateGravatarUrl(input.email),
},
select: {
id: true,
email: true,
displayName: true,
avatar:true,
isConfirmed: true,
createdAt: true,
},
})
const confirmToken = await createActionToken(prisma, user.id, 'email-confirm', 1440)
await sendConfirmationMail(mailer, user.email, confirmToken, lang)
await prisma.userPreference.create({
data: {
id: crypto.randomUUID(),
userId: user.id,
language: lang,
theme: 'light',
},
})
return { user }
}
export async function loginUser(prisma: PrismaClient, input: LoginInput) {
const user = await prisma.user.findUnique({ where: { email: input.email } })
if (!user) throw Errors.INVALID_CREDENTIALS
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,
},
}
}
export async function logoutUser(prisma: PrismaClient, userId: string) {
await prisma.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
})
}

View File

@@ -0,0 +1,30 @@
import { FastifyInstance } from 'fastify'
import { PrismaClient } from '../generated/prisma/client.js'
export interface JwtPayload {
userId: string
tokenVersion: number
}
export function signAuthToken(fastify: FastifyInstance, payload: JwtPayload): string {
return fastify.jwt.sign(payload, { expiresIn: '7d' })
}
export async function verifyAuthToken(
fastify: FastifyInstance,
prisma: PrismaClient,
token: string
): Promise<JwtPayload> {
const payload = fastify.jwt.verify<JwtPayload>(token)
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { tokenVersion: true },
})
if (!user || user.tokenVersion !== payload.tokenVersion) {
throw new Error('TOKEN_REVOKED')
}
return payload
}

View 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`
}

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

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

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

View File

@@ -0,0 +1,60 @@
import { PrismaClient } from '../generated/prisma/client.js'
import { Errors } from '../errors/AppError.js'
import { UpdateDisplayNameInput } from '../schemas/user.schema.js'
export async function confirmEmail(prisma: PrismaClient, token: string) {
const actionToken = await prisma.actionToken.findUnique({
where: { token },
})
if (!actionToken || actionToken.type !== 'email-confirm') {
throw Errors.INVALID_TOKEN
}
if (actionToken.expiresAt < new Date()) {
throw Errors.TOKEN_EXPIRED
}
const user = await prisma.user.findUnique({
where: { id: actionToken.userId },
select: { isConfirmed: true },
})
if (user?.isConfirmed) {
throw Errors.ALREADY_CONFIRMED
}
await prisma.$transaction([
prisma.user.update({
where: { id: actionToken.userId },
data: { isConfirmed: true },
}),
prisma.actionToken.delete({
where: { id: actionToken.id },
}),
])
return { success: true }
}
export async function updateDisplayName(
prisma: PrismaClient,
userId: string,
input: UpdateDisplayNameInput
) {
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) throw Errors.USER_NOT_FOUND
return await prisma.user.update({
where: { id: userId },
data: { displayName: input.displayName },
select: {
id: true,
email: true,
displayName: true,
avatar: true,
isConfirmed: true,
createdAt: true,
},
})
}

View File

@@ -4,4 +4,24 @@ declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient
}
interface FastifyRequest {
user: {
userId: string
tokenVersion: number
}
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
userId: string
tokenVersion: number
}
user: {
userId: string
tokenVersion: number
}
}
}