diff --git a/src/constants/cookie.keys.ts b/src/constants/cookie.keys.ts new file mode 100644 index 0000000..b6d682a --- /dev/null +++ b/src/constants/cookie.keys.ts @@ -0,0 +1,4 @@ +export const COOKIE_KEYS = { + AUTH: "auth_token", + CSRF: "csrf_token", +}; diff --git a/src/helpers/http/userHeader/cookies/clearCookies.ts b/src/helpers/http/userHeader/cookies/clearCookies.ts new file mode 100644 index 0000000..6882057 --- /dev/null +++ b/src/helpers/http/userHeader/cookies/clearCookies.ts @@ -0,0 +1,33 @@ +import { serialize } from "cookie"; + +export const clearCookies = ( + set: any, + cookieKeys: string[], + options?: Partial<{ + httpOnly: boolean; + secure: boolean; + sameSite: "strict" | "lax" | "none"; + path: string; + }> +) => { + // Define the default configurations for clearing cookies + const defaultOptions = { + httpOnly: true, + secure: true, + sameSite: "strict" as const, + path: "/", + ...options, + }; + + // Create an array of cleared cookies with the specified names + const clearedCookies = cookieKeys.map((name) => { + return serialize(name, "", { + ...defaultOptions, + expires: new Date(0), + }); + }); + + // Set the cleared cookies in the response headers + set.headers["set-cookie"] = clearedCookies; + return clearedCookies; +}; diff --git a/src/helpers/http/userHeader/cookies/setCookies.ts b/src/helpers/http/userHeader/cookies/setCookies.ts index eac9790..5c807b6 100644 --- a/src/helpers/http/userHeader/cookies/setCookies.ts +++ b/src/helpers/http/userHeader/cookies/setCookies.ts @@ -1,15 +1,34 @@ import { serialize } from "cookie"; -export const setCookie = async (set: any, payload: string) => { +export const setCookie = async ( + set: any, + name: string, + payload: string, + options?: Partial<{ + httpOnly: boolean; + secure: boolean; + sameSite: "strict" | "lax" | "none"; + maxAge: number; + path: string; + }> +) => { + // Define the default configurations for the cookie const cookieLifetime = Number(process.env.SESSION_EXPIRE!); - const serializedCookie = serialize("auth_token", payload, { + const defaultOptions = { httpOnly: true, secure: true, - sameSite: "strict", + sameSite: "strict" as const, maxAge: cookieLifetime, path: "/", - }); + }; + // Merge the default options with the provided options + const finalOptions = { ...defaultOptions, ...options }; + + // Create the serialized cookie string + const serializedCookie = serialize(name, payload, finalOptions); + + // Set the cookie in the response headers set.headers["set-cookie"] = serializedCookie; return serializedCookie; }; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index f200c5b..f2ec672 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -2,3 +2,64 @@ export interface LoginWithPasswordRequest { identifier: string; password: string; } + +export interface JWTSessionPayload { + id: string; + isAuthenticated: boolean; + userId: string; + deviceType: string; + deviceOs: string; + deviceIp: string; + isOnline: boolean; + lastOnline: Date; + validUntil: Date; + deletedAt: null; + createdAt: Date; + updatedAt: Date; + user: User; + iat: number; + exp: number; +} +interface User { + id: string; + name: string; + username: string; + email: string; + birthDate: null; + gender: null; + phoneCC: null; + phoneNumber: null; + bioProfile: null; + profilePicture: null; + commentPicture: null; + preferenceId: null; + verifiedAt: null; + disabledAt: null; + deletedAt: null; + createdAt: Date; + updatedAt: Date; + roles: Role[]; +} +interface Role { + id: string; + name: string; + primaryColor: string; + secondaryColor: string; + pictureImage: string; + badgeImage: null; + isSuperadmin: boolean; + canEditMedia: boolean; + canManageMedia: boolean; + canEditEpisodes: boolean; + canManageEpisodes: boolean; + canEditComment: boolean; + canManageComment: boolean; + canEditUser: boolean; + canManageUser: boolean; + canEditSystem: boolean; + canManageSystem: boolean; + createdBy: string; + deletedAt: null; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/modules/auth/controller/authVerification.controller.ts b/src/modules/auth/controller/authVerification.controller.ts index ba3a9ae..463ff01 100644 --- a/src/modules/auth/controller/authVerification.controller.ts +++ b/src/modules/auth/controller/authVerification.controller.ts @@ -1,11 +1,13 @@ -import { Context } from "elysia"; -import { getCookie } from "../../../helpers/http/userHeader/cookies/getCookies"; -import { authVerificationService } from "../services/authVerification.service"; -import { mainErrorHandler } from "../../../helpers/error/handler"; import { returnErrorResponse, returnWriteResponse, } from "../../../helpers/callback/httpResponse"; +import { Context } from "elysia"; +import { getCookie } from "../../../helpers/http/userHeader/cookies/getCookies"; +import { authVerificationService } from "../services/authVerification.service"; +import { mainErrorHandler } from "../../../helpers/error/handler"; +import { clearCookies } from "../../../helpers/http/userHeader/cookies/clearCookies"; +import { COOKIE_KEYS } from "../../../constants/cookie.keys"; export const authVerification = async (ctx: Context) => { try { @@ -13,9 +15,10 @@ export const authVerification = async (ctx: Context) => { if (!cookie.auth_token) return returnErrorResponse(ctx.set, 401, "Auth token not found"); - const authService = authVerificationService(cookie.auth_token); + const authService = await authVerificationService(cookie.auth_token); return returnWriteResponse(ctx.set, 200, "User authenticated", authService); } catch (error) { + clearCookies(ctx.set, [COOKIE_KEYS.AUTH]); return mainErrorHandler(ctx.set, error); } }; diff --git a/src/modules/auth/controller/loginWithPassword.controller.ts b/src/modules/auth/controller/loginWithPassword.controller.ts index ebc4387..dfaea9b 100644 --- a/src/modules/auth/controller/loginWithPassword.controller.ts +++ b/src/modules/auth/controller/loginWithPassword.controller.ts @@ -9,6 +9,7 @@ import { LoginWithPasswordRequest } from "../auth.types"; import { mainErrorHandler } from "../../../helpers/error/handler"; import { getUserHeaderInformation } from "../../../helpers/http/userHeader/getUserHeaderInformation"; import { setCookie } from "../../../helpers/http/userHeader/cookies/setCookies"; +import { COOKIE_KEYS } from "../../../constants/cookie.keys"; export const loginWithPassword = async ( ctx: Context & { body: LoginWithPasswordRequest } @@ -22,8 +23,8 @@ export const loginWithPassword = async ( try { const jwtToken = await loginWithPasswordService(ctx.body, userHeaderInfo); - const cookie = setCookie(ctx.set, jwtToken); - return returnWriteResponse(ctx.set, 200, "Autentication Success", cookie); + const cookie = setCookie(ctx.set, COOKIE_KEYS.AUTH, jwtToken); + return returnWriteResponse(ctx.set, 200, "Authentication Success", cookie); } catch (error) { return mainErrorHandler(ctx.set, error); } diff --git a/src/modules/auth/services/authVerification.service.ts b/src/modules/auth/services/authVerification.service.ts index f6989ad..fb59c33 100644 --- a/src/modules/auth/services/authVerification.service.ts +++ b/src/modules/auth/services/authVerification.service.ts @@ -1,11 +1,47 @@ import { AppError } from "../../../helpers/error/instances/app"; import { jwtDecode } from "../../../helpers/http/jwt/decode"; +import { prisma } from "../../../utils/databases/prisma/connection"; +import { redis } from "../../../utils/databases/redis/connection"; +import { storeUserSessionToCacheRepo } from "../../userSession/repositories/storeUserSessionToCache.repository"; +import { storeUserSessionToCacheService } from "../../userSession/services/storeUserSessionToCache.service"; +import { JWTSessionPayload } from "../auth.types"; -export const authVerificationService = (cookie: string) => { +export const authVerificationService = async (cookie: string) => { try { - const userToken = jwtDecode(cookie); - return userToken; + // Decode the JWT token to get the session payload + const jwtSession = jwtDecode(cookie) as JWTSessionPayload; + + // Check if the session exists in Redis + const sessionCheckOnRedis = await redis.exists(jwtSession.id); + if (!sessionCheckOnRedis) { + // If not found in Redis, check the database + const sessionCheckOnDB = await prisma.userSession.findUnique({ + where: { + id: jwtSession.id, + }, + }); + + // If the session found in the database, store it in Redis. if not, throw an error + if ( + !sessionCheckOnDB || + !sessionCheckOnDB.isAuthenticated || + new Date(sessionCheckOnDB.validUntil) < new Date() + ) { + throw new AppError(401, "Session invalid or expired"); + } else { + // Store the session in Redis with the remaining time until expiration + const timeExpires = Math.floor( + (new Date(sessionCheckOnDB.validUntil).getTime() - + new Date().getTime()) / + 1000 + ); + await storeUserSessionToCacheService(sessionCheckOnDB!, timeExpires); + return sessionCheckOnDB; + } + } else { + return jwtSession; + } } catch (error) { - throw new AppError(401, "Token is invalid"); + throw new AppError(401, "Token is invalid", error); } }; diff --git a/src/modules/debug/debug.controller.ts b/src/modules/debug/debug.controller.ts new file mode 100644 index 0000000..c0408dc --- /dev/null +++ b/src/modules/debug/debug.controller.ts @@ -0,0 +1,12 @@ +import { Context } from "elysia"; +import { mainErrorHandler } from "../../helpers/error/handler"; +import { debugService } from "./debug.service"; + +export const debugController = (ctx: Context) => { + return Math.floor( + (new Date("2025-07-13 16:22:12.335").getTime() - new Date().getTime()) / + 1000 + ); +}; + +// buat debug untuk date to number (second) diff --git a/src/modules/debug/debug.service.ts b/src/modules/debug/debug.service.ts new file mode 100644 index 0000000..8ce09b6 --- /dev/null +++ b/src/modules/debug/debug.service.ts @@ -0,0 +1,6 @@ +import { AppError } from "../../helpers/error/instances/app"; + +export const debugService = () => { + // return "OK2"; + throw new AppError(404, "not found"); +}; diff --git a/src/modules/debug/index.ts b/src/modules/debug/index.ts new file mode 100644 index 0000000..9e2d148 --- /dev/null +++ b/src/modules/debug/index.ts @@ -0,0 +1,7 @@ +import Elysia from "elysia"; +import { debugController } from "./debug.controller"; + +export const debugModule = new Elysia({ prefix: "/debug" }).get( + "/", + debugController +); diff --git a/src/modules/userSession/repositories/createUserSession.repository.ts b/src/modules/userSession/repositories/insertUserSessionToDB.repository.ts similarity index 100% rename from src/modules/userSession/repositories/createUserSession.repository.ts rename to src/modules/userSession/repositories/insertUserSessionToDB.repository.ts diff --git a/src/modules/userSession/repositories/storeUserSessionToCache.repository.ts b/src/modules/userSession/repositories/storeUserSessionToCache.repository.ts new file mode 100644 index 0000000..4d480c9 --- /dev/null +++ b/src/modules/userSession/repositories/storeUserSessionToCache.repository.ts @@ -0,0 +1,19 @@ +import { Prisma } from "@prisma/client"; +import { AppError } from "../../../helpers/error/instances/app"; +import { redis } from "../../../utils/databases/redis/connection"; + +export const storeUserSessionToCacheRepo = async ( + userSession: Prisma.UserSessionUncheckedCreateInput, + timeExpires: number +) => { + try { + await redis.set( + `${process.env.app_name}:users:${userSession.userId}:sessions:${userSession.id}`, + String(userSession.validUntil), + "EX", + timeExpires + ); + } catch (error) { + throw new AppError(401, "Failed to store user session to cache"); + } +}; diff --git a/src/modules/userSession/services/createUserSession.service.ts b/src/modules/userSession/services/createUserSession.service.ts index dfdc442..45e9530 100644 --- a/src/modules/userSession/services/createUserSession.service.ts +++ b/src/modules/userSession/services/createUserSession.service.ts @@ -1,6 +1,6 @@ import { createUserSessionServiceParams } from "../userSession.types"; -import { redis } from "../../../utils/databases/redis/connection"; -import { createUserSessionRepo } from "../repositories/createUserSession.repository"; +import { createUserSessionRepo } from "../repositories/insertUserSessionToDB.repository"; +import { storeUserSessionToCacheRepo } from "../repositories/storeUserSessionToCache.repository"; export const createUserSessionService = async ( data: createUserSessionServiceParams @@ -16,12 +16,8 @@ export const createUserSessionService = async ( validUntil: new Date(new Date().getTime() + sessionLifetime * 1000), }); - await redis.set( - `${process.env.app_name}:users:${data.userId}:sessions:${newUserSession.id}`, - String(newUserSession.validUntil), - "EX", - Number(process.env.SESSION_EXPIRE!) - ); + const timeExpires = Number(process.env.SESSION_EXPIRE!); + await storeUserSessionToCacheRepo(newUserSession, timeExpires); return newUserSession; } catch (error) { diff --git a/src/modules/userSession/services/storeUserSessionToCache.service.ts b/src/modules/userSession/services/storeUserSessionToCache.service.ts new file mode 100644 index 0000000..cfd50ad --- /dev/null +++ b/src/modules/userSession/services/storeUserSessionToCache.service.ts @@ -0,0 +1,15 @@ +import { Prisma } from "@prisma/client"; +import { AppError } from "../../../helpers/error/instances/app"; +import { storeUserSessionToCacheRepo } from "../repositories/storeUserSessionToCache.repository"; + +export const storeUserSessionToCacheService = async ( + userSession: Prisma.UserSessionUncheckedCreateInput, + timeExpires: number +) => { + try { + await storeUserSessionToCacheRepo(userSession, timeExpires); + return; + } catch (error) { + throw new AppError(401, "Failed to store user session to cache"); + } +}; diff --git a/src/routes.ts b/src/routes.ts index 526fc3f..599c090 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,11 +1,13 @@ import Elysia from "elysia"; import {authModule} from './modules/auth'; +import {debugModule} from './modules/debug'; import {userModule} from './modules/user'; import {userRoleModule} from './modules/userRole'; import {userSessionModule} from './modules/userSession'; const routes = new Elysia() .use(authModule) +.use(debugModule) .use(userModule) .use(userRoleModule) .use(userSessionModule);