forgot password functionality

This commit is contained in:
2026-01-08 13:06:20 -06:00
parent dad65b81aa
commit 45f40c2f5d
4 changed files with 310 additions and 4 deletions

4
.gitignore vendored
View File

@@ -68,4 +68,6 @@ node_modules/
# dataconnect generated files # dataconnect generated files
.dataconnect .dataconnect
.DS_Store .DS_Store
previews/

View File

@@ -256,6 +256,21 @@ const initializeAgenda = async (mongoUri, pool, io) => { // Now accepts pgPool
console.error("Error cleaning up users:", error); 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('ready', () => console.log('Agenda connected to MongoDB and ready!'));
agenda.on('start', (job) => console.log(`Job "${job.attrs.name}" starting`)); agenda.on('start', (job) => console.log(`Job "${job.attrs.name}" starting`));

197
index.js
View File

@@ -13,7 +13,7 @@ const cronParser = require('cron-parser');
const { ObjectId } = require('mongodb'); const { ObjectId } = require('mongodb');
const { RateLimiterMemory } = require('rate-limiter-flexible'); const { RateLimiterMemory } = require('rate-limiter-flexible');
const crypto = require('crypto'); 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 // HTTP rate limiter: 5 requests per second per IP
const httpRateLimiter = new RateLimiterMemory({ const httpRateLimiter = new RateLimiterMemory({
@@ -33,6 +33,12 @@ const resendVerificationRateLimiter = new RateLimiterMemory({
duration: 20, 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 // WebSocket connection rate limiter: 1 connection per second per IP
const wsConnectionRateLimiter = new RateLimiterMemory({ const wsConnectionRateLimiter = new RateLimiterMemory({
points: 5, points: 5,
@@ -72,13 +78,16 @@ let agenda;
(async () => { (async () => {
// 1. Connect to MongoDB // 1. Connect to MongoDB
await connectDB(); await connectDB();
agenda = await initializeAgenda('mongodb://localhost:27017/myScheduledApp', pool, io); agenda = await initializeAgenda('mongodb://localhost:27017/myScheduledApp', pool, io);
})(); })();
(async () => { (async () => {
await pool.query("update user_tokens set connected=FALSE where connected=TRUE"); await pool.query("update user_tokens set connected=FALSE where connected=TRUE");
await pool.query("update device_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 JWT_SECRET = process.env.JWT_SECRET;
const TOKEN_EXPIRY = '5d'; 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) => { app.get('/device_list', authenticateToken, async (req, res) => {
try { try {
console.log("device List request"); console.log("device List request");

View File

@@ -111,4 +111,100 @@ async function sendVerificationEmail(toEmail, token, name) {
} }
} }
module.exports = { sendVerificationEmail }; // 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: `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=ABeeZee:ital@0;1&display=swap" rel="stylesheet">
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: 'ABeeZee', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden; max-width: 600px;">
<!-- Header with brand color -->
<tr>
<td align="center" style="background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%); padding: 40px 20px;">
<h1 style="margin: 0; color: #ffffff; font-size: 32px; font-weight: bold; letter-spacing: 0.5px;">BlindMaster</h1>
<p style="margin: 10px 0 0 0; color: #ffffff; font-size: 14px; opacity: 0.95;">Smart Home Automation</p>
</td>
</tr>
<!-- Message -->
<tr>
<td style="padding: 50px 40px 30px 40px; text-align: center;">
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 28px; font-weight: normal;">
Password Reset Request
</h2>
<p style="margin: 0 0 30px 0; color: #666666; font-size: 16px; line-height: 1.6;">
${name && name.trim() ? `Hi ${name.trim()}, we` : 'We'} received a request to reset your password. Use the code below to continue:
</p>
</td>
</tr>
<!-- Code Display -->
<tr>
<td align="center" style="padding: 0 40px 40px 40px;">
<div style="display: inline-block; padding: 20px 40px; background-color: #f9f9f9; border-radius: 8px; border: 2px solid #FF9800;">
<p style="margin: 0; color: #FF9800; font-size: 36px; font-weight: bold; letter-spacing: 8px; font-family: 'Courier New', monospace;">
${code}
</p>
</div>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding: 0 40px;">
<div style="border-top: 1px solid #e0e0e0;"></div>
</td>
</tr>
<!-- Footer info -->
<tr>
<td style="padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #999999; font-size: 13px; line-height: 1.5;">
This code will expire in <strong style="color: #666666;">15 minutes</strong>.
</p>
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.5;">
If you didn't request a password reset, please ignore this email.
</p>
</td>
</tr>
<!-- Footer bar -->
<tr>
<td align="center" style="background-color: #f9f9f9; padding: 25px 40px;">
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2026 BlindMaster. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
});
console.log("Password reset email sent successfully:", info.messageId);
return true;
} catch (error) {
console.error("Error sending password reset email:", error);
return false;
}
}
module.exports = { sendVerificationEmail, sendPasswordResetEmail };