feat: add password reset ("forgot password") flow
Build & Push Docker Image / build (push) Successful in 4m12s
Build & Push Docker Image / build (push) Successful in 4m12s
Add a self-service password reset to the login flow: - Login page now shows a "Passwort vergessen?" link under the password field - New /forgot-password page requests a reset email by address - New /reset-password page sets a new password from an emailed token - Backend: POST /auth/forgot-password and /auth/reset-password with dedicated rate limiters; tokens stored as SHA-256 hashes with a 1h expiry - Generic responses avoid leaking account existence or SMTP/SSO state; SSO-only accounts are skipped - New sendPasswordResetEmail mailer + email/auth i18n keys (de + en) - DB migration: reset_token_hash, reset_token_expires, reset_requested_at Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
|
||||
<p>${t(lang, 'email.resetPassword.intro')}</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${resetUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
${t(lang, 'email.resetPassword.button')}
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">
|
||||
${t(lang, 'email.linkHint')}<br/>
|
||||
<a href="${resetUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(resetUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">${t(lang, 'email.resetPassword.validity')}</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.resetPassword.footer')}</p>
|
||||
</div>
|
||||
`,
|
||||
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
|
||||
|
||||
+131
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user