From 915f9e864e4d4d98d370da99df9c1c2a7c857a0a Mon Sep 17 00:00:00 2001 From: Raffi Date: Wed, 1 Apr 2026 07:30:50 +0200 Subject: [PATCH] modification du token d'authentification --- package-lock.json | 168 +++++++++++++++++++++++++++- package.json | 1 + prisma/schema.prisma | 24 ++-- src/app.ts | 4 + src/routes/auth.ts | 34 +++--- src/services/actionToken.service.ts | 51 +++++++++ src/services/auth.service.ts | 77 ++----------- src/services/authToken.service.ts | 30 +++++ src/services/token.service.ts | 17 --- 9 files changed, 287 insertions(+), 119 deletions(-) create mode 100644 src/services/actionToken.service.ts create mode 100644 src/services/authToken.service.ts delete mode 100644 src/services/token.service.ts diff --git a/package-lock.json b/package-lock.json index 4314750..d5d250d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "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", @@ -620,6 +621,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", @@ -679,6 +703,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", @@ -1154,6 +1187,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", @@ -1206,6 +1251,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", @@ -1399,6 +1450,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", @@ -1541,6 +1601,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", @@ -1566,6 +1641,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", @@ -1615,6 +1702,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", @@ -1624,6 +1721,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", @@ -1773,6 +1880,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", @@ -1890,6 +2003,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", @@ -1985,6 +2113,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", @@ -2446,6 +2580,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", @@ -2481,7 +2635,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": { @@ -2608,6 +2761,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", diff --git a/package.json b/package.json index a7f5d32..1889b98 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0fec6c2..d55a458 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,32 +18,24 @@ model ActionToken { 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()) + createdAt DateTime @default(now()) publicKey String? encryptedPrivateKey String? ActionToken ActionToken[] - AuthToken AuthToken[] - UserPreference UserPreference? + UserPreference UserPreference? } + model UserPreference { id String @id userId String @unique diff --git a/src/app.ts b/src/app.ts index cef1094..dd227d9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,6 @@ import Fastify from 'fastify' 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' @@ -11,6 +12,9 @@ export default function buildApp() { app.register(errorHandler) app.register(cookie) + app.register(jwt, { + secret: process.env.JWT_SECRET!, + }) app.register(prismaPlugin) app.register(mailerPlugin) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index f8f96f8..f1518a9 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,28 +1,21 @@ import { FastifyInstance } from 'fastify' -import { RegisterSchema } from '../schemas/auth.schema.js' -import { registerUser } from '../services/auth.service.js' - -import { LoginSchema } from '../schemas/auth.schema.js' -import { loginUser } from '../services/auth.service.js' +import { RegisterSchema, LoginSchema } from '../schemas/auth.schema.js' +import { registerUser, loginUser } from '../services/auth.service.js' +import { signAuthToken } from '../services/authToken.service.js' export default async function authRoutes(fastify: FastifyInstance) { fastify.post('/auth/register', async (request, reply) => { - const body = RegisterSchema.parse(request.body) // Zod throw → handler global - + const body = RegisterSchema.parse(request.body) const lang = request.headers['accept-language'] - ?.split(',')[0] - .split('-')[0] as 'fr' | 'en' - + ?.split(',')[0] + .split('-')[0] as 'fr' | 'en' const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr' - const { user, authToken } = await registerUser( - fastify.prisma, - fastify.mailer, - body, - validLang - ) + const { user } = await registerUser(fastify.prisma, fastify.mailer, body, validLang) - reply.setCookie('authToken', authToken, { + const token = signAuthToken(fastify, { userId: user.id, tokenVersion: 0 }) + + reply.setCookie('authToken', token, { httpOnly: true, sameSite: 'strict', maxAge: 60 * 60 * 24 * 7, @@ -33,15 +26,16 @@ export default async function authRoutes(fastify: FastifyInstance) { fastify.post('/auth/login', async (request, reply) => { const body = LoginSchema.parse(request.body) + const { user } = await loginUser(fastify.prisma, body) - const { user, authToken } = await loginUser(fastify.prisma, body) + const token = signAuthToken(fastify, { userId: user.id, tokenVersion: user.tokenVersion }) - reply.setCookie('authToken', authToken, { + reply.setCookie('authToken', token, { httpOnly: true, sameSite: 'strict', maxAge: 60 * 60 * 24 * 7, }) - return reply.status(200).send({ user }) + return reply.status(200).send({ user }) }) } \ No newline at end of file diff --git a/src/services/actionToken.service.ts b/src/services/actionToken.service.ts new file mode 100644 index 0000000..80b3f4a --- /dev/null +++ b/src/services/actionToken.service.ts @@ -0,0 +1,51 @@ +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 { + 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.used) throw Errors.INVALID_TOKEN + if (actionToken.expiresAt < new Date()) throw Errors.INVALID_TOKEN + + await prisma.actionToken.update({ + where: { token }, + data: { used: true }, + }) + + return actionToken +} \ No newline at end of file diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index f47012b..9d0601a 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -3,8 +3,8 @@ import crypto from 'crypto' import { PrismaClient } from '../generated/prisma/client.js' import { Transporter } from 'nodemailer' import { RegisterInput, LoginInput } from '../schemas/auth.schema.js' -import { generateToken, generateAuthTokenExpiry, generateActionTokenExpiry } from './token.service.js' import { sendConfirmationMail } from './mail.service.js' +import { createActionToken } from './actionToken.service.js' import { Errors } from '../errors/AppError.js' export async function registerUser( @@ -13,18 +13,12 @@ export async function registerUser( input: RegisterInput, lang: 'fr' | 'en' = 'fr' ) { - // 1. Vérif email unique - const existing = await prisma.user.findUnique({ - where: { email: input.email }, - }) - if (existing) { - throw Errors.EMAIL_TAKEN } + const existing = await prisma.user.findUnique({ where: { email: input.email } }) + if (existing) throw Errors.EMAIL_TAKEN - // 2. Hash du mot de passe const passwordHash = await argon2.hash(input.password) - - // 3. Création du user const displayName = input.email.split('@')[0] + const user = await prisma.user.create({ data: { id: crypto.randomUUID(), @@ -42,33 +36,9 @@ export async function registerUser( }, }) - // 4. ActionToken pour confirmation mail - const confirmToken = generateToken() - await prisma.actionToken.create({ - data: { - id: crypto.randomUUID(), - userId: user.id, - token: confirmToken, - type: 'email-confirm', - expiresAt: generateActionTokenExpiry(1440), // 24h - }, - }) - - // 5. Envoi du mail avec la langue + const confirmToken = await createActionToken(prisma, user.id, 'email-confirm', 1440) await sendConfirmationMail(mailer, user.email, confirmToken, lang) - // 6. AuthToken pour la persistance - const authToken = generateToken() - await prisma.authToken.create({ - data: { - id: crypto.randomUUID(), - userId: user.id, - token: authToken, - expiresAt: generateAuthTokenExpiry(), - }, - }) - - // 7 Création des préférences avec la langue détectée await prisma.userPreference.create({ data: { id: crypto.randomUUID(), @@ -76,40 +46,17 @@ export async function registerUser( language: lang, theme: 'light', }, -}) + }) - return { user, authToken } + return { user } } -export async function loginUser( - prisma: PrismaClient, - input: LoginInput -) { - // 1. Vérif user existe - const user = await prisma.user.findUnique({ - where: { email: input.email }, - }) +export async function loginUser(prisma: PrismaClient, input: LoginInput) { + const user = await prisma.user.findUnique({ where: { email: input.email } }) + if (!user) throw Errors.INVALID_CREDENTIALS - if (!user) { - throw Errors.INVALID_CREDENTIALS - } - - // 2. Vérif password const valid = await argon2.verify(user.passwordHash, input.password) - if (!valid) { - throw Errors.INVALID_CREDENTIALS - } - - // 3. Création AuthToken - const authToken = generateToken() - await prisma.authToken.create({ - data: { - id: crypto.randomUUID(), - userId: user.id, - token: authToken, - expiresAt: generateAuthTokenExpiry(), - }, - }) + if (!valid) throw Errors.INVALID_CREDENTIALS return { user: { @@ -118,7 +65,7 @@ export async function loginUser( displayName: user.displayName, isConfirmed: user.isConfirmed, createdAt: user.createdAt, + tokenVersion: user.tokenVersion, }, - authToken, } } \ No newline at end of file diff --git a/src/services/authToken.service.ts b/src/services/authToken.service.ts new file mode 100644 index 0000000..a2eeceb --- /dev/null +++ b/src/services/authToken.service.ts @@ -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 { + const payload = fastify.jwt.verify(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 +} \ No newline at end of file diff --git a/src/services/token.service.ts b/src/services/token.service.ts deleted file mode 100644 index c2c5034..0000000 --- a/src/services/token.service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import crypto from 'crypto' - -export function generateToken(length = 32): string { - return crypto.randomBytes(length).toString('hex') -} - -export function generateAuthTokenExpiry(): Date { - const date = new Date() - date.setDate(date.getDate() + 7) - return date -} - -export function generateActionTokenExpiry(minutes = 1440): Date { - const date = new Date() - date.setMinutes(date.getMinutes() + minutes) - return date -} \ No newline at end of file