diff --git a/backend/Dockerfile b/backend/Dockerfile index 0a502eff9..87cb41809 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,15 +1,18 @@ -# ── Stage: Production ───────────────────────────────────────────────────────── +# Stage: Production FROM node:20-alpine # Set working directory WORKDIR /app # Install dependencies first (layer caching) -COPY package*.json ./ +COPY --chown=node:node package*.json ./ RUN npm ci --omit=dev # Copy source -COPY . . +COPY --chown=node:node . . + +# Switch to non-root user (security best practice) +USER node # Expose backend port EXPOSE 1234 diff --git a/backend/app.js b/backend/app.js index 5a2bfdbe2..447a2c321 100644 --- a/backend/app.js +++ b/backend/app.js @@ -28,6 +28,9 @@ const dashboardLimiter = rateLimit({ skip: (req) => process.env.NODE_ENV === 'development', }); +// FIX 3: Strict limiter for auth endpoints (login / register) +const { authLimiter } = require('./middleware/auth_limiter'); + const adminWhitelist = ['https://urbackend.bitbros.in']; @@ -74,7 +77,9 @@ const storageRoute = require('./routes/storage'); const schemaRoute = require('./routes/schemas'); // ROUTES SETUP -app.use('/api/auth', dashboardLimiter, authRoute); // Developer Auth +app.use('/api/auth/login', authLimiter); // Strict limiter on login +app.use('/api/auth/register', authLimiter); // Strict limiter on register +app.use('/api/auth', dashboardLimiter, authRoute); // Developer Auth (general) app.use('/api/projects', dashboardLimiter, projectRoute); // Project Mgmt app.use('/api/userAuth', limiter, logger, userAuthRoute); app.use('/api/data', limiter, cors(adminCorsOptions), logger, dataRoute); diff --git a/backend/controllers/auth.controller.js b/backend/controllers/auth.controller.js index 30a57ad47..5ae7f2296 100644 --- a/backend/controllers/auth.controller.js +++ b/backend/controllers/auth.controller.js @@ -5,14 +5,53 @@ const bcrypt = require("bcryptjs"); const z = require("zod"); const jwt = require("jsonwebtoken"); const sendOtp = require("../utils/emailService"); +const crypto = require("crypto"); const { loginSchema, changePasswordSchema, deleteAccountSchema, - onlyEmailSchema + onlyEmailSchema, + resetPasswordSchema, + verifyOtpSchema } = require("../utils/input.validation"); +const JWT_EXPIRES_IN = '7d'; +const OTP_MAX_ATTEMPTS = 5; +async function createAndStoreOtp(userId) { + const otp = crypto.randomInt(100000, 1000000).toString(); + + // Delete any existing OTP for this user + await otpSchema.deleteOne({ userId }); + + // Hash OTP before storing + const salt = await bcrypt.genSalt(10); + const hashedOtp = await bcrypt.hash(otp, salt); + + await new otpSchema({ userId, otp: hashedOtp }).save(); + return otp; +} + +// HELPER: Validate OTP with attempt tracking +async function validateOtp(userId, passedOtp) { + const otpDoc = await otpSchema.findOne({ userId }); + if (!otpDoc) throw { status: 400, message: "No OTP found. Please request a new one." }; + + if (otpDoc.attempts >= OTP_MAX_ATTEMPTS) { + await otpDoc.deleteOne(); + throw { status: 429, message: "Too many incorrect attempts. Please request a new OTP." }; + } + + const isMatch = await bcrypt.compare(passedOtp.toString(), otpDoc.otp); + if (!isMatch) { + otpDoc.attempts += 1; + await otpDoc.save(); + const remaining = OTP_MAX_ATTEMPTS - otpDoc.attempts; + throw { status: 400, message: `Incorrect OTP. ${remaining} attempt(s) remaining.` }; + } + + return otpDoc; +} module.exports.register = async (req, res) => { try { @@ -47,19 +86,20 @@ module.exports.login = async (req, res) => { const validPass = await bcrypt.compare(password, dev.password); if (!validPass) return res.status(400).json({ error: "Invalid password" }); - const token = jwt.sign({ _id: dev._id, isVerified: dev.isVerified }, process.env.JWT_SECRET); + // FIX 1: JWT now expires in 7 days + const token = jwt.sign( + { _id: dev._id, isVerified: dev.isVerified }, + process.env.JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); res.json({ token }); } catch (err) { - console.log("LOGIN ERROR CAUGHT:", err.message); - console.log("Is ZodError?", err instanceof z.ZodError); - if (err instanceof z.ZodError) { return res.status(400).json({ error: "Validation Failed", details: err.issues }); } - console.error("Server Error:", err); res.status(500).json({ error: "Internal Server Error" }); } @@ -114,55 +154,95 @@ module.exports.deleteAccount = async (req, res) => { module.exports.sendOtp = async (req, res) => { try { const { email } = onlyEmailSchema.parse(req.body); - const otp = Math.floor(100000 + Math.random() * 900000); - // Secure: OTP logging removed + const existingUser = await Developer.findOne({ email }); if (!existingUser) return res.status(400).json({ error: "User not found" }); if (existingUser.isVerified) return res.status(400).json({ error: "User already verified" }); - const existingOtp = await otpSchema.findOne({ userId: existingUser._id }); - if (existingOtp) { - await existingOtp.deleteOne(); - } - const newOtp = new otpSchema( - { - userId: existingUser._id, - otp - }); - newOtp.save(); - await sendOtp(email, otp); + const otp = await createAndStoreOtp(existingUser._id); + + await sendOtp(email, otp); // Send raw OTP to user's email res.json({ message: "OTP sent successfully" }); } catch (err) { + if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); console.error(err); res.status(500).json({ error: "Internal Server Error" }); } } - module.exports.verifyOtp = async (req, res) => { try { - const { email } = onlyEmailSchema.parse(req.body); - const { otp } = req.body; - const existingUser = await Developer.findOne({ email }).sort({ createdAt: -1 }); - if (!existingUser) return res.status(400).json({ error: "User not found" }); + const { email, otp } = verifyOtpSchema.parse(req.body); - const existingOtp = await otpSchema.findOne({ userId: existingUser._id }); - if (!existingOtp) return res.status(400).json({ error: "You havn't requested an OTP" }); + const existingUser = await Developer.findOne({ email }); + if (!existingUser) return res.status(400).json({ error: "User not found" }); - // Secure: OTP verification logging removed - if (existingOtp.otp !== otp) return res.status(400).json({ error: "Incorrect OTP" }); + const otpDoc = await validateOtp(existingUser._id, otp); - await existingOtp.deleteOne(); + await otpDoc.deleteOne(); existingUser.isVerified = true; await existingUser.save(); - // Generate new token with isVerified: true - const token = jwt.sign({ _id: existingUser._id, isVerified: existingUser.isVerified }, process.env.JWT_SECRET); + // FIX 1: JWT with expiry + const token = jwt.sign( + { _id: existingUser._id, isVerified: true }, + process.env.JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); res.status(200).json({ message: "OTP verified successfully", token }); } catch (err) { + if (err.status) return res.status(err.status).json({ error: err.message }); + if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); + console.error(err); + res.status(500).json({ error: "Internal Server Error" }); + } +} + + +// FIX 5: Forgot Password — generate + send reset OTP +module.exports.forgotPassword = async (req, res) => { + try { + const { email } = onlyEmailSchema.parse(req.body); + + const dev = await Developer.findOne({ email }); + // Return same message regardless of whether email exists (prevents user enumeration) + if (!dev) return res.status(200).json({ message: "If this email is registered, an OTP has been sent." }); + + const otp = await createAndStoreOtp(dev._id); + + await sendOtp(email, otp, { subject: "Password Reset OTP — urBackend" }); + res.status(200).json({ message: "If this email is registered, an OTP has been sent." }); + } catch (err) { + if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); + console.error(err); + res.status(500).json({ error: "Internal Server Error" }); + } +} + + +// FIX 5: Reset Password — verify OTP then set new password +module.exports.resetPassword = async (req, res) => { + try { + const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); + + const dev = await Developer.findOne({ email }); + if (!dev) return res.status(400).json({ error: "User not found" }); + + const otpDoc = await validateOtp(dev._id, otp); + + // OTP matched — update password + await otpDoc.deleteOne(); + const salt = await bcrypt.genSalt(10); + dev.password = await bcrypt.hash(newPassword, salt); + await dev.save(); + + res.status(200).json({ message: "Password reset successfully. Please log in with your new password." }); + } catch (err) { + if (err.status) return res.status(err.status).json({ error: err.message }); + if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); console.error(err); res.status(500).json({ error: "Internal Server Error" }); } diff --git a/backend/middleware/auth_limiter.js b/backend/middleware/auth_limiter.js new file mode 100644 index 000000000..540e06732 --- /dev/null +++ b/backend/middleware/auth_limiter.js @@ -0,0 +1,13 @@ +const rateLimit = require('express-rate-limit'); + +// Strict limiter for sensitive auth endpoints (login, register) +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + message: { error: "Too many attempts. Please try again in 15 minutes." }, + skip: (req) => process.env.NODE_ENV === 'development', + standardHeaders: true, + legacyHeaders: false, +}); + +module.exports = { authLimiter }; diff --git a/backend/models/otp.js b/backend/models/otp.js index ebc318bd1..5d8efd9de 100644 --- a/backend/models/otp.js +++ b/backend/models/otp.js @@ -1,9 +1,11 @@ const mongoose = require('mongoose'); const otpSchema = new mongoose.Schema({ - userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'User' }, - otp: { type: String, required: true }, + userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'Developer' }, + otp: { type: String, required: true }, // bcrypt hash (FIX 2) + attempts: { type: Number, default: 0 }, // brute-force guard (FIX 4) createdAt: { type: Date, default: Date.now, expires: '10m' } // MongoDB TTL index }); module.exports = mongoose.model('Otp', otpSchema); + diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 965d50deb..a66063e8a 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -7,7 +7,9 @@ const { changePassword, deleteAccount, sendOtp, - verifyOtp + verifyOtp, + forgotPassword, + resetPassword } = require('../controllers/auth.controller'); @@ -23,9 +25,12 @@ router.put('/change-password', authorization, changePassword); // DELETE ACCOUNT (Protected - Danger Zone) router.delete('/delete-account', authorization, deleteAccount); +// OTP (email verification) router.post('/send-otp', sendOtp); - - router.post('/verify-otp', verifyOtp); -module.exports = router; \ No newline at end of file +// PASSWORD RESET (FIX 5) +router.post('/forgot-password', forgotPassword); +router.post('/reset-password', resetPassword); + +module.exports = router; diff --git a/backend/utils/emailService.js b/backend/utils/emailService.js index dfb5c72d0..7a0eee2bb 100644 --- a/backend/utils/emailService.js +++ b/backend/utils/emailService.js @@ -5,12 +5,12 @@ const dotenv = require('dotenv'); dotenv.config(); const resend = new Resend(process.env.RESEND_API_KEY || 're_dummy_key_for_testing'); -async function sendOtp(email, otp) { +async function sendOtp(email, otp, { subject = "Verify your urBackend account" } = {}) { try { const { data, error } = await resend.emails.send({ from: 'urBackend ', to: email, - subject: 'Verify your urBackend account', + subject: subject, html: `
diff --git a/backend/utils/input.validation.js b/backend/utils/input.validation.js index 8acaa4fe1..306f1ad6a 100644 --- a/backend/utils/input.validation.js +++ b/backend/utils/input.validation.js @@ -38,6 +38,18 @@ module.exports.onlyEmailSchema = z.object({ email: z.string().email("Invalid email format") }); +module.exports.verifyOtpSchema = z.object({ + email: z.string().email("Invalid email format"), + otp: z.string().length(6, "OTP must be 6 digits") +}); + +module.exports.resetPasswordSchema = z.object({ + email: z.string().email("Invalid email format"), + otp: z.string().length(6, "OTP must be 6 digits"), + newPassword: z.string().min(6, "Password must be at least 6 characters").max(100, "Password is too long.") +}); + + module.exports.createProjectSchema = z.object({ name: z.string().min(1, "Project name is required"), description: z.string().optional() diff --git a/docker-compose.yml b/docker-compose.yml index 0e6a8d50c..e4aeb6e9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,6 @@ services: image: mongo:7 container_name: urbackend_mongo restart: unless-stopped - ports: - - '27017:27017' volumes: - mongo_data:/data/db @@ -15,8 +13,6 @@ services: image: redis:latest container_name: urbackend_redis restart: unless-stopped - ports: - - '6379:6379' volumes: - redis_data:/data