← back to NationalPaperHangers
routes/auth.js
117 lines
const express = require('express');
const slugify = require('slugify');
const db = require('../lib/db');
const { hashPassword, verifyPassword } = require('../lib/auth');
const { isValidEmail } = require('../lib/utils');
const router = express.Router();
const GENERIC_LOGIN_ERROR = 'Invalid email or password';
function safeNext(input) {
// Only allow same-origin paths; reject //evil.com, /\\foo, full URLs.
const v = String(input || '').trim();
if (!v.startsWith('/')) return '/admin';
if (v.startsWith('//') || v.startsWith('/\\')) return '/admin';
return v;
}
function regenerateSession(req) {
return new Promise((resolve, reject) => {
req.session.regenerate(err => err ? reject(err) : resolve());
});
}
router.get('/login', (req, res) => {
res.render('auth/login', {
title: 'Installer login · National Paper Hangers',
error: null,
next: safeNext(req.query.next || '/admin')
});
});
router.post('/login', async (req, res, next) => {
try {
const email = (req.body.email || '').toLowerCase().trim();
const password = req.body.password || '';
const nextUrl = safeNext(req.body.next || '/admin');
const installer = await db.one('SELECT id, password_hash FROM installers WHERE email = $1', [email]);
// Constant-time comparison even on miss: run bcrypt against a dummy hash.
const hash = installer && installer.password_hash
? installer.password_hash
: '$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidinv';
const ok = await verifyPassword(password, hash);
if (!installer || !ok) {
return res.status(401).render('auth/login', {
title: 'Installer login',
error: GENERIC_LOGIN_ERROR,
next: nextUrl
});
}
await regenerateSession(req);
req.session.installerId = installer.id;
await db.query('UPDATE installers SET last_login_at = now() WHERE id = $1', [installer.id]);
res.redirect(nextUrl);
} catch (err) { next(err); }
});
router.get('/signup', (req, res) => {
res.render('auth/signup', { title: 'Apply to list · National Paper Hangers', error: null, form: {} });
});
router.post('/signup', async (req, res, next) => {
try {
const f = req.body;
const email = (f.email || '').toLowerCase().trim();
const password = f.password || '';
const businessName = (f.business_name || '').trim();
const contactName = (f.contact_name || '').trim();
const city = (f.city || '').trim();
const stateAbbr = (f.state || '').trim().toUpperCase();
const zip = (f.zip || '').trim();
const errs = [];
if (!isValidEmail(email)) errs.push('Valid email required');
if (password.length < 8) errs.push('Password must be at least 8 characters');
if (!businessName) errs.push('Business name required');
if (!city) errs.push('City required');
if (!stateAbbr || stateAbbr.length !== 2) errs.push('State (2-letter) required');
if (errs.length) {
return res.status(400).render('auth/signup', { title: 'Apply to list', error: errs.join('; '), form: f });
}
const exists = await db.one('SELECT id FROM installers WHERE email = $1', [email]);
if (exists) {
return res.status(400).render('auth/signup', { title: 'Apply to list', error: 'An account already exists for that email', form: f });
}
const baseSlug = slugify(businessName, { lower: true, strict: true }).slice(0, 60) || 'installer';
let slug = baseSlug;
let n = 2;
while (await db.one('SELECT id FROM installers WHERE slug = $1', [slug])) {
slug = `${baseSlug}-${n++}`;
}
const hash = await hashPassword(password);
const r = await db.one(
`INSERT INTO installers (slug, email, password_hash, business_name, contact_name, city, state, zip, status, tier, subscription_status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending','basic','inactive')
RETURNING id`,
[slug, email, hash, businessName, contactName, city, stateAbbr, zip]
);
await regenerateSession(req);
req.session.installerId = r.id;
res.redirect('/admin?welcome=1');
} catch (err) { next(err); }
});
router.post('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/'));
});
module.exports = router;