diff --git a/server/config/database.js b/server/config/database.js index 344e6ac..25155fe 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -384,6 +384,25 @@ export async function initDatabase() { } } + // Password reset: store a SHA-256 hash of the reset token (never the raw token) + if (!(await db.columnExists('users', 'reset_token_hash'))) { + await db.exec('ALTER TABLE users ADD COLUMN reset_token_hash TEXT DEFAULT NULL'); + } + if (!(await db.columnExists('users', 'reset_token_expires'))) { + if (isPostgres) { + await db.exec('ALTER TABLE users ADD COLUMN reset_token_expires TIMESTAMP DEFAULT NULL'); + } else { + await db.exec('ALTER TABLE users ADD COLUMN reset_token_expires DATETIME DEFAULT NULL'); + } + } + if (!(await db.columnExists('users', 'reset_requested_at'))) { + if (isPostgres) { + await db.exec('ALTER TABLE users ADD COLUMN reset_requested_at TIMESTAMP DEFAULT NULL'); + } else { + await db.exec('ALTER TABLE users ADD COLUMN reset_requested_at DATETIME DEFAULT NULL'); + } + } + // Federation sync: add deleted + updated_at to federated_rooms if (!(await db.columnExists('federated_rooms', 'deleted'))) { await db.exec('ALTER TABLE federated_rooms ADD COLUMN deleted INTEGER DEFAULT 0'); diff --git a/server/config/mailer.js b/server/config/mailer.js index 708cf0d..b700775 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -92,6 +92,50 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl }); } +/** + * Send a password reset email with a clickable link. + * @param {string} to - recipient email + * @param {string} name - user's display name + * @param {string} resetUrl - full password reset URL + * @param {string} appName - branding app name (default "Redlight") + * @param {string} lang - language code + */ +export async function sendPasswordResetEmail(to, name, resetUrl, appName = 'Redlight', lang = 'en') { + if (!transporter) { + throw new Error('SMTP not configured'); + } + + const from = process.env.SMTP_FROM || process.env.SMTP_USER; + const headerAppName = sanitizeHeaderValue(appName); + const safeName = escapeHtml(name); + + await transporter.sendMail({ + from: `"${headerAppName}" <${from}>`, + to, + subject: t(lang, 'email.resetPassword.subject', { appName: headerAppName }), + html: ` +
+

${t(lang, 'email.greeting', { name: safeName })}

+

${t(lang, 'email.resetPassword.intro')}

+

+ + ${t(lang, 'email.resetPassword.button')} + +

+

+ ${t(lang, 'email.linkHint')}
+ ${escapeHtml(resetUrl)} +

+

${t(lang, 'email.resetPassword.validity')}

+
+

${t(lang, 'email.resetPassword.footer')}

+
+ `, + text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.resetPassword.intro')}\n${resetUrl}\n\n${t(lang, 'email.resetPassword.validity')}\n\n- ${appName}`, + }); +} + /** * Send a federation meeting invitation email. * @param {string} to - recipient email diff --git a/server/routes/auth.js b/server/routes/auth.js index 9b0b976..0431f0c 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; +import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -11,7 +12,7 @@ import * as OTPAuth from 'otpauth'; import { getDb } from '../config/database.js'; import redis from '../config/redis.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js'; -import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; +import { isMailerConfigured, sendVerificationEmail, sendPasswordResetEmail } from '../config/mailer.js'; import { getOAuthConfig, discoverOIDC } from '../config/oauth.js'; import { getAppName } from '../config/appName.js'; import { log } from '../config/logger.js'; @@ -167,6 +168,26 @@ const twoFaLimiter = rateLimit({ store: makeRedisStore('rl:2fa:'), }); +// Rate limit forgot-password to prevent SMTP abuse / email enumeration spam +const forgotPasswordLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests. Please try again later.' }, + store: makeRedisStore('rl:forgot:'), +}); + +// Rate limit reset-password submissions to slow token brute-forcing +const resetPasswordLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many attempts. Please try again later.' }, + store: makeRedisStore('rl:reset:'), +}); + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars'); @@ -391,6 +412,115 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res) } }); +// Hash a reset token for storage/lookup (never store the raw token) +function hashResetToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +// POST /api/auth/forgot-password +router.post('/forgot-password', forgotPasswordLimiter, async (req, res) => { + try { + const { email } = req.body; + if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) { + return res.status(400).json({ error: 'A valid email address is required' }); + } + + // Generic response used in every branch so we never reveal whether an + // account exists, whether it uses SSO, or whether SMTP is configured. + const genericResponse = { message: 'If an account exists for that address, a password reset email has been sent.' }; + + if (!isMailerConfigured()) { + return res.json(genericResponse); + } + + const db = getDb(); + const user = await db.get('SELECT id, name, display_name, email, language, oauth_provider, reset_requested_at FROM users WHERE email = ?', [email.toLowerCase()]); + + // No account, or an SSO-only account that has no local password to reset + if (!user || user.oauth_provider) { + return res.json(genericResponse); + } + + // Server-side 60s throttle per account to limit repeated emails + if (user.reset_requested_at) { + const secondsAgo = (Date.now() - new Date(user.reset_requested_at).getTime()) / 1000; + if (secondsAgo < 60) { + return res.json(genericResponse); + } + } + + const resetToken = uuidv4(); + const tokenHash = hashResetToken(resetToken); + const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour + const now = new Date().toISOString(); + + await db.run( + 'UPDATE users SET reset_token_hash = ?, reset_token_expires = ?, reset_requested_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [tokenHash, expires, now, user.id] + ); + + const baseUrl = getBaseUrl(req); + const resetUrl = `${baseUrl}/reset-password?token=${resetToken}`; + const appName = await getAppName(); + + try { + await sendPasswordResetEmail(email.toLowerCase(), user.display_name || user.name, resetUrl, appName, user.language || 'en'); + } catch (mailErr) { + log.auth.error(`Password reset mail failed: ${mailErr.message}`); + // Still return generic success — don't leak SMTP state to the client + } + + res.json(genericResponse); + } catch (err) { + log.auth.error(`Forgot password error: ${err.message}`); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// POST /api/auth/reset-password +router.post('/reset-password', resetPasswordLimiter, async (req, res) => { + try { + const { token, password } = req.body; + if (!token || typeof token !== 'string') { + return res.status(400).json({ error: 'Invalid or expired reset link.' }); + } + if (!password || typeof password !== 'string') { + return res.status(400).json({ error: 'A new password is required' }); + } + if (password.length < MIN_PASSWORD_LENGTH) { + return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }); + } + if (password.length > MAX_PASSWORD_LENGTH) { + return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` }); + } + + const db = getDb(); + const tokenHash = hashResetToken(token); + const user = await db.get('SELECT id, reset_token_expires FROM users WHERE reset_token_hash = ?', [tokenHash]); + + if (!user) { + return res.status(400).json({ error: 'Invalid or expired reset link.' }); + } + if (!user.reset_token_expires || new Date(user.reset_token_expires) < new Date()) { + return res.status(400).json({ error: 'Invalid or expired reset link.' }); + } + + const hash = await bcrypt.hash(password, 12); + + // Set the new password, verify the email (proves mailbox ownership), and + // clear the reset token so it can't be reused. + await db.run( + 'UPDATE users SET password_hash = ?, email_verified = 1, reset_token_hash = NULL, reset_token_expires = NULL, reset_requested_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [hash, user.id] + ); + + res.json({ message: 'Password has been reset successfully. You can now sign in.' }); + } catch (err) { + log.auth.error(`Reset password error: ${err.message}`); + res.status(500).json({ error: 'Password could not be reset' }); + } +}); + // POST /api/auth/login router.post('/login', loginLimiter, async (req, res) => { try { diff --git a/src/App.jsx b/src/App.jsx index 1000716..6fa9ad9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,8 @@ import Home from './pages/Home'; import Login from './pages/Login'; import Register from './pages/Register'; import VerifyEmail from './pages/VerifyEmail'; +import ForgotPassword from './pages/ForgotPassword'; +import ResetPassword from './pages/ResetPassword'; import Dashboard from './pages/Dashboard'; import RoomDetail from './pages/RoomDetail'; import Settings from './pages/Settings'; @@ -52,6 +54,8 @@ export default function App() { : } /> : } /> } /> + : } /> + } /> } /> } /> diff --git a/src/i18n/de.json b/src/i18n/de.json index 978a517..e45ea44 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -102,6 +102,22 @@ "oauthNoToken": "Kein Authentifizierungstoken erhalten.", "oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.", "oauthRedirecting": "Du wirst angemeldet...", + "forgotPassword": "Passwort vergessen?", + "forgotPasswordTitle": "Passwort zurücksetzen", + "forgotPasswordSubtitle": "Gib deine E-Mail-Adresse ein. Wir senden dir einen Link zum Zurücksetzen deines Passworts.", + "forgotPasswordSubmit": "Link senden", + "forgotPasswordSent": "Wenn ein Konto für diese Adresse existiert, haben wir dir eine E-Mail zum Zurücksetzen des Passworts gesendet.", + "forgotPasswordSentTitle": "Prüfe dein Postfach", + "forgotPasswordFailed": "Anfrage konnte nicht gesendet werden. Bitte versuche es erneut.", + "resetPasswordTitle": "Neues Passwort festlegen", + "resetPasswordSubtitle": "Wähle ein neues Passwort für dein Konto.", + "newPassword": "Neues Passwort", + "resetPasswordSubmit": "Passwort zurücksetzen", + "resetPasswordSuccess": "Dein Passwort wurde zurückgesetzt. Du kannst dich jetzt anmelden.", + "resetPasswordSuccessTitle": "Passwort zurückgesetzt", + "resetPasswordFailed": "Passwort konnte nicht zurückgesetzt werden.", + "resetTokenMissing": "Kein Token zum Zurücksetzen vorhanden.", + "passwordMinLength": "Passwort muss mindestens 8 Zeichen lang sein", "2fa": { "title": "Zwei-Faktor-Authentifizierung", "prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", @@ -650,6 +666,13 @@ "validity": "Dieser Link ist 24 Stunden gültig.", "footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren." }, + "resetPassword": { + "subject": "{appName} - Passwort zurücksetzen", + "intro": "Du hast angefordert, dein Passwort zurückzusetzen. Klicke auf den Button, um ein neues Passwort festzulegen:", + "button": "Passwort zurücksetzen", + "validity": "Dieser Link ist 1 Stunde gültig.", + "footer": "Falls du das nicht angefordert hast, kannst du diese E-Mail ignorieren – dein Passwort bleibt unverändert." + }, "invite": { "subject": "{appName} - Du wurdest eingeladen", "title": "Du wurdest eingeladen! 🎉", diff --git a/src/i18n/en.json b/src/i18n/en.json index 3ad6de5..5ea6b37 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -102,6 +102,22 @@ "oauthNoToken": "No authentication token received.", "oauthLoginFailed": "Could not complete sign in. Please try again.", "oauthRedirecting": "Signing you in...", + "forgotPassword": "Forgot password?", + "forgotPasswordTitle": "Reset your password", + "forgotPasswordSubtitle": "Enter your email address and we'll send you a link to reset your password.", + "forgotPasswordSubmit": "Send link", + "forgotPasswordSent": "If an account exists for that address, we've sent you a password reset email.", + "forgotPasswordSentTitle": "Check your inbox", + "forgotPasswordFailed": "Could not send the request. Please try again.", + "resetPasswordTitle": "Set a new password", + "resetPasswordSubtitle": "Choose a new password for your account.", + "newPassword": "New password", + "resetPasswordSubmit": "Reset password", + "resetPasswordSuccess": "Your password has been reset. You can now sign in.", + "resetPasswordSuccessTitle": "Password reset", + "resetPasswordFailed": "Could not reset password.", + "resetTokenMissing": "No reset token provided.", + "passwordMinLength": "Password must be at least 8 characters", "2fa": { "title": "Two-Factor Authentication", "prompt": "Enter the 6-digit code from your authenticator app.", @@ -650,6 +666,13 @@ "validity": "This link is valid for 24 hours.", "footer": "If you didn't register, please ignore this email." }, + "resetPassword": { + "subject": "{appName} - Reset your password", + "intro": "You requested to reset your password. Click the button below to set a new one:", + "button": "Reset Password", + "validity": "This link is valid for 1 hour.", + "footer": "If you didn't request this, you can safely ignore this email — your password will remain unchanged." + }, "invite": { "subject": "{appName} - You've been invited", "title": "You've been invited! 🎉", diff --git a/src/pages/ForgotPassword.jsx b/src/pages/ForgotPassword.jsx new file mode 100644 index 0000000..9f7fa3f --- /dev/null +++ b/src/pages/ForgotPassword.jsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useLanguage } from '../contexts/LanguageContext'; +import { Mail, ArrowRight, Loader2, CheckCircle } from 'lucide-react'; +import BrandLogo from '../components/BrandLogo'; +import api from '../services/api'; +import toast from 'react-hot-toast'; + +export default function ForgotPassword() { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + const { t } = useLanguage(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + try { + await api.post('/auth/forgot-password', { email }); + setSent(true); + } catch (err) { + toast.error(err.response?.data?.error || t('auth.forgotPasswordFailed')); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Animated background */} +
+
+
+
+
+
+
+ +
+
+
+ +
+ + {sent ? ( +
+ +

{t('auth.forgotPasswordSentTitle')}

+

{t('auth.forgotPasswordSent')}

+ + {t('auth.login')} + +
+ ) : ( + <> +
+

{t('auth.forgotPasswordTitle')}

+

{t('auth.forgotPasswordSubtitle')}

+
+ +
+
+ +
+
+
+ + +
+ + )} + + + {t('auth.backToLogin')} + +
+
+
+ ); +} diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 5a385c2..b7f85a4 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -220,6 +220,11 @@ export default function Login() { required />
+
+ + {t('auth.forgotPassword')} + +
+ + + )} + + + {t('auth.backToLogin')} + +
+ + + ); +}