From 5453ed3efa84ae56c48518443134809a1c223c28 Mon Sep 17 00:00:00 2001 From: Ahmed Elazab Date: Sun, 12 Jul 2026 22:55:51 +0300 Subject: [PATCH] feat(auth): add forget/reset password --- .env.example | 7 ++ package-lock.json | 21 +++++ package.json | 2 + prisma/schema.prisma | 19 ++++- src/common/services/email.service.ts | 28 +++++++ src/config/env.ts | 8 +- .../Auth/controllers/Auth.controller.ts | 26 ++++++ src/modules/Auth/routes/Auth.routes.ts | 72 ++++++++++++++++- .../Auth/services/passwordReset.service.ts | 79 +++++++++++++++++++ .../Auth/validation/Auth.validation.ts | 9 +++ 10 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 src/common/services/email.service.ts create mode 100644 src/modules/Auth/services/passwordReset.service.ts diff --git a/.env.example b/.env.example index 377d26c..4c28d03 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,10 @@ COOKIE_SAME_SITE=lax ELASTICSEARCH_URL=http://elasticsearch:9200 ELASTICSEARCH_USERNAME=elastic ELASTICSEARCH_PASSWORD=change_me + +# SMTP / Email +SMTP_HOST= +SMTP_PORT= +SMTP_USER= +SMTP_PASS= +SMTP_FROM_EMAIL= diff --git a/package-lock.json b/package-lock.json index ca62b8b..0c436b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", + "nodemailer": "^9.0.3", "pg": "^8.16.3", "prisma": "^7.0.0", "rest-collection-stream": "^0.0.1", @@ -41,6 +42,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^24.10.1", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.15.6", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", @@ -941,6 +943,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", @@ -3918,6 +3930,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", diff --git a/package.json b/package.json index 42400a5..28e331b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", + "nodemailer": "^9.0.3", "pg": "^8.16.3", "prisma": "^7.0.0", "rest-collection-stream": "^0.0.1", @@ -32,6 +33,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^24.10.1", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.15.6", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b7b4e6d..f370165 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,9 +38,22 @@ model Analyst { role Role @default(SOC_ADMIN) phoneNumber String lastLogin DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - refreshTokens RefreshToken[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + refreshTokens RefreshToken[] + passwordResetTokens PasswordResetToken[] +} + +model PasswordResetToken { + id String @id @default(uuid()) + token String @unique + analystId String + analyst Analyst @relation(fields: [analystId], references: [id], onDelete: Cascade) + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([token]) + @@index([analystId]) } model RefreshToken { diff --git a/src/common/services/email.service.ts b/src/common/services/email.service.ts new file mode 100644 index 0000000..fccc13d --- /dev/null +++ b/src/common/services/email.service.ts @@ -0,0 +1,28 @@ +import nodemailer from 'nodemailer'; +import logger from '../utils/logger'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || '', + port: parseInt(process.env.SMTP_PORT || '587', 10), + secure: false, + auth: { + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS || '', + }, +}); + +export const sendEmail = async (to: string, subject: string, html: string) => { + try { + const info = await transporter.sendMail({ + from: `"${process.env.SMTP_FROM_NAME || 'SOC Platform'}" <${process.env.SMTP_FROM_EMAIL || ''}>`, + to, + subject, + html, + }); + logger.info(`Email sent: ${info.messageId}`); + return true; + } catch (error) { + logger.error('Error sending email:', error); + return false; + } +}; diff --git a/src/config/env.ts b/src/config/env.ts index a919970..69e2755 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -24,10 +24,10 @@ export default { FRONTEND_URL: env.get('FRONTEND_URL').asString(), COOKIE_SECURE: env.get('COOKIE_SECURE').default('false').asBoolStrict(), - COOKIE_SAME_SITE: env - .get('COOKIE_SAME_SITE') - .default('lax') - .asEnum(['strict', 'lax', 'none']) as 'strict' | 'lax' | 'none', + COOKIE_SAME_SITE: env.get('COOKIE_SAME_SITE').default('lax').asEnum(['strict', 'lax', 'none']) as + | 'strict' + | 'lax' + | 'none', ELASTICSEARCH_URL: env.get('ELASTICSEARCH_URL').default('http://localhost:9200').asUrlString(), ELASTICSEARCH_USERNAME: env.get('ELASTICSEARCH_USERNAME').default('elastic').asString(), diff --git a/src/modules/Auth/controllers/Auth.controller.ts b/src/modules/Auth/controllers/Auth.controller.ts index e0b9f48..930cbc3 100644 --- a/src/modules/Auth/controllers/Auth.controller.ts +++ b/src/modules/Auth/controllers/Auth.controller.ts @@ -16,6 +16,10 @@ import { STATUS, STATUS_CODE } from '../../../common/constants/constants'; import { LoginData, SignupData } from '../types/types'; import { IRequest } from '../../../common/interfaces/types'; import ApiErrorHandler from '../../../common/utils/ApiErrorHandler'; +import { + requestPasswordReset, + resetPassword as resetPasswordService, +} from '../services/passwordReset.service'; export const signup = catchAsync(async (req: Request, res: Response): Promise => { const data: SignupData = req.body as SignupData; @@ -106,3 +110,25 @@ export const getActiveSessions = catchAsync(async (req: IRequest, res: Response) data: sessions, }); }); + +export const forgetPassword = catchAsync(async (req: Request, res: Response): Promise => { + const { email } = req.body; + + await requestPasswordReset(email); + + res.status(STATUS_CODE.SUCCESS).json({ + status: STATUS.SUCCESS, + message: 'If the email exists, a password reset link has been sent.', + }); +}); + +export const resetPassword = catchAsync(async (req: Request, res: Response): Promise => { + const { token, password } = req.body; + + await resetPasswordService(token, password); + + res.status(STATUS_CODE.SUCCESS).json({ + status: STATUS.SUCCESS, + message: 'Password has been reset successfully.', + }); +}); diff --git a/src/modules/Auth/routes/Auth.routes.ts b/src/modules/Auth/routes/Auth.routes.ts index b9882b1..4681580 100644 --- a/src/modules/Auth/routes/Auth.routes.ts +++ b/src/modules/Auth/routes/Auth.routes.ts @@ -6,10 +6,17 @@ import { logout, logoutAll, getActiveSessions, + forgetPassword, + resetPassword, } from '../controllers/Auth.controller'; import { allowOnlyFirstRun } from '../middlewares/allowFirstRun'; import validationMiddleware from '../../../common/middlewares/validation.middleware'; -import { loginRequestValidation, signupRequestValidation } from '../validation/Auth.validation'; +import { + loginRequestValidation, + signupRequestValidation, + forgetPasswordValidation, + resetPasswordValidation, +} from '../validation/Auth.validation'; import { loginLimiter, refreshLimiter } from '../../../config/limiter'; import { authenticate } from '../../../common/middlewares'; @@ -356,4 +363,67 @@ router.post('/logout-all', authenticate, logoutAll); */ router.get('/sessions', authenticate, getActiveSessions); +/** + * @swagger + * /api/v1/auth/forget-password: + * post: + * summary: Request password reset + * description: Request a password reset link to be sent via email + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * properties: + * email: + * type: string + * format: email + * example: analyst@example.com + * responses: + * 200: + * description: Request received + */ +router.post( + '/forget-password', + validationMiddleware({ body: forgetPasswordValidation }), + forgetPassword, +); + +/** + * @swagger + * /api/v1/auth/reset-password: + * post: + * summary: Reset password + * description: Reset password using token sent via email + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - token + * - password + * properties: + * token: + * type: string + * password: + * type: string + * format: password + * example: NewSecurePassword123! + * responses: + * 200: + * description: Password reset successfully + */ +router.post( + '/reset-password', + validationMiddleware({ body: resetPasswordValidation }), + resetPassword, +); + export default router; diff --git a/src/modules/Auth/services/passwordReset.service.ts b/src/modules/Auth/services/passwordReset.service.ts new file mode 100644 index 0000000..cb7f4ab --- /dev/null +++ b/src/modules/Auth/services/passwordReset.service.ts @@ -0,0 +1,79 @@ +import bcrypt from 'bcrypt'; +import { prisma } from '../../../config/postgres'; +import { sendEmail } from '../../../common/services/email.service'; +import logger from '../../../common/utils/logger'; +import { generateSecureToken, hashToken } from './token.service'; + +const TOKEN_EXPIRY_MS = 60 * 60 * 1000; + +export const requestPasswordReset = async (email: string) => { + try { + const analyst = await prisma.analyst.findUnique({ + where: { email }, + }); + + if (!analyst) { + return true; + } + + const token = generateSecureToken(); + const hashedToken = hashToken(token); + const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS); + + await prisma.passwordResetToken.create({ + data: { + token: hashedToken, + analystId: analyst.id, + expiresAt, + }, + }); + + const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${token}`; + const subject = 'Password Reset Request'; + const html = ` +

Password Reset Request

+

You requested a password reset for your account.

+

Please click the link below to reset your password. This link is valid for 1 hour.

+ Reset Password +

If you did not request this, please ignore this email.

+ `; + + await sendEmail(email, subject, html); + + return true; + } catch (error) { + logger.error('Error requesting password reset:', error); + throw new Error('Failed to request password reset'); + } +}; + +export const resetPassword = async (token: string, newPassword: string) => { + try { + const hashedToken = hashToken(token); + + const resetToken = await prisma.passwordResetToken.findUnique({ + where: { token: hashedToken }, + }); + + if (!resetToken || resetToken.expiresAt < new Date()) { + throw new Error('Invalid or expired password reset token'); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + + await prisma.$transaction([ + prisma.analyst.update({ + where: { id: resetToken.analystId }, + data: { password: hashedPassword }, + }), + prisma.passwordResetToken.deleteMany({ + where: { analystId: resetToken.analystId }, + }), + ]); + + return true; + } catch (error) { + logger.error('Error resetting password:', error); + throw error; + } +}; diff --git a/src/modules/Auth/validation/Auth.validation.ts b/src/modules/Auth/validation/Auth.validation.ts index d207077..534b288 100644 --- a/src/modules/Auth/validation/Auth.validation.ts +++ b/src/modules/Auth/validation/Auth.validation.ts @@ -43,3 +43,12 @@ export const signupRequestValidation = z.object({ }), phoneNumber: phoneNumberSchema, }); + +export const forgetPasswordValidation = z.object({ + email: z.string().trim().toLowerCase().email({ message: 'Please provide a valid email address' }), +}); + +export const resetPasswordValidation = z.object({ + token: z.string().min(1, { message: 'Token is required' }), + password: passwordSchema, +});