add email verification
This commit is contained in:
153
index.js
153
index.js
@@ -12,6 +12,8 @@ const format = require('pg-format');
|
|||||||
const cronParser = require('cron-parser');
|
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 { sendVerificationEmail } = require('./mailer'); // Import the function
|
||||||
|
|
||||||
// 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({
|
||||||
@@ -25,6 +27,12 @@ const authRateLimiter = new RateLimiterMemory({
|
|||||||
duration: 3600, // 1 hour in seconds
|
duration: 3600, // 1 hour in seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resend verification email rate limiter: 1 request per 20 seconds per IP
|
||||||
|
const resendVerificationRateLimiter = new RateLimiterMemory({
|
||||||
|
points: 1,
|
||||||
|
duration: 20,
|
||||||
|
});
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -526,7 +534,7 @@ app.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
if (!email || !password) return res.status(400).json({error: 'email and password required'});
|
if (!email || !password) return res.status(400).json({error: 'email and password required'});
|
||||||
try {
|
try {
|
||||||
const {rows} = await pool.query('select id, password_hash_string from users where email = $1', [email]);
|
const {rows} = await pool.query('select id, password_hash_string, is_verified from users where email = $1', [email]);
|
||||||
if (rows.length === 0) return res.status(401).json({error: 'Invalid Credentials'});
|
if (rows.length === 0) return res.status(401).json({error: 'Invalid Credentials'});
|
||||||
const user = rows[0]
|
const user = rows[0]
|
||||||
console.log('user found');
|
console.log('user found');
|
||||||
@@ -534,6 +542,13 @@ app.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
if (!verified) return res.status(401).json({ error: 'Invalid credentials' });
|
if (!verified) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
console.log("password correct");
|
console.log("password correct");
|
||||||
|
|
||||||
|
if (!user.is_verified) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Email not verified. Please check your email and verify your account before logging in.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const token = await createToken(user.id); // token is now tied to ID
|
const token = await createToken(user.id); // token is now tied to ID
|
||||||
|
|
||||||
res.status(200).json({token});
|
res.status(200).json({token});
|
||||||
@@ -558,23 +573,143 @@ app.post('/create_user', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute hash and token once for reuse
|
||||||
|
const hashedPass = await hash(password);
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const newUser = await pool.query(
|
||||||
const hashedPass = await hash(password);
|
`insert into users (name, email, password_hash_string, verification_token, is_verified)
|
||||||
|
values (nullif($1, ''), $2, $3, $4, false)
|
||||||
await pool.query("insert into users (name, email, password_hash_string) values (nullif($1, ''), $2, $3)",
|
RETURNING id, email`,
|
||||||
[name, email, hashedPass]
|
[name, email, hashedPass, token]
|
||||||
);
|
);
|
||||||
return res.sendStatus(201);
|
|
||||||
|
await sendVerificationEmail(email, token, name);
|
||||||
|
|
||||||
|
// Create temporary token for verification checking
|
||||||
|
const tempToken = await createToken(newUser.rows[0].id);
|
||||||
|
|
||||||
|
res.status(201).json({ message: "User registered. Please check your email.", token: tempToken });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') {
|
||||||
|
// Email already exists - check if verified
|
||||||
|
try {
|
||||||
|
const {rows} = await pool.query('SELECT is_verified FROM users WHERE email = $1', [email]);
|
||||||
|
if (rows.length === 1 && !rows[0].is_verified) {
|
||||||
|
// User exists but not verified - replace their record (reuse hashedPass and token)
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE users
|
||||||
|
SET name = nullif($1, ''), password_hash_string = $2, verification_token = $3
|
||||||
|
WHERE email = $4`,
|
||||||
|
[name, hashedPass, token, email]
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendVerificationEmail(email, token, name);
|
||||||
|
|
||||||
|
// Get user ID and create temp token
|
||||||
|
const {rows: userRows} = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
|
||||||
|
const tempToken = await createToken(userRows[0].id);
|
||||||
|
|
||||||
|
return res.status(201).json({ message: "User registered. Please check your email.", token: tempToken });
|
||||||
|
}
|
||||||
|
} catch (updateErr) {
|
||||||
|
console.error('Error updating unverified user:', updateErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is verified or something went wrong - email is truly in use
|
||||||
return res.status(409).json({ error: 'Email already in use' });
|
return res.status(409).json({ error: 'Email already in use' });
|
||||||
}
|
}
|
||||||
return res.sendStatus(500);
|
return res.sendStatus(500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/verify-email', async (req, res) => {
|
||||||
|
const { token } = req.query;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(400).send('Missing token');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Find the user with this token
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM users WHERE verification_token = $1`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(400).send('Invalid or expired token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.rows[0];
|
||||||
|
|
||||||
|
// 2. Verify them and clear the token
|
||||||
|
// We clear the token so the link cannot be used twice
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE users
|
||||||
|
SET is_verified = true, verification_token = NULL
|
||||||
|
WHERE id = $1`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Send them to a success page or back to the app
|
||||||
|
res.send(`
|
||||||
|
<h1>Email Verified!</h1>
|
||||||
|
<p>You can now close this window and log in to the app.</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).send('Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/verification_status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {rows} = await pool.query('SELECT is_verified FROM users WHERE id = $1', [req.user]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({error: 'User not found'});
|
||||||
|
res.status(200).json({is_verified: rows[0].is_verified});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/resend_verification', authenticateToken, 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 verification email.',
|
||||||
|
retryAfter: Math.ceil(rejRes.msBeforeNext / 1000) // seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {rows} = await pool.query('SELECT email, name, is_verified FROM users WHERE id = $1', [req.user]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({error: 'User not found'});
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
|
||||||
|
if (user.is_verified) {
|
||||||
|
return res.status(400).json({error: 'Email already verified'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
await pool.query('UPDATE users SET verification_token = $1 WHERE id = $2', [token, req.user]);
|
||||||
|
|
||||||
|
await sendVerificationEmail(user.email, token, user.name);
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Verification email sent' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/verify', authenticateToken, async (req, res) => {
|
app.get('/verify', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Issue a new token to extend session
|
// Issue a new token to extend session
|
||||||
@@ -1168,8 +1303,8 @@ app.post('/update_schedule', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, '127.0.0.1', () => {
|
||||||
console.log(`Example app listening at http://localhost:${port}`);
|
console.log(`Example app listening on 127.0.0.1:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/periph_schedule_list', authenticateToken, async (req, res) => {
|
app.post('/periph_schedule_list', authenticateToken, async (req, res) => {
|
||||||
|
|||||||
43
mailer.js
Normal file
43
mailer.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const { SESClient, SendRawEmailCommand } = require('@aws-sdk/client-ses');
|
||||||
|
|
||||||
|
const ses = new SESClient({
|
||||||
|
region: process.env.AWS_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the transporter
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
SES: { ses, aws: { SendRawEmailCommand } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to send email
|
||||||
|
async function sendVerificationEmail(toEmail, token, name) {
|
||||||
|
const verificationLink = `https://wahwa.com/verify-email?token=${token}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"BlindMaster" <${process.env.EMAIL_FROM}>`, // Sender address
|
||||||
|
to: toEmail,
|
||||||
|
subject: "Verify your BlindMaster account",
|
||||||
|
html: `
|
||||||
|
<div style="font-family: sans-serif; padding: 20px;">
|
||||||
|
<h2>Welcome${name && name.trim() ? `, ${name.trim()}` : ''}!</h2>
|
||||||
|
<p>Please verify your email address to complete your registration.</p>
|
||||||
|
<a href="${verificationLink}" style="padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px;">Verify Email</a>
|
||||||
|
<p style="margin-top: 20px; font-size: 12px; color: #888;">Link expires in 24 hours.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
console.log("Email sent successfully:", info.messageId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending email:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendVerificationEmail };
|
||||||
1352
package-lock.json
generated
1352
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-ses": "^3.962.0",
|
||||||
"agenda": "^5.0.0",
|
"agenda": "^5.0.0",
|
||||||
"argon2": "^0.43.0",
|
"argon2": "^0.43.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongoose": "^8.16.1",
|
"mongoose": "^8.16.1",
|
||||||
"node-schedule": "^2.1.1",
|
"node-schedule": "^2.1.1",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.16.0",
|
||||||
"pg-format": "^1.0.4",
|
"pg-format": "^1.0.4",
|
||||||
"rate-limiter-flexible": "^9.0.1",
|
"rate-limiter-flexible": "^9.0.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user