Add mail verification and use .env insteads of environment in compose
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
This commit is contained in:
@@ -134,6 +134,9 @@ export async function initDatabase() {
|
||||
theme TEXT DEFAULT 'dark',
|
||||
avatar_color TEXT DEFAULT '#6366f1',
|
||||
avatar_image TEXT DEFAULT NULL,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_token_expires TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
@@ -189,6 +192,9 @@ export async function initDatabase() {
|
||||
theme TEXT DEFAULT 'dark',
|
||||
avatar_color TEXT DEFAULT '#6366f1',
|
||||
avatar_image TEXT DEFAULT NULL,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verification_token TEXT,
|
||||
verification_token_expires DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -247,6 +253,19 @@ export async function initDatabase() {
|
||||
if (!(await db.columnExists('rooms', 'moderator_code'))) {
|
||||
await db.exec('ALTER TABLE rooms ADD COLUMN moderator_code TEXT');
|
||||
}
|
||||
if (!(await db.columnExists('users', 'email_verified'))) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0');
|
||||
}
|
||||
if (!(await db.columnExists('users', 'verification_token'))) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN verification_token TEXT');
|
||||
}
|
||||
if (!(await db.columnExists('users', 'verification_token_expires'))) {
|
||||
if (isPostgres) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires TIMESTAMP');
|
||||
} else {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires DATETIME');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default admin ───────────────────────────────────────────────────────
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
@@ -256,7 +275,7 @@ export async function initDatabase() {
|
||||
if (!existingAdmin) {
|
||||
const hash = bcrypt.hashSync(adminPassword, 12);
|
||||
await db.run(
|
||||
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
|
||||
'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)',
|
||||
['Administrator', adminEmail, hash, 'admin']
|
||||
);
|
||||
console.log(`✅ Default admin created: ${adminEmail}`);
|
||||
|
||||
70
server/config/mailer.js
Normal file
70
server/config/mailer.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
let transporter;
|
||||
|
||||
export function initMailer() {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = parseInt(process.env.SMTP_PORT || '587', 10);
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
console.warn('⚠️ SMTP not configured – email verification disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user, pass },
|
||||
});
|
||||
|
||||
console.log('✅ SMTP mailer configured');
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isMailerConfigured() {
|
||||
return !!transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the verification email with a clickable link.
|
||||
* @param {string} to – recipient email
|
||||
* @param {string} name – user's display name
|
||||
* @param {string} verifyUrl – full verification URL
|
||||
* @param {string} appName – branding app name (default "Redlight")
|
||||
*/
|
||||
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
|
||||
if (!transporter) {
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${appName}" <${from}>`,
|
||||
to,
|
||||
subject: `${appName} – E-Mail bestätigen / Verify your email`,
|
||||
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;">Hey ${name} 👋</h2>
|
||||
<p>Bitte bestätige deine E-Mail-Adresse, indem du auf den folgenden Button klickst:</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${verifyUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
E-Mail bestätigen
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">
|
||||
Oder kopiere diesen Link in deinen Browser:<br/>
|
||||
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${verifyUrl}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">Der Link ist 24 Stunden gültig.</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">Falls du dich nicht registriert hast, ignoriere diese E-Mail.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hey ${name},\n\nBitte bestätige deine E-Mail: ${verifyUrl}\n\nDer Link ist 24 Stunden gültig.\n\n– ${appName}`,
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDatabase } from './config/database.js';
|
||||
import { initMailer } from './config/mailer.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import roomRoutes from './routes/rooms.js';
|
||||
import recordingRoutes from './routes/recordings.js';
|
||||
@@ -26,6 +27,7 @@ app.use(express.json());
|
||||
// Initialize database & start server
|
||||
async function start() {
|
||||
await initDatabase();
|
||||
initMailer();
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, generateToken } from '../middleware/auth.js';
|
||||
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -37,8 +39,36 @@ router.post('/register', async (req, res) => {
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
|
||||
// If SMTP is configured, require email verification
|
||||
if (isMailerConfigured()) {
|
||||
const verificationToken = uuidv4();
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO users (name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, 0, ?, ?)',
|
||||
[name, email.toLowerCase(), hash, verificationToken, expires]
|
||||
);
|
||||
|
||||
// Build verification URL
|
||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||
|
||||
// Load app name from branding settings
|
||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
||||
let appName = 'Redlight';
|
||||
if (brandingSetting?.value) {
|
||||
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
|
||||
}
|
||||
|
||||
await sendVerificationEmail(email.toLowerCase(), name, verifyUrl, appName);
|
||||
|
||||
return res.status(201).json({ needsVerification: true, message: 'Verifizierungs-E-Mail wurde gesendet' });
|
||||
}
|
||||
|
||||
// No SMTP configured – register and login immediately (legacy behaviour)
|
||||
const result = await db.run(
|
||||
'INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)',
|
||||
'INSERT INTO users (name, email, password_hash, email_verified) VALUES (?, ?, ?, 1)',
|
||||
[name, email.toLowerCase(), hash]
|
||||
);
|
||||
|
||||
@@ -52,6 +82,86 @@ router.post('/register', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/verify-email?token=...
|
||||
router.get('/verify-email', async (req, res) => {
|
||||
try {
|
||||
const { token } = req.query;
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'Token fehlt' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get(
|
||||
'SELECT id, verification_token_expires FROM users WHERE verification_token = ? AND email_verified = 0',
|
||||
[token]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: 'Ungültiger oder bereits verwendeter Token' });
|
||||
}
|
||||
|
||||
if (new Date(user.verification_token_expires) < new Date()) {
|
||||
return res.status(400).json({ error: 'Token ist abgelaufen. Bitte registriere dich erneut.' });
|
||||
}
|
||||
|
||||
await db.run(
|
||||
'UPDATE users SET email_verified = 1, verification_token = NULL, verification_token_expires = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
res.json({ verified: true, message: 'E-Mail erfolgreich verifiziert' });
|
||||
} catch (err) {
|
||||
console.error('Verify email error:', err);
|
||||
res.status(500).json({ error: 'Verifizierung fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/resend-verification
|
||||
router.post('/resend-verification', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'E-Mail ist erforderlich' });
|
||||
}
|
||||
|
||||
if (!isMailerConfigured()) {
|
||||
return res.status(400).json({ error: 'SMTP ist nicht konfiguriert' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
|
||||
if (!user || user.email_verified) {
|
||||
// Don't reveal whether account exists
|
||||
return res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
|
||||
}
|
||||
|
||||
const verificationToken = uuidv4();
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
await db.run(
|
||||
'UPDATE users SET verification_token = ?, verification_token_expires = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[verificationToken, expires, user.id]
|
||||
);
|
||||
|
||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||
|
||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
||||
let appName = 'Redlight';
|
||||
if (brandingSetting?.value) {
|
||||
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
|
||||
}
|
||||
|
||||
await sendVerificationEmail(email.toLowerCase(), user.name, verifyUrl, appName);
|
||||
|
||||
res.json({ message: 'Falls ein Konto existiert, wurde eine neue E-Mail gesendet.' });
|
||||
} catch (err) {
|
||||
console.error('Resend verification error:', err);
|
||||
res.status(500).json({ error: 'E-Mail konnte nicht gesendet werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
@@ -68,6 +178,10 @@ router.post('/login', async (req, res) => {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
if (!user.email_verified && isMailerConfigured()) {
|
||||
return res.status(403).json({ error: 'E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.', needsVerification: true });
|
||||
}
|
||||
|
||||
const token = generateToken(user.id);
|
||||
const { password_hash, ...safeUser } = user;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user