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>
208 lines
8.2 KiB
TypeScript
208 lines
8.2 KiB
TypeScript
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);
|
|
});
|
|
});
|