diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..60478b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Environment variables declared in this file are NOT automatically loaded by Prisma. +# Please add `import "dotenv/config";` to your `prisma.config.ts` file, or use the Prisma CLI with Bun +# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars. + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +# The following `prisma+postgres` URL is similar to the URL produced by running a local Prisma Postgres +# server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the +# one found in a remote Prisma Postgres URL, does not contain any sensitive information. + +DATABASE_URL="postgresql://user:password@localhost:5432/dbname" + +# Envois de mails + +SMTP_HOST="smtp.example.com" +SMTP_PORT=587 +SMTP_USER="your@email.com" +SMTP_PASS="your-smtp-password" +MAIL_FROM="no-reply@example.com" + +APP_URL=BACK_OFFICE_SERVER_URL + +FRONT_URL=YOUR_FRONT_URL #is used for links in emails + +#Secrets +JWT_SECRET="your-jwt-secret" + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a31fbc --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# 🗂️ Fastify User Management Boilerplate + +A production-ready back-office boilerplate for **user account management**, built with Fastify, Prisma, Zod, and PostgreSQL. Designed to be forked and extended as the backend foundation of any application requiring authentication and account lifecycle management. + +--- + +## ✨ Features + +- **Authentication** – JWT-based auth with cookie support and token versioning (revocation on password change) +- **Registration & Login** – Secure password hashing with Argon2 +- **Google OAuth** – Login or register via Google access token, with automatic account merge detection +- **Password recovery** – Forgot password flow with time-limited action tokens +- **Password change** – Authenticated password update with session revocation +- **Email change** – Two-step flow with a 15-minute confirmation window and a 24-hour rollback period +- **Account deletion** – Soft-delete with a 7-day cancellation window +- **Input validation** – Zod schemas with shared password rules +- **Email notifications** – Nodemailer integration (confirmation, recovery, alerts) +- **Scheduled tasks** – node-cron for background jobs (e.g. purging expired accounts) +- **Security-first error codes** – Intentionally vague error responses to prevent user enumeration + +--- + +## 🧱 Stack + +| Layer | Technology | +|---|---| +| Framework | [Fastify](https://fastify.dev/) v5 | +| ORM | [Prisma](https://www.prisma.io/) v7 | +| Database | PostgreSQL (via `pg` + `@prisma/adapter-pg`) | +| Validation | [Zod](https://zod.dev/) v4 | +| Auth | `@fastify/jwt` + `@fastify/cookie` | +| Password hashing | [Argon2](https://github.com/ranisalt/node-argon2) | +| Email | [Nodemailer](https://nodemailer.com/) | +| Scheduled jobs | [node-cron](https://github.com/node-cron/node-cron) | +| Runtime | Node.js + [tsx](https://github.com/privatenumber/tsx) | +| Language | TypeScript | + +--- + +## 📁 Project Structure + +``` +src/ +├── app.ts # Fastify app factory & plugin registration +├── server.ts # Entry point +├── routes/ # Route definitions (declared before services) +├── services/ # Business logic +├── schemas/ # Zod validation schemas (incl. shared.schema.ts) +├── plugins/ # Fastify plugins (JWT, cookies, DB, etc.) +├── middleware/ # Auth guards and request hooks +├── errors/ # Custom error classes and error codes +├── types/ # TypeScript type definitions +└── generated/ # Prisma generated client +``` + +--- + +## 🚀 Getting Started + +### Prerequisites + +- Node.js ≥ 20 +- PostgreSQL database +- An SMTP server (or service like Mailtrap / Resend for dev) + +### Installation + +```bash +git clone https://github.com/your-username/your-repo.git +cd your-repo +npm install +``` + +### Environment + +Rename `.env.example` to `.env` and fill in the values: + +```env +# Database +DATABASE_URL="postgresql://user:password@localhost:5432/dbname" + +# Email +SMTP_HOST="smtp.example.com" +SMTP_PORT=587 +SMTP_USER="your@email.com" +SMTP_PASS="your-smtp-password" +MAIL_FROM="no-reply@example.com" + +# URLs +APP_URL=BACK_OFFICE_SERVER_URL +FRONT_URL=YOUR_FRONT_URL # Used for links in emails + +# Secrets +JWT_SECRET="your-jwt-secret" +``` + +### Database + +```bash +npx prisma migrate dev +``` + +### Run (development) + +```bash +npm run dev +``` + +--- + +## 🔐 Auth Flow Overview + +- On login, a **JWT is issued** (stored in an HTTP-only cookie) containing the user's ID and a `tokenVersion`. +- On **password change**, `tokenVersion` is incremented, invalidating all previously issued tokens. +- Action tokens (password reset, email confirmation, etc.) are **single-use** and deleted on consumption. +- The `lang` field on requests is derived from the `Accept-Language` header (defaults to `fr`). + +### Google OAuth + +The Google flow accepts an `access_token` obtained by the client after the Google sign-in, and exchanges it for user info via the [Google UserInfo API](https://www.googleapis.com/oauth2/v3/userinfo). Three cases are handled: + +| Situation | Behaviour | +|---|---| +| Email exists, no `googleId` | Returns `mergeRequired: true` — the client must prompt the user to link their accounts | +| Existing Google account | Logs the user in directly (checks for pending deletion) | +| New user | Creates the account with `googleId`, picture and display name from Google; sends a confirmation email if the address is not already verified by Google | + +User preferences (`language`, `theme`) are automatically created on first Google sign-in. + +--- + +## 🛣️ API Routes + +### Auth — `/auth` + +| Method | Route | Auth | Description | +|---|---|---|---| +| `POST` | `/auth/register` | — | Create a new account | +| `POST` | `/auth/login` | — | Login and receive a JWT cookie | +| `POST` | `/auth/logout` | ✅ | Invalidate the session cookie | +| `POST` | `/auth/google` | — | Login or register via Google access token | + +### User — `/user` + +| Method | Route | Auth | Description | +|---|---|---|---| +| `GET` | `/users` | — | List all users | +| `GET` | `/user/confirm?token=` | — | Confirm email address | +| `PATCH` | `/user/display-name` | ✅ | Update display name | +| `POST` | `/user/pwd-recovery-request` | — | Request a password reset email | +| `POST` | `/user/pwd-recovery` | — | Reset password via token | +| `PATCH` | `/user/pwd-change` | ✅ | Change password (re-issues JWT cookie) | +| `POST` | `/user/email-change-request` | ✅ | Request an email change (sends confirmation to new address) | +| `GET` | `/user/email-change-confirm?token=` | — | Confirm the new email address | +| `GET` | `/user/email-change-rollback?token=` | — | Rollback to previous email (24h window) | +| `DELETE` | `/user/account` | ✅ | Schedule account deletion (7-day window) | +| `GET` | `/user/delete-cancel?token=` | — | Cancel scheduled account deletion | + +> ✅ = requires a valid `authToken` cookie + +--- + +## 📋 Available Scripts + +```bash +npm run dev # Start development server with tsx +npm run build # Compile TypeScript to dist/ +npm run start # Run compiled server +``` + +--- + +## 📄 License + +ISC diff --git a/src/routes/auth.ts b/src/routes/auth.ts index d25aebb..7a11e79 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -42,7 +42,7 @@ export default async function authRoutes(fastify: FastifyInstance) { 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' }) + return reply.status(200).send({ success : true }) }) fastify.post('/auth/google', async (request, reply) => { diff --git a/src/routes/users.ts b/src/routes/users.ts index 40edf04..a392f02 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -10,21 +10,7 @@ import { emailChangeRequest, emailChangeConfirm, emailRollback } from '../servic import { scheduleAccountDeletion, cancelAccountDeletion } from '../services/accountDeletion.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 } @@ -64,7 +50,7 @@ export default async function userRoutes(fastify: FastifyInstance) { 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.' }) + return reply.status(200).send({ success : true }) }) fastify.patch('/user/pwd-change', { preHandler: verifyAuth }, async (request, reply) => { @@ -80,7 +66,7 @@ export default async function userRoutes(fastify: FastifyInstance) { path: '/', }) - return reply.status(200).send({ message: 'Mot de passe mis à jour avec succès.' }) + return reply.status(200).send({ success : true }) }) fastify.post('/user/email-change-request', { preHandler: verifyAuth }, async (request, reply) => { @@ -92,7 +78,7 @@ export default async function userRoutes(fastify: FastifyInstance) { 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.' }) + return reply.status(200).send({ success : true }) }) fastify.get('/user/email-change-confirm', async (request, reply) => { @@ -101,7 +87,7 @@ export default async function userRoutes(fastify: FastifyInstance) { await emailChangeConfirm(fastify.prisma, token) - return reply.status(200).send({ message: 'Email mis à jour avec succès.' }) + return reply.status(200).send({ success : true }) }) fastify.get('/user/email-change-rollback', async (request, reply) => { @@ -110,12 +96,12 @@ export default async function userRoutes(fastify: FastifyInstance) { await emailRollback(fastify.prisma, token) - return reply.status(200).send({ message: 'Email restauré avec succès.' }) + return reply.status(200).send({ success : true }) }) 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.' }) + return reply.status(200).send({ success : true }) }) fastify.get('/user/delete-cancel', async (request, reply) => { @@ -123,6 +109,6 @@ export default async function userRoutes(fastify: FastifyInstance) { if (!token) throw Errors.INVALID_TOKEN await cancelAccountDeletion(fastify.prisma, token) - return reply.status(200).send({ message: 'Suppression annulée, votre compte est restauré.' }) + return reply.status(200).send({ success : true }) }) } \ No newline at end of file diff --git a/src/services/passwordManagement.service.ts b/src/services/passwordManagement.service.ts index 79b6a81..115ec90 100644 --- a/src/services/passwordManagement.service.ts +++ b/src/services/passwordManagement.service.ts @@ -51,7 +51,8 @@ export async function changePassword( }) if (!user) throw Errors.USER_NOT_FOUND - + if (!user.passwordHash) throw Errors.UNAUTHORIZED + const isValid = await argon2.verify(user.passwordHash, oldPassword) if (!isValid) throw Errors.INVALID_CREDENTIALS