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 + + + +
+
+

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 is:

+
${otp}
+
+ +

Important:

+ + +

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 + + + +
+
+

PeerPrep ${purpose}

+
+
+

Hello ${username},

+

You have requested a ${purpose} code. Please use the following One-Time Password (OTP):

+ +
+

Your ${purpose} code is:

+
${otp}
+
+ +

Important:

+ + +

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