From f7d327c0788ae43daf1e31acdc53baf16d4e37dd Mon Sep 17 00:00:00 2001
From: Sofiane Lasri-Trienpont <alasri250@gmail.com>
Date: Sat, 9 Nov 2024 17:12:11 +0100
Subject: [PATCH] feat(auth): implement authentication system with register,
 login, and middleware

Add API endpoints for user registration and login using bcrypt for
password hashing and comparison. Introduce a Pinia store for managing
authentication state, including login, register, and logout actions.
Implement middleware to protect admin routes, redirecting
unauthenticated users to the login page. Integrate Prisma for database
interactions.
---
 middleware/auth.ts            |  6 ++++
 server/api/auth/login.ts      | 23 +++++++++++++
 server/api/auth/register.ts   | 29 ++++++++++++++++
 server/api/auth/userExists.ts |  6 ++++
 server/prisma.ts              |  5 +++
 stores/auth.ts                | 64 +++++++++++++++++++++++++++++++++++
 6 files changed, 133 insertions(+)
 create mode 100644 middleware/auth.ts
 create mode 100644 server/api/auth/login.ts
 create mode 100644 server/api/auth/register.ts
 create mode 100644 server/api/auth/userExists.ts
 create mode 100644 server/prisma.ts
 create mode 100644 stores/auth.ts

diff --git a/middleware/auth.ts b/middleware/auth.ts
new file mode 100644
index 0000000..caeee18
--- /dev/null
+++ b/middleware/auth.ts
@@ -0,0 +1,6 @@
+export default defineNuxtRouteMiddleware((to) => {
+    const isAuthenticated = false; // TODO: check if user is authenticated
+    if (!isAuthenticated && to.path.startsWith('/admin')) {
+        return navigateTo('/login');
+    }
+});
\ No newline at end of file
diff --git a/server/api/auth/login.ts b/server/api/auth/login.ts
new file mode 100644
index 0000000..858c241
--- /dev/null
+++ b/server/api/auth/login.ts
@@ -0,0 +1,23 @@
+import { compare } from 'bcrypt';
+import { prisma } from '~/server/prisma';
+
+export default defineEventHandler(async (event) => {
+    const userExists = await prisma.user.count();
+    if (!userExists) {
+        throw createError({ statusCode: 403, statusMessage: 'No users exist' });
+    }
+
+    const { email, password } = await readBody(event);
+
+    const user = await prisma.user.findUnique({
+        where: { email },
+    });
+
+    if (!user || !(await compare(password, user.password))) {
+        throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' });
+    }
+
+    // Set up session or token logic here
+
+    return { user };
+});
\ No newline at end of file
diff --git a/server/api/auth/register.ts b/server/api/auth/register.ts
new file mode 100644
index 0000000..9ecf56f
--- /dev/null
+++ b/server/api/auth/register.ts
@@ -0,0 +1,29 @@
+import { hash } from 'bcrypt';
+import { prisma } from '~/server/prisma';
+
+export default defineEventHandler(async (event) => {
+    const userExists = await prisma.user.count();
+    if (userExists) {
+        throw createError({ statusCode: 403, statusMessage: 'User already exists' });
+    }
+
+    const { email, name, password } = await readBody(event);
+
+    if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password) || !/[^A-Za-z0-9]/.test(password)) {
+        return new Response('Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', {
+            status: 422,
+        });
+    }
+
+    const hashedPassword = await hash(password, 10);
+
+    const user = await prisma.user.create({
+        data: {
+            email,
+            name,
+            password: hashedPassword,
+        },
+    });
+
+    return { user };
+});
\ No newline at end of file
diff --git a/server/api/auth/userExists.ts b/server/api/auth/userExists.ts
new file mode 100644
index 0000000..d0ecb41
--- /dev/null
+++ b/server/api/auth/userExists.ts
@@ -0,0 +1,6 @@
+import {prisma} from "~/server/prisma";
+
+export default defineEventHandler(async (event) => {
+    const userCount = await prisma.user.count();
+    return { userExists: userCount > 0 };
+})
\ No newline at end of file
diff --git a/server/prisma.ts b/server/prisma.ts
new file mode 100644
index 0000000..2e08334
--- /dev/null
+++ b/server/prisma.ts
@@ -0,0 +1,5 @@
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+export { prisma };
\ No newline at end of file
diff --git a/stores/auth.ts b/stores/auth.ts
new file mode 100644
index 0000000..f0c899f
--- /dev/null
+++ b/stores/auth.ts
@@ -0,0 +1,64 @@
+import { defineStore } from 'pinia';
+import { useRouter } from 'vue-router';
+
+export const useAuthStore = defineStore('auth', {
+    state: () => ({
+        user: null,
+    }),
+
+    actions: {
+        async login(email: string, password: string) {
+            try {
+                const response = await fetch('/api/auth/login', {
+                    method: 'POST',
+                    headers: {
+                        "Content-Type": "application/json",
+                    },
+                    body: JSON.stringify({ email, password }),
+                });
+
+                if (!response.ok) {
+                    // Promise.reject() is used to avoid "'throw' of exception caught locally" warning
+                    // https://stackoverflow.com/a/60725482/12680199
+                    return Promise.reject(Error('Invalid credentials'));
+                }
+
+                this.user = await response.json();
+                const router = useRouter();
+                await router.push('/admin');
+            } catch (error) {
+                console.error(error);
+            }
+        },
+
+        async register(email: string, password: string, name: string) {
+            try {
+                const response = await fetch('/api/auth/register', {
+                    method: 'POST',
+                    headers: {
+                        "Content-Type": "application/json",
+                    },
+                    body: JSON.stringify({ email, password, name }),
+                });
+
+                if (!response.ok) {
+                    // Promise.reject() is used to avoid "'throw' of exception caught locally" warning
+                    // https://stackoverflow.com/a/60725482/12680199
+                    return Promise.reject(Error('Error registering user'));
+                }
+
+                this.user = await response.json();
+                const router = useRouter();
+                await router.push('/admin');
+            } catch (error) {
+                console.error(error);
+            }
+        },
+
+        async logout() {
+            this.user = null;
+            const router = useRouter();
+            await router.push('/');
+        }
+    }
+})
\ No newline at end of file
-- 
GitLab