feat: customer password reset via email round-trip (#32)
SonarQube Analysis / sonarqube (pull_request) Successful in 2m57s
Tests / backend-unit (pull_request) Successful in 53s
Tests / frontend-e2e (pull_request) Failing after 7m40s
Tests / backend-integration (pull_request) Failing after 3h14m41s

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:
2026-08-17 17:36:19 -05:00
co-authored by Claude Opus 5
parent c04f4a1370
commit db7c61c89d
13 changed files with 946 additions and 24 deletions
+76
View File
@@ -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>
);
}