diff --git a/backend/user-service/controller/auth-controller.js b/backend/user-service/controller/auth-controller.js
index 34e8bece..effaa0fc 100644
--- a/backend/user-service/controller/auth-controller.js
+++ b/backend/user-service/controller/auth-controller.js
@@ -4,6 +4,9 @@ import { UserRepository } from "../model/user-repository.js";
import { formatUserResponse } from "./user-controller.js";
import { generateToken, generateRefreshToken } from "../middleware/jwtAuth.js";
import { redisService } from "../services/redis-service.js";
+import { otpService } from "../services/otp-service.js";
+import { emailService } from "../services/email-service.js";
+import { AUTH_ERRORS, sendErrorResponse } from "../errors/index.js";
function secondsToMs(seconds) {
return parseInt(seconds) * 1000;
@@ -14,12 +17,12 @@ function secondsToMs(seconds) {
* Returns existing token if it has sufficient remaining time, null otherwise
*/
async function shouldReuseToken(userId) {
- const MINIMUM_REMAINING_TIME = 300; // 5 minutes in seconds
+ const MINIMUM_REMAINING_TIME = 300;
try {
const existingToken = await redisService.getWhitelistToken(userId);
if (!existingToken) {
- return null; // No existing token
+ return null;
}
const remainingTTL = await redisService.getWhitelistTokenTTL(userId);
@@ -30,7 +33,7 @@ async function shouldReuseToken(userId) {
return existingToken;
}
- return null; // Token expires soon, generate new one
+ return null;
} catch (error) {
console.error("Error checking token reuse:", error);
return null;
@@ -41,93 +44,76 @@ export async function handleLogin(req, res) {
const { email, password } = req.body;
if (!email || !password) {
- return res.status(400).json({
- message: "Missing email and/or password",
- error: "MISSING_CREDENTIALS",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.MISSING_CREDENTIALS);
}
try {
const user = await UserRepository.findByEmail(email.toLowerCase());
if (!user) {
- return res.status(401).json({
- message: "Invalid email or password",
- error: "INVALID_CREDENTIALS",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.INVALID_CREDENTIALS);
}
const match = await argon2.verify(user.password, password);
if (!match) {
- return res.status(401).json({
- message: "Invalid email or password",
- error: "INVALID_CREDENTIALS",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.INVALID_CREDENTIALS);
}
- // Update lastLogin timestamp
const updatedUser = await UserRepository.updateById(user.id, {
lastLogin: new Date(),
});
- // Check if we can reuse an existing token
- let accessToken = await shouldReuseToken((updatedUser || user).id);
+ let accessToken = await shouldReuseToken(
+ (updatedUser || user).id.toString()
+ );
let tokenWasReused = !!accessToken;
- // Generate new tokens if no existing token to reuse
if (!accessToken) {
accessToken = generateToken(updatedUser || user);
- // Store new access token in Redis whitelist with TTL matching JWT expiration
- const tokenTTL = parseInt(process.env.JWT_EXPIRES_IN || 900);
+ const tokenTTL = parseInt(process.env.JWT_EXPIRES_IN);
const whitelistStored = await redisService.storeWhitelistToken(
- (updatedUser || user).id,
+ (updatedUser || user).id.toString(),
accessToken,
tokenTTL
);
if (!whitelistStored) {
console.warn(
- "Failed to store token in whitelist - Redis may not be available"
+ "Failed to store token in whitelist - whitelist may not be available"
);
}
}
const refreshToken = generateRefreshToken(updatedUser || user);
-
- // Set httpOnly cookies for both tokens
res.cookie("accessToken", accessToken, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === "true",
- sameSite: process.env.COOKIE_SAME_SITE || "lax",
- maxAge: secondsToMs(process.env.JWT_EXPIRES_IN || 900),
- path: "/", // Available for all paths
+ sameSite: process.env.COOKIE_SAME_SITE,
+ maxAge: secondsToMs(process.env.JWT_EXPIRES_IN),
+ path: "/",
domain: process.env.COOKIE_DOMAIN,
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === "true",
- sameSite: process.env.COOKIE_SAME_SITE || "lax",
- maxAge: secondsToMs(process.env.JWT_REFRESH_EXPIRES_IN || 604800),
- path: "/", // Available for all paths
+ sameSite: process.env.COOKIE_SAME_SITE,
+ maxAge: secondsToMs(process.env.JWT_REFRESH_EXPIRES_IN),
+ path: "/",
domain: process.env.COOKIE_DOMAIN,
});
- // Return success response (tokens are in cookies)
return res.status(200).json({
message: "User logged in successfully",
data: {
- expiresIn: parseInt(process.env.JWT_EXPIRES_IN || 900),
+ expiresIn: parseInt(process.env.JWT_EXPIRES_IN),
tokenReused: tokenWasReused,
user: formatUserResponse(updatedUser || user),
},
});
} catch (err) {
console.error("Login error:", err);
- return res.status(500).json({
- message: "Internal server error",
- error: "SERVER_ERROR",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.SERVER_ERROR);
}
}
@@ -140,10 +126,7 @@ export async function handleVerifyToken(req, res) {
});
} catch (err) {
console.error("Token verification error:", err);
- return res.status(500).json({
- message: "Internal server error",
- error: "SERVER_ERROR",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.TOKEN_VERIFICATION_ERROR);
}
}
@@ -152,60 +135,44 @@ export async function handleRefreshToken(req, res) {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
- return res.status(401).json({
- message: "Refresh token not provided. Please login again.",
- error: "MISSING_REFRESH_TOKEN",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.MISSING_REFRESH_TOKEN);
}
-
- // Verify refresh token
const decoded = jwt.verify(
refreshToken,
process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET
);
if (decoded.type !== "refresh") {
- return res.status(401).json({
- message: "Invalid token type",
- error: "INVALID_TOKEN_TYPE",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.INVALID_TOKEN_TYPE);
}
- // Get user from database
const user = await UserRepository.findById(decoded.id);
if (!user) {
res.clearCookie("refreshToken", { path: "/auth" });
- return res.status(401).json({
- message: "User not found. Please login again.",
- error: "USER_NOT_FOUND",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.USER_NOT_FOUND);
}
- // Generate new access token
const newAccessToken = generateToken(user);
- // Store new access token in Redis whitelist (replaces old one)
- const tokenTTL = parseInt(process.env.JWT_EXPIRES_IN || 900);
+ const tokenTTL = parseInt(process.env.JWT_EXPIRES_IN);
const whitelistStored = await redisService.storeWhitelistToken(
- user.id,
+ user.id.toString(),
newAccessToken,
tokenTTL
);
if (!whitelistStored) {
console.warn(
- "Failed to store new token in whitelist - Redis may not be available"
+ "Failed to store new token in whitelist - whitelist may not be available"
);
}
const newRefreshToken = generateRefreshToken(user);
-
- // Update both access and refresh token cookies
res.cookie("accessToken", newAccessToken, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === "true",
- sameSite: process.env.COOKIE_SAME_SITE || "lax",
- maxAge: secondsToMs(process.env.JWT_EXPIRES_IN || 900),
+ sameSite: process.env.COOKIE_SAME_SITE,
+ maxAge: secondsToMs(process.env.JWT_EXPIRES_IN),
path: "/",
domain: process.env.COOKIE_DOMAIN,
});
@@ -213,8 +180,8 @@ export async function handleRefreshToken(req, res) {
res.cookie("refreshToken", newRefreshToken, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === "true",
- sameSite: process.env.COOKIE_SAME_SITE || "lax",
- maxAge: secondsToMs(process.env.JWT_REFRESH_EXPIRES_IN || 604800),
+ sameSite: process.env.COOKIE_SAME_SITE,
+ maxAge: secondsToMs(process.env.JWT_REFRESH_EXPIRES_IN),
path: "/",
domain: process.env.COOKIE_DOMAIN,
});
@@ -222,7 +189,7 @@ export async function handleRefreshToken(req, res) {
return res.status(200).json({
message: "Token refreshed successfully",
data: {
- expiresIn: parseInt(process.env.JWT_EXPIRES_IN || 900),
+ expiresIn: parseInt(process.env.JWT_EXPIRES_IN),
user: formatUserResponse(user),
},
});
@@ -232,42 +199,31 @@ export async function handleRefreshToken(req, res) {
res.clearCookie("refreshToken", { path: "/auth" });
if (err.name === "TokenExpiredError") {
- return res.status(401).json({
- message: "Refresh token expired. Please login again.",
- error: "REFRESH_TOKEN_EXPIRED",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.REFRESH_TOKEN_EXPIRED);
}
- return res.status(401).json({
- message: "Invalid refresh token. Please login again.",
- error: "INVALID_REFRESH_TOKEN",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.INVALID_REFRESH_TOKEN);
}
}
export async function handleLogout(req, res) {
try {
- // Get user ID from the verified token
const userId = req.userId;
if (userId) {
- // Remove token from Redis whitelist
const removed = await redisService.removeWhitelistToken(userId);
if (removed) {
console.log(`Token removed from whitelist for user ${userId}`);
} else {
- console.warn(
- "Failed to remove token from whitelist - Redis may not be available"
- );
+ console.warn("Failed to remove token from whitelist");
}
}
- // Clear both access and refresh token cookies
res.clearCookie("accessToken", {
path: "/",
httpOnly: true,
secure: process.env.COOKIE_SECURE === "true",
- sameSite: process.env.COOKIE_SAME_SITE || "lax",
+ sameSite: process.env.COOKIE_SAME_SITE,
domain: process.env.COOKIE_DOMAIN,
});
@@ -275,7 +231,7 @@ export async function handleLogout(req, res) {
path: "/",
httpOnly: true,
secure: process.env.COOKIE_SECURE === "true",
- sameSite: process.env.COOKIE_SAME_SITE || "lax",
+ sameSite: process.env.COOKIE_SAME_SITE,
domain: process.env.COOKIE_DOMAIN,
});
@@ -288,40 +244,26 @@ export async function handleLogout(req, res) {
});
} catch (err) {
console.error("Logout error:", err);
- return res.status(500).json({
- message: "Internal server error",
- error: "SERVER_ERROR",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.SERVER_ERROR);
}
}
/**
- * Reset token TTL to full duration (activity-based refresh)
- * Useful for keeping very active users logged in
+ * Reset token TTL to full duration
+ * For keeping very active users logged in
*/
export async function handleResetTokenTTL(req, res) {
try {
const userId = req.userId;
- const fullTTL = parseInt(process.env.JWT_EXPIRES_IN || 900);
-
- // Check if token exists
+ const fullTTL = parseInt(process.env.JWT_EXPIRES_IN);
const currentTTL = await redisService.getWhitelistTokenTTL(userId);
if (currentTTL <= 0) {
- return res.status(401).json({
- message: "Token has expired or does not exist",
- error: "TOKEN_EXPIRED",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.TOKEN_EXPIRED);
}
-
- // Reset TTL to full duration
const reset = await redisService.resetWhitelistTokenTTL(userId, fullTTL);
if (!reset) {
- return res.status(500).json({
- message:
- "Failed to reset token TTL. Please try refreshing your session.",
- error: "TTL_RESET_FAILED",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.TTL_RESET_FAILED);
}
return res.status(200).json({
@@ -333,9 +275,193 @@ export async function handleResetTokenTTL(req, res) {
});
} catch (err) {
console.error("Token TTL reset error:", err);
- return res.status(500).json({
- message: "Internal server error",
- error: "SERVER_ERROR",
+ return sendErrorResponse(res, AUTH_ERRORS.SERVER_ERROR);
+ }
+}
+
+/**
+ * Generate and send OTP for user verification
+ * Uses access token from cookie to identify the user
+ */
+export async function handleSendVerificationOTP(req, res) {
+ const otpPurpose = "verification";
+ try {
+ const userId = req.userId;
+ const user = req.user;
+
+ if (user.isVerified) {
+ return sendErrorResponse(res, AUTH_ERRORS.ALREADY_VERIFIED);
+ }
+
+ const normalizedEmail = user.email.toLowerCase();
+
+ const existingOTP = await redisService.getOTP(userId, otpPurpose);
+ if (existingOTP) {
+ const remainingTTL = otpService.getRemainingTTL(existingOTP);
+ if (remainingTTL > 60) {
+ const minutes = Math.floor(remainingTTL / 60);
+ const seconds = remainingTTL % 60;
+ const timeString =
+ minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
+
+ return res.status(400).json({
+ message: AUTH_ERRORS.OTP_ALREADY_SENT.message(timeString),
+ error: AUTH_ERRORS.OTP_ALREADY_SENT.code,
+ retryAfterSeconds: remainingTTL,
+ });
+ }
+ }
+ const otpData = otpService.generateOTPData(normalizedEmail, otpPurpose);
+ const storeResult = await redisService.storeOTP(
+ userId,
+ otpData,
+ otpPurpose
+ );
+ if (!storeResult) {
+ console.error("Failed to store OTP in Cache");
+ return sendErrorResponse(res, AUTH_ERRORS.STORAGE_ERROR);
+ }
+ const emailResult = await emailService.sendRegistrationOTP(
+ normalizedEmail,
+ otpData.otp,
+ { username: user.username }
+ );
+
+ if (!emailResult.success) {
+ console.error("Failed to send OTP email:", emailResult.error);
+ await redisService.deleteOTP(userId, otpPurpose);
+ return sendErrorResponse(res, AUTH_ERRORS.EMAIL_ERROR);
+ }
+
+ console.log(
+ `Verification OTP generated and sent for user ${userId} (${normalizedEmail})`
+ );
+
+ return res.status(200).json({
+ message: "OTP sent successfully to your email address.",
+ data: {
+ email: normalizedEmail,
+ expiryMinutes: Math.floor(otpData.ttl / 60),
+ },
+ });
+ } catch (error) {
+ console.error("Error generating verification OTP:", error);
+ return sendErrorResponse(res, AUTH_ERRORS.VERIFICATION_SERVER_ERROR);
+ }
+}
+
+/**
+ * Verify OTP for user verification
+ */
+export async function handleVerifyOTP(req, res) {
+ const otpPurpose = "verification";
+ try {
+ const { otp } = req.body;
+ const userId = req.userId;
+ const user = req.user;
+
+ console.log(
+ `Verifying OTP for user. userId: ${userId}, user._id: ${user._id}, user.id: ${user.id}`
+ );
+
+ if (!otp) {
+ return sendErrorResponse(res, AUTH_ERRORS.MISSING_OTP);
+ }
+
+ if (user.isVerified) {
+ return sendErrorResponse(res, AUTH_ERRORS.ALREADY_VERIFIED);
+ }
+
+ const storedOTPData = await redisService.getOTP(userId, otpPurpose);
+
+ const validationResult = otpService.validateOTP(
+ storedOTPData,
+ otp,
+ user.email.toLowerCase(),
+ otpPurpose
+ );
+
+ if (!validationResult.isValid) {
+ if (storedOTPData && !validationResult.shouldDelete) {
+ await redisService.incrementOTPAttempts(userId, otpPurpose);
+ }
+ if (validationResult.shouldDelete) {
+ await redisService.deleteOTP(userId, otpPurpose);
+ }
+
+ const errorMessage = otpService.getErrorMessage(validationResult);
+ return res.status(400).json({
+ message: errorMessage,
+ error: validationResult.reason,
+ attemptsRemaining: validationResult.attemptsRemaining,
+ });
+ }
+ try {
+ console.log(`Attempting to update user ${user.id} with isVerified: true`);
+ const updatedUser = await UserRepository.updateById(user.id, {
+ isVerified: true,
+ });
+
+ if (!updatedUser) {
+ console.error("Update returned null - user may not exist");
+ return sendErrorResponse(res, AUTH_ERRORS.USER_NOT_FOUND_UPDATE);
+ }
+
+ console.log(
+ `User update successful. isVerified is now: ${updatedUser.isVerified}`
+ );
+ await redisService.deleteOTP(userId, "verification");
+
+ console.log(
+ `User ${userId} (${user.email}) successfully verified via OTP`
+ );
+
+ return res.status(200).json({
+ message: "Email verified successfully",
+ data: {
+ user: formatUserResponse(updatedUser),
+ verifiedAt: new Date().toISOString(),
+ },
+ });
+ } catch (updateError) {
+ console.error("Error updating user verification status:", updateError);
+ return sendErrorResponse(res, AUTH_ERRORS.UPDATE_ERROR);
+ }
+ } catch (error) {
+ console.error("OTP verification error:", error);
+ return sendErrorResponse(res, AUTH_ERRORS.SERVER_ERROR);
+ }
+}
+
+/**
+ * Check current user's verification status
+ */
+export async function handleCheckVerificationStatus(req, res) {
+ const otpPurpose = "verification";
+ try {
+ const userId = req.userId;
+ const user = req.user;
+
+ const hasOTP = await redisService.hasOTP(userId, otpPurpose);
+ const otpData = hasOTP
+ ? await redisService.getOTP(userId, otpPurpose)
+ : null;
+
+ return res.status(200).json({
+ message: "Verification status retrieved",
+ data: {
+ email: user.email,
+ username: user.username,
+ isVerified: user.isVerified,
+ hasActivePendingOTP: hasOTP,
+ otpExpiresIn: otpData ? otpService.getRemainingTTL(otpData) : 0,
+ canSendOTP:
+ !user.isVerified &&
+ (!otpData || otpService.getRemainingTTL(otpData) <= 60),
+ },
});
+ } catch (error) {
+ console.error("Check verification status error:", error);
+ return sendErrorResponse(res, AUTH_ERRORS.SERVER_ERROR);
}
}
diff --git a/backend/user-service/controller/user-controller.js b/backend/user-service/controller/user-controller.js
index 67105826..278c26df 100644
--- a/backend/user-service/controller/user-controller.js
+++ b/backend/user-service/controller/user-controller.js
@@ -1,6 +1,11 @@
import argon2 from "argon2";
import { isValidObjectId } from "mongoose";
import { UserRepository } from "../model/user-repository.js";
+import {
+ USER_ERRORS,
+ sendUserErrorResponse,
+ sendErrorResponse,
+} from "../errors/index.js";
export async function createUser(req, res) {
try {
@@ -9,22 +14,19 @@ export async function createUser(req, res) {
if (existingUser) {
const conflict =
existingUser.username === username ? "username" : "email";
- return res.status(409).json({
- message: `User with this ${conflict} already exists`,
- error: "USER_EXISTS",
+ return sendUserErrorResponse(res, USER_ERRORS.USER_EXISTS, conflict, {
field: conflict,
});
}
const hashedPassword = await argon2.hash(password, {
type: argon2.argon2id,
- memoryCost: parseInt(process.env.ARGON2_MEMORY_COST) || 65536, // 64 MB
- timeCost: parseInt(process.env.ARGON2_TIME_COST) || 3,
- parallelism: parseInt(process.env.ARGON2_PARALLELISM) || 4,
- hashLength: parseInt(process.env.ARGON2_HASH_LENGTH) || 32,
- saltLength: parseInt(process.env.ARGON2_SALT_LENGTH) || 16,
+ memoryCost: parseInt(process.env.ARGON2_MEMORY_COST),
+ timeCost: parseInt(process.env.ARGON2_TIME_COST),
+ parallelism: parseInt(process.env.ARGON2_PARALLELISM),
+ hashLength: parseInt(process.env.ARGON2_HASH_LENGTH),
+ saltLength: parseInt(process.env.ARGON2_SALT_LENGTH),
});
- // Create user data object
const userData = {
username,
email: email.toLowerCase(),
@@ -39,25 +41,22 @@ export async function createUser(req, res) {
);
return res.status(201).json({
- message: `User ${username} created successfully`,
- data: formatUserResponse(createdUser),
+ message: `User ${username} created successfully. Please login to verify your email address.`,
+ data: {
+ user: formatUserResponse(createdUser),
+ },
});
} catch (err) {
console.error("Create user error:", err);
if (err.code === 11000) {
const field = Object.keys(err.keyPattern)[0];
- return res.status(409).json({
- message: `User with this ${field} already exists`,
- error: "DUPLICATE_KEY",
+ return sendUserErrorResponse(res, USER_ERRORS.DUPLICATE_KEY, field, {
field,
});
}
- return res.status(500).json({
- message: "Failed to create user",
- error: "SERVER_ERROR",
- });
+ return sendUserErrorResponse(res, USER_ERRORS.SERVER_ERROR);
}
}
@@ -67,10 +66,7 @@ export async function getUserProfile(req, res) {
const user = await _findUserById(userId);
if (!user) {
- return res.status(404).json({
- message: `User not found`,
- error: "USER_NOT_FOUND",
- });
+ return sendErrorResponse(res, USER_ERRORS.USER_NOT_FOUND);
}
return res.status(200).json({
@@ -79,10 +75,7 @@ export async function getUserProfile(req, res) {
});
} catch (err) {
console.error("Error in getUserProfile:", err);
- return res.status(500).json({
- message: "Database or server error",
- error: "INTERNAL_SERVER_ERROR",
- });
+ return sendErrorResponse(res, USER_ERRORS.INTERNAL_SERVER_ERROR);
}
}
@@ -91,18 +84,12 @@ export async function getUser(req, res) {
const userId = req.params.id;
const tokenUserId = req.userId;
if (userId !== tokenUserId) {
- return res.status(403).json({
- message: "Access denied. You can only access your own user data.",
- error: "UNAUTHORIZED_ACCESS",
- });
+ return sendErrorResponse(res, USER_ERRORS.UNAUTHORIZED_ACCESS);
}
const user = await _findUserById(userId);
if (!user) {
- return res.status(404).json({
- message: `User not found`,
- error: "USER_NOT_FOUND",
- });
+ return sendErrorResponse(res, USER_ERRORS.USER_NOT_FOUND);
}
return res.status(200).json({
@@ -111,10 +98,7 @@ export async function getUser(req, res) {
});
} catch (err) {
console.error("Get user error:", err);
- return res.status(500).json({
- message: "Failed to retrieve user",
- error: "SERVER_ERROR",
- });
+ return sendErrorResponse(res, USER_ERRORS.RETRIEVE_SERVER_ERROR);
}
}
@@ -125,28 +109,19 @@ export async function updateUser(req, res) {
const user = await _findUserById(userId);
if (!user) {
- return res.status(404).json({
- message: "User not found",
- error: "USER_NOT_FOUND",
- });
+ return sendErrorResponse(res, USER_ERRORS.USER_NOT_FOUND);
}
if (username && username !== user.username) {
const existingUser = await _findUserByUsername(username);
if (existingUser && existingUser.id !== userId) {
- return res.status(409).json({
- message: "Username already exists",
- error: "USERNAME_EXISTS",
- });
+ return sendErrorResponse(res, USER_ERRORS.USERNAME_EXISTS);
}
}
if (email && email !== user.email) {
const existingUser = await _findUserByEmail(email);
if (existingUser && existingUser.id !== userId) {
- return res.status(409).json({
- message: "Email already exists",
- error: "EMAIL_EXISTS",
- });
+ return sendErrorResponse(res, USER_ERRORS.EMAIL_EXISTS);
}
}
const updateData = {};
@@ -160,11 +135,11 @@ export async function updateUser(req, res) {
if (password) {
const hashedPassword = await argon2.hash(password, {
type: argon2.argon2id,
- memoryCost: parseInt(process.env.ARGON2_MEMORY_COST) || 65536,
- timeCost: parseInt(process.env.ARGON2_TIME_COST) || 3,
- parallelism: parseInt(process.env.ARGON2_PARALLELISM) || 4,
- hashLength: parseInt(process.env.ARGON2_HASH_LENGTH) || 32,
- saltLength: parseInt(process.env.ARGON2_SALT_LENGTH) || 16,
+ memoryCost: parseInt(process.env.ARGON2_MEMORY_COST),
+ timeCost: parseInt(process.env.ARGON2_TIME_COST),
+ parallelism: parseInt(process.env.ARGON2_PARALLELISM),
+ hashLength: parseInt(process.env.ARGON2_HASH_LENGTH),
+ saltLength: parseInt(process.env.ARGON2_SALT_LENGTH),
});
updatedUser = await UserRepository.updatePassword(userId, hashedPassword);
if (Object.keys(updateData).length > 0) {
@@ -180,10 +155,7 @@ export async function updateUser(req, res) {
});
} catch (err) {
console.error("Update user error:", err);
- return res.status(500).json({
- message: "Failed to update user",
- error: "SERVER_ERROR",
- });
+ return sendErrorResponse(res, USER_ERRORS.UPDATE_SERVER_ERROR);
}
}
@@ -194,10 +166,7 @@ export async function updateUserPrivilege(req, res) {
const user = await _findUserById(userId);
if (!user) {
- return res.status(404).json({
- message: "User not found",
- error: "USER_NOT_FOUND",
- });
+ return sendErrorResponse(res, USER_ERRORS.USER_NOT_FOUND);
}
const updatedUser = await UserRepository.updatePrivilege(userId, isAdmin);
@@ -208,10 +177,7 @@ export async function updateUserPrivilege(req, res) {
});
} catch (err) {
console.error("Update user privilege error:", err);
- return res.status(500).json({
- message: "Failed to update user privilege",
- error: "SERVER_ERROR",
- });
+ return sendErrorResponse(res, USER_ERRORS.PRIVILEGE_SERVER_ERROR);
}
}
@@ -221,10 +187,7 @@ export async function deleteUser(req, res) {
const user = await _findUserById(userId);
if (!user) {
- return res.status(404).json({
- message: "User not found",
- error: "USER_NOT_FOUND",
- });
+ return sendErrorResponse(res, USER_ERRORS.USER_NOT_FOUND);
}
if (process.env.NODE_ENV === "production") {
await UserRepository.softDelete(userId);
@@ -239,10 +202,7 @@ export async function deleteUser(req, res) {
}
} catch (err) {
console.error("Delete user error:", err);
- return res.status(500).json({
- message: "Failed to delete user",
- error: "SERVER_ERROR",
- });
+ return sendErrorResponse(res, USER_ERRORS.DELETE_SERVER_ERROR);
}
}
@@ -255,6 +215,7 @@ export function formatUserResponse(user) {
email: user.email,
isAdmin: user.isAdmin,
isActive: user.isActive,
+ isVerified: user.isVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastLogin: user.lastLogin,
@@ -268,7 +229,6 @@ export function formatUserResponse(user) {
};
}
-// Helper functions for database operations
async function _findUserByUsernameOrEmail(username, email) {
return await UserRepository.findByUsernameOrEmail(username, email);
}
diff --git a/backend/user-service/docker-compose.yml b/backend/user-service/docker-compose.yml
index 953f6da0..a725fdc3 100644
--- a/backend/user-service/docker-compose.yml
+++ b/backend/user-service/docker-compose.yml
@@ -19,31 +19,10 @@ services:
container_name: peerprep-user-service
ports:
- "3001:3001"
+ env_file:
+ - .env.docker
environment:
- - NODE_ENV=production
- - PORT=3001
- REDIS_URL=redis://redis:6379
- # MongoDB connection (you'll need to update these)
- - DB_CLOUD_URI=${DB_CLOUD_URI}
- - DB_LOCAL_URI=mongodb://host.docker.internal:27017/peerprepUserServiceDB
- - ENV=${ENV:-PROD}
- # JWT Configuration
- - JWT_SECRET=${JWT_SECRET}
- - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-15m}
- - JWT_REFRESH_EXPIRES_IN=${JWT_REFRESH_EXPIRES_IN:-7d}
- # Cookie Configuration
- - COOKIE_DOMAIN=${COOKIE_DOMAIN:-localhost}
- - COOKIE_SECURE=${COOKIE_SECURE:-false}
- - COOKIE_SAME_SITE=${COOKIE_SAME_SITE:-lax}
- # Security Configuration
- - ARGON2_MEMORY_COST=${ARGON2_MEMORY_COST:-65536}
- - ARGON2_TIME_COST=${ARGON2_TIME_COST:-3}
- - ARGON2_PARALLELISM=${ARGON2_PARALLELISM:-4}
- - ARGON2_HASH_LENGTH=${ARGON2_HASH_LENGTH:-32}
- - ARGON2_SALT_LENGTH=${ARGON2_SALT_LENGTH:-16}
- # CORS Configuration
- - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:3001,http://localhost:3002}
depends_on:
- redis
networks:
diff --git a/backend/user-service/errors/auth-errors.js b/backend/user-service/errors/auth-errors.js
new file mode 100644
index 00000000..1c144261
--- /dev/null
+++ b/backend/user-service/errors/auth-errors.js
@@ -0,0 +1,171 @@
+/**
+ * Authentication related error definitions
+ * Centralized error messages and codes for better maintainability
+ */
+
+export const AUTH_ERRORS = {
+ MISSING_CREDENTIALS: {
+ code: "MISSING_CREDENTIALS",
+ message: "Missing email and/or password",
+ status: 400,
+ },
+ INVALID_CREDENTIALS: {
+ code: "INVALID_CREDENTIALS",
+ message: "Invalid email or password",
+ status: 401,
+ },
+
+ MISSING_TOKEN: {
+ code: "MISSING_TOKEN",
+ message: "No access token provided in cookie",
+ status: 401,
+ },
+ INVALID_TOKEN: {
+ code: "INVALID_TOKEN",
+ message: "Invalid token",
+ status: 401,
+ },
+ INVALID_SIGNATURE: {
+ code: "INVALID_SIGNATURE",
+ message: "Invalid token signature",
+ status: 401,
+ },
+ TOKEN_NOT_WHITELISTED: {
+ code: "TOKEN_NOT_WHITELISTED",
+ message: "Token is not authorized. Please login again.",
+ status: 401,
+ },
+ MISSING_REFRESH_TOKEN: {
+ code: "MISSING_REFRESH_TOKEN",
+ message: "Refresh token not provided. Please login again.",
+ status: 401,
+ },
+ INVALID_TOKEN_TYPE: {
+ code: "INVALID_TOKEN_TYPE",
+ message: "Invalid token type",
+ status: 401,
+ },
+ USER_NOT_FOUND: {
+ code: "USER_NOT_FOUND",
+ message: "User not found. Please login again.",
+ status: 401,
+ },
+ REFRESH_TOKEN_EXPIRED: {
+ code: "REFRESH_TOKEN_EXPIRED",
+ message: "Refresh token expired. Please login again.",
+ status: 401,
+ },
+ INVALID_REFRESH_TOKEN: {
+ code: "INVALID_REFRESH_TOKEN",
+ message: "Invalid refresh token. Please login again.",
+ status: 401,
+ },
+ TOKEN_EXPIRED: {
+ code: "TOKEN_EXPIRED",
+ message: "Token has expired",
+ status: 401,
+ },
+ TTL_RESET_FAILED: {
+ code: "TTL_RESET_FAILED",
+ message: "Failed to reset token TTL. Please try refreshing your session.",
+ status: 500,
+ },
+
+ ALREADY_VERIFIED: {
+ code: "ALREADY_VERIFIED",
+ message: "User is already verified",
+ status: 400,
+ },
+ OTP_ALREADY_SENT: {
+ code: "OTP_ALREADY_SENT",
+ message: timeString =>
+ `OTP already sent. Please wait ${timeString} before requesting a new one.`,
+ status: 400,
+ },
+ MISSING_OTP: {
+ code: "MISSING_OTP",
+ message: "OTP is required",
+ status: 400,
+ },
+ STORAGE_ERROR: {
+ code: "STORAGE_ERROR",
+ message: "Failed to generate OTP. Please try again later.",
+ status: 500,
+ },
+ EMAIL_ERROR: {
+ code: "EMAIL_ERROR",
+ message: "Failed to send OTP email. Please try again later.",
+ status: 500,
+ },
+ UPDATE_ERROR: {
+ code: "UPDATE_ERROR",
+ message: "Verification completed but failed to update user status",
+ status: 500,
+ },
+ USER_NOT_FOUND_UPDATE: {
+ code: "USER_NOT_FOUND",
+ message: "Failed to update user - user not found",
+ status: 500,
+ },
+
+ SERVER_ERROR: {
+ code: "SERVER_ERROR",
+ message: "Internal server error",
+ status: 500,
+ },
+ VERIFICATION_SERVER_ERROR: {
+ code: "SERVER_ERROR",
+ message: "Internal server error. Please try again later.",
+ status: 500,
+ },
+ TOKEN_VERIFICATION_ERROR: {
+ code: "SERVER_ERROR",
+ message: "Internal server error",
+ status: 500,
+ },
+
+ DATABASE_ERROR: {
+ code: "DATABASE_ERROR",
+ message: "Internal server error",
+ status: 500,
+ },
+
+ NOT_AUTHENTICATED: {
+ code: "NOT_AUTHENTICATED",
+ message: "Authentication required",
+ status: 401,
+ },
+ NOT_ADMIN: {
+ code: "NOT_ADMIN",
+ message: "Admin access required",
+ status: 403,
+ },
+};
+
+/**
+ * Helper function to create error response
+ * @param {Object} error - Error object from AUTH_ERRORS
+ * @param {Object} additionalData - Additional data to include in response
+ * @returns {Object} Formatted error response
+ */
+export function createErrorResponse(error, additionalData = {}) {
+ const response = {
+ message:
+ typeof error.message === "function" ? error.message() : error.message,
+ error: error.code,
+ ...additionalData,
+ };
+
+ return response;
+}
+
+/**
+ * Helper function to send error response
+ * @param {Object} res - Express response object
+ * @param {Object} error - Error object from AUTH_ERRORS
+ * @param {Object} additionalData - Additional data to include in response
+ */
+export function sendErrorResponse(res, error, additionalData = {}) {
+ const errorResponse = createErrorResponse(error, additionalData);
+ return res.status(error.status).json(errorResponse);
+}
diff --git a/backend/user-service/errors/index.js b/backend/user-service/errors/index.js
new file mode 100644
index 00000000..e372b663
--- /dev/null
+++ b/backend/user-service/errors/index.js
@@ -0,0 +1,19 @@
+export * from "./auth-errors.js";
+export * from "./user-errors.js";
+export * from "./validation-errors.js";
+
+export {
+ AUTH_ERRORS,
+ createErrorResponse,
+ sendErrorResponse,
+} from "./auth-errors.js";
+export {
+ USER_ERRORS,
+ createUserErrorResponse,
+ sendUserErrorResponse,
+} from "./user-errors.js";
+export {
+ VALIDATION_ERRORS,
+ createValidationErrorResponse,
+ sendValidationErrorResponse,
+} from "./validation-errors.js";
diff --git a/backend/user-service/errors/user-errors.js b/backend/user-service/errors/user-errors.js
new file mode 100644
index 00000000..78b92f02
--- /dev/null
+++ b/backend/user-service/errors/user-errors.js
@@ -0,0 +1,108 @@
+export const USER_ERRORS = {
+ USER_EXISTS: {
+ code: "USER_EXISTS",
+ message: field => `User with this ${field} already exists`,
+ status: 409,
+ },
+ DUPLICATE_KEY: {
+ code: "DUPLICATE_KEY",
+ message: field => `User with this ${field} already exists`,
+ status: 409,
+ },
+ USERNAME_EXISTS: {
+ code: "USERNAME_EXISTS",
+ message: "Username already exists",
+ status: 409,
+ },
+ EMAIL_EXISTS: {
+ code: "EMAIL_EXISTS",
+ message: "Email already exists",
+ status: 409,
+ },
+
+ USER_NOT_FOUND: {
+ code: "USER_NOT_FOUND",
+ message: "User not found",
+ status: 404,
+ },
+ UNAUTHORIZED_ACCESS: {
+ code: "UNAUTHORIZED_ACCESS",
+ message: "Access denied. You can only access your own user data.",
+ status: 403,
+ },
+
+ SERVER_ERROR: {
+ code: "SERVER_ERROR",
+ message: "Failed to create user",
+ status: 500,
+ },
+ UPDATE_SERVER_ERROR: {
+ code: "SERVER_ERROR",
+ message: "Failed to update user",
+ status: 500,
+ },
+ DELETE_SERVER_ERROR: {
+ code: "SERVER_ERROR",
+ message: "Failed to delete user",
+ status: 500,
+ },
+ PRIVILEGE_SERVER_ERROR: {
+ code: "SERVER_ERROR",
+ message: "Failed to update user privilege",
+ status: 500,
+ },
+ INTERNAL_SERVER_ERROR: {
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Database or server error",
+ status: 500,
+ },
+ RETRIEVE_SERVER_ERROR: {
+ code: "SERVER_ERROR",
+ message: "Failed to retrieve user",
+ status: 500,
+ },
+};
+
+/**
+ * Helper function to create user error response
+ * @param {Object} error - Error object from USER_ERRORS
+ * @param {string|Object} param - Parameter for dynamic messages or additional data
+ * @param {Object} additionalData - Additional data to include in response
+ * @returns {Object} Formatted error response
+ */
+export function createUserErrorResponse(
+ error,
+ param = null,
+ additionalData = {}
+) {
+ let message = error.message;
+
+ if (typeof error.message === "function" && param) {
+ message = error.message(param);
+ }
+
+ const response = {
+ message,
+ error: error.code,
+ ...additionalData,
+ };
+
+ return response;
+}
+
+/**
+ * Helper function to send user error response
+ * @param {Object} res - Express response object
+ * @param {Object} error - Error object from USER_ERRORS
+ * @param {string|Object} param - Parameter for dynamic messages
+ * @param {Object} additionalData - Additional data to include in response
+ */
+export function sendUserErrorResponse(
+ res,
+ error,
+ param = null,
+ additionalData = {}
+) {
+ const errorResponse = createUserErrorResponse(error, param, additionalData);
+ return res.status(error.status).json(errorResponse);
+}
diff --git a/backend/user-service/errors/validation-errors.js b/backend/user-service/errors/validation-errors.js
new file mode 100644
index 00000000..2aaead2d
--- /dev/null
+++ b/backend/user-service/errors/validation-errors.js
@@ -0,0 +1,58 @@
+export const VALIDATION_ERRORS = {
+ VALIDATION_ERROR: {
+ code: "VALIDATION_ERROR",
+ message: "Validation error",
+ status: 400,
+ },
+ INVALID_USER_ID: {
+ code: "INVALID_USER_ID",
+ message: "Invalid user ID format",
+ status: 400,
+ },
+};
+
+/**
+ * Helper function to create validation error response
+ * @param {Object} error - Error object from VALIDATION_ERRORS
+ * @param {Object} details - Validation error details
+ * @param {Object} additionalData - Additional data to include in response
+ * @returns {Object} Formatted error response
+ */
+export function createValidationErrorResponse(
+ error,
+ details = null,
+ additionalData = {}
+) {
+ const response = {
+ message: error.message,
+ error: error.code,
+ ...additionalData,
+ };
+
+ if (details) {
+ response.details = details;
+ }
+
+ return response;
+}
+
+/**
+ * Helper function to send validation error response
+ * @param {Object} res - Express response object
+ * @param {Object} error - Error object from VALIDATION_ERRORS
+ * @param {Object} details - Validation error details
+ * @param {Object} additionalData - Additional data to include in response
+ */
+export function sendValidationErrorResponse(
+ res,
+ error,
+ details = null,
+ additionalData = {}
+) {
+ const errorResponse = createValidationErrorResponse(
+ error,
+ details,
+ additionalData
+ );
+ return res.status(error.status).json(errorResponse);
+}
diff --git a/backend/user-service/index.js b/backend/user-service/index.js
index 3ae83bf5..32588358 100644
--- a/backend/user-service/index.js
+++ b/backend/user-service/index.js
@@ -18,8 +18,8 @@ app.use(
// Rate limiting
const limiter = rateLimit({
- windowMs: 15 * 60 * 1000, // 15 minutes
- max: 100, // limit each IP to 100 requests per windowMs
+ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS),
+ max: parseInt(process.env.RATE_LIMIT_MAX),
message: {
error: "Too many requests from this IP, please try again later.",
},
@@ -28,8 +28,8 @@ const limiter = rateLimit({
});
const authLimiter = rateLimit({
- windowMs: 15 * 60 * 1000, // 15 minutes
- max: 5, // limit each IP to 5 auth requests per windowMs
+ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS),
+ max: parseInt(process.env.AUTH_RATE_LIMIT_MAX),
message: {
error: "Too many authentication attempts, please try again later.",
},
@@ -104,16 +104,34 @@ app.use((req, res, next) => {
next();
});
-// Health check endpoint
-app.get("/health", (req, res) => {
- res.status(200).json({
- status: "OK",
- timestamp: new Date().toISOString(),
- service: "user-service",
- version: process.env.npm_package_version || "1.0.0",
- });
+// Email service debug endpoint (for development)
+app.get("/debug/email-status", async (req, res) => {
+ try {
+ const { emailService } = await import("./services/email-service.js");
+ const status = emailService.getStatus();
+
+ res.status(200).json({
+ message: "Email service status",
+ data: {
+ ...status,
+ environment: {
+ EMAIL_ENABLED: process.env.EMAIL_ENABLED,
+ EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
+ EMAIL_FROM: process.env.EMAIL_FROM,
+ MAILTRAP_HOST: process.env.MAILTRAP_HOST,
+ MAILTRAP_PORT: process.env.MAILTRAP_PORT,
+ MAILTRAP_USER: process.env.MAILTRAP_USER ? "SET" : "NOT_SET",
+ MAILTRAP_PASS: process.env.MAILTRAP_PASS ? "SET" : "NOT_SET",
+ },
+ },
+ });
+ } catch (error) {
+ res.status(500).json({
+ message: "Failed to get email service status",
+ error: error.message,
+ });
+ }
});
-
// Routes
app.use("/users", userRoutes);
app.use("/auth", authLimiter, authRoutes);
diff --git a/backend/user-service/middleware/jwtAuth.js b/backend/user-service/middleware/jwtAuth.js
index a336fd13..0b3b50be 100644
--- a/backend/user-service/middleware/jwtAuth.js
+++ b/backend/user-service/middleware/jwtAuth.js
@@ -1,54 +1,45 @@
import jwt from "jsonwebtoken";
import { UserRepository } from "../model/user-repository.js";
import { redisService } from "../services/redis-service.js";
+import { AUTH_ERRORS, sendErrorResponse } from "../errors/index.js";
/**
* Middleware to verify JWT token and extract user information
*/
export const verifyToken = (req, res, next) => {
- // Get token from cookie instead of Authorization header
const token = req.cookies.accessToken;
if (!token) {
- return res.status(401).json({
- message: "No access token provided in cookie",
- error: "MISSING_TOKEN",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.MISSING_TOKEN);
}
jwt.verify(token, process.env.JWT_SECRET, async (err, decoded) => {
if (err) {
- // Clear the invalid cookie
res.clearCookie("accessToken", {
path: "/",
httpOnly: true,
secure: process.env.COOKIE_SECURE === "true",
- sameSite: process.env.COOKIE_SAME_SITE || "lax",
+ sameSite: process.env.COOKIE_SAME_SITE,
domain: process.env.COOKIE_DOMAIN,
});
- let message = "Invalid token";
- let error = "INVALID_TOKEN";
+ let errorToReturn = AUTH_ERRORS.INVALID_TOKEN;
if (err.name === "TokenExpiredError") {
- message = "Token has expired";
- error = "TOKEN_EXPIRED";
+ errorToReturn = AUTH_ERRORS.TOKEN_EXPIRED;
} else if (err.name === "JsonWebTokenError") {
- message = "Invalid token signature";
- error = "INVALID_SIGNATURE";
+ errorToReturn = AUTH_ERRORS.INVALID_SIGNATURE;
}
- return res.status(401).json({ message, error });
+ return sendErrorResponse(res, errorToReturn);
}
try {
- // Check if token is whitelisted for this user
const isWhitelisted = await redisService.isTokenWhitelisted(
decoded.id,
token
);
if (!isWhitelisted) {
- // Clear the unauthorized cookie
res.clearCookie("accessToken", {
path: "/",
httpOnly: true,
@@ -57,25 +48,16 @@ export const verifyToken = (req, res, next) => {
domain: process.env.COOKIE_DOMAIN,
});
- return res.status(401).json({
- message: "Token is not authorized. Please login again.",
- error: "TOKEN_NOT_WHITELISTED",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.TOKEN_NOT_WHITELISTED);
}
- // Fetch the latest user data from database
const user = await UserRepository.findById(decoded.id);
if (!user) {
- // Remove invalid token from whitelist
await redisService.removeWhitelistToken(decoded.id);
- return res.status(401).json({
- message: "User not found",
- error: "USER_NOT_FOUND",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.USER_NOT_FOUND);
}
- // Set user information in request object
req.userId = user.id;
req.user = {
id: user.id,
@@ -90,10 +72,7 @@ export const verifyToken = (req, res, next) => {
next();
} catch (dbError) {
console.error("Database error in token verification:", dbError);
- return res.status(500).json({
- message: "Internal server error",
- error: "DATABASE_ERROR",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.DATABASE_ERROR);
}
});
};
@@ -103,17 +82,11 @@ export const verifyToken = (req, res, next) => {
*/
export const isAdmin = (req, res, next) => {
if (!req.user) {
- return res.status(401).json({
- message: "Authentication required",
- error: "NOT_AUTHENTICATED",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.NOT_AUTHENTICATED);
}
if (!req.user.isAdmin) {
- return res.status(403).json({
- message: "Admin access required",
- error: "NOT_ADMIN",
- });
+ return sendErrorResponse(res, AUTH_ERRORS.NOT_ADMIN);
}
next();
@@ -130,7 +103,7 @@ export const generateToken = (user, sessionId = null) => {
email: user.email,
isAdmin: user.isAdmin,
isVerified: user.isVerified,
- sessionId: sessionId || Date.now().toString(), // Unique per login
+ sessionId: sessionId || Date.now().toString(),
},
process.env.JWT_SECRET,
{
@@ -142,7 +115,7 @@ export const generateToken = (user, sessionId = null) => {
};
/**
- * Generate refresh token (longer expiry)
+ * Generate refresh token
*/
export const generateRefreshToken = user => {
return jwt.sign(
diff --git a/backend/user-service/middleware/validation.js b/backend/user-service/middleware/validation.js
index 1b9f75c8..fa8b96ea 100644
--- a/backend/user-service/middleware/validation.js
+++ b/backend/user-service/middleware/validation.js
@@ -1,4 +1,8 @@
import Joi from "joi";
+import {
+ VALIDATION_ERRORS,
+ sendValidationErrorResponse,
+} from "../errors/index.js";
/**
* Validation schemas for user service
@@ -87,6 +91,17 @@ export const userSchemas = {
.messages({
"string.pattern.base": "Invalid user ID format",
}),
+
+ // OTP validation schemas
+ verifyOTPOnly: Joi.object({
+ otp: Joi.string()
+ .pattern(/^\d{6}$/)
+ .required()
+ .messages({
+ "string.pattern.base": "OTP must be a 6-digit number",
+ "any.required": "OTP is required",
+ }),
+ }),
};
/**
@@ -107,11 +122,11 @@ export const validate = (schema, property = "body") => {
value: detail.context?.value,
}));
- return res.status(400).json({
- message: "Validation error",
- error: "VALIDATION_ERROR",
- details: errors,
- });
+ return sendValidationErrorResponse(
+ res,
+ VALIDATION_ERRORS.VALIDATION_ERROR,
+ errors
+ );
}
req[property] = value;
next();
@@ -135,10 +150,7 @@ export const validateUserIdParam = (req, res, next) => {
const { error } = userSchemas.mongoId.validate(req.params.id);
if (error) {
- return res.status(400).json({
- message: "Invalid user ID format",
- error: "INVALID_USER_ID",
- });
+ return sendValidationErrorResponse(res, VALIDATION_ERRORS.INVALID_USER_ID);
}
next();
diff --git a/backend/user-service/model/user-model.js b/backend/user-service/model/user-model.js
index bd7fb547..40f50264 100644
--- a/backend/user-service/model/user-model.js
+++ b/backend/user-service/model/user-model.js
@@ -31,7 +31,7 @@ const UserModelSchema = new Schema({
},
createdAt: {
type: Date,
- default: Date.now, // Setting default to the current date/time
+ default: Date.now,
},
updatedAt: {
type: Date,
@@ -76,14 +76,11 @@ const UserModelSchema = new Schema({
},
},
});
-
-// Pre-save middleware to update updatedAt
UserModelSchema.pre("save", function (next) {
this.updatedAt = Date.now();
next();
});
-// Pre-update middleware to update updatedAt
UserModelSchema.pre(
["findOneAndUpdate", "updateOne", "updateMany"],
function (next) {
@@ -91,16 +88,12 @@ UserModelSchema.pre(
next();
}
);
-
-// Virtual for full name
UserModelSchema.virtual("profile.fullName").get(function () {
if (this.profile.firstName && this.profile.lastName) {
return `${this.profile.firstName} ${this.profile.lastName}`;
}
return this.profile.firstName || this.profile.lastName || this.username;
});
-
-// Transform function to handle JSON serialization
UserModelSchema.set("toJSON", {
virtuals: true,
transform: function (doc, ret) {
diff --git a/backend/user-service/model/user-repository.js b/backend/user-service/model/user-repository.js
index 7557de30..8e8bf1d1 100644
--- a/backend/user-service/model/user-repository.js
+++ b/backend/user-service/model/user-repository.js
@@ -3,7 +3,7 @@ import mongoose from "mongoose";
import "dotenv/config";
/**
- * Database Connection Helper
+ * Database Connection
*/
export async function connectToDB() {
let mongoDBUri =
@@ -43,7 +43,7 @@ export class UserRepository {
}
/**
- * Find user by ID including password (for authentication)
+ * Find user by ID including password
*/
static async findByIdWithPassword(id) {
try {
@@ -210,6 +210,8 @@ export class UserRepository {
*/
static async updateById(id, updateData) {
try {
+ console.log(`Updating user ${id} with data:`, updateData);
+
const user = await userModel
.findByIdAndUpdate(
id,
@@ -218,8 +220,13 @@ export class UserRepository {
)
.select("-password");
+ console.log(
+ `Update result for user ${id}:`,
+ user ? `Success - isVerified: ${user.isVerified}` : "User not found"
+ );
return user;
} catch (error) {
+ console.error(`Error in updateById for user ${id}:`, error);
throw new Error(`Failed to update user: ${error.message}`);
}
}
diff --git a/backend/user-service/package-lock.json b/backend/user-service/package-lock.json
index ba9eb649..8b7b888e 100644
--- a/backend/user-service/package-lock.json
+++ b/backend/user-service/package-lock.json
@@ -19,6 +19,7 @@
"joi": "^18.0.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.5.4",
+ "nodemailer": "^6.9.15",
"redis": "^5.8.2"
},
"devDependencies": {
@@ -1609,6 +1610,15 @@
"webidl-conversions": "^3.0.0"
}
},
+ "node_modules/nodemailer": {
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
+ "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz",
diff --git a/backend/user-service/package.json b/backend/user-service/package.json
index 91c4030f..364e168e 100644
--- a/backend/user-service/package.json
+++ b/backend/user-service/package.json
@@ -30,6 +30,7 @@
"joi": "^18.0.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.5.4",
+ "nodemailer": "^6.9.15",
"redis": "^5.8.2"
}
}
diff --git a/backend/user-service/routes/auth-routes.js b/backend/user-service/routes/auth-routes.js
index 9df8be89..70a4f1e9 100644
--- a/backend/user-service/routes/auth-routes.js
+++ b/backend/user-service/routes/auth-routes.js
@@ -6,13 +6,21 @@ import {
handleRefreshToken,
handleLogout,
handleResetTokenTTL,
+ handleSendVerificationOTP,
+ handleVerifyOTP,
+ handleCheckVerificationStatus,
} from "../controller/auth-controller.js";
import { verifyToken } from "../middleware/jwtAuth.js";
+import {
+ validate,
+ userSchemas,
+ normalizeEmail,
+} from "../middleware/validation.js";
const router = express.Router();
// Login endpoint
-router.post("/login", handleLogin);
+router.post("/login", normalizeEmail, validate(userSchemas.login), handleLogin);
// Token verification endpoint
router.get("/verify-token", verifyToken, handleVerifyToken);
@@ -23,7 +31,17 @@ router.post("/refresh", handleRefreshToken);
// Logout endpoint
router.post("/logout", verifyToken, handleLogout);
-// Reset token TTL to full duration (activity-based refresh)
+// Reset token TTL to full duration
router.post("/reset-ttl", verifyToken, handleResetTokenTTL);
+// OTP verification endpoints
+router.post("/send-otp", verifyToken, handleSendVerificationOTP);
+router.post(
+ "/verify-otp",
+ verifyToken,
+ validate(userSchemas.verifyOTPOnly),
+ handleVerifyOTP
+);
+router.get("/verification-status", verifyToken, handleCheckVerificationStatus);
+
export default router;
diff --git a/backend/user-service/services/email-service.js b/backend/user-service/services/email-service.js
new file mode 100644
index 00000000..ccfbfd5f
--- /dev/null
+++ b/backend/user-service/services/email-service.js
@@ -0,0 +1,260 @@
+import nodemailer from "nodemailer";
+import {
+ generateRegistrationOTPTemplate,
+ generateRegistrationOTPTextTemplate,
+ generateGenericOTPTemplate,
+ generateGenericOTPTextTemplate,
+} from "../templates/email-templates.js";
+
+/**
+ * Email Service for sending OTP and other transactional emails
+ * Configured for Mailtrap SMTP sandbox
+ */
+class EmailService {
+ constructor() {
+ this.transporter = null;
+ this.isConfigured = false;
+ this.emailConfig = {
+ enabled: process.env.EMAIL_ENABLED === "true",
+ provider: process.env.EMAIL_PROVIDER,
+ fromEmail: process.env.EMAIL_FROM,
+ fromName: process.env.EMAIL_FROM_NAME,
+
+ // Mailtrap configuration
+ mailtrap: {
+ host: process.env.MAILTRAP_HOST,
+ port: parseInt(process.env.MAILTRAP_PORT),
+ user: process.env.MAILTRAP_USER,
+ pass: process.env.MAILTRAP_PASS,
+ },
+
+ // SMTP configuration (for production)
+ smtp: {
+ host: process.env.SMTP_HOST,
+ port: parseInt(process.env.SMTP_PORT),
+ secure: process.env.SMTP_SECURE === "true",
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASS,
+ },
+ };
+
+ this.initializeTransporter();
+ }
+
+ /**
+ * Initialize email transporter based on configuration
+ */
+ initializeTransporter() {
+ console.log("Initializing email service...");
+ console.log("Email config:", {
+ enabled: this.emailConfig.enabled,
+ provider: this.emailConfig.provider,
+ fromEmail: this.emailConfig.fromEmail,
+ fromName: this.emailConfig.fromName,
+ host: this.emailConfig.mailtrap?.host,
+ port: this.emailConfig.mailtrap?.port,
+ hasUser: !!this.emailConfig.mailtrap?.user,
+ hasPass: !!this.emailConfig.mailtrap?.pass,
+ });
+
+ if (!this.emailConfig.enabled) {
+ console.log("Email service is disabled");
+ return;
+ }
+
+ try {
+ let transporterConfig;
+
+ switch (this.emailConfig.provider) {
+ case "mailtrap":
+ transporterConfig = {
+ host: this.emailConfig.mailtrap.host,
+ port: this.emailConfig.mailtrap.port,
+ auth: {
+ user: this.emailConfig.mailtrap.user,
+ pass: this.emailConfig.mailtrap.pass,
+ },
+ connectionTimeout: 10000, // 10 seconds
+ greetingTimeout: 5000, // 5 seconds
+ socketTimeout: 10000, // 10 seconds
+ };
+ break;
+
+ case "smtp":
+ transporterConfig = {
+ host: this.emailConfig.smtp.host,
+ port: this.emailConfig.smtp.port,
+ secure: this.emailConfig.smtp.secure,
+ auth: {
+ user: this.emailConfig.smtp.user,
+ pass: this.emailConfig.smtp.pass,
+ },
+ };
+ break;
+
+ default:
+ console.warn(`Unknown email provider: ${this.emailConfig.provider}`);
+ return;
+ }
+
+ this.transporter = nodemailer.createTransport(transporterConfig);
+ this.isConfigured = true;
+
+ console.log(
+ `Email transporter created successfully for provider: ${this.emailConfig.provider}`
+ );
+
+ // Verify configuration
+ this.verifyConnection();
+ } catch (error) {
+ console.error("Failed to initialize email transporter:", error);
+ this.isConfigured = false;
+ }
+ }
+
+ /**
+ * Verify email service connection
+ */
+ async verifyConnection() {
+ if (!this.transporter) {
+ return false;
+ }
+
+ try {
+ await this.transporter.verify();
+ console.log("Email service connected successfully");
+ return true;
+ } catch (error) {
+ console.error("Email service connection failed:", error);
+ this.isConfigured = false;
+ return false;
+ }
+ }
+
+ /**
+ * Send OTP email for registration verification
+ * @param {string} email - Recipient email
+ * @param {string} otp - OTP code
+ * @param {Object} options - Additional options (username, etc.)
+ * @returns {Object} Send result
+ */
+ async sendRegistrationOTP(email, otp, options = {}) {
+ const { username = "User" } = options;
+
+ const emailData = {
+ to: email,
+ subject: "Verify Your PeerPrep Account - OTP Code",
+ html: generateRegistrationOTPTemplate(otp, username),
+ text: generateRegistrationOTPTextTemplate(otp, username),
+ };
+
+ return await this.sendEmail(emailData);
+ }
+
+ /**
+ * Send generic OTP email
+ * @param {string} email - Recipient email
+ * @param {string} otp - OTP code
+ * @param {string} purpose - Purpose of OTP
+ * @param {Object} options - Additional options
+ * @returns {Object} Send result
+ */
+ async sendOTP(email, otp, purpose = "verification", options = {}) {
+ const { username = "User", expiryMinutes = 5 } = options;
+
+ const emailData = {
+ to: email,
+ subject: `Your PeerPrep ${purpose} Code`,
+ html: generateGenericOTPTemplate(otp, purpose, username, expiryMinutes),
+ text: generateGenericOTPTextTemplate(
+ otp,
+ purpose,
+ username,
+ expiryMinutes
+ ),
+ };
+
+ return await this.sendEmail(emailData);
+ }
+
+ /**
+ * Core email sending function
+ * @param {Object} emailData - Email data (to, subject, html, text)
+ * @returns {Object} Send result
+ */
+ async sendEmail(emailData) {
+ const result = {
+ success: false,
+ error: null,
+ };
+
+ // Check if email service is properly configured
+ if (!this.emailConfig.enabled || !this.isConfigured || !this.transporter) {
+ const error = "Email service is not properly configured";
+ console.error(error);
+ result.error = error;
+ return result;
+ }
+
+ try {
+ const mailOptions = {
+ from: `"${this.emailConfig.fromName}" <${this.emailConfig.fromEmail}>`,
+ to: emailData.to,
+ subject: emailData.subject,
+ html: emailData.html,
+ text: emailData.text,
+ };
+
+ console.log(
+ `Attempting to send email to ${emailData.to} via ${this.emailConfig.provider}`
+ );
+
+ // Add timeout protection (15 seconds)
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(
+ () => reject(new Error("Email sending timeout after 15 seconds")),
+ 15000
+ );
+ });
+
+ const info = await Promise.race([
+ this.transporter.sendMail(mailOptions),
+ timeoutPromise,
+ ]);
+
+ result.success = true;
+
+ console.log(
+ `Email sent successfully to ${emailData.to}. Message ID: ${info.messageId}`
+ );
+ } catch (error) {
+ console.error("Failed to send email:", error);
+ result.error = error.message;
+
+ // Log additional error details for debugging
+ if (error.code) {
+ console.error("Error code:", error.code);
+ }
+ if (error.response) {
+ console.error("SMTP response:", error.response);
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Get service status
+ */
+ getStatus() {
+ return {
+ enabled: this.emailConfig.enabled,
+ configured: this.isConfigured,
+ provider: this.emailConfig.provider,
+ };
+ }
+}
+
+// Export singleton instance
+export const emailService = new EmailService();
+export { EmailService };
diff --git a/backend/user-service/services/otp-service.js b/backend/user-service/services/otp-service.js
new file mode 100644
index 00000000..43407961
--- /dev/null
+++ b/backend/user-service/services/otp-service.js
@@ -0,0 +1,163 @@
+import crypto from "crypto";
+
+/**
+ * OTP Service for handling One-Time Password generation, validation, and management
+ * Provides secure OTP generation with configurable length and TTL
+ */
+class OTPService {
+ constructor() {
+ this.defaultOTPLength = parseInt(process.env.OTP_LENGTH);
+ this.defaultTTL = parseInt(process.env.OTP_TTL_SECONDS);
+ this.maxAttempts = parseInt(process.env.OTP_MAX_ATTEMPTS);
+ }
+
+ /**
+ * Generate a secure OTP with specified length
+ */
+ generateOTP(length = this.defaultOTPLength) {
+ const minValue = 10 ** (length - 1);
+ const maxValue = 10 ** length - 1;
+
+ let otp;
+ do {
+ const randomBytes = crypto.randomBytes(4);
+ const randomNumber = randomBytes.readUInt32BE(0);
+ otp = (randomNumber % (maxValue - minValue + 1)) + minValue;
+ } while (otp.toString().length !== length);
+
+ return otp.toString();
+ }
+
+ /**
+ * Validate OTP format
+ */
+ isValidOTPFormat(otp, expectedLength = this.defaultOTPLength) {
+ if (!otp || typeof otp !== "string") {
+ return false;
+ }
+ const otpRegex = new RegExp(`^\\d{${expectedLength}}$`);
+ return otpRegex.test(otp);
+ }
+
+ /**
+ * Generate OTP data object with metadata
+ */
+ generateOTPData(
+ email,
+ purpose = "registration",
+ length = this.defaultOTPLength,
+ ttl = this.defaultTTL
+ ) {
+ const otp = this.generateOTP(length);
+ const timestamp = Date.now();
+ const expiresAt = timestamp + ttl * 1000;
+
+ return {
+ otp,
+ email: email.toLowerCase(),
+ purpose,
+ attempts: 0,
+ maxAttempts: this.maxAttempts,
+ createdAt: timestamp,
+ expiresAt,
+ ttl,
+ };
+ }
+
+ /**
+ * Validate OTP attempt and check constraints
+ */
+ validateOTP(storedOTPData, providedOTP, email, purpose = "registration") {
+ const result = {
+ isValid: false,
+ reason: null,
+ attemptsRemaining: 0,
+ shouldDelete: false,
+ };
+ if (!storedOTPData) {
+ result.reason = "OTP_NOT_FOUND";
+ return result;
+ }
+ if (Date.now() > storedOTPData.expiresAt) {
+ result.reason = "OTP_EXPIRED";
+ result.shouldDelete = true;
+ return result;
+ }
+ if (storedOTPData.attempts >= storedOTPData.maxAttempts) {
+ result.reason = "MAX_ATTEMPTS_EXCEEDED";
+ result.shouldDelete = true;
+ return result;
+ }
+ if (storedOTPData.purpose !== purpose) {
+ result.reason = "PURPOSE_MISMATCH";
+ return result;
+ }
+
+ if (!this.isValidOTPFormat(providedOTP, storedOTPData.otp.length)) {
+ result.reason = "INVALID_FORMAT";
+ result.attemptsRemaining =
+ storedOTPData.maxAttempts - storedOTPData.attempts - 1;
+ return result;
+ }
+ const isOTPMatch = crypto.timingSafeEqual(
+ Buffer.from(storedOTPData.otp, "utf8"),
+ Buffer.from(providedOTP, "utf8")
+ );
+
+ if (isOTPMatch) {
+ result.isValid = true;
+ result.shouldDelete = true;
+ return result;
+ } else {
+ result.reason = "OTP_MISMATCH";
+ result.attemptsRemaining =
+ storedOTPData.maxAttempts - storedOTPData.attempts - 1;
+ return result;
+ }
+ }
+
+ /**
+ * Get Redis key for OTP storage
+ */
+ getOTPKey(email, purpose = "registration") {
+ return `otp:${purpose}:${email.toLowerCase()}`;
+ }
+
+ /**
+ * Get user-friendly error message for validation result
+ */
+ getErrorMessage(validationResult) {
+ const errorMessages = {
+ OTP_NOT_FOUND: "OTP not found or has expired. Please request a new OTP.",
+ OTP_EXPIRED: "OTP has expired. Please request a new OTP.",
+ MAX_ATTEMPTS_EXCEEDED:
+ "Maximum verification attempts exceeded. Please request a new OTP.",
+ PURPOSE_MISMATCH:
+ "Invalid OTP type. Please use the correct verification link.",
+ INVALID_FORMAT: `Invalid OTP format. Please enter a ${this.defaultOTPLength}-digit number.`,
+ OTP_MISMATCH: `Incorrect OTP. ${validationResult.attemptsRemaining} attempt(s) remaining.`,
+ };
+
+ return (
+ errorMessages[validationResult.reason] ||
+ "OTP validation failed. Please try again."
+ );
+ }
+
+ /**
+ * Calculate remaining TTL for display purposes
+ */
+ getRemainingTTL(otpData) {
+ if (!otpData || !otpData.expiresAt) {
+ return 0;
+ }
+
+ const remaining = Math.max(
+ 0,
+ Math.floor((otpData.expiresAt - Date.now()) / 1000)
+ );
+ return remaining;
+ }
+}
+export const otpService = new OTPService();
+export { OTPService };
diff --git a/backend/user-service/services/redis-service.js b/backend/user-service/services/redis-service.js
index d97232ff..6e2907fd 100644
--- a/backend/user-service/services/redis-service.js
+++ b/backend/user-service/services/redis-service.js
@@ -6,6 +6,10 @@ class RedisService {
this.isConnected = false;
}
+ whitelistKeyString(userId) {
+ return `whitelist:${String(userId)}`;
+ }
+
/**
* Initialize Redis connection
*/
@@ -28,27 +32,30 @@ class RedisService {
});
this.client.on("error", err => {
- console.error("Redis Client Error:", err);
+ console.error("Caching Service Error:", err);
this.isConnected = false;
});
this.client.on("connect", () => {
- console.log("Connected to Redis");
+ console.log("Connected to Caching Service");
this.isConnected = true;
});
this.client.on("disconnect", () => {
- console.log("Disconnected from Redis");
+ console.log("Disconnected from Caching Service");
this.isConnected = false;
});
-
await this.client.connect();
} catch (error) {
- console.error("Failed to connect to Redis:", error);
+ console.error("Failed to connect to Caching Service:", error);
this.isConnected = false;
}
}
+ async isClientAvailable() {
+ return this.isConnected && !!this.client;
+ }
+
/**
* Store token in whitelist for a user
* Key: whitelist:userId
@@ -56,13 +63,15 @@ class RedisService {
* TTL: matches JWT expiration time
*/
async storeWhitelistToken(userId, token, expiryInSeconds) {
- if (!this.isConnected || !this.client) {
- console.warn("Redis not available - token whitelisting disabled");
+ if (!this.isClientAvailable()) {
+ console.warn(
+ "Caching Service not available - token whitelisting disabled"
+ );
return false;
}
try {
- const key = `whitelist:${userId}`;
+ const key = this.whitelistKeyString(userId);
await this.client.setEx(key, expiryInSeconds, token);
console.log(
`Token whitelisted for user ${userId} with TTL ${expiryInSeconds}s`
@@ -79,13 +88,13 @@ class RedisService {
* Returns true if the token matches the stored token for the user
*/
async isTokenWhitelisted(userId, token) {
- if (!this.isConnected || !this.client) {
- console.warn("Service not available");
+ if (!this.isClientAvailable()) {
+ console.warn("Caching Service not available");
return true;
}
try {
- const key = `whitelist:${userId}`;
+ const key = this.whitelistKeyString(userId);
const storedToken = await this.client.get(key);
return storedToken === token;
} catch (error) {
@@ -95,16 +104,16 @@ class RedisService {
}
/**
- * Remove token from whitelist (logout)
+ * Remove token from whitelist
*/
async removeWhitelistToken(userId) {
- if (!this.isConnected || !this.client) {
- console.warn("Service not available - token removal disabled");
+ if (!this.isClientAvailable()) {
+ console.warn("Caching Service not available - token removal disabled");
return false;
}
try {
- const key = `whitelist:${userId}`;
+ const key = this.whitelistKeyString(userId);
const result = await this.client.del(key);
console.log(`Token removed from whitelist for user ${userId}`);
return result > 0;
@@ -118,11 +127,11 @@ class RedisService {
* Get remaining TTL for a whitelisted token
*/
async getWhitelistTokenTTL(userId) {
- if (!this.isConnected || !this.client) {
+ if (!this.isClientAvailable()) {
return -1;
}
try {
- const key = `whitelist:${userId}`;
+ const key = this.whitelistKeyString(userId);
return await this.client.ttl(key);
} catch (error) {
console.error("Error getting whitelist token TTL:", error);
@@ -134,12 +143,12 @@ class RedisService {
* Get whitelisted token for a user
*/
async getWhitelistToken(userId) {
- if (!this.isConnected || !this.client) {
+ if (!this.isClientAvailable()) {
return null;
}
try {
- const key = `whitelist:${userId}`;
+ const key = this.whitelistKeyString(userId);
return await this.client.get(key);
} catch (error) {
console.error("Error getting whitelist token:", error);
@@ -151,12 +160,12 @@ class RedisService {
* Reset TTL for an existing whitelisted token to a specific value
*/
async resetWhitelistTokenTTL(userId, newTTLSeconds) {
- if (!this.isConnected || !this.client) {
- console.warn("Service not available - token TTL reset disabled");
+ if (!this.isClientAvailable()) {
+ console.warn("Caching Service not available - token TTL reset disabled");
return false;
}
try {
- const key = `whitelist:${userId}`;
+ const key = this.whitelistKeyString(userId);
const tokenExists = await this.client.exists(key);
if (!tokenExists) {
console.warn(`No whitelisted token found for user ${userId}`);
@@ -179,7 +188,7 @@ class RedisService {
* Clear all whitelisted tokens
*/
async clearWhitelist() {
- if (!this.isConnected || !this.client) {
+ if (!this.isClientAvailable()) {
return false;
}
try {
@@ -213,6 +222,178 @@ class RedisService {
client: !!this.client,
};
}
+
+ otpKeyString(identifier, purpose) {
+ return `otp:${String(purpose)}:${String(identifier)}`;
+ }
+
+ /**
+ * Store OTP data in Redis with TTL
+ * Key: otp:purpose:identifier (e.g., otp:verification:userId123 or otp:registration:user@example.com)
+ */
+ async storeOTP(identifier, otpData, purpose = "verification") {
+ if (!this.isClientAvailable()) {
+ console.warn("Caching Service not available - OTP storage disabled");
+ return false;
+ }
+ try {
+ const key = this.otpKeyString(identifier, purpose);
+ const serializedData = JSON.stringify(otpData);
+ const ttl = otpData.ttl;
+ await this.client.setEx(key, ttl, serializedData);
+ console.log(
+ `OTP stored for ${identifier} with purpose ${purpose}, TTL: ${ttl}s`
+ );
+ return true;
+ } catch (error) {
+ console.error("Error storing OTP:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Retrieve OTP data from Redis
+ */
+ async getOTP(identifier, purpose = "verification") {
+ if (!this.isClientAvailable()) {
+ console.warn("Caching Service not available - OTP retrieval disabled");
+ return null;
+ }
+ try {
+ const key = this.otpKeyString(identifier, purpose);
+ const serializedData = await this.client.get(key);
+ if (!serializedData) {
+ return null;
+ }
+
+ return JSON.parse(serializedData);
+ } catch (error) {
+ console.error("Error retrieving OTP:", error);
+ return null;
+ }
+ }
+
+ /**
+ * Delete OTP from Redis
+ */
+ async deleteOTP(identifier, purpose = "verification") {
+ if (!this.isClientAvailable()) {
+ console.warn("Caching Service not available - OTP deletion disabled");
+ return false;
+ }
+
+ try {
+ const key = this.otpKeyString(identifier, purpose);
+ const result = await this.client.del(key);
+
+ console.log(`OTP deleted for ${identifier} with purpose ${purpose}`);
+ return result > 0;
+ } catch (error) {
+ console.error("Error deleting OTP:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Increment OTP attempt counter
+ */
+ async incrementOTPAttempts(identifier, purpose = "verification") {
+ if (!this.isClientAvailable()) {
+ console.warn(
+ "Caching Service not available - OTP attempt increment disabled"
+ );
+ return null;
+ }
+ try {
+ const key = this.otpKeyString(identifier, purpose);
+ const serializedData = await this.client.get(key);
+
+ if (!serializedData) {
+ return null;
+ }
+
+ const otpData = JSON.parse(serializedData);
+ otpData.attempts = (otpData.attempts || 0) + 1;
+
+ const ttl = await this.client.ttl(key);
+ if (ttl > 0) {
+ await this.client.setEx(key, ttl, JSON.stringify(otpData));
+ }
+ console.log(
+ `OTP attempts incremented for ${identifier}, now: ${otpData.attempts}`
+ );
+ return otpData;
+ } catch (error) {
+ console.error("Error incrementing OTP attempts:", error);
+ return null;
+ }
+ }
+
+ /**
+ * Get remaining TTL for OTP
+ */
+ async getOTPTTL(identifier, purpose = "verification") {
+ if (!this.isClientAvailable()) {
+ console.warn("Caching Service not available - OTP TTL check disabled");
+ return -1;
+ }
+
+ try {
+ const key = this.otpKeyString(identifier, purpose);
+ return await this.client.ttl(key);
+ } catch (error) {
+ console.error("Error getting OTP TTL:", error);
+ return -1;
+ }
+ }
+
+ /**
+ * Check if OTP exists for identifier and purpose
+ */
+ async hasOTP(identifier, purpose = "verification") {
+ if (!this.isClientAvailable()) {
+ return false;
+ }
+
+ try {
+ const key = this.otpKeyString(identifier, purpose);
+ const exists = await this.client.exists(key);
+ return exists === 1;
+ } catch (error) {
+ console.error("Error checking OTP existence:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Clean up expired OTPs (maintenance function)
+ * This is automatically handled by Redis TTL, but can be called manually
+ */
+ async cleanupExpiredOTPs(purpose = null) {
+ if (!this.isClientAvailable()) {
+ console.warn("Caching Service not available - OTP cleanup disabled");
+ return 0;
+ }
+
+ try {
+ const pattern = purpose ? `otp:${purpose}:*` : "otp:*";
+ const keys = await this.client.keys(pattern);
+
+ let cleanedCount = 0;
+ for (const key of keys) {
+ const ttl = await this.client.ttl(key);
+ if (ttl === -2) {
+ cleanedCount++;
+ }
+ }
+
+ console.log(`OTP cleanup completed: ${cleanedCount} expired keys found`);
+ return cleanedCount;
+ } catch (error) {
+ console.error("Error during OTP cleanup:", error);
+ return 0;
+ }
+ }
}
export const redisService = new RedisService();
diff --git a/backend/user-service/templates/email-templates.js b/backend/user-service/templates/email-templates.js
new file mode 100644
index 00000000..278118ec
--- /dev/null
+++ b/backend/user-service/templates/email-templates.js
@@ -0,0 +1,179 @@
+/**
+ * Email Templates for OTP and other transactional emails
+ * Professional, responsive email templates for PeerPrep
+ */
+
+/**
+ * Generate HTML template for registration OTP email
+ */
+export function generateRegistrationOTPTemplate(otp, username) {
+ return `
+
+
+
+
+
+ Verify Your PeerPrep Account
+
+
+
+
+
+
+
Hello ${username},
+
Thank you for registering with PeerPrep! To complete your account verification, please use the following One-Time Password (OTP):
+
+
+
Your verification code is:
+
${otp}
+
+
+
Important:
+
+ - This code will expire in 5 minutes
+ - Use this code only once to verify your account
+ - Do not share this code with anyone
+
+
+
If you didn't request this verification, please ignore this email or contact our support team.
+
+
+
+
+
+`;
+}
+
+/**
+ * Generate text template for registration OTP email
+ */
+export function generateRegistrationOTPTextTemplate(otp, username) {
+ return `
+Welcome to PeerPrep!
+
+Hello ${username},
+
+Thank you for registering with PeerPrep! To complete your account verification, please use the following One-Time Password (OTP):
+
+Your verification code: ${otp}
+
+Important:
+- This code will expire in 5 minutes
+- Use this code only once to verify your account
+- Do not share this code with anyone
+
+If you didn't request this verification, please ignore this email or contact our support team.
+
+Best regards,
+The PeerPrep Team
+
+This is an automated email. Please do not reply to this message.
+`;
+}
+
+/**
+ * Generate HTML template for generic OTP email
+ */
+export function generateGenericOTPTemplate(
+ otp,
+ purpose,
+ username,
+ expiryMinutes
+) {
+ return `
+
+
+
+
+
+ Your PeerPrep ${purpose} Code
+
+
+
+
+
+
+
Hello ${username},
+
You have requested a ${purpose} code. Please use the following One-Time Password (OTP):
+
+
+
Your ${purpose} code is:
+
${otp}
+
+
+
Important:
+
+ - This code will expire in ${expiryMinutes} minutes
+ - Use this code only once
+ - Do not share this code with anyone
+
+
+
If you didn't request this code, please ignore this email or contact our support team.
+
+
+
+
+
+`;
+}
+
+/**
+ * Generate text template for generic OTP email
+ *
+ */
+export function generateGenericOTPTextTemplate(
+ otp,
+ purpose,
+ username,
+ expiryMinutes
+) {
+ return `
+PeerPrep ${purpose}
+
+Hello ${username},
+
+You have requested a ${purpose} code. Please use the following One-Time Password (OTP):
+
+Your ${purpose} code: ${otp}
+
+Important:
+- This code will expire in ${expiryMinutes} minutes
+- Use this code only once
+- Do not share this code with anyone
+
+If you didn't request this code, please ignore this email or contact our support team.
+
+Best regards,
+The PeerPrep Team
+
+This is an automated email. Please do not reply to this message.
+`;
+}
diff --git a/env.example b/env.example
index 6cd0e104..baad55f9 100644
--- a/env.example
+++ b/env.example
@@ -38,3 +38,34 @@ COOKIE_DOMAIN=localhost
# CORS Origins
CORS_ORIGIN=http://localhost:3000
+
+# OTP Configuration
+OTP_LENGTH=6
+OTP_TTL_SECONDS=300
+OTP_MAX_ATTEMPTS=3
+
+# Email Service Configuration
+EMAIL_ENABLED=false
+EMAIL_PROVIDER=mailtrap
+EMAIL_FROM=noreply@peerprep.com
+EMAIL_FROM_NAME=PeerPrep Team
+
+# Mailtrap Configuration (for development)
+MAILTRAP_HOST=sandbox.smtp.mailtrap.io
+MAILTRAP_PORT=2525
+MAILTRAP_USER=your-mailtrap-user
+MAILTRAP_PASS=your-mailtrap-password
+
+# SMTP Configuration (for production)
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=your-smtp-user
+SMTP_PASS=your-smtp-password
+
+# Argon2 Password Hashing Configuration
+ARGON2_MEMORY_COST=65536
+ARGON2_TIME_COST=3
+ARGON2_PARALLELISM=4
+ARGON2_HASH_LENGTH=32
+ARGON2_SALT_LENGTH=16