diff --git a/.env b/.env index be821b7..d818c6f 100644 --- a/.env +++ b/.env @@ -11,4 +11,5 @@ DATABASE_URL="prisma+postgres://localhost:51213/?api_key=eyJkYXRhYmFzZVVybCI6InBvc3RncmVzOi8vcG9zdGdyZXM6cG9zdGdyZXNAbG9jYWxob3N0OjUxMjE0L3RlbXBsYXRlMT9zc2xtb2RlPWRpc2FibGUmY29ubmVjdGlvbl9saW1pdD0xMCZjb25uZWN0X3RpbWVvdXQ9MCZtYXhfaWRsZV9jb25uZWN0aW9uX2xpZmV0aW1lPTAmcG9vbF90aW1lb3V0PTAmc29ja2V0X3RpbWVvdXQ9MCIsIm5hbWUiOiJkZWZhdWx0Iiwic2hhZG93RGF0YWJhc2VVcmwiOiJwb3N0Z3JlczovL3Bvc3RncmVzOnBvc3RncmVzQGxvY2FsaG9zdDo1MTIxNS90ZW1wbGF0ZTE_c3NsbW9kZT1kaXNhYmxlJmNvbm5lY3Rpb25fbGltaXQ9MTAmY29ubmVjdF90aW1lb3V0PTAmbWF4X2lkbGVfY29ubmVjdGlvbl9saWZldGltZT0wJnBvb2xfdGltZW91dD0wJnNvY2tldF90aW1lb3V0PTAifQ" ACCESS_TOKEN_SECRET=71b4c7d01c2cb2f2dd4ec05e0d991489c63194e1a9725e450342090468c262e5be5d9bd61813496a6942a9845fd039f1bafb736bbc97715ac210e807b119cdfb -REFRESH_TOKEN_SECRET=71b4c7d01c2cb2f2dd4ec05e0d991489c63194e1a9725e450342090468c262e5be5d9bd61813496a6942a9845fd039f1bafb736bbc97715ac210e807b119cdfb \ No newline at end of file +REFRESH_TOKEN_SECRET=71b4c7d01c2cb2f2dd4ec05e0d991489c63194e1a9725e450342090468c262e5be5d9bd61813496a6942a9845fd039f1bafb736bbc97715ac210e807b119cdfb +JWT_SECRET=71b4c7d01c2cb2f2dd4ec05e0d991489c63194e1a9725e450342090468c262e5 \ No newline at end of file diff --git a/.env.example b/.env.example index 59137b8..9998c26 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -DATABASE_URL= -PORT= +DATABASE_URL="file:./dev.db" +PORT=3000 REDIS_URI_WITH_AUTH= APP_ENV=development -SERVER_TIME_ZONE= +SERVER_TIME_ZONE=Asia/Qatar CORS_ORIGIN=* -RATE_LIMIT_WINDOW= -RATE_LIMIT_MAX= -JWT_SECRET= +RATE_LIMIT_WINDOW=1 minute +RATE_LIMIT_MAX=120 +JWT_SECRET=my_super_secret_key_12345 JWT_ACCESS_EXPIRES_IN=15m JWT_REFRESH_EXPIRES_IN=7d \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4e97efe..b3841a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.0", "@prisma/client": "^5.19.1", + "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", "chalk": "^5.6.2", "dotenv": "^17.4.2", @@ -26,6 +27,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^2.4.6", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.0.3", @@ -937,6 +939,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -1116,6 +1128,20 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -1818,7 +1844,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2358,6 +2383,26 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", diff --git a/package.json b/package.json index 749b814..cca676a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "^6.0.0", "@prisma/client": "^5.19.1", + "bcrypt": "^6.0.0", "bcryptjs": "^3.0.3", "chalk": "^5.6.2", "dotenv": "^17.4.2", @@ -34,6 +35,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^2.4.6", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.0.3", diff --git a/prisma/dev.db b/prisma/dev.db new file mode 100644 index 0000000..56dc8a9 Binary files /dev/null and b/prisma/dev.db differ diff --git a/prisma/migrations/20260507135532_init/migration.sql b/prisma/migrations/20260507135532_init/migration.sql new file mode 100644 index 0000000..0f31bdf --- /dev/null +++ b/prisma/migrations/20260507135532_init/migration.sql @@ -0,0 +1,48 @@ +-- CreateTable +CREATE TABLE "Users" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "firstName" TEXT, + "lastName" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "UserAuths" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "UserAuths_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "UserVerifications" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "UserVerifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "UserTokens" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "resetPasswordToken" TEXT, + "resetPasswordExpires" DATETIME, + "refreshToken" TEXT NOT NULL, + "accessToken" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "UserTokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Users_email_key" ON "Users"("email"); diff --git a/prisma/migrations/20260510205009_/migration.sql b/prisma/migrations/20260510205009_/migration.sql new file mode 100644 index 0000000..cc06970 --- /dev/null +++ b/prisma/migrations/20260510205009_/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Users" ADD COLUMN "bio" TEXT; +ALTER TABLE "Users" ADD COLUMN "phone" TEXT; +ALTER TABLE "Users" ADD COLUMN "profilePicture" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 800f3ee..bdb6d3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,8 +3,8 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "sqlite" + url = "file:./dev.db" } model Users { @@ -13,6 +13,8 @@ model Users { firstName String? lastName String? phone String? + profilePicture String? + bio String? company String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -26,8 +28,8 @@ model Users { model UserAuths { id String @id @default(uuid()) userId String @unique - passwordHash String - recognisedDevices String[] @default([]) + passwordHash String + recognisedDevices String @default("") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user Users @relation(fields: [userId], references: [id]) @@ -46,7 +48,9 @@ model UserVerifications { model UserTokens { id String @id @default(uuid()) - userId String @unique + userId String @unique + resetPasswordToken String? + resetPasswordExpires DateTime? refreshToken String accessToken String deviceId String diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index cf891f1..d78efed 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -2,7 +2,8 @@ import authService from "../services/auth.service"; import AuthService from "../services/auth.service"; import {FastifyReply, FastifyRequest} from "fastify"; import {sendResponse} from "../helpers"; -import {LoginRequest, SignupRequest, VerifyDeviceChangeRequest, RefreshTokenRequest} from "../schemas"; +import {LoginRequest, SignupRequest,ForgotPasswordRequest, ResetPasswordRequest,VerifyDeviceChangeRequest,RefreshTokenRequest} from "../schemas"; +//import {LoginRequest, SignupRequest, VerifyDeviceChangeRequest, RefreshTokenRequest} from "../schemas"; class AuthController { constructor() { @@ -31,16 +32,36 @@ class AuthController { }); return sendResponse(reply, result); } + public static async forgotPassword(request: FastifyRequest, reply: FastifyReply) { + const { email } = ForgotPasswordRequest.parse(request.body ?? {}); + const result = await AuthService.forgotPassword({ + deviceId: request.headers['x-device-id'], + email, + }); + return sendResponse(reply, result); + } - public static async verifyDeviceChange(request: FastifyRequest, reply: FastifyReply) { + public static async verifyDeviceChange(request: FastifyRequest, reply: FastifyReply) { const {otp} = VerifyDeviceChangeRequest.parse(request.body ?? {}); const result = await AuthService.verifyDeviceChange({ deviceId: request.headers['x-device-id'], otp, + userId: "" }); return sendResponse(reply, result); } + + public static async resetPassword(request: FastifyRequest, reply: FastifyReply) { + const { token, newPassword } = ResetPasswordRequest.parse(request.body ?? {}); + const result = await AuthService.resetPassword({ + deviceId: request.headers['x-device-id'], + token, + newPassword, + }); + return sendResponse(reply, result); + } + public static async refreshToken(request: FastifyRequest, reply: FastifyReply) { const {refreshToken} = RefreshTokenRequest.parse(request.body ?? {}); const result = await AuthService.refreshToken({ @@ -50,6 +71,8 @@ class AuthController { return sendResponse(reply, result); } + + } export const AuthenticationController = AuthController; diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index a536132..ebb73b1 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,30 +1,159 @@ -import {FastifyReply, FastifyRequest} from "fastify"; +import { FastifyRequest, FastifyReply } from "fastify"; import UserService from "../services/user.service"; -import {sendResponse} from "../helpers"; -import {ChangePasswordRequest} from "../schemas"; - -UserService.initialize(); +import AuthService from "../services/auth.service"; +import { sendResponse } from "../helpers"; class UserController { static initialize() { new UserController(); } - public static async getUserProfile(request: FastifyRequest, reply: FastifyReply) { - const userId = request.user?.id; - const response = await UserService.getUserById(userId); - return sendResponse(reply, response); + // GET current user profile + public static async getProfile(request: FastifyRequest, reply: FastifyReply) { + try { + // Try to get user ID from middleware OR from header + const userId = (request as any).user?.id || (request.headers as any)['x-user-id']; + + if (!userId) { + return reply.status(401).send({ + success: false, + message: "User not authenticated", + error: "UNAUTHORIZED" + }); + } + + const response = await UserService.getUserById(userId); + return sendResponse(reply, response); + } catch (error: any) { + console.error('Get profile error:', error); + + if (error.message === "User not found") { + return reply.status(404).send({ + success: false, + message: "User not found", + error: "NOT_FOUND" + }); + } + + return reply.status(500).send({ + success: false, + message: "Internal server error", + error: error.message + }); + } + } + + // UPDATE user profile + public static async updateProfile(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = (request as any).user?.id || (request.headers as any)['x-user-id']; + + if (!userId) { + return reply.status(401).send({ + success: false, + message: "User not authenticated", + error: "UNAUTHORIZED" + }); + } + + const updateData = request.body as { + firstName?: string; + lastName?: string; + phone?: string; + profilePicture?: string; + bio?: string; + }; + + const response = await UserService.updateUserProfile(userId, updateData); + return sendResponse(reply, response); + } catch (error: any) { + console.error('Update profile error:', error); + + if (error.message === "User not found") { + return reply.status(404).send({ + success: false, + message: "User not found", + error: "NOT_FOUND" + }); + } + + return reply.status(500).send({ + success: false, + message: "Internal server error", + error: error.message + }); + } } + // Change password public static async changePassword(request: FastifyRequest, reply: FastifyReply) { - const {currentPassword, newPassword} = ChangePasswordRequest.parse(request.body ?? {}); - const result = await UserService.changePassword(request.user!.id, { - currentPassword, - newPassword, - deviceId: request.headers['x-device-id'], - }); - return sendResponse(reply, result); + try { + const userId = (request as any).user?.id || (request.headers as any)['x-user-id']; + + if (!userId) { + return reply.status(401).send({ + success: false, + message: "User not authenticated", + error: "UNAUTHORIZED" + }); + } + + const { currentPassword, newPassword } = request.body as { + currentPassword: string; + newPassword: string; + }; + + const response = await AuthService.changePassword(userId, { + currentPassword, + newPassword, + deviceId: request.headers['x-device-id'] as string + }); + + return sendResponse(reply, response); + } catch (error: any) { + console.error('Change password error:', error); + return reply.status(500).send({ + success: false, + message: "Internal server error", + error: error.message + }); + } + } + + // GET user by ID + public static async getProfileById(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params as { id: string }; + + if (!id) { + return reply.status(400).send({ + success: false, + message: "User ID is required", + error: "MISSING_ID" + }); + } + + const response = await UserService.getUserById(id); + return sendResponse(reply, response); + } catch (error: any) { + console.error('Get profile by ID error:', error); + + if (error.message === "User not found") { + return reply.status(404).send({ + success: false, + message: "User not found", + error: "NOT_FOUND" + }); + } + + return reply.status(500).send({ + success: false, + message: "Internal server error", + error: error.message + }); + } } } export const UserCtrl = UserController; +export { UserCtrl as UserController }; \ No newline at end of file diff --git a/src/exceptions/error.code.ts b/src/exceptions/error.code.ts index 1fc591a..0dbd576 100644 --- a/src/exceptions/error.code.ts +++ b/src/exceptions/error.code.ts @@ -46,6 +46,7 @@ export enum CustomErrorCode { TENANT_REJECTED='TENANT_REJECTED', TENANT_SUSPENDED='TENANT_SUSPENDED', TENANT_UNAVAILABLE='TENANT_UNAVAILABLE', + NOT_FOUND = "NOT_FOUND", // Flight FLIGHT_NOT_FOUND = 'TB_FLT_500', diff --git a/src/helpers/jwt.ts b/src/helpers/jwt.ts index fd66ea1..2a36b26 100644 --- a/src/helpers/jwt.ts +++ b/src/helpers/jwt.ts @@ -1,5 +1,9 @@ import jwt from 'jsonwebtoken'; -import {config} from '../config'; +import dotenv from 'dotenv'; +import path from 'path'; + +// Load .env from root +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); export enum TOKEN_TYPE { AUTH_TOKEN = 'AUTH_TOKEN', @@ -14,23 +18,32 @@ export interface TokenPayload { tokenType: TOKEN_TYPE; } +// Read directly from process.env +const JWT_SECRET = process.env.JWT_SECRET; +const ACCESS_EXPIRES = process.env.JWT_ACCESS_EXPIRES_IN || '15m'; +const REFRESH_EXPIRES = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; + +console.log('JWT_SECRET loaded:', JWT_SECRET ? 'YES' : 'NO'); + export function generateJwtToken(payload: TokenPayload): string { + if (!JWT_SECRET) { + throw new Error('JWT_SECRET is not defined. Please check your .env file'); + } + + // KEEP tokenType - don't remove it if (payload.tokenType === TOKEN_TYPE.AUTH_TOKEN) { - return jwt.sign(payload, config.jwt.secret, { - expiresIn: config.jwt.accessExpiresIn as jwt.SignOptions['expiresIn'], - }); + return jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_EXPIRES } as jwt.SignOptions); } else if (payload.tokenType === TOKEN_TYPE.REFRESH_TOKEN) { - return jwt.sign(payload, config.jwt.secret, { - expiresIn: config.jwt.refreshExpiresIn as jwt.SignOptions['expiresIn'], - }); + return jwt.sign(payload, JWT_SECRET, { expiresIn: REFRESH_EXPIRES } as jwt.SignOptions); } else if (payload.tokenType === TOKEN_TYPE.RESET_TOKEN) { - return jwt.sign(payload, config.jwt.secret, { - expiresIn: '1h', - }); + return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' } as jwt.SignOptions); } throw new Error('Invalid token type'); } export function verifyToken(token: string): TokenPayload { - return jwt.verify(token, config.jwt.secret) as TokenPayload; -} + if (!JWT_SECRET) { + throw new Error('JWT_SECRET is not defined'); + } + return jwt.verify(token, JWT_SECRET) as TokenPayload; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9076a7f..3b99846 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,14 @@ import {config} from "dotenv"; config(); +config({ path: resolve(__dirname, '../.env.example') }); + +console.log('Loaded JWT_SECRET:', process.env.JWT_SECRET ? 'YES' : 'NO'); import {buildServer} from "./server"; import {config as environmentVariables} from './config'; import {connectRedis} from "./lib"; import {Logger} from "./helpers/Logger"; +import { resolve } from "path/win32"; const app = buildServer(); const port = Number(environmentVariables.port ?? 3000); diff --git a/src/interfaces/IService.interface.ts b/src/interfaces/IService.interface.ts index 6554271..9c917d0 100644 --- a/src/interfaces/IService.interface.ts +++ b/src/interfaces/IService.interface.ts @@ -2,5 +2,6 @@ export interface IService { success: boolean; message?: string; data?: T; + error?: string; meta?: Record } \ No newline at end of file diff --git a/src/interfaces/auth.interface.ts b/src/interfaces/auth.interface.ts index dce1073..81130d9 100644 --- a/src/interfaces/auth.interface.ts +++ b/src/interfaces/auth.interface.ts @@ -23,7 +23,7 @@ export interface RefreshTokenDTO extends DeviceId { } export interface ResetPasswordDTO extends DeviceId{ - authToken: string; + token: string; newPassword: string; } @@ -38,6 +38,8 @@ export interface ChangePasswordDTO extends DeviceId{ export interface VerifyDeviceChangeOTPDTO extends DeviceId { otp: string; + userId: string; + } diff --git a/src/middlewares/requireAuth.ts b/src/middlewares/requireAuth.ts index a44fb8b..ac78e79 100644 --- a/src/middlewares/requireAuth.ts +++ b/src/middlewares/requireAuth.ts @@ -2,7 +2,7 @@ import {FastifyReply, FastifyRequest} from "fastify"; import {CustomErrorCode, ForbiddenError, UnAuthorizedError} from "../exceptions"; import {prisma} from "../lib/db"; import {TOKEN_TYPE, verifyToken} from "../helpers"; -import {redisClient} from "../lib"; +//import {redisClient} from "../lib"; export async function requireAuthHook( request: FastifyRequest, @@ -28,6 +28,7 @@ function isPublicRoute(url: string) { async function authenticateBearer(request: FastifyRequest, reply: FastifyReply) { const authToken = request.headers["x-auth-token"]; const deviceId = request.headers["x-device-id"]; + if (!authToken || typeof authToken !== "string") { throw new UnAuthorizedError({msg: "Missing auth token", errorCode: CustomErrorCode.AUTH_INVALID}) } @@ -37,23 +38,20 @@ async function authenticateBearer(request: FastifyRequest, reply: FastifyReply) } try { - - const cacheKey = `travelBase_token:${authToken}:${deviceId}`; - const cachedToken = await redisClient.get(cacheKey); - if (!cachedToken) { - const dbToken = await prisma.userTokens.findFirst({ - where: {accessToken: authToken, deviceId} - }) - if (!dbToken) { - throw new ForbiddenError({ - msg: 'Invalid token', - errorCode: CustomErrorCode.AUTH_INVALID - }); - } else { - await redisClient.set(cacheKey, JSON.stringify(dbToken.accessToken)); - await redisClient.expire(cacheKey, 60 * 60 * 24 * 7); // 7 days + const dbToken = await prisma.userTokens.findFirst({ + where: { + accessToken: authToken, + deviceId: deviceId } + }); + + if (!dbToken) { + throw new ForbiddenError({ + msg: 'Invalid token', + errorCode: CustomErrorCode.AUTH_INVALID + }); } + const decodedJwtData = verifyToken(authToken); if (!decodedJwtData || decodedJwtData.tokenType !== TOKEN_TYPE.AUTH_TOKEN) { @@ -70,10 +68,9 @@ async function authenticateBearer(request: FastifyRequest, reply: FastifyReply) id: decodedJwtData.userId, email: decodedJwtData.email, } - + return true; } catch (error: unknown) { - console.log(error); throw new UnAuthorizedError({msg: "Invalid auth token", errorCode: CustomErrorCode.AUTH_INVALID}) } } diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index 17a1f75..1bdf951 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -1,7 +1,6 @@ -import {prisma} from '../lib/db'; - -class UserRepository { +import { prisma } from "../lib/db"; +export class UserRepository { static async findById(id: string) { return prisma.users.findUnique({where: {id}}); } @@ -20,7 +19,53 @@ class UserRepository { data: {passwordHash}, }); } + + async findById(userId: string) { + return await prisma.users.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + phone: true, + profilePicture: true, + bio: true, + createdAt: true, + updatedAt: true, + } + }); + } + async updateProfile(userId: string, data: { + firstName?: string; + lastName?: string; + phone?: string; + profilePicture?: string; + bio?: string; + }) { + return await prisma.users.update({ + where: { id: userId }, + data: { + ...data, + updatedAt: new Date() + }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + phone: true, + profilePicture: true, + bio: true, + createdAt: true, + updatedAt: true + } + }); + } } + + + export default UserRepository; diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 3eeebd1..ec69fdc 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -6,6 +6,15 @@ AuthenticationController.initialize(); export async function AuthRouter(app: FastifyInstance) { app.post("/v1/auth/signup", async (request: FastifyRequest, reply: FastifyReply) => AuthenticationController.signup(request, reply)); app.post("/v1/auth/login", async (request: FastifyRequest, reply: FastifyReply) => AuthenticationController.login(request, reply)); + + app.post("/v1/auth/forgot-password", async (request: FastifyRequest, reply: FastifyReply) => + AuthenticationController.forgotPassword(request, reply) + ); + + app.post("/v1/auth/reset-password", async (request: FastifyRequest, reply: FastifyReply) => + AuthenticationController.resetPassword(request, reply) + ); + app.post("/v1/auth/verify-device-change", async (request, reply) => AuthenticationController.verifyDeviceChange(request, reply)); app.post("/v1/auth/refresh-token", async (request, reply) => AuthenticationController.refreshToken(request, reply)); } diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index e0287f1..0ab2b66 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -1,10 +1,41 @@ -import {FastifyInstance, FastifyReply, FastifyRequest} from "fastify"; -import {UserCtrl} from "../controllers"; -import {requireAuthHook} from "../middlewares"; +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { UserCtrl } from "../controllers"; +import { requireAuthHook } from "../middlewares"; -UserCtrl.initialize(); +export async function userRoutes(app: FastifyInstance) { + + // Get current user's profile - WITH AUTH ✅ + app.get("/v1/users/profile", + { preHandler: requireAuthHook }, // ← ADD THIS + async (request: FastifyRequest, reply: FastifyReply) => + UserCtrl.getProfile(request, reply) + ); -export async function UserRouter(app: FastifyInstance) { - app.post("/v1/users/me", async (request: FastifyRequest, reply: FastifyReply) => UserCtrl.getUserProfile(request, reply)); - app.patch("/v1/user/change-password", {preHandler: requireAuthHook}, async (request: FastifyRequest, reply: FastifyReply) => UserCtrl.changePassword(request, reply)); -} + // Update current user's profile - WITH AUTH ✅ + app.put("/v1/users/profile", + { preHandler: requireAuthHook }, // ← ADD THIS + async (request: FastifyRequest, reply: FastifyReply) => + UserCtrl.updateProfile(request, reply) + ); + + // Get user by ID - WITH AUTH ✅ + app.get("/v1/users/:id", + { preHandler: requireAuthHook }, // ← ADD THIS + async (request: FastifyRequest, reply: FastifyReply) => + UserCtrl.getProfileById(request, reply) + ); + + // Get current user via /me endpoint - WITH AUTH ✅ + app.post("/v1/users/me", + { preHandler: requireAuthHook }, // ← ADD THIS + async (request: FastifyRequest, reply: FastifyReply) => + UserCtrl.getProfile(request, reply) + ); + + // Change password - WITH AUTH ✅ (already had it) + app.patch("/v1/user/change-password", + { preHandler: requireAuthHook }, + async (request: FastifyRequest, reply: FastifyReply) => + UserCtrl.changePassword(request, reply) + ); +} \ No newline at end of file diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index c48526b..9c1f85a 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -5,6 +5,14 @@ export const LoginRequest = z.object({ password: z.string().min(8).max(128), }); +export const ForgotPasswordRequest = z.object({ + email: z.string().email().min(4).max(255), +}); + +export const ResetPasswordRequest = z.object({ + token: z.string().min(32), + newPassword: z.string().min(8).max(128), +}); export const SignupRequest = z.object({ email: z.email().min(4).max(255), password: z.string().min(8).max(128), @@ -15,12 +23,10 @@ export const SignupRequest = z.object({ }); export const VerifyDeviceChangeRequest = z.object({ - deviceId: z.string().min(1).max(255), otp: z.string().min(6).max(6), }); export const RefreshTokenRequest = z.object({ refreshToken: z.string().min(1).max(255), - deviceId: z.string().min(1).max(255), }) diff --git a/src/server.ts b/src/server.ts index 62f9b4d..bb32bbb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,11 +3,11 @@ import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import {healthRoutes, welcomeroutes, AuthRouter, UserRouter, FlightRouter} from './routes'; import { welcomeroutes } from './routes'; -import {healthRoutes, AuthRouter, UserRouter} from './routes'; +//import {healthRoutes, AuthRouter, UserRouter} from './routes'; import {config} from "./config"; import {ALLOWED_HEADERS, ALLOWED_METHODS} from "./enums"; import {fastifyErrorHandler} from "./exceptions"; -import {requireAuthHook, requireDeviceHook} from "./middlewares"; +//import {requireAuthHook, requireDeviceHook} from "./middlewares"; export function buildServer() { const app = Fastify({logger: true}); @@ -23,8 +23,9 @@ export function buildServer() { // timeWindow: config.system.rateLimitWindowMs ?? "1 minute", // }); - app.addHook("preHandler", requireDeviceHook); - app.addHook("preHandler", requireAuthHook); + + //app.addHook("preHandler", requireDeviceHook) + //app.addHook("preHandler", requireAuthHook); app.register(AuthRouter); app.register(UserRouter); app.register(FlightRouter); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 0c02c56..fe13cf2 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,8 +1,12 @@ +import crypto from "crypto"; +//@ts-ignore +import bcrypt from "bcrypt"; import { ForgotPasswordDTO, IService, LoginDTO, RefreshTokenDTO, + ResetPasswordDTO, SignupDTO, VerifyDeviceChangeOTPDTO } from "../interfaces"; @@ -20,9 +24,11 @@ class AuthService { } public static async signup(input: SignupDTO): Promise { - const {email, password, firstName, lastName, phone, company, deviceId} = input; - - const existingUser = await prisma.users.findUnique({where: {email}}); + console.log('1. Signup started'); + const { email, password, firstName, lastName, phone, company, deviceId } = input; + + console.log('2. Checking existing user'); + const existingUser = await prisma.users.findUnique({ where: { email } }); if (existingUser) { throw new BadRequestError({ msg: "Account with the email already exists", @@ -30,17 +36,23 @@ class AuthService { }); } + console.log('3. Creating password hash'); const passwordHash = await hashPassword(password); - const {user, accessToken, refreshToken} = await prisma.$transaction(async (tx) => { + console.log('4. Starting transaction'); + const { user, accessToken, refreshToken } = await prisma.$transaction(async (tx) => { + console.log('4a. Creating user'); const user = await tx.users.create({ - data: {email, firstName, lastName, phone, company}, + data: { email, firstName, lastName, phone, company }, }); + console.log('4b. User created:', user.id); + console.log('4c. Creating userAuth'); await tx.userAuths.create({ - data: {userId: user.id, passwordHash, recognisedDevices: [deviceId]}, + data: { userId: user.id, passwordHash }, }); + console.log('4d. Generating tokens'); const accessToken = generateJwtToken({ userId: user.id, email: user.email, @@ -54,13 +66,15 @@ class AuthService { tokenType: TOKEN_TYPE.REFRESH_TOKEN }); + console.log('4e. Creating userTokens'); await tx.userTokens.create({ - data: {userId: user.id, deviceId, accessToken, refreshToken}, + data: { userId: user.id, deviceId, accessToken, refreshToken }, }); - return {user, accessToken, refreshToken}; + return { user, accessToken, refreshToken }; }); + console.log('5. Signup successful'); return { success: true, message: "Signup successful", @@ -73,11 +87,9 @@ class AuthService { } public static async login(input: LoginDTO): Promise { - const {email, password, deviceId} = input; + const { email, password, deviceId } = input; - const user = await prisma.users.findUnique({ - where: {email}, - }); + const user = await prisma.users.findUnique({ where: { email } }); if (!user) { throw new UnAuthorizedError({ msg: "Invalid email or password", @@ -86,7 +98,14 @@ class AuthService { } const userAuth = await prisma.userAuths.findFirst({ - where: {userId: user.id}, + where: { userId: user.id }, + select: { + id: true, + userId: true, + passwordHash: true, + createdAt: true, + updatedAt: true, + }, }); if (!userAuth) { throw new UnAuthorizedError({ @@ -103,23 +122,6 @@ class AuthService { }); } - const isRecognisedDevice = userAuth.recognisedDevices.includes(deviceId); - if (!isRecognisedDevice) { - const otp = Math.floor(100000 + Math.random() * 900000).toString(); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); - - await prisma.userVerifications.create({ - data: {userId: user.id, token: otp, deviceId, expiresAt}, - }); - - // TODO: send OTP to user.email via email service - - throw new UnAuthorizedError({ - msg: "Unrecognised device. A verification code has been sent to your email.", - errorCode: CustomErrorCode.AUTH_BLOCKED, - }); - } - const accessToken = generateJwtToken({ userId: user.id, email: user.email, @@ -133,10 +135,17 @@ class AuthService { tokenType: TOKEN_TYPE.REFRESH_TOKEN }); + await prisma.userTokens.deleteMany({ + where: { userId: user.id, deviceId: deviceId }, + }); - await prisma.userTokens.updateMany({ - where: {userId: user.id, deviceId}, - data: {accessToken, refreshToken}, + await prisma.userTokens.create({ + data: { + userId: user.id, + deviceId: deviceId, + accessToken: accessToken, + refreshToken: refreshToken, + }, }); return { @@ -148,7 +157,6 @@ class AuthService { user, }, }; - } public static async verifyDeviceChange(input: VerifyDeviceChangeOTPDTO): Promise { @@ -204,6 +212,157 @@ class AuthService { }; } + public static async forgotPassword(input: ForgotPasswordDTO): Promise { + const { email, deviceId } = input; + + const user = await prisma.users.findUnique({ + where: { email } + }); + + if (!user) { + return { + success: true, + message: "If an account exists, a password reset link has been sent to your email", + data: {} + } + } + + const resetToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + const existingToken = await prisma.userTokens.findFirst({ + where: { userId: user.id, deviceId: deviceId } + }); + + if (existingToken) { + await prisma.userTokens.update({ + where: { id: existingToken.id }, + data: { + resetPasswordToken: resetToken, + resetPasswordExpires: expiresAt, + } + }); + } else { + await prisma.userTokens.create({ + data: { + userId: user.id, + deviceId: deviceId, + resetPasswordToken: resetToken, + resetPasswordExpires: expiresAt, + refreshToken: crypto.randomBytes(40).toString('hex'), + accessToken: crypto.randomBytes(32).toString('hex') + } + }); + } + + // SECURITY: Only log in development, never expose token + if (process.env.NODE_ENV === 'development') { + console.log(`Password reset requested for: ${email}`); + } + + return { + success: true, + message: "If an account exists, a password reset link has been sent to your email", + data: {} + } + } + + public static async resetPassword(input: ResetPasswordDTO): Promise { + const { token, newPassword } = input; + + const tokenRecord = await prisma.userTokens.findFirst({ + where: { + resetPasswordToken: token, + resetPasswordExpires: { + gt: new Date(), + }, + }, + include: { + user: true, + }, + }); + + if (!tokenRecord) { + throw new BadRequestError({ + msg: "Invalid or expired reset token", + errorCode: CustomErrorCode.AUTH_INVALID, + }); + } + + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(newPassword, saltRounds); + + await prisma.userAuths.updateMany({ + where: { userId: tokenRecord.user.id }, + data: { passwordHash: hashedPassword }, + }); + await prisma.userTokens.update({ + where: { id: tokenRecord.id }, + data: { + resetPasswordToken: null, + resetPasswordExpires: null, + }, + }); + + return { + success: true, + message: "Password has been reset successfully", + data: {} + } + } + + public static async changePassword(userId: string, input: { + currentPassword: string; + newPassword: string; + deviceId: string + }): Promise { + const { currentPassword, newPassword, deviceId } = input; + + const userAuth = await prisma.userAuths.findFirst({ + where: { userId }, + }); + + if (!userAuth) { + throw new UnAuthorizedError({ + msg: "User not found", + errorCode: CustomErrorCode.AUTH_INVALID, + }); + } + + const isMatch = await verifyPassword(currentPassword, userAuth.passwordHash); + if (!isMatch) { + throw new UnAuthorizedError({ + msg: "Current password is incorrect", + errorCode: CustomErrorCode.AUTH_INVALID, + }); + } + + const isSamePassword = await verifyPassword(newPassword, userAuth.passwordHash); + if (isSamePassword) { + throw new BadRequestError({ + msg: "New password must be different from current password", + errorCode: CustomErrorCode.BAD_REQUEST, + }); + } + + const newPasswordHash = await hashPassword(newPassword); + + await prisma.userAuths.updateMany({ + where: { userId }, + data: { passwordHash: newPasswordHash }, + }); + + await prisma.userTokens.updateMany({ + where: { userId, deviceId }, + data: { deviceId }, + }); + + return { + success: true, + message: "Password changed successfully", + data: {}, + }; + } public static async refreshToken(input: RefreshTokenDTO): Promise { const {refreshToken, deviceId} = input; @@ -212,7 +371,6 @@ class AuthService { where: {refreshToken} }); - if (!tokenRecord) { throw new BadRequestError({ msg: "Session expired, please login again", @@ -233,7 +391,6 @@ class AuthService { throw new NotFoundError({msg: "user not found", errorCode: CustomErrorCode.RESOURCE_NOT_FOUND}) } - const tokenPayload = verifyToken(refreshToken); if (!tokenPayload || tokenPayload.tokenType !== TOKEN_TYPE.REFRESH_TOKEN || tokenPayload.deviceId !== deviceId) { throw new BadRequestError({ @@ -266,25 +423,6 @@ class AuthService { data: {accessToken: newAccessToken, refreshToken: newRefreshToken, user} }; } - - public static async forgotPassword(input: ForgotPasswordDTO): Promise { - return { - success: true, - message: "Password reset link sent to your email", - data: { - confirmationToken: "" // a jwt token that will used an header to verify the reset is coming from our server initiated request - } - } - } - - public static async resetPassword(input: ForgotPasswordDTO): Promise { - return { - success: true, - message: "Password reset successful", - } - } - } - export default AuthService; \ No newline at end of file diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 26a123a..8e43f94 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -15,19 +15,61 @@ class UserService { new UserService(); } + // GET user by ID public static async getUserById(userId: string): Promise { const user = await prisma.users.findFirst({ where: {id: userId} }); if (!user) { - throw new NotFoundError({msg: "User not found", errorCode: CustomErrorCode.RESOURCE_NOT_FOUND}) + throw new NotFoundError({ + msg: "User not found", + errorCode: CustomErrorCode.RESOURCE_NOT_FOUND + }); } return { success: true, message: "User profile retrieved successfully", - data: {user} + data: { + user + } + }; + } + + // UPDATE user profile - ADD THIS METHOD + public static async updateUserProfile(userId: string, updateData: { + firstName?: string; + lastName?: string; + phone?: string; + profilePicture?: string; + bio?: string; + }): Promise { + const user = await prisma.users.findFirst({ + where: {id: userId} + }); + + if (!user) { + throw new NotFoundError({ + msg: "User not found", + errorCode: CustomErrorCode.RESOURCE_NOT_FOUND + }); + } + + const updatedUser = await prisma.users.update({ + where: {id: userId}, + data: { + ...updateData, + updatedAt: new Date() + } + }); + + return { + success: true, + message: "User profile updated successfully", + data: { + user: updatedUser + } } }