feat: customer password reset via email round-trip (#32) #43
Generated
+52
@@ -11,6 +11,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^8.6.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"node-pg-migrate": "^7.6.1",
|
"node-pg-migrate": "^7.6.1",
|
||||||
@@ -2865,6 +2866,48 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"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==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|||||||
+18
-17
@@ -19,30 +19,31 @@
|
|||||||
"migrate:create": "node-pg-migrate create --migration-file-language js"
|
"migrate:create": "node-pg-migrate create --migration-file-language js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
|
||||||
"pg": "^8.12.0",
|
|
||||||
"multer": "^1.4.5-lts.1",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"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-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": {
|
"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/bcryptjs": "^2.4.6",
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/nodemailer": "^6.4.15",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"jest": "^29.7.0",
|
|
||||||
"ts-jest": "^29.2.4",
|
|
||||||
"@types/jest": "^29.5.12",
|
"@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",
|
"supertest": "^7.0.0",
|
||||||
"@types/supertest": "^6.0.2"
|
"ts-jest": "^29.2.4",
|
||||||
|
"tsx": "^4.16.5",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ import { pool } from '../db';
|
|||||||
import { requireCustomer } from '../middleware/customerAuth';
|
import { requireCustomer } from '../middleware/customerAuth';
|
||||||
import { sendMail } from '../mailer';
|
import { sendMail } from '../mailer';
|
||||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
import { passwordResetRequestLimiter } from '../rateLimit';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -93,6 +95,98 @@ router.post('/verify-email', async (req: Request, res: Response) => {
|
|||||||
res.json({ status: 'verified' });
|
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) => {
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
|
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
|
||||||
|
|||||||
@@ -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<string | undefined> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+191
@@ -19,9 +19,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.47.0",
|
"@playwright/test": "^1.47.0",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
@@ -1401,6 +1403,28 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
"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": {
|
"node_modules/@types/prismjs": {
|
||||||
"version": "1.26.6",
|
"version": "1.26.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||||
@@ -3231,6 +3255,103 @@
|
|||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3295,6 +3416,49 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/property-information": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
||||||
@@ -4317,6 +4481,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/string-convert": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
||||||
@@ -4400,6 +4574,13 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
@@ -4642,6 +4823,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -8,21 +8,23 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.4.0",
|
||||||
|
"@uiw/react-md-editor": "^4.0.4",
|
||||||
|
"antd": "^5.20.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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",
|
"react-markdown": "^9.0.1",
|
||||||
"remark-gfm": "^4.0.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"@uiw/react-md-editor": "^4.0.4"
|
"remark-gfm": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.47.0",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0"
|
||||||
"@playwright/test": "^1.47.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
|
||||||
|
<Title level={3}>Reset your password</Title>
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<>
|
||||||
|
{/* Worded so it reveals nothing about whether the address has an
|
||||||
|
account — the server deliberately answers the same either way. */}
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
message="Check your email"
|
||||||
|
description="If an account exists for that address, we've sent a link to reset the password. The link expires in one hour."
|
||||||
|
/>
|
||||||
|
<Paragraph style={{ marginTop: 16 }}>
|
||||||
|
<Link to="/login">Back to sign in</Link>
|
||||||
|
</Paragraph>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
Enter the email address for your account and we'll send you a link to choose a new password.
|
||||||
|
</Paragraph>
|
||||||
|
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
|
||||||
|
<Form layout="vertical" onFinish={onFinish}>
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
rules={[{ required: true, type: 'email', message: 'Enter a valid email address' }]}
|
||||||
|
>
|
||||||
|
<Input autoComplete="email" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button block type="primary" htmlType="submit" loading={loading}>
|
||||||
|
Send reset link
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Text type="secondary">
|
||||||
|
Remembered it? <Link to="/login">Sign in</Link>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ export default function Login() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
|
<Link to="/forgot-password">Forgot password?</Link>
|
||||||
|
<br />
|
||||||
No account yet? <Link to="/register">Create one</Link>
|
No account yet? <Link to="/register">Create one</Link>
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
|
||||||
|
<Title level={3}>Reset your password</Title>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="This link is incomplete"
|
||||||
|
description="It's missing its reset token. Request a new link and use the most recent email."
|
||||||
|
/>
|
||||||
|
<Paragraph style={{ marginTop: 16 }}>
|
||||||
|
<Link to="/forgot-password">Request a new link</Link>
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card style={{ maxWidth: 420, margin: '64px auto' }}>
|
||||||
|
<Title level={3}>Choose a new password</Title>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
Signing in elsewhere will end — you'll stay signed in on this device.
|
||||||
|
</Paragraph>
|
||||||
|
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
|
||||||
|
<Form layout="vertical" onFinish={onFinish}>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="New password"
|
||||||
|
rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}
|
||||||
|
>
|
||||||
|
<Input.Password autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="confirm"
|
||||||
|
label="Confirm new password"
|
||||||
|
dependencies={['password']}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Confirm the password' },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator: (_, value) =>
|
||||||
|
!value || getFieldValue('password') === value
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(new Error('The passwords do not match'))
|
||||||
|
})
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button block type="primary" htmlType="submit" loading={loading}>
|
||||||
|
Set new password
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Link to="/login">Back to sign in</Link>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -84,3 +84,19 @@ export function deleteMyAccount(): Promise<void> {
|
|||||||
export function exportMyData(): void {
|
export function exportMyData(): void {
|
||||||
window.location.href = '/api/customers/me/export';
|
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<Customer> {
|
||||||
|
return fetch('/api/customers/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, password })
|
||||||
|
}).then(res => handle<Customer>(res));
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import Register from './customer/Register';
|
|||||||
import Account from './customer/Account';
|
import Account from './customer/Account';
|
||||||
import PrivacyPolicy from './customer/PrivacyPolicy';
|
import PrivacyPolicy from './customer/PrivacyPolicy';
|
||||||
import VerifyEmail from './customer/VerifyEmail';
|
import VerifyEmail from './customer/VerifyEmail';
|
||||||
|
import ForgotPassword from './customer/ForgotPassword';
|
||||||
|
import ResetPassword from './customer/ResetPassword';
|
||||||
import Cart from './cart/Cart';
|
import Cart from './cart/Cart';
|
||||||
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
|
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
|
||||||
import { CartProvider } from './cart/CartContext';
|
import { CartProvider } from './cart/CartContext';
|
||||||
@@ -72,6 +74,8 @@ function Root() {
|
|||||||
<Route path="/cart" element={<Cart />} />
|
<Route path="/cart" element={<Cart />} />
|
||||||
<Route path="/privacy" element={<PrivacyPolicy />} />
|
<Route path="/privacy" element={<PrivacyPolicy />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user