Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
38 changes: 26 additions & 12 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions src/common/services/email.service.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
2 changes: 2 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
26 changes: 26 additions & 0 deletions src/modules/Auth/controllers/Auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
const data: SignupData = req.body as SignupData;
Expand Down Expand Up @@ -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<void> => {
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<void> => {
const { token, password } = req.body;

await resetPasswordService(token, password);

res.status(STATUS_CODE.SUCCESS).json({
status: STATUS.SUCCESS,
message: 'Password has been reset successfully.',
});
});
72 changes: 71 additions & 1 deletion src/modules/Auth/routes/Auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
79 changes: 79 additions & 0 deletions src/modules/Auth/services/passwordReset.service.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<h1>Password Reset Request</h1>
<p>You requested a password reset for your account.</p>
<p>Please click the link below to reset your password. This link is valid for 1 hour.</p>
<a href="${resetLink}">Reset Password</a>
<p>If you did not request this, please ignore this email.</p>
`;

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;
}
};
9 changes: 9 additions & 0 deletions src/modules/Auth/validation/Auth.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Loading