Files
redefined-designs/backend/src/routes/customers.ts
T
bermudalambandClaude Opus 5 13c010ff51
SonarQube Analysis / sonarqube (pull_request) Successful in 3m0s
Tests / backend-unit (pull_request) Successful in 48s
Tests / frontend-e2e (pull_request) Failing after 9m41s
feat(admin): disable and re-enable customer accounts (#33)
Adds customers.disabled_at, admin disable/enable endpoints, a Status
column and toggle on the Customers tab, and enforcement across every
path that authenticates.

Enforcement lives in attachCustomer, which previously validated only the
session token and its expiry and never read the customer row. Register,
login and password reset all mint sessions, so a single check in the
middleware covers every path rather than three separate ones — and it
means an existing rd_session cookie stops working at once instead of at
its 30-day expiry. Disabling also deletes the sessions outright, so
eviction does not wait for the next request.

Disabling releases the items the customer was holding, in the same
transaction. A disabled account cannot check out, so leaving its
reservations would keep one-of-a-kind stock off the storefront for up to
the cart expiry window for no purpose. Guarded on 'reserved' so a sold
item is never resurrected. Re-enabling restores sign-in but does not give
the items back — they may since have sold.

Sign-in returns an explicit 403 rather than a generic credential failure.
That does confirm the address has an account, which sits awkwardly beside
the deliberately non-enumerating reset in #32; the trade was made the
other way because a disabled customer told "invalid email or password"
resets their password, succeeds, is still locked out, and concludes the
site is broken. The check runs only after the password verifies, so it is
not a bulk membership oracle, and /register already reveals existence.

A reset token issued before the disable no longer mints a session, and no
new tokens are issued for a disabled account — while still answering 200,
so that endpoint stays non-enumerating.

Self-service GDPR export and deletion are blocked along with everything
else, so those requests now need servicing by hand. Worth checking the
privacy policy does not promise unconditional self-service.

Also fixes an unrelated bug the e2e run surfaced: the admin inventory
fired a request per keystroke in the price fields with no sequencing, so
an older response could land after a newer one and repaint stale rows.
Only the most recently issued request may now set state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:48:28 -05:00

289 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 && !customer.disabled_at) {
// 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;
// A token issued before the account was disabled would otherwise still mint a
// fresh session.
const { rows: owner } = await pool.query(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]);
if (owner[0]?.disabled_at) {
return res.status(403).json({ error: 'this account has been disabled' });
}
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' });
}
// Only after the password checks out, so a wrong password still looks like a
// wrong password and this does not become a bulk membership oracle.
if (customer.disabled_at) {
return res.status(403).json({ error: 'this account has been disabled' });
}
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;