diff --git a/backend/app.js b/backend/app.js index 361082fd7..9ee12157d 100644 --- a/backend/app.js +++ b/backend/app.js @@ -18,6 +18,7 @@ const { getPublicIp } = require('./utils/network'); // Initialize Queue Workers require('./queues/emailQueue'); +require('./queues/authEmailQueue'); // Middleware (We apply admin options globally except where overridden via dynamic handling) app.use(express.json()); @@ -78,9 +79,7 @@ const schemaRoute = require('./routes/schemas'); const releaseRoute = require('./routes/releases'); // ROUTES SETUP -app.use('/api/auth/login', cors(adminCorsOptions), authLimiter); // Strict limiter on login -app.use('/api/auth/register', cors(adminCorsOptions), authLimiter); // Strict limiter on register -app.use('/api/auth', cors(adminCorsOptions), dashboardLimiter, authRoute); // Developer Auth (general) +app.use('/api/auth', cors(adminCorsOptions), authRoute); // Developer Auth (general) app.use('/api/projects', cors(adminCorsOptions), dashboardLimiter, projectRoute); // Project Mgmt app.use('/api/userAuth', cors(adminCorsOptions), limiter, logger, userAuthRoute); app.use('/api/releases', cors(adminCorsOptions), releaseRoute); diff --git a/backend/controllers/project.controller.js b/backend/controllers/project.controller.js index 6e8bbbed1..78e864294 100644 --- a/backend/controllers/project.controller.js +++ b/backend/controllers/project.controller.js @@ -14,8 +14,16 @@ const { getCompiledModel } = require("../utils/injectModel") const QueryEngine = require("../utils/queryEngine"); const { storageRegistry } = require("../utils/registry"); const { deleteProjectByApiKeyCache, setProjectById, getProjectById, deleteProjectById } = require("../services/redisCaching"); +const { v4: uuidv4 } = require('uuid'); const { getPublicIp } = require("../utils/network"); +const validateUsersSchema = (schema) => { + if (!Array.isArray(schema)) return false; + const hasEmail = schema.find(f => f.key === 'email' && f.type === 'String' && f.required); + const hasPassword = schema.find(f => f.key === 'password' && f.type === 'String' && f.required); + return !!(hasEmail && hasPassword); +}; + const getBucket = (project) => @@ -95,20 +103,38 @@ module.exports.getAllProject = async (req, res) => { module.exports.getSingleProject = async (req, res) => { try { - let project; - project = await getProjectById(req.params.projectId); - let projectObj; - if (!project) { - project = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }).select('-publishableKey -secretKey -jwtSecret'); + let projectObj = await getProjectById(req.params.projectId); + + if (!projectObj) { + const project = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }).select('-publishableKey -secretKey -jwtSecret'); if (!project) return res.status(404).json({ error: "Project not found." }); projectObj = project.toObject(); - await setProjectById(req.params.projectId, project); + await setProjectById(req.params.projectId, projectObj); + } + + // Ownership Check (Even for Cache) + if (projectObj.owner.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: "Access denied." }); } - projectObj = project; + // Just to be safe, we remove sensitive fields again even if from cache delete projectObj.publishableKey; delete projectObj.secretKey; delete projectObj.jwtSecret; + + // SANITIZE COLLECTION MODELS: Remove "password" from "users" collection schema + if (projectObj.collections && Array.isArray(projectObj.collections)) { + projectObj.collections = projectObj.collections.map(col => { + if (col.name === 'users' && col.model) { + return { + ...col, + model: col.model.filter(m => m.key !== 'password') + }; + } + return col; + }); + } + res.json(projectObj); } catch (err) { res.status(500).json({ error: err.message }); @@ -283,6 +309,7 @@ module.exports.deleteExternalStorageConfig = async (req, res) => { +// POST REQ FOR CREATE COLLECTION module.exports.createCollection = async (req, res) => { try { const { projectId, collectionName, schema } = createCollectionSchema.parse(req.body); @@ -295,6 +322,12 @@ module.exports.createCollection = async (req, res) => { if (!project.jwtSecret) project.jwtSecret = uuidv4(); + if (collectionName === 'users') { + if (!validateUsersSchema(schema)) { + return res.status(422).json({ error: "The 'users' collection must have required 'email' and 'password' string fields." }); + } + } + project.collections.push({ name: collectionName, model: schema }); await project.save(); @@ -302,7 +335,6 @@ module.exports.createCollection = async (req, res) => { await setProjectById(projectId, project); await deleteProjectByApiKeyCache(project.publishableKey); await deleteProjectByApiKeyCache(project.secretKey); - // RESPONSE const projectObj = project.toObject(); delete projectObj.publishableKey; delete projectObj.secretKey; @@ -371,7 +403,12 @@ module.exports.getData = async (req, res) => { // const collectionsList = await mongoose.connection.db.listCollections({ name: finalCollectionName }).toArray(); - const features = new QueryEngine(model.find(), req.query) + const query = model.find(); + if (collectionName === 'users') { + query.select('-password'); + } + + const features = new QueryEngine(query, req.query) .filter() .sort() .paginate(); @@ -391,6 +428,10 @@ module.exports.insertData = async (req, res) => { const project = await Project.findOne({ _id: projectId, owner: req.user._id }); if (!project) return res.status(404).json({ error: "Project not found." }); + if (collectionName === 'users') { + return res.status(400).json({ error: "Direct inserts into 'users' collection are not allowed. Please use the Auth signup or admin endpoints." }); + } + const finalCollectionName = `${project._id}_${collectionName}`; const incomingData = req.body; @@ -419,7 +460,6 @@ module.exports.insertData = async (req, res) => { project.databaseUsed = (project.databaseUsed || 0) + docSize; } await project.save(); - await project.save(); res.json(result); } catch (err) { @@ -480,6 +520,14 @@ module.exports.editRow = async (req, res) => { const connection = await getConnection(projectId); const Model = getCompiledModel(connection, collectionConfig, projectId, project.resources.db.isExternal); + if (collectionName === 'users') { + delete req.body.password; + // Also ensure it's not and nested or sneaky + Object.keys(req.body).forEach(key => { + if (key.toLowerCase().includes('password')) delete req.body[key]; + }); + } + const docToEdit = await Model.findById(id); if (!docToEdit) { return res.status(404).json({ error: "Document not found." }); @@ -505,8 +553,12 @@ module.exports.editRow = async (req, res) => { } const updatedDoc = await docToEdit.save(); + const responseData = updatedDoc.toObject(); + if (collectionName === 'users') { + delete responseData.password; + } - res.json({ success: true, message: "Document edited successfully", data: updatedDoc }); + res.json({ success: true, message: "Document edited successfully", data: responseData }); } catch (err) { console.error("Edit Error:", err); @@ -754,35 +806,41 @@ module.exports.deleteProject = async (req, res) => { if (!project) { return res.status(404).json({ error: "Project not found or access denied." }); } - for (const col of project.collections) { - const collectionName = `${project._id}_${col.name}`; + + // DROP COLLECTIONS: Only for internal databases + if (!project.resources.db.isExternal) { + for (const col of project.collections) { + const collectionName = `${project._id}_${col.name}`; + try { + await mongoose.connection.db.dropCollection(collectionName); + } catch (e) { } + } + try { - await mongoose.connection.db.dropCollection(collectionName); + await mongoose.connection.db.dropCollection(`${project._id}_users`); } catch (e) { } } - try { - await mongoose.connection.db.dropCollection(`${project._id}_users`); - } catch (e) { } - - // DELETE FILES - const supabase = await getStorage(project); - const bucket = getBucket(project); + // DELETE: Only for internal Infraa + if (!isExternalStorage(project)) { + const supabase = await getStorage(project); + const bucket = getBucket(project); - let hasMoreFiles = true; + let hasMoreFiles = true; - while (hasMoreFiles) { - const { data: files, error } = await supabase.storage - .from(bucket) - .list(projectId, { limit: 100 }); + while (hasMoreFiles) { + const { data: files, error } = await supabase.storage + .from(bucket) + .list(projectId, { limit: 100 }); - if (error) throw error; + if (error) throw error; - if (files && files.length > 0) { - const paths = files.map(f => `${projectId}/${f.name}`); - await supabase.storage.from(bucket).remove(paths); - } else { - hasMoreFiles = false; + if (files && files.length > 0) { + const paths = files.map(f => `${projectId}/${f.name}`); + await supabase.storage.from(bucket).remove(paths); + } else { + hasMoreFiles = false; + } } } @@ -800,7 +858,8 @@ module.exports.deleteProject = async (req, res) => { module.exports.analytics = async (req, res) => { try { const { projectId } = req.params; - const project = await Project.findOne({ _id: projectId }); + const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + if (!project) return res.status(404).json({ error: "Project not found or access denied." }); const totalRequests = await Log.countDocuments({ projectId }); const logs = await Log.find({ projectId }).sort({ timestamp: -1 }).limit(50); @@ -828,4 +887,71 @@ module.exports.analytics = async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } +} + +// FUNCTION - TOGGLE AUTH +module.exports.toggleAuth = async (req, res) => { + try { + const { projectId } = req.params; + const { enable } = req.body; // true or false + + // Ensure user owns project + const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + if (!project) return res.status(404).json({ error: "Project not found" }); + + if (enable) { + let usersCol = project.collections.find(c => c.name === 'users'); + if (!usersCol) { + usersCol = { + name: 'users', + model: [ + { key: 'email', type: 'String', required: true }, + { key: 'username', type: 'String', required: false }, + { key: 'password', type: 'String', required: true }, + { key: 'emailVerified', type: 'Boolean', required: false } + ] + }; + project.collections.push(usersCol); + } else { + if (!validateUsersSchema(usersCol.model)) { + return res.status(422).json({ + error: "Invalid Users Schema", + message: "The 'users' collection must have required 'email' and 'password' string fields. Please fix the schema before enabling Auth." + }); + } + } + } + + project.isAuthEnabled = !!enable; + await project.save(); + + await deleteProjectById(projectId); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); + + const projectObj = project.toObject(); + delete projectObj.publishableKey; + delete projectObj.secretKey; + delete projectObj.jwtSecret; + + if (projectObj.collections && Array.isArray(projectObj.collections)) { + projectObj.collections = projectObj.collections.map(col => { + if (col.name === 'users' && col.model) { + return { + ...col, + model: col.model.filter(m => m.key !== 'password') + }; + } + return col; + }); + } + + res.json({ + message: `Authentication ${project.isAuthEnabled ? 'enabled' : 'disabled'} successfully`, + isAuthEnabled: project.isAuthEnabled, + project: projectObj + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } } \ No newline at end of file diff --git a/backend/controllers/userAuth.controller.js b/backend/controllers/userAuth.controller.js index 754cd1521..1a045e14d 100644 --- a/backend/controllers/userAuth.controller.js +++ b/backend/controllers/userAuth.controller.js @@ -2,21 +2,29 @@ const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const { z } = require('zod'); const mongoose = require('mongoose'); -const { loginSchema, userSignupSchema } = require('../utils/input.validation'); +const Project = require('../models/Project'); +const redis = require('../config/redis'); +const { authEmailQueue } = require('../queues/authEmailQueue'); +const { loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('../utils/input.validation'); +const { getConnection } = require('../utils/connection.manager'); +const { getCompiledModel } = require('../utils/injectModel'); + +// POST REQ FOR SIGNUP module.exports.signup = async (req, res) => { try { const project = req.project; - const data = userSignupSchema.parse(req.body); - - // Zod Validation (Prevents NoSQL Injection too) const { email, password, username, ...otherData } = userSignupSchema.parse(req.body); - const collectionName = `${project._id}_users`; - const collection = mongoose.connection.db.collection(collectionName); + // Get Mongoose Model + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - const existingUser = await collection.findOne({ email }); + const existingUser = await Model.findOne({ email }); if (existingUser) { return res.status(400).json({ error: "User already exists with this email." }); } @@ -24,44 +32,63 @@ module.exports.signup = async (req, res) => { const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(password, salt); - const newUser = { + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + + const newUserPayload = { username, email, password: hashedPassword, + emailVerified: false, ...otherData, createdAt: new Date() }; - const result = await collection.insertOne(newUser); + // Model.create handles validation and default values + const result = await Model.create(newUserPayload); + + await redis.set(`project:${project._id}:otp:verification:${email}`, otp, 'EX', 300); + + await authEmailQueue.add('send-verification-email', { + email, + otp, + type: 'verification', + pname: project.name + }); const token = jwt.sign( - { userId: result.insertedId, projectId: project._id }, - project.jwtSecret + { userId: result._id, projectId: project._id }, + project.jwtSecret, + { expiresIn: '7d' } ); res.status(201).json({ - message: "User registered successfully", + message: "User registered successfully. Please verify your email.", token: token, - userId: result.insertedId + userId: result._id }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - res.status(500).json({ error: err.message }); // Fixed: .json() + if (err instanceof z.ZodError) { + return res.status(400).json({ error: err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed" }); + } + res.status(500).json({ error: err.message }); console.log(err) } } +// POST REQ FOR LOGIN module.exports.login = async (req, res) => { try { const project = req.project; - // Validate Input const { email, password } = loginSchema.parse(req.body); - const collectionName = `${project._id}_users`; - const collection = mongoose.connection.db.collection(collectionName); + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); - const user = await collection.findOne({ email }); + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const user = await Model.findOne({ email }); if (!user) return res.status(400).json({ error: "Invalid email or password" }); const validPass = await bcrypt.compare(password, user.password); @@ -69,7 +96,8 @@ module.exports.login = async (req, res) => { const token = jwt.sign( { userId: user._id, projectId: project._id }, - project.jwtSecret + project.jwtSecret, + { expiresIn: '7d' } ); res.json({ token }); @@ -80,6 +108,7 @@ module.exports.login = async (req, res) => { } } +// FUNCTION - GET CURRENT USER module.exports.me = async (req, res) => { try { const project = req.project; @@ -91,13 +120,16 @@ module.exports.me = async (req, res) => { try { const decoded = jwt.verify(token, project.jwtSecret); - const collectionName = `${project._id}_users`; - const collection = mongoose.connection.db.collection(collectionName); + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); - const user = await collection.findOne( + const user = await Model.findOne( { _id: new mongoose.Types.ObjectId(decoded.userId) }, - { projection: { password: 0 } } - ); + { password: 0 } + ).lean(); if (!user) return res.status(404).json({ error: "User not found" }); @@ -110,4 +142,327 @@ module.exports.me = async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } -} \ No newline at end of file +} + +// POST REQ FOR ADMIN CREATE USER +module.exports.createAdminUser = async (req, res) => { + try { + const project = req.project; + + const parsedData = userSignupSchema.parse(req.body); + const { email, password, username, ...otherData } = parsedData; + + // Get Mongoose Model + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const existingUser = await Model.findOne({ email }); + if (existingUser) { + return res.status(400).json({ error: "User already exists with this email." }); + } + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + const newUserPayload = { + username, + email, + password: hashedPassword, + emailVerified: true, + ...otherData, + createdAt: new Date() + }; + + const result = await Model.create(newUserPayload); + + res.status(201).json({ + message: "User created successfully", + user: { _id: result._id, email, username, createdAt: newUserPayload.createdAt } + }); + + } catch (err) { + if (err instanceof z.ZodError) { + console.error(err); + return res.status(400).json({ error: err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed" }); + } + res.status(500).json({ error: err.message }); + } +} + +// PATCH REQ FOR ADMIN RESET PASSWORD +module.exports.resetPassword = async (req, res) => { + try { + const project = req.project; + const { userId } = req.params; + + const { newPassword } = req.body; + + if (!newPassword || newPassword.length < 6) { + return res.status(400).json({ error: "Password must be at least 6 characters" }); + } + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(newPassword, salt); + + const result = await Model.updateOne( + { _id: new mongoose.Types.ObjectId(userId) }, + { $set: { password: hashedPassword } } + ); + + if (result.matchedCount === 0) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ message: "Password updated successfully" }); + + } catch (err) { + res.status(500).json({ error: err.message }); + } +} + +// POST REQ FOR EMAIL VERIFICATION +module.exports.verifyEmail = async (req, res) => { + try { + const project = req.project; + const { email, otp } = verifyOtpSchema.parse(req.body); + + const redisKey = `project:${project._id}:otp:verification:${email}`; + const storedOtp = await redis.get(redisKey); + + if (!storedOtp || storedOtp !== otp) { + return res.status(400).json({ error: "Invalid or expired OTP" }); + } + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const result = await Model.updateOne( + { email }, + { $set: { emailVerified: true } } + ); + + if (result.matchedCount === 0) return res.status(404).json({ error: "User not found" }); + + await redis.del(redisKey); + res.json({ message: "Email verified successfully" }); + + } catch (err) { + if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); + res.status(500).json({ error: err.message }); + } +}; + +// POST REQ FOR PASSWORD RESET REQUEST +module.exports.requestPasswordReset = async (req, res) => { + try { + const project = req.project; + const { email } = onlyEmailSchema.parse(req.body); + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const user = await Model.findOne({ email }); + if (!user) { + return res.json({ message: "If that email exists, a reset code has been sent." }); + } + + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + await redis.set(`project:${project._id}:otp:reset:${email}`, otp, 'EX', 300); + + await authEmailQueue.add('send-reset-email', { email, otp, type: 'password_reset', pname: project.name }); + + res.json({ message: "If that email exists, a reset code has been sent." }); + } catch (err) { + if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); + res.status(500).json({ error: err.message }); + } +}; + +// POST REQ FOR PASSWORD RESET CONFIRMATION +module.exports.resetPasswordUser = async (req, res) => { + try { + const project = req.project; + const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); + + const redisKey = `project:${project._id}:otp:reset:${email}`; + const storedOtp = await redis.get(redisKey); + + if (!storedOtp || storedOtp !== otp) { + return res.status(400).json({ error: "Invalid or expired OTP" }); + } + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(newPassword, salt); + + const collection = await getAuthCollection(project); + + const result = await collection.updateOne( + { email }, + { $set: { password: hashedPassword } } + ); + + if (result.matchedCount === 0) return res.status(404).json({ error: "User not found" }); + + await redis.del(redisKey); + res.json({ message: "Password updated successfully" }); + + } catch (err) { + if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); + res.status(500).json({ error: err.message }); + } +}; + +// PATCH REQ FOR UPDATE PROFILE +module.exports.updateProfile = async (req, res) => { + try { + const project = req.project; + + const tokenHeader = req.header('Authorization'); + if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); + const token = tokenHeader.replace("Bearer ", ""); + + let decoded; + try { + decoded = jwt.verify(token, project.jwtSecret); + } catch (err) { + return res.status(401).json({ error: "Access Denied: Invalid or expired token" }); + } + + const username = req.body.username; + if (!username || username.length < 3 || username.length > 50) { + return res.status(400).json({ error: "Username must be between 3 and 50 characters." }); + } + + const collection = await getAuthCollection(project); + + const result = await collection.updateOne( + { _id: new mongoose.Types.ObjectId(decoded.userId) }, + { $set: { username } } + ); + + res.json({ message: "Profile updated successfully" }); + + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +// POST REQ FOR CHANGE PASSWORD +module.exports.changePasswordUser = async (req, res) => { + try { + const project = req.project; + + const tokenHeader = req.header('Authorization'); + if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); + const token = tokenHeader.replace("Bearer ", ""); + + let decoded; + try { + decoded = jwt.verify(token, project.jwtSecret); + } catch (err) { + return res.status(401).json({ error: "Access Denied: Invalid or expired token" }); + } + + const { currentPassword, newPassword } = changePasswordSchema.parse(req.body); + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const user = await Model.findOne({ _id: new mongoose.Types.ObjectId(decoded.userId) }); + if (!user) return res.status(404).json({ error: "User not found" }); + + const validPass = await bcrypt.compare(currentPassword, user.password); + if (!validPass) return res.status(400).json({ error: "Invalid current password" }); + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(newPassword, salt); + + await Model.updateOne( + { _id: new mongoose.Types.ObjectId(decoded.userId) }, + { $set: { password: hashedPassword } } + ); + + res.json({ message: "Password changed successfully" }); + + } catch (err) { + if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); + res.status(500).json({ error: err.message }); + } +}; + +// FUNCTION - GET USER DETAILS (ADMIN) +module.exports.getUserDetails = async (req, res) => { + try { + const project = req.project; + const { userId } = req.params; + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const user = await Model.findOne( + { _id: new mongoose.Types.ObjectId(userId) }, + { password: 0 } + ).lean(); + if (!user) return res.status(404).json({ error: "User not found" }); + + res.json(user); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +// PUT REQ FOR UPDATE ADMIN USER +module.exports.updateAdminUser = async (req, res) => { + try { + const project = req.project; + const { userId } = req.params; + const updateData = req.body; + + delete updateData.password; + delete updateData._id; + + const sanitizedUpdateData = sanitize(updateData); + + // Get Mongoose Model + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const result = await Model.updateOne( + { _id: new mongoose.Types.ObjectId(userId) }, + { $set: sanitizedUpdateData }, + { runValidators: true } + ); + + if (result.matchedCount === 0) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ message: "User updated successfully" }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; \ No newline at end of file diff --git a/backend/middleware/checkAuthEnabled.js b/backend/middleware/checkAuthEnabled.js new file mode 100644 index 000000000..c1d74aa8c --- /dev/null +++ b/backend/middleware/checkAuthEnabled.js @@ -0,0 +1,34 @@ +// FUNCTION - CHECK AUTH ENABLED (MIDDLEWARE) +module.exports = (req, res, next) => { + const project = req.project; + + if (!project.isAuthEnabled) { + return res.status(403).json({ + error: "Authentication service is disabled", + message: "Please enable Auth in the urBackend dashboard for this project to use this endpoint." + }); + } + + const usersCollection = project.collections?.find(c => c.name === 'users'); + + if (!usersCollection) { + return res.status(403).json({ + error: "User Schema Missing", + message: "Authentication is enabled, but the 'users' collection schema has not been defined. Please create a 'users' collection in the dashboard to define your custom user fields." + }); + } + + const hasEmail = usersCollection.model.find(f => f.key === 'email' && f.type === 'String' && f.required); + const hasPassword = usersCollection.model.find(f => f.key === 'password' && f.type === 'String' && f.required); + + if (!hasEmail || !hasPassword) { + return res.status(422).json({ + error: "Invalid Users Schema", + message: "The 'users' collection is missing required 'email' and 'password' string fields. Please fix the schema in the dashboard." + }); + } + + req.usersSchema = usersCollection.model; + + next(); +}; diff --git a/backend/middleware/loadProjectForAdmin.js b/backend/middleware/loadProjectForAdmin.js new file mode 100644 index 000000000..514c8afc7 --- /dev/null +++ b/backend/middleware/loadProjectForAdmin.js @@ -0,0 +1,19 @@ +// FUNCTION - LOAD PROJECT FOR ADMIN (MIDDLEWARE) +const Project = require('../models/Project'); + +module.exports = async (req, res, next) => { + try { + const { projectId } = req.params; + if (!projectId) return res.status(400).json({ error: "Project ID is required" }); + + const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + if (!project) { + return res.status(404).json({ error: "Project not found or access denied" }); + } + + req.project = project; + next(); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; diff --git a/backend/middleware/verifyApiKey.js b/backend/middleware/verifyApiKey.js index 8ef6ee1e7..ef3d8694d 100644 --- a/backend/middleware/verifyApiKey.js +++ b/backend/middleware/verifyApiKey.js @@ -21,6 +21,7 @@ module.exports = async (req, res, next) => { if (!project) { project = await Project.findOne({ [keyField]: hashedApi }) .select(` + name owner resources collections @@ -30,6 +31,7 @@ module.exports = async (req, res, next) => { storageUsed jwtSecret allowedDomains + isAuthEnabled `) .populate('owner', 'isVerified') .lean(); diff --git a/backend/models/Project.js b/backend/models/Project.js index 1f894bc43..4afd7a2c7 100644 --- a/backend/models/Project.js +++ b/backend/models/Project.js @@ -49,6 +49,7 @@ const projectSchema = new mongoose.Schema({ type: String, required: true }, + isAuthEnabled: { type: Boolean, default: false }, collections: [collectionSchema], allowedDomains: { diff --git a/backend/queues/authEmailQueue.js b/backend/queues/authEmailQueue.js new file mode 100644 index 000000000..3f925ba57 --- /dev/null +++ b/backend/queues/authEmailQueue.js @@ -0,0 +1,37 @@ +const { Queue, Worker } = require('bullmq'); +const connection = require('../config/redis'); +const { sendAuthOtpEmail } = require('../utils/emailService'); + +// Create the email queue specifically for fast OTPs +const authEmailQueue = new Queue('auth-email-queue', { connection }); + +// Initialize Worker with Rate Limiting +const worker = new Worker('auth-email-queue', async (job) => { + const { email, otp, type, pname } = job.data; + const redact = (e) => e.replace(/(.{2})(.*)(?=@)/, (gp1, gp2, gp3) => gp2 + "*".repeat(gp3.length)); + const maskedEmail = redact(email); + + try { + console.log(`[Queue] Processing ${type} email for: ${maskedEmail}`); + await sendAuthOtpEmail(email, { otp, type, pname}); + } catch (error) { + console.error(`[Queue] Failed to send auth email to ${maskedEmail}:`, error); + throw error; + } +}, { + connection, + limiter: { + max: 2, + duration: 1000, + } +}); + +worker.on('completed', (job) => { + console.log(`[Queue] Job ${job.id} completed successfully`); +}); + +worker.on('failed', (job, err) => { + console.error(`[Queue] Job ${job.id} failed:`, err); +}); + +module.exports = { authEmailQueue }; diff --git a/backend/queues/emailQueue.js b/backend/queues/emailQueue.js index 09318f1d1..f4f202b9c 100644 --- a/backend/queues/emailQueue.js +++ b/backend/queues/emailQueue.js @@ -1,13 +1,7 @@ const { Queue, Worker } = require('bullmq'); -const IORedis = require('ioredis'); +const connection = require('../config/redis'); const { sendReleaseEmail } = require('../utils/emailService'); -const connection = new IORedis({ - host: process.env.REDIS_HOST || '127.0.0.1', - port: process.env.REDIS_PORT || 6379, - maxRetriesPerRequest: null, -}); - // Create the email queue const emailQueue = new Queue('email-queue', { connection }); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index a66063e8a..b87aeea00 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -13,11 +13,24 @@ const { } = require('../controllers/auth.controller'); +const { authLimiter } = require('../middleware/auth_limiter'); +const rateLimit = require('express-rate-limit'); +const dashboardLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 1000, + message: { error: "Dashboard usage limit exceeded. Slow down!" }, + skip: (req) => process.env.NODE_ENV === 'development', +}); + + // REGISTER ROUTE -router.post('/register', register); +router.post('/register', authLimiter, register); // LOGIN ROUTE -router.post('/login', login); +router.post('/login', authLimiter, login); + +// Apply more relaxed dashboard limiter to the rest +router.use(dashboardLimiter); // CHANGE PASSWORD (Protected) router.put('/change-password', authorization, changePassword); diff --git a/backend/routes/projects.js b/backend/routes/projects.js index ad91f08a7..d0b85aee1 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -26,76 +26,88 @@ const { deleteExternalDbConfig, deleteExternalStorageConfig, analytics, - updateAllowedDomains + updateAllowedDomains, + toggleAuth } = require("../controllers/project.controller") +const { createAdminUser, resetPassword, getUserDetails, updateAdminUser } = require('../controllers/userAuth.controller'); + const upload = multer({ storage: storage, limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB Limit -// ROUTES -// CREATE PROJECT +// POST REQ FOR CREATE PROJECT router.post('/', authMiddleware, verifyEmail, createProject); -// GET ALL PROJECTS +// GET REQ FOR ALL PROJECTS router.get('/', authMiddleware, getAllProject); -// GET SINGLE PROJECT +// GET REQ FOR SINGLE PROJECT router.get('/:projectId', authMiddleware, getSingleProject); -// REGENERATE API KEY +// PATCH REQ FOR REGENERATE KEY router.patch('/:projectId/regenerate-key', authMiddleware, regenerateApiKey); -// CREATE COLLECTION (Fixed Validation) +// POST REQ FOR CREATE COLLECTION router.post('/collection', authMiddleware, verifyEmail, createCollection); -// DELETE COLLECTION +// DELETE REQ FOR COLLECTION router.delete('/:projectId/collections/:collectionName', authMiddleware, verifyEmail, deleteCollection); -// INTERNAL DATA ROUTES - -// GET DATA +// GET REQ FOR DATA router.get('/:projectId/collections/:collectionName/data', authMiddleware, getData); -// DELETE ROW +// DELETE REQ FOR ROW router.delete('/:projectId/collections/:collectionName/data/:id', authMiddleware, deleteRow); -// EDIT ROW +// PATCH REQ FOR EDIT ROW router.patch('/:projectId/collections/:collectionName/data/:id', authMiddleware, editRow); -// LIST FILES +// GET REQ FOR FILES router.get('/:projectId/storage/files', authMiddleware, listFiles); -// UPLOAD FILE +// POST REQ FOR UPLOAD FILE router.post('/:projectId/storage/upload', authMiddleware, verifyEmail, upload.single('file'), uploadFile); -// DELETE FILE +// POST REQ FOR DELETE FILE router.post('/:projectId/storage/delete', authMiddleware, verifyEmail, deleteFile); -// DELETE PROJECT +// DELETE REQ FOR PROJECT router.delete('/:projectId', authMiddleware, verifyEmail, deleteProject); -// UPDATE PROJECT +// PATCH REQ FOR UPDATE PROJECT router.patch('/:projectId', authMiddleware, updateProject); -// UPDATE ALLOWED DOMAINS +// PATCH REQ FOR ALLOWED DOMAINS router.patch('/:projectId/allowed-domains', authMiddleware, verifyEmail, updateAllowedDomains); -// UPDATE EXTERNAL CONFIG +// PATCH REQ FOR BYOD CONFIG router.patch('/:projectId/byod-config', authMiddleware, updateExternalConfig); -// DELETE EXTERNAL DB CONFIG +// DELETE REQ FOR BYOD DB CONFIG router.delete('/:projectId/byod-config/db', authMiddleware, deleteExternalDbConfig); -// DELETE EXTERNAL SUPABASE CONFIG +// DELETE REQ FOR BYOD STORAGE CONFIG router.delete('/:projectId/byod-config/storage', authMiddleware, deleteExternalStorageConfig); -// INSERT DATA (Dashboard) +// POST REQ FOR INSERT DATA router.post('/:projectId/collections/:collectionName/data', authMiddleware, verifyEmail, insertData); -// DELETE ALL FILES +// DELETE REQ FOR ALL FILES router.delete('/:projectId/storage/files', authMiddleware, deleteAllFiles); -// ANALYTICS +// GET REQ FOR ANALYTICS router.get('/:projectId/analytics', authMiddleware, analytics); +// PATCH REQ FOR TOGGLE AUTH +router.patch('/:projectId/auth/toggle', authMiddleware, verifyEmail, toggleAuth); + +// ADMIN AUTH ROUTES +const checkAuthEnabled = require('../middleware/checkAuthEnabled'); +const loadProjectForAdmin = require('../middleware/loadProjectForAdmin'); + +router.post('/:projectId/admin/users', authMiddleware, loadProjectForAdmin, checkAuthEnabled, createAdminUser); +router.patch('/:projectId/admin/users/:userId/password', authMiddleware, loadProjectForAdmin, checkAuthEnabled, resetPassword); +router.get('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, getUserDetails); +router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, updateAdminUser); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/userAuth.js b/backend/routes/userAuth.js index 964523bbb..157fe56bd 100644 --- a/backend/routes/userAuth.js +++ b/backend/routes/userAuth.js @@ -1,15 +1,27 @@ const express = require('express'); const router = express.Router(); const verifyApiKey = require('../middleware/verifyApiKey'); -const { signup, login, me } = require('../controllers/userAuth.controller'); +const checkAuthEnabled = require('../middleware/checkAuthEnabled'); +const { signup, login, me, verifyEmail, requestPasswordReset, resetPasswordUser, updateProfile, changePasswordUser } = require('../controllers/userAuth.controller'); // SIGNUP ROUTE -router.post('/signup', verifyApiKey, signup); +router.post('/signup', verifyApiKey, checkAuthEnabled, signup); // LOGIN ROUTE -router.post('/login', verifyApiKey, login); +router.post('/login', verifyApiKey, checkAuthEnabled, login); // GET CURRENT USER -router.get('/me', verifyApiKey, me); +router.get('/me', verifyApiKey, checkAuthEnabled, me); + +// EMAIL VERIFICATION +router.post('/verify-email', verifyApiKey, checkAuthEnabled, verifyEmail); + +// PASSWORD RESET +router.post('/request-password-reset', verifyApiKey, checkAuthEnabled, requestPasswordReset); +router.post('/reset-password', verifyApiKey, checkAuthEnabled, resetPasswordUser); + +// PROFILE MANAGEMENT +router.put('/update-profile', verifyApiKey, checkAuthEnabled, updateProfile); +router.put('/change-password', verifyApiKey, checkAuthEnabled, changePasswordUser); module.exports = router; \ No newline at end of file diff --git a/backend/utils/emailService.js b/backend/utils/emailService.js index 8ebbb0afc..b0dd67595 100644 --- a/backend/utils/emailService.js +++ b/backend/utils/emailService.js @@ -126,4 +126,85 @@ async function sendReleaseEmail(email, { version, title, content }) { } } -module.exports = { sendOtp, sendReleaseEmail }; \ No newline at end of file + // FUNCTION - SEND AUTH OTP EMAIL +async function sendAuthOtpEmail(email, { otp, type, pname }) { + const rawPname = pname || "urBackend"; + + let safeEmailHandle = rawPname.replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); + if (safeEmailHandle.length < 3) { + safeEmailHandle = "urbackend"; + } + safeEmailHandle = safeEmailHandle.substring(0, 30); + + const safeProjectNameHtml = escapeHtml(rawPname); + + const safeDisplayName = rawPname.replace(/[\r\n]/g, '').trim(); + const finalDisplayName = /^[a-zA-Z0-9 ]+$/.test(safeDisplayName) + ? safeDisplayName + : `"${safeDisplayName.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + + const isVerify = type === 'verification'; + const subject = isVerify ? "Verify your account" : "Reset your password"; + const header = isVerify ? "Verify your email address" : "Reset your password"; + const desc = isVerify + ? "Use the following code to complete your verification process." + : "Use the following code to reset your password."; + + try { + const htmlContent = ` + + +
+ + + +