feat: add email verification flow with SMTP support

- Introduce email_verified column and email_verifications table for token-based verification
- Add nodemailer dependency and create mailer service (SMTP + dev fallback to console)
- On registration, generate 24h verification token and send email (or log URL in dev)
- Add /verify-email GET route to validate tokens and mark user as verified
- Add /resend-verification POST route for logged-in users to re-send link
- Update flash system with new warning style for unverified users
- Update .env.example with APP_BASE_URL and SMTP_* configuration variables

Note: existing users are grandfathered in (email_verified=1) via migration.
This commit is contained in:
Brian Fertig 2026-04-09 22:48:38 -06:00
parent b4af77066a
commit 04be828e67
10 changed files with 176 additions and 4 deletions

View File

@ -4,3 +4,15 @@ SESSION_SECRET=change-me-to-a-long-random-string
ADMIN_BOOTSTRAP_EMAIL=admin@example.com ADMIN_BOOTSTRAP_EMAIL=admin@example.com
DB_PATH=./tunes.db DB_PATH=./tunes.db
MEDIA_DIR=./media MEDIA_DIR=./media
# ── Email verification ──────────────────────────────────────────────────────
# Full URL of this app — used to build the /verify-email link in emails.
APP_BASE_URL=http://localhost:3000
# SMTP credentials. Leave all SMTP_* vars blank to print the verify URL to the console (dev mode).
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-smtp-username
SMTP_PASS=your-smtp-password
SMTP_FROM="Bri-Tunes" <noreply@example.com>

View File

@ -0,0 +1,12 @@
-- Existing rows get email_verified = 1 (grandfathered in). New inserts will explicitly pass 0.
ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 1;
CREATE TABLE IF NOT EXISTS email_verifications (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_email_ver_token ON email_verifications(token);
CREATE INDEX IF NOT EXISTS idx_email_ver_user_id ON email_verifications(user_id);

9
package-lock.json generated
View File

@ -19,6 +19,7 @@
"express-session": "^1.18.0", "express-session": "^1.18.0",
"multer": "^2.1.1", "multer": "^2.1.1",
"music-metadata": "^11.12.3", "music-metadata": "^11.12.3",
"nodemailer": "^8.0.5",
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"zod": "^3.23.8" "zod": "^3.23.8"
@ -1219,6 +1220,14 @@
"node-gyp-build-test": "build-test.js" "node-gyp-build-test": "build-test.js"
} }
}, },
"node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",

View File

@ -20,6 +20,7 @@
"express-session": "^1.18.0", "express-session": "^1.18.0",
"multer": "^2.1.1", "multer": "^2.1.1",
"music-metadata": "^11.12.3", "music-metadata": "^11.12.3",
"nodemailer": "^8.0.5",
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@ -414,6 +414,12 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
border: 1px solid var(--danger); border: 1px solid var(--danger);
box-shadow: 0 0 10px rgba(255, 77, 109, 0.5); box-shadow: 0 0 10px rgba(255, 77, 109, 0.5);
} }
.flash-warning {
background: rgba(255, 179, 71, 0.08);
color: var(--neon-amber);
border: 1px solid rgba(255, 179, 71, 0.4);
box-shadow: 0 0 8px rgba(255, 179, 71, 0.3);
}
@keyframes glitch-in { @keyframes glitch-in {
0% { transform: translate(0); opacity: 0; } 0% { transform: translate(0); opacity: 0; }
20% { transform: translate(-4px, 2px); opacity: 1; } 20% { transform: translate(-4px, 2px); opacity: 1; }

View File

@ -1,6 +1,8 @@
const express = require('express'); const express = require('express');
const { z } = require('zod'); const { z } = require('zod');
const users = require('../services/users'); const users = require('../services/users');
const mailer = require('../services/mailer');
const { requireUser } = require('../middleware/auth');
const registerSchema = z.object({ const registerSchema = z.object({
email: z.string().email().max(200), email: z.string().email().max(200),
@ -40,7 +42,13 @@ module.exports = function authRoutes(csrfProtection) {
password: parsed.data.password, password: parsed.data.password,
}); });
req.session.user = user; req.session.user = user;
req.flash('success', `Welcome, ${user.displayName}!`);
const token = users.createVerificationToken(user.id);
const base = process.env.APP_BASE_URL || 'http://localhost:3000';
mailer.sendVerificationEmail(user.email, `${base}/verify-email?token=${token}`)
.catch(err => console.error('[mailer] send failed:', err));
req.flash('success', `Welcome, ${user.displayName}! Check your email to verify your account.`);
res.redirect('/'); res.redirect('/');
} catch (err) { } catch (err) {
next(err); next(err);
@ -76,5 +84,37 @@ module.exports = function authRoutes(csrfProtection) {
req.session.destroy(() => res.redirect('/')); req.session.destroy(() => res.redirect('/'));
}); });
router.get('/verify-email', (req, res, next) => {
try {
const { token } = req.query;
if (!token || typeof token !== 'string') {
return res.render('auth/verify-email', { title: 'Verify Email', state: 'invalid' });
}
const result = users.verifyEmailToken(token);
if (!result.ok) {
return res.render('auth/verify-email', { title: 'Verify Email', state: 'invalid' });
}
if (req.session.user && req.session.user.id === result.userId) {
req.session.user = { ...req.session.user, emailVerified: true };
}
res.render('auth/verify-email', { title: 'Verify Email', state: 'success' });
} catch (err) { next(err); }
});
router.post('/resend-verification', requireUser, csrfProtection, (req, res, next) => {
try {
if (req.session.user.emailVerified) {
req.flash('success', 'Your email is already verified.');
return res.redirect('/');
}
const token = users.createVerificationToken(req.session.user.id);
const base = process.env.APP_BASE_URL || 'http://localhost:3000';
mailer.sendVerificationEmail(req.session.user.email, `${base}/verify-email?token=${token}`)
.catch(err => console.error('[mailer] resend failed:', err));
req.flash('success', 'Verification email sent. Check your inbox.');
res.redirect('/');
} catch (err) { next(err); }
});
return router; return router;
}; };

30
src/services/mailer.js Normal file
View File

@ -0,0 +1,30 @@
const nodemailer = require('nodemailer');
const { SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, SMTP_FROM } = process.env;
const configured = !!(SMTP_HOST && SMTP_USER && SMTP_PASS);
const transport = configured
? nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT || 587),
secure: SMTP_SECURE === 'true',
auth: { user: SMTP_USER, pass: SMTP_PASS },
})
: null;
async function sendVerificationEmail(to, verifyUrl) {
if (!configured) {
console.log('[mailer] SMTP not configured — verification URL:');
console.log(' ', verifyUrl);
return;
}
await transport.sendMail({
from: SMTP_FROM || '"Bri-Tunes" <noreply@example.com>',
to,
subject: 'Verify your Bri-Tunes email address',
text: `Verify your email:\n\n${verifyUrl}\n\nExpires in 24 hours.`,
html: `<p>Click to verify your email:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Expires in 24 hours.</p>`,
});
}
module.exports = { sendVerificationEmail };

View File

@ -1,18 +1,34 @@
const crypto = require('crypto');
const argon2 = require('argon2'); const argon2 = require('argon2');
const { db } = require('../../db'); const { db } = require('../../db');
const insertStmt = db.prepare( const insertStmt = db.prepare(
`INSERT INTO users (email, display_name, password_hash, role) VALUES (?, ?, ?, ?)` `INSERT INTO users (email, display_name, password_hash, role, email_verified)
VALUES (?, ?, ?, ?, 0)`
); );
const findByEmailStmt = db.prepare('SELECT * FROM users WHERE email = ?'); const findByEmailStmt = db.prepare('SELECT * FROM users WHERE email = ?');
const findByIdStmt = db.prepare('SELECT * FROM users WHERE id = ?'); const findByIdStmt = db.prepare('SELECT * FROM users WHERE id = ?');
const updateNameStmt = db.prepare('UPDATE users SET display_name = ? WHERE id = ?'); const updateNameStmt = db.prepare('UPDATE users SET display_name = ? WHERE id = ?');
const updatePasswordStmt = db.prepare('UPDATE users SET password_hash = ? WHERE id = ?'); const updatePasswordStmt = db.prepare('UPDATE users SET password_hash = ? WHERE id = ?');
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'"); const countAdminsStmt = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'");
const markVerifiedStmt = db.prepare('UPDATE users SET email_verified = 1 WHERE id = ?');
const deleteVerifTokensStmt = db.prepare('DELETE FROM email_verifications WHERE user_id = ?');
const insertVerifTokenStmt = db.prepare(
`INSERT INTO email_verifications (user_id, token, expires_at)
VALUES (?, ?, datetime('now', '+1 day'))`
);
const findVerifTokenStmt = db.prepare(
`SELECT * FROM email_verifications
WHERE token = ? AND expires_at > datetime('now')`
);
function publicView(row) { function publicView(row) {
if (!row) return null; if (!row) return null;
return { id: row.id, email: row.email, displayName: row.display_name, role: row.role }; return {
id: row.id, email: row.email,
displayName: row.display_name, role: row.role,
emailVerified: row.email_verified === 1,
};
} }
async function createUser({ email, displayName, password }) { async function createUser({ email, displayName, password }) {
@ -58,6 +74,23 @@ async function changePassword(id, currentPassword, newPassword) {
return { ok: true }; return { ok: true };
} }
function createVerificationToken(userId) {
const token = crypto.randomBytes(32).toString('hex');
deleteVerifTokensStmt.run(userId);
insertVerifTokenStmt.run(userId, token);
return token;
}
function verifyEmailToken(token) {
const row = findVerifTokenStmt.get(token);
if (!row) return { ok: false, reason: 'invalid_or_expired' };
db.transaction(() => {
markVerifiedStmt.run(row.user_id);
deleteVerifTokensStmt.run(row.user_id);
})();
return { ok: true, userId: row.user_id };
}
module.exports = { module.exports = {
createUser, createUser,
verifyCredentials, verifyCredentials,
@ -65,4 +98,6 @@ module.exports = {
findById, findById,
updateDisplayName, updateDisplayName,
changePassword, changePassword,
createVerificationToken,
verifyEmailToken,
}; };

View File

@ -0,0 +1,18 @@
<div class="form-card">
<% if (state === 'success') { %>
<h1>Email verified</h1>
<p>Your email address is confirmed.</p>
<p><a href="/" class="btn-primary">Go to home</a></p>
<% } else { %>
<h1>Link invalid or expired</h1>
<p>Verification links expire after 24 hours.</p>
<% if (user) { %>
<form method="post" action="/resend-verification">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-primary">Send a new link</button>
</form>
<% } else { %>
<p><a href="/login">Log in</a> to request a new verification email.</p>
<% } %>
<% } %>
</div>

View File

@ -1,3 +1,12 @@
<% if (flash) { %> <% if (flash) { %>
<div class="flash flash-<%= flash.type %>"><%= flash.message %></div> <div class="flash flash-<%= flash.type %>"><%= flash.message %></div>
<% } %> <% } %>
<% if (user && !user.emailVerified) { %>
<div class="flash flash-warning">
Please verify your email address.
<form method="post" action="/resend-verification" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="linklike">Resend link</button>
</form>
</div>
<% } %>