diff --git a/.gitignore b/.gitignore index 7a0b79a..8277deb 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,6 @@ node_modules/ # dataconnect generated files .dataconnect -.DS_Store \ No newline at end of file +.DS_Store + +previews/ \ No newline at end of file diff --git a/agenda.js b/agenda.js index 57d7731..bdc7428 100644 --- a/agenda.js +++ b/agenda.js @@ -256,6 +256,21 @@ const initializeAgenda = async (mongoUri, pool, io) => { // Now accepts pgPool console.error("Error cleaning up users:", error); } }); + + // Define the password reset token deletion job + agenda.define('deletePasswordResetToken', async (job) => { + const { email } = job.attrs.data; + try { + const result = await pool.query('DELETE FROM password_reset_tokens WHERE email = $1', [email]); + if (result.rowCount > 0) { + console.log(`Deleted expired password reset token for ${email}`); + } + } catch (error) { + console.error(`Error deleting password reset token for ${email}:`, error); + } + }); + + await agenda.start(); agenda.on('ready', () => console.log('Agenda connected to MongoDB and ready!')); agenda.on('start', (job) => console.log(`Job "${job.attrs.name}" starting`)); diff --git a/index.js b/index.js index 841be60..21c97f1 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,7 @@ const cronParser = require('cron-parser'); const { ObjectId } = require('mongodb'); const { RateLimiterMemory } = require('rate-limiter-flexible'); const crypto = require('crypto'); -const { sendVerificationEmail } = require('./mailer'); // Import the function +const { sendVerificationEmail, sendPasswordResetEmail } = require('./mailer'); // HTTP rate limiter: 5 requests per second per IP const httpRateLimiter = new RateLimiterMemory({ @@ -33,6 +33,12 @@ const resendVerificationRateLimiter = new RateLimiterMemory({ duration: 20, }); +// Password reset rate limiter: 10 attempts per 15 minutes per IP +const passwordResetRateLimiter = new RateLimiterMemory({ + points: 10, + duration: 900, // 15 minutes in seconds +}); + // WebSocket connection rate limiter: 1 connection per second per IP const wsConnectionRateLimiter = new RateLimiterMemory({ points: 5, @@ -72,13 +78,16 @@ let agenda; (async () => { // 1. Connect to MongoDB await connectDB(); - agenda = await initializeAgenda('mongodb://localhost:27017/myScheduledApp', pool, io); })(); (async () => { await pool.query("update user_tokens set connected=FALSE where connected=TRUE"); await pool.query("update device_tokens set connected=FALSE where connected=TRUE"); + + // Clear all expired password reset tokens on startup + await pool.query("DELETE FROM password_reset_tokens WHERE expires_at < NOW()"); + console.log("Cleared expired password reset tokens"); })(); const JWT_SECRET = process.env.JWT_SECRET; const TOKEN_EXPIRY = '5d'; @@ -802,6 +811,190 @@ app.post('/change_password', authenticateToken, async (req, res) => { } }); +// Helper function to generate 6-character alphanumeric code +function generateResetCode() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude confusing chars like 0, O, 1, I + let code = ''; + for (let i = 0; i < 6; i++) { + code += chars.charAt(crypto.randomInt(chars.length)); + } + return code; +} + +app.post('/forgot-password', async (req, res) => { + const ip = req.ip || req.connection.remoteAddress; + try { + await resendVerificationRateLimiter.consume(ip); + } catch (rejRes) { + return res.status(429).json({ + error: 'Please wait before requesting another code.', + retryAfter: Math.ceil(rejRes.msBeforeNext / 1000), // seconds + remainingAttempts: 0 + }); + } + + const { email } = req.body; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + try { + // Check if user exists + const {rows} = await pool.query('SELECT id, name FROM users WHERE email = $1', [email]); + + // Always return success to prevent email enumeration + if (rows.length === 0) { + return res.status(200).json({ message: 'If an account exists, a reset code has been sent' }); + } + + const user = rows[0]; + const code = generateResetCode(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + // Insert or update token with ON CONFLICT + await pool.query( + `INSERT INTO password_reset_tokens (email, token, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (email) + DO UPDATE SET token = $2, expires_at = $3, created_at = CURRENT_TIMESTAMP`, + [email, code, expiresAt] + ); + + // Cancel any existing job for this email + await agenda.cancel({ name: 'deletePasswordResetToken', 'data.email': email }); + + // Schedule job to delete token after 15 minutes + await agenda.schedule(expiresAt, 'deletePasswordResetToken', { email }); + + // Send email + await sendPasswordResetEmail(email, code, user.name); + + res.status(200).json({ message: 'If an account exists, a reset code has been sent' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/verify-reset-code', async (req, res) => { + const { email, code } = req.body; + + if (!email || !code) { + return res.status(400).json({ error: 'Email and code are required' }); + } + + // Rate limit verification attempts + const ip = req.ip || req.connection.remoteAddress; + try { + const rateLimiterRes = await passwordResetRateLimiter.consume(ip); + // Store remaining attempts for later use + res.locals.remainingAttempts = rateLimiterRes.remainingPoints; + } catch (rejRes) { + return res.status(429).json({ + error: 'Too many verification attempts. Please try again later.', + retryAfter: Math.ceil(rejRes.msBeforeNext / 1000 / 60), // minutes + remainingAttempts: 0 + }); + } + + try { + const {rows} = await pool.query( + 'SELECT token, expires_at FROM password_reset_tokens WHERE email = $1', + [email] + ); + + if (rows.length === 0) { + return res.status(401).json({ error: 'Invalid or expired code' }); + } + + const resetToken = rows[0]; + + if (new Date() > new Date(resetToken.expires_at)) { + await pool.query('DELETE FROM password_reset_tokens WHERE email = $1', [email]); + return res.status(401).json({ error: 'Code has expired' }); + } + + if (resetToken.token !== code.toUpperCase()) { + return res.status(401).json({ + error: 'Invalid code', + remainingAttempts: res.locals.remainingAttempts || 0 + }); + } + + res.status(200).json({ message: 'Code verified' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/reset-password', async (req, res) => { + const { email, code, newPassword } = req.body; + + if (!email || !code || !newPassword) { + return res.status(400).json({ error: 'Email, code, and new password are required' }); + } + + if (newPassword.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + + // Rate limit password reset attempts + const ip = req.ip || req.connection.remoteAddress; + try { + await passwordResetRateLimiter.consume(ip); + } catch (rejRes) { + return res.status(429).json({ + error: 'Too many password reset attempts. Please try again later.', + retryAfter: Math.ceil(rejRes.msBeforeNext / 1000 / 60), // minutes + remainingAttempts: 0 + }); + } + + try { + const {rows} = await pool.query( + 'SELECT token, expires_at FROM password_reset_tokens WHERE email = $1', + [email] + ); + + if (rows.length === 0) { + return res.status(401).json({ error: 'Invalid or expired code' }); + } + + const resetToken = rows[0]; + + if (new Date() > new Date(resetToken.expires_at)) { + await pool.query('DELETE FROM password_reset_tokens WHERE email = $1', [email]); + return res.status(401).json({ error: 'Code has expired' }); + } + + if (resetToken.token !== code.toUpperCase()) { + return res.status(401).json({ error: 'Invalid code' }); + } + + // Hash new password + const hashedPassword = await hash(newPassword); + + // Update password + await pool.query( + 'UPDATE users SET password_hash_string = $1 WHERE email = $2', + [hashedPassword, email] + ); + + // Delete the used token + await pool.query('DELETE FROM password_reset_tokens WHERE email = $1', [email]); + + // Cancel scheduled deletion job + await agenda.cancel({ name: 'deletePasswordResetToken', 'data.email': email }); + + res.status(200).json({ message: 'Password reset successfully' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + app.get('/device_list', authenticateToken, async (req, res) => { try { console.log("device List request"); diff --git a/mailer.js b/mailer.js index a05ddff..1253ea9 100644 --- a/mailer.js +++ b/mailer.js @@ -111,4 +111,100 @@ async function sendVerificationEmail(toEmail, token, name) { } } -module.exports = { sendVerificationEmail }; \ No newline at end of file +// Helper function to send password reset email +async function sendPasswordResetEmail(toEmail, code, name) { + try { + const info = await transporter.sendMail({ + from: `"BlindMaster" <${process.env.EMAIL_FROM}>`, + to: toEmail, + subject: "Reset your BlindMaster password", + html: ` + + +
+ + + + + +| + + | +