Compare commits
7 Commits
5e7e7b69e9
...
auth
| Author | SHA1 | Date | |
|---|---|---|---|
| 886dfa4f24 | |||
| 9e247a3bfc | |||
| 2f9c7d4f53 | |||
| 915f9e864e | |||
| a556760872 | |||
| e3968e6dbf | |||
| e0f862b361 |
168
package-lock.json
generated
168
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,27 +18,28 @@ 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?
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -11,8 +11,19 @@ export class AppError extends Error {
|
||||
|
||||
// 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'),
|
||||
ALREADY_CONFIRMED: new AppError('ALREADY_CONFIRMED', 400, 'User is already confirmed'),
|
||||
|
||||
//Auth errors
|
||||
UNAUTHORIZED: new AppError('UNAUTHORIZED', 401, 'Non authentifié'),
|
||||
USER_NOT_FOUND: new AppError('USER_NOT_FOUND', 404, 'Utilisateur introuvable'),
|
||||
}
|
||||
19
src/middleware/verifyAuth.ts
Normal file
19
src/middleware/verifyAuth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
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, 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) // Zod throw → handler global
|
||||
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, authToken } = await registerUser(
|
||||
fastify.prisma,
|
||||
fastify.mailer,
|
||||
body
|
||||
)
|
||||
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,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return reply.status(201).send({ user })
|
||||
@@ -26,15 +28,23 @@ 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,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return reply.status(200).send({ user })
|
||||
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' })
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
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 { updateDisplayName } from '../services/user.service.js'
|
||||
|
||||
|
||||
export default async function userRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/users', async (request, reply) => {
|
||||
@@ -16,4 +22,21 @@ 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 })
|
||||
})
|
||||
}
|
||||
7
src/schemas/user.schema.ts
Normal file
7
src/schemas/user.schema.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const UpdateDisplayNameSchema = z.object({
|
||||
displayName: z.string().min(2).max(32),
|
||||
})
|
||||
|
||||
export type UpdateDisplayNameInput = z.infer<typeof UpdateDisplayNameSchema>
|
||||
51
src/services/actionToken.service.ts
Normal file
51
src/services/actionToken.service.ts
Normal file
@@ -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<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.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
|
||||
}
|
||||
@@ -3,27 +3,22 @@ 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(
|
||||
prisma: PrismaClient,
|
||||
mailer: Transporter,
|
||||
input: RegisterInput
|
||||
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 de l'user
|
||||
const displayName = input.email.split('@')[0]
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -41,64 +36,27 @@ export async function registerUser(
|
||||
},
|
||||
})
|
||||
|
||||
// 4. ActionToken pour confirmation mail
|
||||
const confirmToken = generateToken()
|
||||
await prisma.actionToken.create({
|
||||
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,
|
||||
token: confirmToken,
|
||||
type: 'email-confirm',
|
||||
expiresAt: generateActionTokenExpiry(1440), // 24h
|
||||
language: lang,
|
||||
theme: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
// 5. Envoi du mail
|
||||
await sendConfirmationMail(mailer, user.email, confirmToken)
|
||||
|
||||
// 6. AuthToken pour la persistance
|
||||
const authToken = generateToken()
|
||||
await prisma.authToken.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
token: authToken,
|
||||
expiresAt: generateAuthTokenExpiry(),
|
||||
},
|
||||
})
|
||||
|
||||
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: {
|
||||
@@ -107,7 +65,14 @@ export async function loginUser(
|
||||
displayName: user.displayName,
|
||||
isConfirmed: user.isConfirmed,
|
||||
createdAt: user.createdAt,
|
||||
tokenVersion: user.tokenVersion,
|
||||
},
|
||||
authToken,
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutUser(prisma: PrismaClient, userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
})
|
||||
}
|
||||
30
src/services/authToken.service.ts
Normal file
30
src/services/authToken.service.ts
Normal 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
|
||||
}
|
||||
@@ -1,20 +1,51 @@
|
||||
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
|
||||
token: string,
|
||||
lang: Lang = 'fr'
|
||||
): Promise<void> {
|
||||
const url = `${process.env.APP_URL}/auth/confirm-email?token=${token}`
|
||||
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: 'Confirmez votre adresse mail',
|
||||
html: `
|
||||
<p>Merci de vous être inscrit !</p>
|
||||
<p>Cliquez sur ce lien pour confirmer votre adresse mail (valable 24h) :</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
60
src/services/user.service.ts
Normal file
60
src/services/user.service.ts
Normal 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' || actionToken.used) {
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
20
src/types/fastify.d.ts
vendored
20
src/types/fastify.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user