From 9814150ba849f79d9166a98b5c85328a3ee7bece Mon Sep 17 00:00:00 2001 From: Michelle Date: Fri, 27 Feb 2026 17:23:22 +0100 Subject: [PATCH] Add verification resend timestamp and cooldown handling for email verification --- server/config/database.js | 9 +++++++++ server/routes/auth.js | 16 +++++++++++++--- src/components/Layout.jsx | 8 ++++++-- src/pages/Login.jsx | 8 ++++++-- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/server/config/database.js b/server/config/database.js index be448af..d50df4f 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -138,6 +138,7 @@ export async function initDatabase() { email_verified INTEGER DEFAULT 0, verification_token TEXT, verification_token_expires TIMESTAMP, + verification_resend_at TIMESTAMP DEFAULT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); @@ -226,6 +227,7 @@ export async function initDatabase() { email_verified INTEGER DEFAULT 0, verification_token TEXT, verification_token_expires DATETIME, + verification_resend_at DATETIME DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -357,6 +359,13 @@ export async function initDatabase() { if (!(await db.columnExists('rooms', 'presentation_name'))) { await db.exec('ALTER TABLE rooms ADD COLUMN presentation_name TEXT DEFAULT NULL'); } + if (!(await db.columnExists('users', 'verification_resend_at'))) { + if (isPostgres) { + await db.exec('ALTER TABLE users ADD COLUMN verification_resend_at TIMESTAMP DEFAULT NULL'); + } else { + await db.exec('ALTER TABLE users ADD COLUMN verification_resend_at DATETIME DEFAULT NULL'); + } + } // ── Default admin ─────────────────────────────────────────────────────── const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com'; diff --git a/server/routes/auth.js b/server/routes/auth.js index 9a79050..14c83bf 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -145,19 +145,29 @@ router.post('/resend-verification', async (req, res) => { } const db = getDb(); - const user = await db.get('SELECT id, name, display_name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]); + const user = await db.get('SELECT id, name, display_name, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]); if (!user || user.email_verified) { // Don't reveal whether account exists return res.json({ message: 'If an account exists, a new email has been sent.' }); } + // Server-side 60s rate limit + if (user.verification_resend_at) { + const secondsAgo = (Date.now() - new Date(user.verification_resend_at).getTime()) / 1000; + if (secondsAgo < 60) { + const waitSeconds = Math.ceil(60 - secondsAgo); + return res.status(429).json({ error: `Please wait ${waitSeconds} seconds before requesting another email.`, waitSeconds }); + } + } + const verificationToken = uuidv4(); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); await db.run( - 'UPDATE users SET verification_token = ?, verification_token_expires = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', - [verificationToken, expires, user.id] + 'UPDATE users SET verification_token = ?, verification_token_expires = ?, verification_resend_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [verificationToken, expires, now, user.id] ); const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index f6861a3..22b1b83 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -29,8 +29,12 @@ export default function Layout() { await api.post('/auth/resend-verification', { email: user.email }); toast.success(t('auth.emailVerificationResendSuccess')); setResendCooldown(60); - } catch { - toast.error(t('auth.emailVerificationResendFailed')); + } catch (err) { + const wait = err.response?.data?.waitSeconds; + if (wait) { + setResendCooldown(wait); + } + toast.error(err.response?.data?.error || t('auth.emailVerificationResendFailed')); } finally { setResending(false); } diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 14ecbf2..c6566f3 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -31,8 +31,12 @@ export default function Login() { await api.post('/auth/resend-verification', { email }); toast.success(t('auth.emailVerificationResendSuccess')); setResendCooldown(60); - } catch { - toast.error(t('auth.emailVerificationResendFailed')); + } catch (err) { + const wait = err.response?.data?.waitSeconds; + if (wait) { + setResendCooldown(wait); + } + toast.error(err.response?.data?.error || t('auth.emailVerificationResendFailed')); } finally { setResending(false); }