optimize auth

This commit is contained in:
sianida26 2024-02-14 09:56:37 +07:00
parent 65131f9653
commit c6034de16a
25 changed files with 609 additions and 145 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -6,17 +6,18 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"key:generate": "bun run src/core/utils/generateJwtSecret.ts"
}, },
"prisma": { "prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^1.1.0", "@auth/prisma-adapter": "^1.1.0",
"@mantine/core": "^7.5.0", "@mantine/core": "^7.5.2",
"@mantine/form": "^7.5.0", "@mantine/form": "^7.5.2",
"@mantine/hooks": "^7.5.0", "@mantine/hooks": "^7.5.2",
"@mantine/notifications": "^7.5.0", "@mantine/notifications": "^7.5.2",
"@prisma/client": "5.8.1", "@prisma/client": "5.8.1",
"@tanstack/react-query": "^4.36.1", "@tanstack/react-query": "^4.36.1",
"@tanstack/react-query-devtools": "^4.36.1", "@tanstack/react-query-devtools": "^4.36.1",
@ -44,6 +45,7 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.0.5",
"@types/node": "^20.11.7", "@types/node": "^20.11.7",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",

View File

@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@ -17,8 +14,8 @@ model User {
emailVerified DateTime? emailVerified DateTime?
passwordHash String? passwordHash String?
photoProfile UserPhotoProfiles? photoProfile UserPhotoProfiles?
roles Role[] directPermissions Permission[] @relation("PermissionToUser")
directPermissions Permission[] roles Role[] @relation("RoleToUser")
} }
model UserPhotoProfiles { model UserPhotoProfiles {
@ -34,8 +31,8 @@ model Role {
name String name String
description String @default("") description String @default("")
isActive Boolean @default(false) isActive Boolean @default(false)
users User[] permissions Permission[] @relation("PermissionToRole")
permissions Permission[] users User[] @relation("RoleToUser")
} }
model Permission { model Permission {
@ -44,6 +41,6 @@ model Permission {
name String name String
description String @default("") description String @default("")
isActive Boolean @default(false) isActive Boolean @default(false)
roles Role[] roles Role[] @relation("PermissionToRole")
directUsers User[] directUsers User[] @relation("PermissionToUser")
} }

View File

@ -1,4 +1,4 @@
import hashPassword from "../../src/features/auth/tools/hashPassword"; import hashPassword from "../../src/modules/auth/utils/hashPassword";
import { User, PrismaClient, Prisma } from "@prisma/client"; import { User, PrismaClient, Prisma } from "@prisma/client";
import { DefaultArgs } from "@prisma/client/runtime/library"; import { DefaultArgs } from "@prisma/client/runtime/library";
import { log } from "console"; import { log } from "console";

View File

@ -1,7 +1,6 @@
import guestOnly from "@/modules/auth/actions/guestOnly";
import React from "react"; import React from "react";
import guestOnly from "@/features/auth/actions/guestOnly";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
} }

View File

@ -1,7 +1,5 @@
"use client"; "use client";
import signIn from "@/modules/auth/actions/signIn";
import getUser from "@/features/auth/actions/getUser";
import signIn from "@/features/auth/actions/signIn";
import { import {
Paper, Paper,
PasswordInput, PasswordInput,

View File

@ -1,21 +1,18 @@
"use client" "use client";
import getUser from "@//features/auth/actions/getUser";
import logout from "@/features/auth/actions/logout"; import logout from "@/modules/auth/actions/logout";
import { redirect } from "next/navigation"; import { useEffect } from "react";
import React, { useEffect } from "react";
/** /**
* LogoutPage component handles the logout process. * LogoutPage component handles the logout process.
* It checks if a user is logged in, logs them out, and redirects to the login page. * It checks if a user is logged in, logs them out, and redirects to the login page.
*/ */
export default function LogoutPage() { export default function LogoutPage() {
useEffect(() => { useEffect(() => {
const logoutAction = async () => await logout();
const logoutAction = async () => await logout() logoutAction();
}, []);
logoutAction()
}, [])
return <div></div>; return <div></div>;
} }

View File

@ -1,14 +1,12 @@
import guestOnly from "@/modules/auth/actions/guestOnly";
import React from "react"; import React from "react";
import guestOnly from "@/features/auth/actions/guestOnly";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
} }
export default async function RegisterLayout({ children }: Props) { export default async function RegisterLayout({ children }: Props) {
await guestOnly();
await guestOnly()
return <>{children}</>; return <>{children}</>;
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import createUser from "@/features/auth/actions/createUser"; import createUser from "@/modules/auth/actions/createUser";
import { import {
Paper, Paper,
PasswordInput, PasswordInput,
@ -15,36 +15,40 @@ import { useForm } from "@mantine/form";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
export interface RegisterFormSchema { export interface RegisterFormSchema {
email: string, email: string;
password: string, password: string;
passwordConfirmation: string, passwordConfirmation: string;
name: string, name: string;
} }
export default function RegisterPage() { export default function RegisterPage() {
const [errorMessage, setErrorMessage] = useState("");
const [errorMessage, setErrorMessage] = useState("")
const form = useForm<RegisterFormSchema>({ const form = useForm<RegisterFormSchema>({
initialValues: { initialValues: {
email: "", email: "",
password: "", password: "",
passwordConfirmation: "", passwordConfirmation: "",
name: "" name: "",
}, },
validate: { validate: {
email: (value: string) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'), email: (value: string) =>
password: (value: string) => (value.length >= 6 ? null : 'Password should be at least 6 characters'), /^\S+@\S+$/.test(value) ? null : "Invalid email",
passwordConfirmation: (value: string, values: RegisterFormSchema) => value === values.password ? null : 'Passwords should match', password: (value: string) =>
name: (value: string) => (value.length > 0 ? null : 'Name is required'), value.length >= 6
} ? null
: "Password should be at least 6 characters",
passwordConfirmation: (value: string, values: RegisterFormSchema) =>
value === values.password ? null : "Passwords should match",
name: (value: string) =>
value.length > 0 ? null : "Name is required",
},
}); });
const handleSubmit = async (values: RegisterFormSchema) => { const handleSubmit = async (values: RegisterFormSchema) => {
const formData = new FormData(); const formData = new FormData();
Object.entries(values) Object.entries(values).forEach(([key, value]) => {
.forEach(([key, value]) => { formData.append(key, value);
formData.append(key, value)
}); });
const response = await createUser(formData); const response = await createUser(formData);
@ -53,19 +57,21 @@ export default function RegisterPage() {
setErrorMessage(response.error.message); setErrorMessage(response.error.message);
if (response.error.errors) { if (response.error.errors) {
const errors = Object.entries(response.error.errors) const errors = Object.entries(response.error.errors).reduce(
.reduce((prev, [k,v]) => { (prev, [k, v]) => {
prev[k] = v[0] prev[k] = v[0];
return prev; return prev;
}, {} as {[k: string]: string}) },
{} as { [k: string]: string }
);
form.setErrors(errors) form.setErrors(errors);
console.log(form.errors) console.log(form.errors);
} else { } else {
form.clearErrors() form.clearErrors();
}
} }
} }
};
return ( return (
<div className="w-screen h-screen flex items-center justify-center"> <div className="w-screen h-screen flex items-center justify-center">
@ -73,7 +79,9 @@ export default function RegisterPage() {
<Text size="lg" fw={500} mb={30}> <Text size="lg" fw={500} mb={30}>
Register Register
</Text> </Text>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}> <form
onSubmit={form.onSubmit((values) => handleSubmit(values))}
>
<Stack> <Stack>
<TextInput <TextInput
label="Name" label="Name"

View File

@ -1,3 +1,5 @@
@layer tailwind{
@tailwind base; @tailwind base;
}
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;

15
src/core/db/index.ts Normal file
View File

@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare global {
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
}
const prisma = globalThis.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;

View File

@ -0,0 +1,31 @@
export const BaseErrorCodes = ["UNKOWN_ERROR"] as const;
interface ErrorOptions {
message?: string;
errorCode: (typeof BaseErrorCodes)[number] | (string & {});
}
class BaseError extends Error {
public readonly errorCode: (typeof BaseErrorCodes)[number] | (string & {});
constructor(options: ErrorOptions) {
super(options.message ?? "Undetermined Error");
this.errorCode = options.errorCode ?? "UNKOWN_ERROR";
Object.setPrototypeOf(this, new.target.prototype);
console.error("error:", options)
}
getActionResponseObject() {
return {
success: false,
error: {
message: this.message,
errorCode: this.errorCode,
},
} as const;
}
}
export default BaseError;

View File

@ -0,0 +1,105 @@
"use server";
import { z } from "zod";
import prisma from "@/core/db";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { hashPassword } from "../utils/hashPassword";
import { createJwtToken } from "../utils/createJwtToken";
/**
* Interface for the schema of a new user.
*/
interface CreateUserSchema {
name: string;
email: string;
password: string;
}
/**
* Validation schema for creating a user.
*/
const createUserSchema = z
.object({
name: z.string(),
email: z.string().email(),
password: z.string().min(6),
passwordConfirmation: z.string().optional(),
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Password confirmation must match the password",
path: ["passwordConfirmation"],
});
/**
* Creates a new user in the system.
*
* @param formData - The form data containing user details.
* @returns An object indicating the result of the operation.
*/
export default async function createUser(formData: FormData) {
//TODO: Add Throttling
//TODO: Add validation check if the user is already logged in
try {
const parsedData = {
email: formData.get("email")?.toString() ?? "",
name: formData.get("name")?.toString() ?? "",
password: formData.get("password")?.toString() ?? "",
passwordConfirmation: formData
.get("passwordConfirmation")
?.toString(),
};
const validatedFields = createUserSchema.safeParse(parsedData);
if (!validatedFields.success) {
return {
success: false,
error: {
message: "",
errors: validatedFields.error.flatten().fieldErrors,
},
};
}
const existingUser = await prisma.user.findUnique({
where: { email: validatedFields.data.email },
});
if (existingUser) {
return {
success: false,
error: {
message: "",
errors: {
email: ["Email already exists"],
},
},
};
}
const user = await prisma.user.create({
data: {
name: validatedFields.data.name,
email: validatedFields.data.email,
passwordHash: await hashPassword(validatedFields.data.password),
},
});
const token = createJwtToken({ id: user.id });
cookies().set("token", token);
} catch (e: unknown) {
// Handle unexpected errors
console.error(e);
//@ts-ignore
console.log(e.message);
return {
success: false,
error: {
message:
"An unexpected error occurred on the server. Please try again or contact the administrator.",
},
};
}
redirect("/dashboard");
}

View File

@ -0,0 +1,39 @@
"use server";
import { cookies } from "next/headers";
import "server-only";
import getUserFromToken from "../utils/getUserFromToken";
import AuthError from "../error/AuthError";
/**
* Retrieves the user details based on the JWT token from cookies.
* This function is designed to be used in a server-side context within a Next.js application.
* It attempts to parse the user's token, fetch the user's details, and format the response.
* If the token is invalid or the user cannot be found, it gracefully handles these cases.
*
* @returns A promise that resolves to the user's details object or null if the user cannot be authenticated or an error occurs.
* @throws an error if an unexpected error occurs during execution.
*/
export default async function getUser() {
try {
const token = cookies().get("token");
if (!token) return null;
const user = await getUserFromToken(token.value);
if (!user) return null;
return {
name: user.name ?? "",
email: user.email ?? "",
photoUrl: user.photoProfile?.path ?? null,
};
} catch (e: unknown) {
// Handle specific authentication errors gracefully
if (e instanceof AuthError && e.errorCode === "INVALID_JWT_TOKEN") {
return null;
}
throw e;
}
}

View File

@ -0,0 +1,12 @@
"use server";
import { redirect } from "next/navigation";
import getUser from "./getUser";
export default async function guestOnly() {
const user = await getUser();
if (user) {
redirect("dashboard");
}
}

View File

@ -0,0 +1,16 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import "server-only";
/**
* Handles user logout by deleting the authentication token and redirecting to the login page.
* This function is intended to be used on the server side.
*
* @returns A promise that resolves when the logout process is complete.
*/
export default async function logout() {
cookies().delete("token");
redirect("/login");
}

View File

@ -0,0 +1,99 @@
"use server";
import prisma from "@/core/db";
import { User } from "@prisma/client";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import AuthError from "../error/AuthError";
import comparePassword from "../utils/comparePassword";
import { createJwtToken } from "../utils/createJwtToken";
/**
* Handles the sign-in process for a user.
*
* This function validates a user's credentials (email and password), checks against the database,
* and on successful validation, redirects the user to the dashboard and sets a cookie with a JWT token.
* If the validation fails at any stage, it throws a custom AuthError.
*
* @param prevState - The previous state of the application, not currently used.
* @param rawFormData - The raw form data containing the user's email and password.
* @returns A promise that resolves to a redirect to the dashboard on successful authentication,
* or an object containing error details on failure.
* @throws Specific authentication error based on the failure stage.
*/
export default async function signIn(prevState: any, rawFormData: FormData) {
//TODO: Add Throttling
//TODO: Add validation check if the user is already logged in
try {
const formData = {
email: rawFormData.get("email") as string,
password: rawFormData.get("password") as string,
};
// Retrieve user from the database by email
const user = await prisma.user.findUnique({
where: { email: formData.email },
});
// Throw if user not found
if (!user)
throw new AuthError({
errorCode: "EMAIL_NOT_FOUND",
message: "Email or Password does not match",
});
// Throw if user has no password hash
// TODO: Add check if the user uses another provider
if (!user.passwordHash)
throw new AuthError({ errorCode: "EMPTY_USER_HASH" });
// Compare the provided password with the user's stored password hash
const isMatch = await comparePassword(
formData.password,
user.passwordHash
);
if (!isMatch)
throw new AuthError({
errorCode: "INVALID_CREDENTIALS",
message: "Email or Password does not match",
});
//Set cookie
//TODO: Auth: Add expiry
const token = createJwtToken({ id: user.id });
cookies().set("token", token);
} catch (e: unknown) {
// Custom error handling for authentication errors
if (e instanceof AuthError) {
// Specific error handling for known authentication errors
switch (e.errorCode) {
case "EMAIL_NOT_FOUND":
case "INVALID_CREDENTIALS":
return {
errors: {
message:
"Email/Password combination is incorrect. Please try again.",
},
};
default:
// Handle other types of authentication errors
return {
errors: {
message: e.message,
},
};
}
}
// Generic error handling for unexpected server errors
return {
errors: {
message:
"An unexpected error occurred on the server. Please try again or contact the administrator.",
},
};
}
redirect("/dashboard");
}

View File

@ -0,0 +1,5 @@
const authConfig = {
saltRounds: 10,
};
export default authConfig;

View File

@ -0,0 +1,28 @@
import BaseError from "@/core/error/BaseError";
export const AuthErrorCodes = [
"EMAIL_NOT_FOUND",
"EMPTY_USER_HASH",
"INVALID_CREDENTIALS",
"INVALID_JWT_TOKEN",
"JWT_SECRET_EMPTY",
"USER_ALREADY_EXISTS",
] as const;
interface AuthErrorOptions {
message?: string;
errorCode: (typeof AuthErrorCodes)[number] | (string & {});
}
export default class AuthError extends BaseError {
errorCode: (typeof AuthErrorCodes)[number] | (string & {});
constructor(options: AuthErrorOptions) {
super({
errorCode: options.errorCode,
message: options.message,
});
this.errorCode = options.errorCode;
}
}

View File

@ -0,0 +1,7 @@
import { User } from "@prisma/client";
type UserClaims = {
id: User["id"];
};
export default UserClaims;

View File

@ -0,0 +1,17 @@
import bcrypt from "bcrypt";
/**
* Compares a plain text password with a hashed password.
*
* @param password - The plain text password to compare.
* @param hash - The hashed password to compare against.
* @returns True if the passwords match, false otherwise.
*/
async function comparePassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export default comparePassword;

View File

@ -0,0 +1,20 @@
import { SignOptions } from "jsonwebtoken";
import UserClaims from "../types/UserClaims";
import AuthError from "../error/AuthError";
import jwt from "jsonwebtoken";
/**
* Creates a JWT token based on user claims.
*
* @param userClaims - The user claims to encode in the JWT.
* @param options - Optional signing options.
* @returns The generated JWT token.
*/
export function createJwtToken(
userClaims: UserClaims,
options?: SignOptions
): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new AuthError({ errorCode: "JWT_SECRET_EMPTY" });
return jwt.sign(userClaims, secret, options);
}

View File

@ -0,0 +1,21 @@
import jwt, { JwtPayload } from "jsonwebtoken";
import AuthError from "../error/AuthError";
/**
* Decodes a JWT token and retrieves the payload.
*
* @param token - The JWT token to decode.
* @returns The decoded payload.
*/
function decodeJwtToken(token: string): JwtPayload | string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new AuthError({ errorCode: "JWT_SECRET_NOT_EMPTY" });
try {
return jwt.verify(token, secret) as JwtPayload;
} catch (error) {
throw new AuthError({ errorCode: "INVALID_JWT_TOKEN" });
}
}
export default decodeJwtToken;

View File

@ -0,0 +1,34 @@
import { cache } from "react";
import decodeJwtToken from "./decodeJwtToken";
import prisma from "@/core/db";
/**
* Retrieves user data from the database based on the provided JWT token.
*
* This function decodes the JWT token to extract the user ID, and then queries the database using Prisma
* to fetch the user's details, including the profile photo, roles, and direct permissions.
*
* @param token - The JWT token containing the user's ID.
* @returns The user's data if the user exists, or null if no user is found.
* Throws an error if the token is invalid or the database query fails.
*/
const getUserFromToken = cache(async (token: string) => {
// Decode the JWT token to extract the user ID
const decodedToken = decodeJwtToken(token) as { id: string; iat: number };
// Fetch the user from the database
const user = await prisma.user.findFirst({
include: {
photoProfile: true,
roles: true,
directPermissions: true,
},
where: {
id: decodedToken.id,
},
});
return user;
});
export default getUserFromToken;

View File

@ -0,0 +1,14 @@
import bcrypt from "bcrypt";
import authConfig from "../authConfig";
/**
* Hashes a plain text password using bcrypt.
*
* @param password - The plain text password to hash.
* @returns The hashed password.
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, authConfig.saltRounds);
}
export default hashPassword;