diff --git a/.env.example b/.env.example index 4017b03..a189845 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Environment Configuration NODE_ENV=development PORT=3000 +BACKEND_URL=http://localhost:3000 # Database Configuration # PostgreSQL connection string @@ -44,9 +45,9 @@ REDIS_DB=0 EMAIL_ENABLED=false SMTP_HOST=smtp.gmail.com SMTP_PORT=587 -SMTP_USER=your_email@gmail.com -SMTP_PASS=your_app_password_here -EMAIL_FROM="Code Duel " +SMTP_USER=your_email_address +SMTP_PASS=your_app_password +SMTP_FROM=your_email_address # Email Reminder Cron Configuration # Daily reminder time (default: 6 PM daily) diff --git a/.gitignore b/.gitignore index 93081c4..d160f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,6 @@ package-lock.json *.sw? .env -# Generated JS artifacts from TS/TSX (do not commit) -src/**/*.js -src/**/*.js.map +# Temporary files +tmp/ +temp/ diff --git a/README.md b/README.md index d07c56f..3de815c 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ When creating a challenge, configure: - Input validation on all endpoints - SQL injection protection via Prisma ORM - Environment variable validation on startup +- Email verification before account activation ## 📝 Development Notes diff --git a/package.json b/package.json index f9fff79..5fa27aa 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "author": "", "license": "ISC", "dependencies": { - "@prisma/client": "^5.8.0", + "@prisma/client": "^5.22.0", "axios": "^1.6.5", "bcryptjs": "^2.4.3", "bottleneck": "^2.19.5", @@ -31,16 +31,11 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^8.2.1", - "express-request-id": "^3.0.0", - "express-session": "^1.17.3", - "express-validator": "^7.0.1", - "ioredis": "^5.9.3", + "express-validator": "^7.3.1", "jsonwebtoken": "^9.0.2", - "morgan": "^1.10.1", "node-cron": "^3.0.3", "nodemailer": "^8.0.1", - "prisma": "^5.8.0", - "response-time": "^2.3.4", + "prisma": "^5.22.0", "winston": "^3.11.0" }, "devDependencies": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f54567d..741d976 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,9 @@ model User { leetcodeUsername String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + isEmailVerified Boolean @default(false) + emailVerificationToken String? + emailVerificationExpires DateTime? // Email Preferences emailPreferences Json? @default("{\"welcomeEmail\": true, \"streakReminder\": true, \"streakBroken\": true, \"weeklySummary\": true}") diff --git a/src/app.js b/src/app.js index ee58cdf..c9796e1 100644 --- a/src/app.js +++ b/src/app.js @@ -32,27 +32,23 @@ const { const createApp = () => { const app = express(); - // 1. Security Middlewares (Team T066 Implementation) - // Apply global rate limiting to all API routes - app.use('/api/', apiLimiter); - - // Apply strict limiting specifically to auth routes - app.use('/api/auth/', authLimiter); - app.use("/api/admin", adminRoutes); - // 2. CORS configuration + // 1. CORS configuration (must come before rate limiters so preflight requests are handled) app.use( cors({ + // if corsOrigin is wildcard we avoid setting credentials since browsers reject wildcard with credentials origin: config.corsOrigin, - credentials: true, + ...(config.corsOrigin === '*' ? {} : { credentials: true }), }) ); - // Request tracking middleware - app.use(addRequestId()); - app.use(responseTime()); - app.use(requestLogger); + // 2. Security Middlewares (Team T066 Implementation) + // Apply global rate limiting to all API routes + app.use('/api/', apiLimiter); + + // Apply strict limiting specifically to auth routes + app.use('/api/auth/', authLimiter); - // Body parser middleware + // 3. Body parser middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); diff --git a/src/config/env.js b/src/config/env.js index b3e994b..bb1e472 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -7,6 +7,8 @@ const config = { // Server Configuration port: process.env.PORT || 3000, nodeEnv: process.env.NODE_ENV || "development", + backendUrl:process.env.BACKEND_URL || "http://localhost:3000", + // Database Configuration databaseUrl: process.env.DATABASE_URL, diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index d11d794..7e6c44c 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -70,6 +70,26 @@ const validateResetPassword = [ .withMessage("New password must be at least 6 characters"), ]; +/** + * Validation middleware for forgot password + */ +const validateForgotPassword = [ + body("email") + .isEmail() + .normalizeEmail() + .withMessage("Valid email is required"), +]; + +/** + * Validation middleware for reset password + */ +const validateResetPassword = [ + body("token").notEmpty().withMessage("Reset token is required"), + body("newPassword") + .isLength({ min: 6 }) + .withMessage("New password must be at least 6 characters"), +]; + /** * Validation middleware for profile update */ @@ -97,6 +117,18 @@ const validateUpdateProfile = [ * Register a new user * POST /api/auth/register */ + +const verifyEmail = asyncHandler(async (req, res) => { + const { token } = req.query; + + const result = await authService.verifyEmail(token); + + res.status(200).json({ + success: true, + message: result.message, + }); +}); + const register = asyncHandler(async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { @@ -254,6 +286,9 @@ module.exports = { login, getProfile, updateProfile, + validateRegister, + validateLogin, + verifyEmail, forgotPassword, resetPassword, logout, @@ -261,5 +296,4 @@ module.exports = { validateLogin, validateForgotPassword, validateResetPassword, - validateUpdateProfile, }; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 96b7976..c64b156 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -8,7 +8,7 @@ const { authLimiter, registerLimiter } = require("../config/rateLimiter"); * @route POST /api/auth/register * @desc Register a new user * @access Public - */ +*/ router.post( "/register", registerLimiter, @@ -20,8 +20,8 @@ router.post( * @route POST /api/auth/login * @desc Login user * @access Public - */ -router.post("/login", authLimiter, authController.validateLogin, authController.login); +*/ +router.post("/login", authController.validateLogin, authController.login); /** * @route POST /api/auth/forgot-password @@ -49,20 +49,22 @@ router.post( * @route GET /api/auth/profile * @desc Get current user profile * @access Private - */ +*/ router.get("/profile", authenticate, authController.getProfile); /** * @route PUT /api/auth/profile * @desc Update user profile * @access Private - */ -router.put( - "/profile", - authenticate, - authController.validateUpdateProfile, - authController.updateProfile -); +*/ +router.put("/profile", authenticate, authController.updateProfile); + +/** + * @route PUT /api/auth/verify-email + * @desc Verifies user email + * @access Public +*/ +router.get("/verify-email", authController.verifyEmail); /** * @route POST /api/auth/logout diff --git a/src/services/auth.service.js b/src/services/auth.service.js index ee2b7f2..e0214f4 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -5,9 +5,8 @@ const { config } = require("../config/env"); const { generateToken, decodeToken } = require("../utils/jwt"); const { AppError } = require("../middlewares/error.middleware"); const logger = require("../utils/logger"); -const { logAudit } = require("../utils/auditLogger"); -const { sendWelcomeEmail, sendPasswordResetEmail } = require("./email.service"); - +const { sendWelcomeEmail, sendVerificationEmail } = require("./email.service"); +const crypto = require('crypto'); /** * Register a new user */ @@ -30,43 +29,70 @@ const register = async (userData) => { } } - // Hash password + const hashedPassword = await bcrypt.hash(password, 12); - // Create user + const verificationToken = crypto.randomBytes(32).toString("hex"); + const tokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + const user = await prisma.user.create({ data: { email, username, password: hashedPassword, leetcodeUsername: leetcodeUsername || null, + isEmailVerified: false, + emailVerificationToken: verificationToken, + emailVerificationExpires: tokenExpiry, }, }); - // Generate JWT token - const token = generateToken({ userId: user.id }); - logger.info(`New user registered: ${user.username} (${user.email})`); - await logAudit("USER_REGISTERED", user.id, { - username: user.username, - email: user.email, + sendVerificationEmail( + user.email, + user.username, + verificationToken + ).catch((err) => { + logger.error(`Failed to send verification email: ${err.message}`); + }); + + return { + user, + message: "Registration successful. Please verify your email.", + }; +}; +// VerifyEmail +const verifyEmail = async (token) => { + const user = await prisma.user.findFirst({ + where: { + emailVerificationToken: token, + emailVerificationExpires: { + gte: new Date(), + }, + }, }); - // Send welcome email (non-blocking) + + if (!user) { + throw new AppError("Invalid or expired verification token", 400); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + isEmailVerified: true, + emailVerificationToken: null, + emailVerificationExpires: null, + }, + }); + sendWelcomeEmail(user.email, user.username).catch((err) => { logger.error(`Failed to send welcome email: ${err.message}`); }); + + logger.info(`Email verified for user: ${user.username}`); - return { - user: { - id: user.id, - email: user.email, - username: user.username, - leetcodeUsername: user.leetcodeUsername, - createdAt: user.createdAt, - }, - token, - }; + return { message: "Email verified successfully" }; }; /** @@ -91,7 +117,14 @@ const login = async (emailOrUsername, password) => { throw new AppError("Invalid credentials", 401); } - // Generate token + if (!user.isEmailVerified) { + throw new AppError( + "Please verify your email before logging in.", + 403 + ); +} + + // Generate JWT token const token = generateToken({ userId: user.id }); logger.info(`User logged in: ${user.username}`); @@ -381,8 +414,5 @@ module.exports = { login, getProfile, updateProfile, - forgotPassword, - resetPassword, - blacklistToken, - isTokenBlacklisted, + verifyEmail, }; diff --git a/src/services/email.service.js b/src/services/email.service.js index e3bade1..4b848a4 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -259,52 +259,45 @@ const templates = { `, }), - - /** - * Password reset template - */ - passwordReset: (username, resetLink, expiryMinutes) => ({ - subject: "Reset your Code Duel password", + verification: (username, verificationLink) => ({ + subject: "Verify Your Code Duel Account 🔐", html: ` - - - - - - -
-
-

Password Reset Request

-
-
-

Hi ${username},

-

We received a request to reset your password for your Code Duel account.

-

- Reset Password -

-

If the button does not work, use this link:

-

${resetLink}

-
- Security notice: This link expires in ${expiryMinutes} minutes and can be used only once. -
-

If you did not request this change, you can safely ignore this email.

-

The Code Duel Team

-
- -
- - + + + + + + +
+
+

Email Verification 🔐

+
+
+

Hey ${username}! 👋

+

Thanks for registering with Code Duel.

+

Please verify your email address by clicking the button below:

+ + Verify Email + +

This link will expire in 1 hour.

+

If you didn’t create this account, you can safely ignore this email.

+ +

See you inside! 🚀

+

The Code Duel Team

+
+ +
+ + `, }), }; @@ -333,6 +326,37 @@ const sendWelcomeEmail = async (email, username) => { } }; +/** + * Send email verification link + * @param {string} email - User email + * @param {string} username - Username + * @param {string} token - Verification token + */ +const sendVerificationEmail = async (email, username, token) => { + try { + const baseUrl = process.env.BACKEND_URL || "http://localhost:3000"; + + const verificationLink = `${baseUrl}/api/auth/verify-email?token=${token}`; + + const template = templates.verification(username, verificationLink); + + const result = await sendEmail({ + to: email, + subject: template.subject, + html: template.html, + }); + + if (result.success) { + logger.info(`Verification email sent to ${email}`); + } + + return result; + } catch (error) { + logger.error(`Failed to send verification email to ${email}:`, error); + return { success: false, reason: error.message }; + } +}; + /** * Send streak reminder email * @param {string} email - User email @@ -640,7 +664,7 @@ const getLeaderboardRank = async (challengeId, memberId) => { module.exports = { sendWelcomeEmail, - sendPasswordResetEmail, + sendVerificationEmail, sendStreakReminder, sendStreakBrokenNotification, sendWeeklySummary,