diff --git a/backend/package-lock.json b/backend/package-lock.json
index 1c477bc..8d95673 100755
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -11,6 +11,7 @@
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
+ "express-rate-limit": "^8.6.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"node-pg-migrate": "^7.6.1",
@@ -2865,6 +2866,48 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-rate-limit": {
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
+ "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "ip-address": "^10.2.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express-rate-limit/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/express-rate-limit/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -3333,6 +3376,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
+ "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
diff --git a/backend/package.json b/backend/package.json
index a740324..72b4688 100755
--- a/backend/package.json
+++ b/backend/package.json
@@ -19,30 +19,31 @@
"migrate:create": "node-pg-migrate create --migration-file-language js"
},
"dependencies": {
- "express": "^4.19.2",
- "pg": "^8.12.0",
- "multer": "^1.4.5-lts.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
- "nodemailer": "^6.9.14",
+ "express": "^4.19.2",
+ "express-rate-limit": "^8.6.2",
+ "multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
- "node-pg-migrate": "^7.6.1"
+ "node-pg-migrate": "^7.6.1",
+ "nodemailer": "^6.9.14",
+ "pg": "^8.12.0"
},
"devDependencies": {
- "typescript": "^5.5.4",
- "tsx": "^4.16.5",
- "@types/express": "^4.17.21",
- "@types/node": "^20.14.15",
- "@types/multer": "^1.4.11",
- "@types/pg": "^8.11.6",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7",
- "@types/nodemailer": "^6.4.15",
- "@types/node-cron": "^3.0.11",
- "jest": "^29.7.0",
- "ts-jest": "^29.2.4",
+ "@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
+ "@types/multer": "^1.4.11",
+ "@types/node": "^20.14.15",
+ "@types/node-cron": "^3.0.11",
+ "@types/nodemailer": "^6.4.15",
+ "@types/pg": "^8.11.6",
+ "@types/supertest": "^6.0.2",
+ "jest": "^29.7.0",
"supertest": "^7.0.0",
- "@types/supertest": "^6.0.2"
+ "ts-jest": "^29.2.4",
+ "tsx": "^4.16.5",
+ "typescript": "^5.5.4"
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/rateLimit.ts b/backend/src/rateLimit.ts
new file mode 100644
index 0000000..2cfa2ca
--- /dev/null
+++ b/backend/src/rateLimit.ts
@@ -0,0 +1,35 @@
+import rateLimit from 'express-rate-limit';
+import { Request } from 'express';
+
+// First rate limiting in the codebase. The password-reset endpoints need it
+// most: without one, anyone can make the server send unlimited mail to any
+// address. Login and registration are the obvious next candidates.
+//
+// The default in-memory store suits a single-instance deployment, which this
+// is. Running more than one app container would need a shared store, or each
+// instance would enforce its own separate allowance.
+
+const WINDOW_MS = 15 * 60 * 1000;
+const MAX_REQUESTS = 5;
+
+// Keyed on caller *and* address rather than caller alone. Keying on IP only
+// would let one person's reset attempts lock out everyone behind the same
+// NAT or reverse proxy — and everything here arrives via Nginx Proxy Manager,
+// so a great many customers share an apparent address.
+//
+// This key only makes sense on a request that carries an email. Applying the
+// same limiter to an endpoint without one collapses every caller into a single
+// `ip:` bucket, which is a shared allowance rather than a per-caller one.
+function keyByCallerAndEmail(req: Request): string {
+ const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : '';
+ return `${req.ip}:${email}`;
+}
+
+export const passwordResetRequestLimiter = rateLimit({
+ windowMs: WINDOW_MS,
+ limit: MAX_REQUESTS,
+ keyGenerator: keyByCallerAndEmail,
+ standardHeaders: 'draft-7',
+ legacyHeaders: false,
+ message: { error: 'too many attempts, please try again later' }
+});
diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts
index d078a37..5e33b12 100755
--- a/backend/src/routes/customers.ts
+++ b/backend/src/routes/customers.ts
@@ -5,6 +5,8 @@ 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();
@@ -93,6 +95,98 @@ router.post('/verify-email', async (req: Request, res: Response) => {
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',
+ `
Someone asked to reset the password for this account.
+ Choose a new password. This link expires in one hour.
+ If this wasn't you, you can ignore this email — your password has not changed.
`
+ ).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()]);
diff --git a/backend/tests/integration/passwordReset.integration.test.ts b/backend/tests/integration/passwordReset.integration.test.ts
new file mode 100644
index 0000000..9d6a1b4
--- /dev/null
+++ b/backend/tests/integration/passwordReset.integration.test.ts
@@ -0,0 +1,207 @@
+import request from 'supertest';
+import app from '../../src/app';
+import { pool } from '../../src/db';
+import { resetDb, closeDb } from './setup/testDb';
+
+beforeEach(async () => {
+ await resetDb();
+});
+
+afterAll(async () => {
+ await pool.end();
+ await closeDb();
+});
+
+const PASSWORD = 'supersecret123';
+
+async function register(email: string) {
+ const agent = request.agent(app);
+ const res = await agent.post('/api/customers/register').send({ email, password: PASSWORD });
+ expect(res.status).toBe(200);
+ return agent;
+}
+
+async function latestResetToken(email: string): Promise {
+ const { rows } = await pool.query(
+ `SELECT t.token FROM customer_tokens t
+ JOIN customers c ON c.id = t.customer_id
+ WHERE c.email = $1 AND t.kind = 'password_reset'
+ ORDER BY t.created_at DESC LIMIT 1`,
+ [email]
+ );
+ return rows[0]?.token;
+}
+
+describe('POST /api/customers/request-password-reset', () => {
+ it('issues a reset token for a known address', async () => {
+ await register('known@example.com');
+
+ const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'known@example.com' });
+ expect(res.status).toBe(200);
+ expect(await latestResetToken('known@example.com')).toBeTruthy();
+ });
+
+ it('reports the same success for an unknown address, and issues nothing', async () => {
+ const res = await request(app)
+ .post('/api/customers/request-password-reset')
+ .send({ email: 'nobody@example.com' });
+
+ // Differing responses would turn this endpoint into an oracle for which
+ // addresses have accounts.
+ expect(res.status).toBe(200);
+ const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM customer_tokens WHERE kind = 'password_reset'`);
+ expect(rows[0].n).toBe(0);
+ });
+
+ it('matches the address case-insensitively, as login does', async () => {
+ await register('mixed@example.com');
+
+ await request(app).post('/api/customers/request-password-reset').send({ email: 'MiXeD@Example.com ' });
+ expect(await latestResetToken('mixed@example.com')).toBeTruthy();
+ });
+
+ it('invalidates an earlier token when a new one is requested', async () => {
+ await register('twice@example.com');
+
+ await request(app).post('/api/customers/request-password-reset').send({ email: 'twice@example.com' });
+ const first = await latestResetToken('twice@example.com');
+ await request(app).post('/api/customers/request-password-reset').send({ email: 'twice@example.com' });
+ const second = await latestResetToken('twice@example.com');
+
+ expect(second).not.toBe(first);
+ const stale = await request(app)
+ .post('/api/customers/reset-password')
+ .send({ token: first, password: 'brandnewpassword' });
+ expect(stale.status).toBe(400);
+ });
+
+ it('rejects a malformed email without pretending to have sent anything', async () => {
+ const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'not-an-email' });
+ expect(res.status).toBe(400);
+ });
+
+ it('rate limits repeated requests for the same address', async () => {
+ await register('flood@example.com');
+
+ const statuses: number[] = [];
+ for (let i = 0; i < 8; i++) {
+ const res = await request(app).post('/api/customers/request-password-reset').send({ email: 'flood@example.com' });
+ statuses.push(res.status);
+ }
+
+ // Without a limit this endpoint will send unlimited mail to any address.
+ expect(statuses).toContain(429);
+ });
+});
+
+describe('POST /api/customers/reset-password', () => {
+ async function requestReset(email: string): Promise {
+ await request(app).post('/api/customers/request-password-reset').send({ email });
+ const token = await latestResetToken(email);
+ expect(token).toBeTruthy();
+ return token as string;
+ }
+
+ it('sets a new password and rejects the old one', async () => {
+ await register('change@example.com');
+ const token = await requestReset('change@example.com');
+
+ const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
+ expect(res.status).toBe(200);
+
+ const oldLogin = await request(app).post('/api/customers/login').send({ email: 'change@example.com', password: PASSWORD });
+ expect(oldLogin.status).toBe(401);
+
+ const newLogin = await request(app)
+ .post('/api/customers/login')
+ .send({ email: 'change@example.com', password: 'a-brand-new-password' });
+ expect(newLogin.status).toBe(200);
+ });
+
+ it('signs the customer in on success', async () => {
+ await register('signedin@example.com');
+ const token = await requestReset('signedin@example.com');
+
+ const agent = request.agent(app);
+ const res = await agent.post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
+ expect(res.status).toBe(200);
+
+ const me = await agent.get('/api/customers/me');
+ expect(me.status).toBe(200);
+ expect(me.body.email).toBe('signedin@example.com');
+ });
+
+ it('terminates sessions established before the reset', async () => {
+ const oldSession = await register('evict@example.com');
+ expect((await oldSession.get('/api/customers/me')).status).toBe(200);
+
+ const token = await requestReset('evict@example.com');
+ await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
+
+ // A reset prompted by a compromise has to evict the attacker; leaving a
+ // 30-day cookie alive would defeat the point.
+ expect((await oldSession.get('/api/customers/me')).status).toBe(401);
+ });
+
+ it('marks the email verified, since the customer received mail at it', async () => {
+ await register('unverified@example.com');
+ const token = await requestReset('unverified@example.com');
+
+ await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
+
+ const { rows } = await pool.query(`SELECT email_verified FROM customers WHERE email = $1`, ['unverified@example.com']);
+ expect(rows[0].email_verified).toBe(true);
+ });
+
+ it('consumes the token so it cannot be replayed', async () => {
+ await register('replay@example.com');
+ const token = await requestReset('replay@example.com');
+
+ await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
+ const second = await request(app).post('/api/customers/reset-password').send({ token, password: 'another-password' });
+ expect(second.status).toBe(400);
+ });
+
+ it('rejects an expired token', async () => {
+ await register('expired@example.com');
+ const token = await requestReset('expired@example.com');
+ await pool.query(`UPDATE customer_tokens SET expires_at = now() - interval '1 minute' WHERE token = $1`, [token]);
+
+ const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'a-brand-new-password' });
+ expect(res.status).toBe(400);
+ });
+
+ it('refuses a verify_email token, so one kind cannot stand in for another', async () => {
+ await register('crosskind@example.com');
+ const { rows } = await pool.query(
+ `SELECT t.token FROM customer_tokens t JOIN customers c ON c.id = t.customer_id
+ WHERE c.email = $1 AND t.kind = 'verify_email'`,
+ ['crosskind@example.com']
+ );
+ expect(rows[0].token).toBeTruthy();
+
+ const res = await request(app)
+ .post('/api/customers/reset-password')
+ .send({ token: rows[0].token, password: 'a-brand-new-password' });
+ expect(res.status).toBe(400);
+ });
+
+ it('rejects an unknown token', async () => {
+ const res = await request(app)
+ .post('/api/customers/reset-password')
+ .send({ token: 'nonsense', password: 'a-brand-new-password' });
+ expect(res.status).toBe(400);
+ });
+
+ it('enforces the same minimum password length as registration', async () => {
+ await register('short@example.com');
+ const token = await requestReset('short@example.com');
+
+ const res = await request(app).post('/api/customers/reset-password').send({ token, password: 'short' });
+ expect(res.status).toBe(400);
+
+ // A rejected attempt must not burn the token.
+ const retry = await request(app).post('/api/customers/reset-password').send({ token, password: 'long-enough-password' });
+ expect(retry.status).toBe(200);
+ });
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 368a46e..ac486ea 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -19,9 +19,11 @@
},
"devDependencies": {
"@playwright/test": "^1.47.0",
+ "@types/pg": "^8.23.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "pg": "^8.23.0",
"typescript": "^5.5.4",
"vite": "^5.4.0"
}
@@ -1401,6 +1403,28 @@
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
+ "node_modules/@types/node": {
+ "version": "26.2.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
@@ -3231,6 +3255,103 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3295,6 +3416,49 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/property-information": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
@@ -4317,6 +4481,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/string-convert": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
@@ -4400,6 +4574,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -4642,6 +4823,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ce20ea7..8d081e8 100755
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,21 +8,23 @@
"test:e2e": "playwright test"
},
"dependencies": {
+ "@ant-design/icons": "^5.4.0",
+ "@uiw/react-md-editor": "^4.0.4",
+ "antd": "^5.20.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router-dom": "^6.26.0",
- "antd": "^5.20.6",
- "@ant-design/icons": "^5.4.0",
"react-markdown": "^9.0.1",
- "remark-gfm": "^4.0.0",
- "@uiw/react-md-editor": "^4.0.4"
+ "react-router-dom": "^6.26.0",
+ "remark-gfm": "^4.0.0"
},
"devDependencies": {
+ "@playwright/test": "^1.47.0",
+ "@types/pg": "^8.23.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "pg": "^8.23.0",
"typescript": "^5.5.4",
- "vite": "^5.4.0",
- "@playwright/test": "^1.47.0"
+ "vite": "^5.4.0"
}
}
diff --git a/frontend/src/customer/ForgotPassword.tsx b/frontend/src/customer/ForgotPassword.tsx
new file mode 100644
index 0000000..ca72c66
--- /dev/null
+++ b/frontend/src/customer/ForgotPassword.tsx
@@ -0,0 +1,76 @@
+import { useState } from 'react';
+import Form from 'antd/es/form';
+import Input from 'antd/es/input';
+import Button from 'antd/es/button';
+import Typography from 'antd/es/typography';
+import Card from 'antd/es/card';
+import Alert from 'antd/es/alert';
+import { Link } from 'react-router-dom';
+import { requestPasswordReset } from './customerApi';
+
+const { Title, Paragraph, Text } = Typography;
+
+export default function ForgotPassword() {
+ const [sent, setSent] = useState(false);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function onFinish(values: { email: string }) {
+ setLoading(true);
+ setError(null);
+ try {
+ await requestPasswordReset(values.email);
+ setSent(true);
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ Reset your password
+
+ {sent ? (
+ <>
+ {/* Worded so it reveals nothing about whether the address has an
+ account — the server deliberately answers the same either way. */}
+
+
+ Back to sign in
+
+ >
+ ) : (
+ <>
+
+ Enter the email address for your account and we'll send you a link to choose a new password.
+
+ {error && }
+
+
+
+
+
+
+
+
+ Remembered it? Sign in
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/customer/Login.tsx b/frontend/src/customer/Login.tsx
index f76055d..680187a 100755
--- a/frontend/src/customer/Login.tsx
+++ b/frontend/src/customer/Login.tsx
@@ -43,6 +43,8 @@ export default function Login() {
+ Forgot password?
+
No account yet? Create one
diff --git a/frontend/src/customer/ResetPassword.tsx b/frontend/src/customer/ResetPassword.tsx
new file mode 100644
index 0000000..35cf8db
--- /dev/null
+++ b/frontend/src/customer/ResetPassword.tsx
@@ -0,0 +1,97 @@
+import { useState } from 'react';
+import Form from 'antd/es/form';
+import Input from 'antd/es/input';
+import Button from 'antd/es/button';
+import Typography from 'antd/es/typography';
+import Card from 'antd/es/card';
+import Alert from 'antd/es/alert';
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
+import { resetPassword } from './customerApi';
+import { useCustomerAuth } from './CustomerAuthContext';
+
+const { Title, Paragraph } = Typography;
+
+export default function ResetPassword() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get('token');
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+ const { refresh } = useCustomerAuth();
+
+ // A link without a token can't do anything, so say so rather than showing a
+ // form that is guaranteed to fail on submit.
+ if (!token) {
+ return (
+
+ Reset your password
+
+
+ Request a new link
+
+
+ );
+ }
+
+ async function onFinish(values: { password: string }) {
+ setLoading(true);
+ setError(null);
+ try {
+ await resetPassword(token as string, values.password);
+ // The server signs the customer in as part of the reset, so pick up the
+ // new session before navigating.
+ refresh();
+ navigate('/account', { replace: true });
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ Choose a new password
+
+ Signing in elsewhere will end — you'll stay signed in on this device.
+
+ {error && }
+
+
+
+ ({
+ validator: (_, value) =>
+ !value || getFieldValue('password') === value
+ ? Promise.resolve()
+ : Promise.reject(new Error('The passwords do not match'))
+ })
+ ]}
+ >
+
+
+
+
+
+
+ Back to sign in
+
+ );
+}
diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts
index 84697a7..1ffc8af 100755
--- a/frontend/src/customer/customerApi.ts
+++ b/frontend/src/customer/customerApi.ts
@@ -84,3 +84,19 @@ export function deleteMyAccount(): Promise {
export function exportMyData(): void {
window.location.href = '/api/customers/me/export';
}
+
+export function requestPasswordReset(email: string): Promise<{ status: string }> {
+ return fetch('/api/customers/request-password-reset', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email })
+ }).then(res => handle<{ status: string }>(res));
+}
+
+export function resetPassword(token: string, password: string): Promise {
+ return fetch('/api/customers/reset-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token, password })
+ }).then(res => handle(res));
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index f9e4e03..305db59 100755
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -10,6 +10,8 @@ import Register from './customer/Register';
import Account from './customer/Account';
import PrivacyPolicy from './customer/PrivacyPolicy';
import VerifyEmail from './customer/VerifyEmail';
+import ForgotPassword from './customer/ForgotPassword';
+import ResetPassword from './customer/ResetPassword';
import Cart from './cart/Cart';
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
import { CartProvider } from './cart/CartContext';
@@ -72,6 +74,8 @@ function Root() {
} />
} />
} />
+ } />
+ } />
diff --git a/frontend/tests/e2e/password-reset.spec.ts b/frontend/tests/e2e/password-reset.spec.ts
new file mode 100644
index 0000000..5af3745
--- /dev/null
+++ b/frontend/tests/e2e/password-reset.spec.ts
@@ -0,0 +1,145 @@
+import { test, expect, Page } from '@playwright/test';
+import { Client } from 'pg';
+
+const PASSWORD = 'supersecret123';
+const NEW_PASSWORD = 'a-brand-new-password';
+
+const uniqueEmail = () => `reset-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
+
+async function register(page: Page, email: string) {
+ await page.goto('/register');
+ await page.getByRole('textbox', { name: 'Email' }).fill(email);
+ await page.getByLabel('Password').fill(PASSWORD);
+ await page.getByRole('button', { name: 'Create account' }).click();
+ await expect(page).toHaveURL(/\/account/);
+}
+
+async function logout(page: Page) {
+ await page.getByRole('button', { name: 'Log out' }).click();
+ await expect(page).toHaveURL(/\/$/);
+}
+
+test.describe('Password reset', () => {
+ test('the login page offers a way to recover a forgotten password', async ({ page }) => {
+ await page.goto('/login');
+ await page.getByRole('link', { name: 'Forgot password?' }).click();
+
+ await expect(page).toHaveURL(/\/forgot-password/);
+ await expect(page.getByRole('heading', { name: 'Reset your password' })).toBeVisible();
+ });
+
+ test('requesting a reset confirms without revealing whether the account exists', async ({ page }) => {
+ await page.goto('/forgot-password');
+ await page.getByRole('textbox', { name: 'Email' }).fill('definitely-nobody@example.com');
+ await page.getByRole('button', { name: 'Send reset link' }).click();
+
+ // Identical wording either way; a differing message would make this an
+ // account-enumeration oracle.
+ await expect(page.getByText('Check your email')).toBeVisible();
+ await expect(page.getByText(/If an account exists/)).toBeVisible();
+ });
+
+ test('a reset link with no token explains itself instead of failing on submit', async ({ page }) => {
+ await page.goto('/reset-password');
+
+ await expect(page.getByText('This link is incomplete')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Set new password' })).toHaveCount(0);
+ });
+
+ test('rejects a mismatched confirmation before contacting the server', async ({ page }) => {
+ await page.goto('/reset-password?token=whatever');
+ await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
+ await page.getByLabel('Confirm new password').fill('something-else-entirely');
+ await page.getByRole('button', { name: 'Set new password' }).click();
+
+ await expect(page.getByText('The passwords do not match')).toBeVisible();
+ });
+
+ test('reports an invalid token rather than appearing to succeed', async ({ page }) => {
+ await page.goto('/reset-password?token=not-a-real-token');
+ await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
+ await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
+ await page.getByRole('button', { name: 'Set new password' }).click();
+
+ await expect(page.getByText('invalid or expired token')).toBeVisible();
+ await expect(page).toHaveURL(/\/reset-password/);
+ });
+
+ test('a customer can reset their password and sign in with the new one', async ({ page, request }) => {
+ const email = uniqueEmail();
+ await register(page, email);
+ await logout(page);
+
+ // The reset link arrives by email, which the tests can't read. Request the
+ // reset through the real endpoint, then read the issued token the way the
+ // customer's mail client would deliver it.
+ const requested = await request.post('/api/customers/request-password-reset', { data: { email } });
+ expect(requested.ok()).toBeTruthy();
+
+ const token = await readResetToken(email);
+ await page.goto(`/reset-password?token=${token}`);
+ await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
+ await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
+ await page.getByRole('button', { name: 'Set new password' }).click();
+
+ // The reset signs them in, so they land on the account page.
+ await expect(page).toHaveURL(/\/account/);
+ await expect(page.getByText(email)).toBeVisible();
+
+ // And the new password actually works on a fresh sign-in.
+ await logout(page);
+ await page.goto('/login');
+ await page.getByRole('textbox', { name: 'Email' }).fill(email);
+ await page.getByLabel('Password').fill(NEW_PASSWORD);
+ await page.getByRole('button', { name: 'Log in' }).click();
+ await expect(page).toHaveURL(/\/account/);
+ });
+
+ test('the old password stops working after a reset', async ({ page, request }) => {
+ const email = uniqueEmail();
+ await register(page, email);
+ await logout(page);
+
+ await request.post('/api/customers/request-password-reset', { data: { email } });
+ const token = await readResetToken(email);
+ await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } });
+
+ await page.goto('/login');
+ await page.getByRole('textbox', { name: 'Email' }).fill(email);
+ await page.getByLabel('Password').fill(PASSWORD);
+ await page.getByRole('button', { name: 'Log in' }).click();
+
+ await expect(page.getByText('invalid email or password')).toBeVisible();
+ });
+});
+
+// The token is only ever delivered by email, which these tests cannot read.
+//
+// It is read straight from the database rather than through a helper endpoint:
+// an endpoint that returns a password-reset token for an arbitrary address is
+// account takeover for every customer if it is ever reachable, and an
+// environment gate is a thin thing to stand between that and production. Doing
+// it here keeps the capability entirely inside the test process.
+async function readResetToken(email: string): Promise {
+ const client = new Client({
+ host: process.env.TEST_PGHOST || 'localhost',
+ port: parseInt(process.env.TEST_PGPORT || '55432', 10),
+ user: process.env.TEST_PGUSER || 'redefined_test',
+ password: process.env.TEST_PGPASSWORD || 'redefined_test',
+ database: process.env.TEST_PGDATABASE || 'redefined_test'
+ });
+ await client.connect();
+ try {
+ const { rows } = await client.query(
+ `SELECT t.token FROM customer_tokens t
+ JOIN customers c ON c.id = t.customer_id
+ WHERE c.email = $1 AND t.kind = 'password_reset'
+ ORDER BY t.created_at DESC LIMIT 1`,
+ [email]
+ );
+ if (!rows.length) throw new Error(`no password_reset token issued for ${email}`);
+ return rows[0].token as string;
+ } finally {
+ await client.end();
+ }
+}