Compare commits
16 Commits
135f910c62
...
auth
| Author | SHA1 | Date | |
|---|---|---|---|
| 886dfa4f24 | |||
| 9e247a3bfc | |||
| 2f9c7d4f53 | |||
| 915f9e864e | |||
| a556760872 | |||
| e3968e6dbf | |||
| e0f862b361 | |||
| 5e7e7b69e9 | |||
| 928fb55dea | |||
| 915813d681 | |||
| ba68ef4695 | |||
| fd688f2ff3 | |||
| 70ca3fc5de | |||
| d79ba35e1d | |||
| b60d656978 | |||
| 45431a523a |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -51,3 +51,9 @@ docker-compose.override.yml
|
||||
coverage/
|
||||
.tmp/
|
||||
.cache/
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
1913
package-lock.json
generated
1913
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -1,32 +1,37 @@
|
||||
{
|
||||
"name": "bo_liste",
|
||||
"version": "1.0.0",
|
||||
"description": "Back Office de l'application des listes",
|
||||
"description": "BO pour la liste application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/core/server.ts",
|
||||
"dev": "tsx src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/core/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@gitea.raffiskender.com:raffi/bo_listes.git"
|
||||
"url": "bo_liste"
|
||||
},
|
||||
"author": "Raffi",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@prisma/client": "^7.5.0",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"argon2": "^0.44.0",
|
||||
"fastify": "^5.8.4"
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"prisma": "^7.5.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"prisma": "^7.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
45
prisma/schema.prisma
Normal file
45
prisma/schema.prisma
Normal file
@@ -0,0 +1,45 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model ActionToken {
|
||||
id String @id
|
||||
userId String
|
||||
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 User {
|
||||
id String @id
|
||||
email String @unique
|
||||
passwordHash String
|
||||
displayName String
|
||||
tokenVersion Int @default(0)
|
||||
isConfirmed Boolean @default(false)
|
||||
isGoogleUser Boolean @default(false)
|
||||
avatar String
|
||||
createdAt DateTime @default(now())
|
||||
publicKey String?
|
||||
encryptedPrivateKey String?
|
||||
ActionToken ActionToken[]
|
||||
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)
|
||||
}
|
||||
27
src/app.ts
Normal file
27
src/app.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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'
|
||||
import userRoutes from './routes/users.js'
|
||||
import errorHandler from './plugins/errorHandler.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(authRoutes, { prefix: '/api' })
|
||||
app.register(userRoutes, { prefix: '/api' })
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }))
|
||||
|
||||
return app
|
||||
}
|
||||
29
src/errors/AppError.ts
Normal file
29
src/errors/AppError.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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'),
|
||||
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
|
||||
}
|
||||
}
|
||||
34
src/plugins/errorHandler.ts
Normal file
34
src/plugins/errorHandler.ts
Normal 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
22
src/plugins/mailer.ts
Normal 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)
|
||||
})
|
||||
24
src/plugins/prisma.ts
Normal file
24
src/plugins/prisma.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async (fastify: FastifyInstance) => {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
})
|
||||
|
||||
const prisma = new PrismaClient({ adapter })
|
||||
|
||||
fastify.decorate('prisma', prisma)
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
})
|
||||
50
src/routes/auth.ts
Normal file
50
src/routes/auth.ts
Normal 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' })
|
||||
})
|
||||
}
|
||||
42
src/routes/users.ts
Normal file
42
src/routes/users.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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) => {
|
||||
const users = await fastify.prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
isConfirmed: true,
|
||||
isGoogleUser: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
}
|
||||
21
src/schemas/auth.schema.ts
Normal file
21
src/schemas/auth.schema.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const RegisterSchema = z.object({
|
||||
email: z.email({ error: 'Adresse email invalide.' }),
|
||||
password: z
|
||||
.string()
|
||||
.regex(/^.{8,22}$/, { error: 'Le mot de passe doit contenir entre 8 et 22 caractères.' })
|
||||
.regex(/[^A-Za-z0-9]/, { error: 'Le mot de passe doit contenir au moins un caractère spécial.' })
|
||||
.regex(/[A-Z]/, { error: 'Le mot de passe doit contenir au moins une majuscule.' })
|
||||
.regex(/[a-z]/, { error: 'Le mot de passe doit contenir au moins une minuscule.' })
|
||||
.regex(/\d/, { error: 'Le mot de passe doit contenir au moins un chiffre.' }),
|
||||
})
|
||||
|
||||
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>
|
||||
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>
|
||||
15
src/server.ts
Normal file
15
src/server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'dotenv/config'
|
||||
import buildApp from './app.js'
|
||||
|
||||
const app = buildApp()
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await app.listen({ port: 1234, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
start()
|
||||
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
|
||||
}
|
||||
78
src/services/auth.service.ts
Normal file
78
src/services/auth.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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 './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,
|
||||
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: '',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: 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
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
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 } },
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
51
src/services/mail.service.ts
Normal file
51
src/services/mail.service.ts
Normal file
@@ -0,0 +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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
27
src/types/fastify.d.ts
vendored
Normal file
27
src/types/fastify.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
|
||||
"module": "CommonJS",
|
||||
"target": "ES2020",
|
||||
|
||||
"types": ["node"],
|
||||
|
||||
"sourceMap": true,
|
||||
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user