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: `
+
+ `,
+ 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')}
+
+