forgot password functionality
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -68,4 +68,6 @@ node_modules/
|
|||||||
# dataconnect generated files
|
# dataconnect generated files
|
||||||
.dataconnect
|
.dataconnect
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
previews/
|
||||||
15
agenda.js
15
agenda.js
@@ -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
197
index.js
@@ -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");
|
||||||
|
|||||||
98
mailer.js
98
mailer.js
@@ -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 };
|
||||||
Reference in New Issue
Block a user