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:
parent
b4af77066a
commit
04be828e67
12
.env.example
12
.env.example
|
|
@ -4,3 +4,15 @@ SESSION_SECRET=change-me-to-a-long-random-string
|
|||
ADMIN_BOOTSTRAP_EMAIL=admin@example.com
|
||||
DB_PATH=./tunes.db
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
"express-session": "^1.18.0",
|
||||
"multer": "^2.1.1",
|
||||
"music-metadata": "^11.12.3",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pino": "^9.4.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"zod": "^3.23.8"
|
||||
|
|
@ -1219,6 +1220,14 @@
|
|||
"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": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"express-session": "^1.18.0",
|
||||
"multer": "^2.1.1",
|
||||
"music-metadata": "^11.12.3",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pino": "^9.4.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"zod": "^3.23.8"
|
||||
|
|
|
|||
|
|
@ -414,6 +414,12 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
|||
border: 1px solid var(--danger);
|
||||
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 {
|
||||
0% { transform: translate(0); opacity: 0; }
|
||||
20% { transform: translate(-4px, 2px); opacity: 1; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const users = require('../services/users');
|
||||
const mailer = require('../services/mailer');
|
||||
const { requireUser } = require('../middleware/auth');
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email().max(200),
|
||||
|
|
@ -40,7 +42,13 @@ module.exports = function authRoutes(csrfProtection) {
|
|||
password: parsed.data.password,
|
||||
});
|
||||
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('/');
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
@ -76,5 +84,37 @@ module.exports = function authRoutes(csrfProtection) {
|
|||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -1,18 +1,34 @@
|
|||
const crypto = require('crypto');
|
||||
const argon2 = require('argon2');
|
||||
const { db } = require('../../db');
|
||||
|
||||
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 findByIdStmt = db.prepare('SELECT * FROM users 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 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) {
|
||||
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 }) {
|
||||
|
|
@ -58,6 +74,23 @@ async function changePassword(id, currentPassword, newPassword) {
|
|||
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 = {
|
||||
createUser,
|
||||
verifyCredentials,
|
||||
|
|
@ -65,4 +98,6 @@ module.exports = {
|
|||
findById,
|
||||
updateDisplayName,
|
||||
changePassword,
|
||||
createVerificationToken,
|
||||
verifyEmailToken,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,3 +1,12 @@
|
|||
<% if (flash) { %>
|
||||
<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>
|
||||
<% } %>
|
||||
|
|
|
|||
Loading…
Reference in New Issue