Adds "Forgot password?" to the login page, a request page, and a reset page reached by a one-hour, single-use token delivered by email. Reuses customer_tokens with a new password_reset kind alongside verify_email. The request endpoint always answers 200, whether or not the address has an account, so it cannot be used to test addresses for membership. Note /register still reveals existence through its 409 on a duplicate, so this protection is currently partial; closing that is its own change. Completing a reset deletes every session for that customer. A reset prompted by a compromise has to evict the intruder, and leaving a 30-day cookie alive would defeat the point. It also marks the address verified, since receiving the mail is exactly what verification proves, and supersedes any outstanding token so an older link in the inbox cannot be resurrected. Introduces the first rate limiting in the codebase, on the request endpoint only. The limiter is keyed on caller *and* submitted address: keying on IP alone would let one person lock out everyone behind the same proxy, and everything arrives via Nginx Proxy Manager. Applying that same limiter to the reset endpoint, which carries no address, collapsed every caller into one shared bucket -- so that endpoint is deliberately unlimited instead, protected by a 32-byte single-use token whose bcrypt work only runs after the token matches. The e2e tests read the issued token directly from Postgres rather than through a test-support endpoint. An endpoint returning a reset token for an arbitrary address is account takeover for every customer if it is ever reachable, and an environment gate is thin protection against that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
277 lines
12 KiB
TypeScript
Executable File
277 lines
12 KiB
TypeScript
Executable File
import { Router, Request, Response } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import crypto from 'node:crypto';
|
|
import { pool } from '../db';
|
|
import { requireCustomer } from '../middleware/customerAuth';
|
|
import { sendMail } from '../mailer';
|
|
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
|
import { asyncRoute } from '../asyncRoute';
|
|
import { passwordResetRequestLimiter } from '../rateLimit';
|
|
|
|
const router = Router();
|
|
|
|
const SESSION_DAYS = 30;
|
|
|
|
function setSessionCookie(res: Response, token: string) {
|
|
res.cookie('rd_session', token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: SESSION_DAYS * 24 * 60 * 60 * 1000
|
|
});
|
|
}
|
|
|
|
async function createSession(customerId: number): Promise<string> {
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
|
await pool.query(
|
|
`INSERT INTO customer_sessions (token, customer_id, expires_at) VALUES ($1, $2, $3)`,
|
|
[token, customerId, expiresAt]
|
|
);
|
|
return token;
|
|
}
|
|
|
|
function publicCustomer(c: any) {
|
|
return {
|
|
id: c.id,
|
|
email: c.email,
|
|
name: c.name,
|
|
email_verified: c.email_verified,
|
|
marketing_consent: c.marketing_consent,
|
|
created_at: c.created_at
|
|
};
|
|
}
|
|
|
|
router.post('/register', async (req: Request, res: Response) => {
|
|
const { email, password, name, marketingConsent } = req.body;
|
|
if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) {
|
|
return res.status(400).json({ error: 'valid email and password (min 8 chars) required' });
|
|
}
|
|
const normalizedEmail = String(email).toLowerCase().trim();
|
|
const { rows: existing } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]);
|
|
if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' });
|
|
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
const unsubscribeToken = crypto.randomBytes(16).toString('hex');
|
|
const consent = !!marketingConsent;
|
|
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO customers (email, password_hash, name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
|
[
|
|
normalizedEmail, passwordHash, name || null,
|
|
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
|
unsubscribeToken
|
|
]
|
|
);
|
|
const customer = rows[0];
|
|
|
|
const verifyToken = crypto.randomBytes(24).toString('hex');
|
|
await pool.query(
|
|
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`,
|
|
[verifyToken, customer.id, new Date(Date.now() + 24 * 60 * 60 * 1000)]
|
|
);
|
|
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${verifyToken}`;
|
|
sendMail(
|
|
customer.email,
|
|
'Verify your Redefined Designs account',
|
|
`<p>Welcome! Please <a href="${verifyUrl}">verify your email</a> to finish setting up your account.</p>`
|
|
).catch(err => console.error('verify email send failed', err));
|
|
|
|
const sessionToken = await createSession(customer.id);
|
|
setSessionCookie(res, sessionToken);
|
|
res.json(publicCustomer(customer));
|
|
});
|
|
|
|
router.post('/verify-email', async (req: Request, res: Response) => {
|
|
const { token } = req.body;
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
|
|
[token]
|
|
);
|
|
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
|
|
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [rows[0].customer_id]);
|
|
await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]);
|
|
res.json({ status: 'verified' });
|
|
});
|
|
|
|
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
|
|
// Always answers 200, whether or not the address has an account. A response
|
|
// that differed would let anyone test addresses for membership.
|
|
//
|
|
// Note /register still reveals existence via its 409 on a duplicate address,
|
|
// so this protection is currently partial — closing that is its own change.
|
|
router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(async (req: Request, res: Response) => {
|
|
const email = String(req.body?.email || '').toLowerCase().trim();
|
|
if (!email || !isValidEmail(email)) {
|
|
return res.status(400).json({ error: 'a valid email is required' });
|
|
}
|
|
|
|
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]);
|
|
const customer = rows[0];
|
|
|
|
if (customer) {
|
|
// Supersede any outstanding token, so a link cannot be resurrected later
|
|
// from an older message in the customer's inbox.
|
|
await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]);
|
|
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
await pool.query(
|
|
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'password_reset', $3)`,
|
|
[token, customer.id, new Date(Date.now() + RESET_TOKEN_TTL_MS)]
|
|
);
|
|
|
|
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
|
|
sendMail(
|
|
customer.email,
|
|
'Reset your Redefined Designs password',
|
|
`<p>Someone asked to reset the password for this account.</p>
|
|
<p><a href="${resetUrl}">Choose a new password</a>. This link expires in one hour.</p>
|
|
<p>If this wasn't you, you can ignore this email — your password has not changed.</p>`
|
|
).catch(err => console.error('password reset email send failed', err));
|
|
}
|
|
|
|
res.json({ status: 'sent' });
|
|
}));
|
|
|
|
// Deliberately not rate limited. The limiter above is keyed on the submitted
|
|
// email, which this endpoint does not carry, so reusing it would put every
|
|
// customer completing a reset into one shared bucket. Nor is a limit needed
|
|
// here: the token is 32 random bytes, single-use, and expires in an hour, and
|
|
// the expensive bcrypt hash only runs *after* the token has been matched, so
|
|
// invalid guesses cost a single indexed lookup.
|
|
router.post('/reset-password', asyncRoute(async (req: Request, res: Response) => {
|
|
const { token, password } = req.body || {};
|
|
if (!password || String(password).length < 8) {
|
|
// Checked before the token is looked at, so a rejected attempt does not
|
|
// consume the customer's only reset link.
|
|
return res.status(400).json({ error: 'password must be at least 8 characters' });
|
|
}
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
|
|
[token]
|
|
);
|
|
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
|
|
const customerId = rows[0].customer_id;
|
|
|
|
const passwordHash = await bcrypt.hash(String(password), 12);
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
await client.query(
|
|
// The customer has demonstrably received mail at this address, which is
|
|
// exactly what verification proves, so an unverified address becomes
|
|
// verified here.
|
|
`UPDATE customers SET password_hash = $1, email_verified = true WHERE id = $2`,
|
|
[passwordHash, customerId]
|
|
);
|
|
// Every existing session goes, including any an attacker holds. Without
|
|
// this, a reset prompted by a compromise leaves the intruder signed in for
|
|
// up to 30 days.
|
|
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [customerId]);
|
|
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customerId]);
|
|
await client.query('COMMIT');
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
throw err;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
|
|
const { rows: fresh } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [customerId]);
|
|
const sessionToken = await createSession(customerId);
|
|
setSessionCookie(res, sessionToken);
|
|
res.json(publicCustomer(fresh[0]));
|
|
}));
|
|
|
|
router.post('/login', async (req: Request, res: Response) => {
|
|
const { email, password } = req.body;
|
|
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
|
|
const customer = rows[0];
|
|
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
|
|
return res.status(401).json({ error: 'invalid email or password' });
|
|
}
|
|
const sessionToken = await createSession(customer.id);
|
|
setSessionCookie(res, sessionToken);
|
|
res.json(publicCustomer(customer));
|
|
});
|
|
|
|
router.post('/logout', async (req: Request, res: Response) => {
|
|
const token = req.cookies?.rd_session;
|
|
if (token) await pool.query(`DELETE FROM customer_sessions WHERE token = $1`, [token]);
|
|
res.clearCookie('rd_session');
|
|
res.status(204).end();
|
|
});
|
|
|
|
router.get('/me', requireCustomer, async (req: Request, res: Response) => {
|
|
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
|
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
|
res.json(publicCustomer(rows[0]));
|
|
});
|
|
|
|
router.put('/me', requireCustomer, async (req: Request, res: Response) => {
|
|
const { name } = req.body;
|
|
const { rows } = await pool.query(
|
|
`UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`,
|
|
[name || null, req.customerId]
|
|
);
|
|
res.json(publicCustomer(rows[0]));
|
|
});
|
|
|
|
router.post('/change-password', requireCustomer, async (req: Request, res: Response) => {
|
|
const { currentPassword, newPassword } = req.body;
|
|
if (!newPassword || String(newPassword).length < 8) {
|
|
return res.status(400).json({ error: 'new password must be at least 8 characters' });
|
|
}
|
|
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
|
const customer = rows[0];
|
|
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
|
|
return res.status(401).json({ error: 'current password is incorrect' });
|
|
}
|
|
const newHash = await bcrypt.hash(newPassword, 12);
|
|
await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]);
|
|
res.status(204).end();
|
|
});
|
|
|
|
router.post('/me/consent', requireCustomer, async (req: Request, res: Response) => {
|
|
const consent = !!req.body.marketingConsent;
|
|
await pool.query(
|
|
`UPDATE customers SET marketing_consent = $1, marketing_consent_at = now(), marketing_consent_text = $2 WHERE id = $3`,
|
|
[consent, consent ? MARKETING_CONSENT_TEXT : 'Withdrew consent via account settings', req.customerId]
|
|
);
|
|
res.status(204).end();
|
|
});
|
|
|
|
router.get('/me/orders', requireCustomer, async (req: Request, res: Response) => {
|
|
const { rows } = await pool.query(
|
|
`SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name
|
|
FROM orders o JOIN items i ON i.id = o.item_id
|
|
WHERE o.customer_id = $1 ORDER BY o.created_at DESC`,
|
|
[req.customerId]
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
router.get('/me/export', requireCustomer, async (req: Request, res: Response) => {
|
|
const { rows: customerRows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
|
const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
|
|
res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
|
|
res.json({
|
|
customer: publicCustomer(customerRows[0]),
|
|
orders: orderRows,
|
|
exported_at: new Date().toISOString()
|
|
});
|
|
});
|
|
|
|
router.delete('/me', requireCustomer, async (req: Request, res: Response) => {
|
|
await pool.query(`UPDATE orders SET customer_id = NULL WHERE customer_id = $1`, [req.customerId]);
|
|
await pool.query(`DELETE FROM customers WHERE id = $1`, [req.customerId]);
|
|
res.clearCookie('rd_session');
|
|
res.status(204).end();
|
|
});
|
|
|
|
export default router;
|