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 59b97d9..c9a18f7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,18 +32,32 @@ enum crdb_internal_region { } model Analyst { - id String @id @default(uuid()) - firstName String - lastName String - email String @unique - password String - role Role @default(SOC_ADMIN) - phoneNumber String - lastLogin DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - refreshTokens RefreshToken[] - dashboards Dashboard[] + id String @id @default(uuid()) + firstName String + lastName String + email String @unique + password String + role Role @default(SOC_ADMIN) + phoneNumber String + lastLogin DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + refreshTokens RefreshToken[] + passwordResetTokens PasswordResetToken[] + dashboards Dashboard[] +} + +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 bfdb46f..7a2a6aa 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -25,10 +25,12 @@ export default { .asString() as ms.StringValue, FRONTEND_URL: env.get('FRONTEND_URL').asString(), + COOKIE_SECURE: env .get('COOKIE_SECURE') .default(isProduction ? 'true' : 'false') .asBoolStrict(), + COOKIE_SAME_SITE: env .get('COOKIE_SAME_SITE') .default(isProduction ? 'none' : 'lax') 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, +});