feat: customer password reset via email round-trip (#32)
Adds "Forgot password?" to the login page, a request page, and a reset page reached by a one-hour, single-use token delivered by email. Reuses customer_tokens with a new password_reset kind alongside verify_email. The request endpoint always answers 200, whether or not the address has an account, so it cannot be used to test addresses for membership. Note /register still reveals existence through its 409 on a duplicate, so this protection is currently partial; closing that is its own change. Completing a reset deletes every session for that customer. A reset prompted by a compromise has to evict the intruder, and leaving a 30-day cookie alive would defeat the point. It also marks the address verified, since receiving the mail is exactly what verification proves, and supersedes any outstanding token so an older link in the inbox cannot be resurrected. Introduces the first rate limiting in the codebase, on the request endpoint only. The limiter is keyed on caller *and* submitted address: keying on IP alone would let one person lock out everyone behind the same proxy, and everything arrives via Nginx Proxy Manager. Applying that same limiter to the reset endpoint, which carries no address, collapsed every caller into one shared bucket -- so that endpoint is deliberately unlimited instead, protected by a 32-byte single-use token whose bcrypt work only runs after the token matches. The e2e tests read the issued token directly from Postgres rather than through a test-support endpoint. An endpoint returning a reset token for an arbitrary address is account takeover for every customer if it is ever reachable, and an environment gate is thin protection against that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
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",
|
||||||
|
|||||||
+17
-16
@@ -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