From 8d9afbe82e859eacdad42e6ccb6fa2c11d3144de Mon Sep 17 00:00:00 2001
From: Sofiane Lasri-Trienpont <alasri250@gmail.com>
Date: Sat, 9 Nov 2024 18:29:23 +0100
Subject: [PATCH] feat(auth): implement session management for user
 authentication

- Add Session model to Prisma schema for session handling
- Implement session generation in `generateSession` utility
- Update registration and login APIs to create and set sessions
- Add middleware to skip user existence check in development
- Add Prisma client singleton for better connection management
- Include `@prisma/nuxt` module and `cookie` package in dependencies
- Create migration for session table and unique token index
---
 lib/prisma.ts                                 | 15 ++++
 middleware/verifyUserDoesntExists.ts          |  4 +
 middleware/verifyUserExists.ts                |  4 +
 nuxt.config.ts                                |  2 +-
 package-lock.json                             | 89 ++++++++++++++++++-
 package.json                                  |  4 +-
 .../20241109165932_session/migration.sql      | 13 +++
 prisma/schema.prisma                          | 11 +++
 server/api/auth/login.ts                      | 28 ++++--
 server/api/auth/register.ts                   | 18 +++-
 server/api/auth/userExists.ts                 |  2 +-
 server/prisma.ts                              |  5 --
 server/utils/generateSession.ts               | 39 ++++++++
 13 files changed, 215 insertions(+), 19 deletions(-)
 create mode 100644 lib/prisma.ts
 create mode 100644 prisma/migrations/20241109165932_session/migration.sql
 delete mode 100644 server/prisma.ts
 create mode 100644 server/utils/generateSession.ts

diff --git a/lib/prisma.ts b/lib/prisma.ts
new file mode 100644
index 0000000..08a8751
--- /dev/null
+++ b/lib/prisma.ts
@@ -0,0 +1,15 @@
+import { PrismaClient } from '@prisma/client'
+
+const prismaClientSingleton = () => {
+  return new PrismaClient()
+}
+
+declare const globalThis: {
+  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
+} & typeof global;
+
+const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
+
+export default prisma
+
+if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
diff --git a/middleware/verifyUserDoesntExists.ts b/middleware/verifyUserDoesntExists.ts
index a4c30af..e930ae4 100644
--- a/middleware/verifyUserDoesntExists.ts
+++ b/middleware/verifyUserDoesntExists.ts
@@ -1,4 +1,8 @@
 export default defineNuxtRouteMiddleware(async (to) => {
+    if (process.env.NODE_ENV === 'development') {
+        return;
+    }
+
     let userExists = false;
     try {
         await useFetch('/api/auth/userExists', {
diff --git a/middleware/verifyUserExists.ts b/middleware/verifyUserExists.ts
index 3bff935..1bf14d5 100644
--- a/middleware/verifyUserExists.ts
+++ b/middleware/verifyUserExists.ts
@@ -1,4 +1,8 @@
 export default defineNuxtRouteMiddleware(async (to) => {
+    if (process.env.NODE_ENV === 'development') {
+        return;
+    }
+
     let userExists = false;
     try {
         await useFetch('/api/auth/userExists', {
diff --git a/nuxt.config.ts b/nuxt.config.ts
index a0a3d16..2cdfb01 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -8,6 +8,6 @@ export default defineNuxtConfig({
             enabled: true,
         },
     },
-    modules: ['@pinia/nuxt', '@nuxt/ui', '@vueuse/nuxt'],
+    modules: ['@pinia/nuxt', '@nuxt/ui', '@vueuse/nuxt', '@prisma/nuxt'],
     extends: ['@nuxt/ui-pro'],
 })
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index b86f7e1..e27feba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,16 +10,18 @@
         "@nuxt/ui": "^2.19.2",
         "@nuxt/ui-pro": "^1.5.0",
         "@pinia/nuxt": "^0.7.0",
-        "@prisma/client": "^5.22.0",
+        "@prisma/nuxt": "^0.0.35",
         "@vueuse/core": "^11.2.0",
         "@vueuse/nuxt": "^11.2.0",
         "bcrypt": "^5.1.1",
+        "cookie": "^1.0.1",
         "nuxt": "^3.14.159",
         "pinia": "^2.2.6",
         "vue": "latest",
         "vue-router": "latest"
       },
       "devDependencies": {
+        "@prisma/client": "^5.22.0",
         "@types/bcrypt": "^5.0.2",
         "prisma": "^5.22.0"
       }
@@ -1946,6 +1948,83 @@
         "@prisma/debug": "5.22.0"
       }
     },
+    "node_modules/@prisma/nuxt": {
+      "version": "0.0.35",
+      "resolved": "https://registry.npmjs.org/@prisma/nuxt/-/nuxt-0.0.35.tgz",
+      "integrity": "sha512-p8AeISCMGDnMDRj7EOcvM/T61Qy7+RauzMR3zU7V5Jte2dexSg6OBxcsxJyss+qHJFi/Ego0NGuvitCkBXDwaw==",
+      "dependencies": {
+        "@nuxt/devtools-kit": "^1.3.3",
+        "@nuxt/kit": "^3.11.2",
+        "@prisma/client": "^5.17.0",
+        "chalk": "^5.3.0",
+        "defu": "^6.1.4",
+        "execa": "^8.0.1",
+        "prompts": "^2.4.2"
+      }
+    },
+    "node_modules/@prisma/nuxt/node_modules/chalk": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+      "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+      "engines": {
+        "node": "^12.17.0 || ^14.13 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/@prisma/nuxt/node_modules/execa": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+      "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+      "dependencies": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^8.0.1",
+        "human-signals": "^5.0.0",
+        "is-stream": "^3.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^5.1.0",
+        "onetime": "^6.0.0",
+        "signal-exit": "^4.1.0",
+        "strip-final-newline": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=16.17"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
+    "node_modules/@prisma/nuxt/node_modules/get-stream": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+      "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@prisma/nuxt/node_modules/human-signals": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+      "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+      "engines": {
+        "node": ">=16.17.0"
+      }
+    },
+    "node_modules/@prisma/nuxt/node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
     "node_modules/@redocly/ajv": {
       "version": "8.11.2",
       "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -3999,6 +4078,14 @@
       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
       "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
     },
+    "node_modules/cookie": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.1.tgz",
+      "integrity": "sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/cookie-es": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz",
diff --git a/package.json b/package.json
index baadce1..74cd907 100644
--- a/package.json
+++ b/package.json
@@ -13,10 +13,11 @@
     "@nuxt/ui": "^2.19.2",
     "@nuxt/ui-pro": "^1.5.0",
     "@pinia/nuxt": "^0.7.0",
-    "@prisma/client": "^5.22.0",
+    "@prisma/nuxt": "^0.0.35",
     "@vueuse/core": "^11.2.0",
     "@vueuse/nuxt": "^11.2.0",
     "bcrypt": "^5.1.1",
+    "cookie": "^1.0.1",
     "nuxt": "^3.14.159",
     "pinia": "^2.2.6",
     "vue": "latest",
@@ -26,6 +27,7 @@
     "vue": "latest"
   },
   "devDependencies": {
+    "@prisma/client": "^5.22.0",
     "@types/bcrypt": "^5.0.2",
     "prisma": "^5.22.0"
   }
diff --git a/prisma/migrations/20241109165932_session/migration.sql b/prisma/migrations/20241109165932_session/migration.sql
new file mode 100644
index 0000000..56276b3
--- /dev/null
+++ b/prisma/migrations/20241109165932_session/migration.sql
@@ -0,0 +1,13 @@
+-- CreateTable
+CREATE TABLE "Session" (
+    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+    "token" TEXT NOT NULL,
+    "expiresAt" DATETIME NOT NULL,
+    "userId" INTEGER NOT NULL,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 755bb30..8fb9ba9 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -21,6 +21,7 @@ model User {
   createdAt     DateTime        @default(now())
   updatedAt     DateTime        @default(now())
   PasswordReset PasswordReset[]
+  Session       Session[]
 }
 
 model PasswordReset {
@@ -32,3 +33,13 @@ model PasswordReset {
   createdAt DateTime @default(now())
   updatedAt DateTime @default(now())
 }
+
+model Session {
+  id        Int      @id @default(autoincrement())
+  token     String   @unique
+  expiresAt DateTime
+  userId    Int
+  user      User     @relation(fields: [userId], references: [id])
+  createdAt DateTime @default(now())
+  updatedAt DateTime @default(now())
+}
diff --git a/server/api/auth/login.ts b/server/api/auth/login.ts
index 858c241..1e83da5 100644
--- a/server/api/auth/login.ts
+++ b/server/api/auth/login.ts
@@ -1,23 +1,35 @@
-import { compare } from 'bcrypt';
-import { prisma } from '~/server/prisma';
+import {compare} from 'bcrypt';
+import {generateSession} from "~/server/utils/generateSession";
+import prisma from "~/lib/prisma";
 
 export default defineEventHandler(async (event) => {
     const userExists = await prisma.user.count();
     if (!userExists) {
-        throw createError({ statusCode: 403, statusMessage: 'No users exist' });
+        throw createError({statusCode: 403, statusMessage: 'No users exist'});
     }
 
-    const { email, password } = await readBody(event);
+    const {email, password} = await readBody(event);
 
     const user = await prisma.user.findUnique({
-        where: { email },
+        where: {email},
     });
 
     if (!user || !(await compare(password, user.password))) {
-        throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' });
+        throw createError({statusCode: 401, statusMessage: 'Invalid credentials'});
     }
 
-    // Set up session or token logic here
+    const generatedSession = await generateSession(user.id);
 
-    return { user };
+    setCookie(event, 'session', generatedSession.token, {
+        expires: generatedSession.expiresAt,
+        httpOnly: true,
+        secure: true,
+        sameSite: 'strict',
+    });
+
+    return {
+        id: user.id,
+        email: user.email,
+        name: user.name,
+    };
 });
\ No newline at end of file
diff --git a/server/api/auth/register.ts b/server/api/auth/register.ts
index 9ecf56f..8c21932 100644
--- a/server/api/auth/register.ts
+++ b/server/api/auth/register.ts
@@ -1,5 +1,6 @@
 import { hash } from 'bcrypt';
-import { prisma } from '~/server/prisma';
+import {generateSession} from "~/server/utils/generateSession";
+import prisma from "~/lib/prisma";
 
 export default defineEventHandler(async (event) => {
     const userExists = await prisma.user.count();
@@ -25,5 +26,18 @@ export default defineEventHandler(async (event) => {
         },
     });
 
-    return { user };
+    const generatedSession = await generateSession(user.id);
+
+    setCookie(event, 'session', generatedSession.token, {
+        expires: generatedSession.expiresAt,
+        httpOnly: true,
+        secure: true,
+        sameSite: 'strict',
+    });
+
+    return {
+        id: user.id,
+        email: user.email,
+        name: user.name,
+    };
 });
\ No newline at end of file
diff --git a/server/api/auth/userExists.ts b/server/api/auth/userExists.ts
index d0ecb41..ec9d97b 100644
--- a/server/api/auth/userExists.ts
+++ b/server/api/auth/userExists.ts
@@ -1,4 +1,4 @@
-import {prisma} from "~/server/prisma";
+import prisma from "~/lib/prisma";
 
 export default defineEventHandler(async (event) => {
     const userCount = await prisma.user.count();
diff --git a/server/prisma.ts b/server/prisma.ts
deleted file mode 100644
index 2e08334..0000000
--- a/server/prisma.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { PrismaClient } from '@prisma/client';
-
-const prisma = new PrismaClient();
-
-export { prisma };
\ No newline at end of file
diff --git a/server/utils/generateSession.ts b/server/utils/generateSession.ts
new file mode 100644
index 0000000..7c143b0
--- /dev/null
+++ b/server/utils/generateSession.ts
@@ -0,0 +1,39 @@
+import * as crypto from 'crypto';
+import prisma from "~/lib/prisma";
+
+async function generateSessionToken(): Promise<string> {
+    let generatedToken = crypto.randomBytes(32).toString('hex');
+
+    const tokenExists = await prisma.session.findUnique({
+        where: {
+            token: generatedToken,
+        }
+    });
+
+    if (tokenExists) {
+        return generateSessionToken();
+    }
+
+    return generatedToken;
+}
+
+async function generateSession(userId: number): Promise<{ expiresAt: Date; token: string }> {
+    const token = await generateSessionToken();
+    const expirationDate = new Date();
+    expirationDate.setDate(expirationDate.getDate() + 7);
+
+    prisma.session.create({
+        data: {
+            token,
+            userId,
+            expiresAt: expirationDate,
+        }
+    });
+
+    return {
+        token,
+        expiresAt: expirationDate,
+    };
+}
+
+export { generateSession };
\ No newline at end of file
-- 
GitLab