Merge pull request 'feat: customer password reset via email round-trip (#32)' (#43) from feature/password-reset into main
Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
name: Build QA Image
|
||||
|
||||
# Manual only. Builds the QA image from a chosen ref, pushes it to the Gitea
|
||||
# container registry, and emails when it is ready to redeploy.
|
||||
#
|
||||
# It deliberately does NOT restart the QA stack. Redeploying stays a human
|
||||
# action in Portainer, so nothing changes what is running without someone
|
||||
# deciding it should.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Branch, tag, or commit to build
|
||||
required: true
|
||||
default: main
|
||||
|
||||
env:
|
||||
IMAGE: gitea.bermudalamb.synology.me/bermudalamb/redefined-designs
|
||||
# The build runs against a Docker-in-Docker service rather than the host
|
||||
# daemon. Mounting the host socket into the runner would give every workflow
|
||||
# on every branch root-equivalent control of the NAS, production included.
|
||||
# Because the image is pushed to a registry, it does not need to survive in
|
||||
# the build daemon.
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
docker:
|
||||
image: docker:27-dind
|
||||
options: --privileged
|
||||
env:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
|
||||
steps:
|
||||
- name: Check required configuration
|
||||
run: |
|
||||
missing=""
|
||||
[ -n "${{ secrets.REGISTRY_TOKEN }}" ] || missing="$missing REGISTRY_TOKEN"
|
||||
[ -n "${{ vars.REGISTRY_USER }}" ] || missing="$missing REGISTRY_USER"
|
||||
[ -n "${{ secrets.BREVO_API_KEY }}" ] || missing="$missing BREVO_API_KEY"
|
||||
[ -n "${{ vars.QA_NOTIFY_TO }}" ] || missing="$missing QA_NOTIFY_TO"
|
||||
[ -n "${{ vars.QA_NOTIFY_FROM }}" ] || missing="$missing QA_NOTIFY_FROM"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::Missing configuration:$missing"
|
||||
echo "Secrets go in Settings > Actions > Secrets; variables in Settings > Actions > Variables."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required secrets and variables are present."
|
||||
|
||||
- name: Checkout ${{ inputs.ref }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure a docker CLI is available
|
||||
run: |
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "docker CLI already present: $(docker --version)"
|
||||
exit 0
|
||||
fi
|
||||
echo "docker CLI missing from the runner image; installing the static binary."
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz
|
||||
tar -xzf /tmp/docker.tgz -C /tmp
|
||||
install -m 0755 /tmp/docker/docker /usr/local/bin/docker
|
||||
docker --version
|
||||
|
||||
- name: Wait for the build daemon
|
||||
run: |
|
||||
# A privileged service container is the one runner capability this
|
||||
# workflow cannot verify in advance. Fail here with an explanation
|
||||
# rather than at `docker build` with a connection refused.
|
||||
for i in $(seq 1 30); do
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo "Build daemon reachable after ${i}s."
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::No Docker daemon at $DOCKER_HOST after 30s."
|
||||
echo "The dind service needs privileged containers. If the runner"
|
||||
echo "forbids them, this workflow cannot build without host socket access."
|
||||
exit 1
|
||||
|
||||
- name: Record what is being built
|
||||
id: meta
|
||||
run: |
|
||||
echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "full_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "subject<<EOF"
|
||||
git log -1 --pretty=%s
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to the Gitea registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
| docker login gitea.bermudalamb.synology.me \
|
||||
-u "${{ vars.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
# Tagged twice: :qa is what the stack pulls, and the commit tag makes
|
||||
# it possible to tell what is actually deployed and to roll back to a
|
||||
# specific build rather than "the previous one".
|
||||
docker build --no-cache \
|
||||
-t "$IMAGE:qa" \
|
||||
-t "$IMAGE:${{ steps.meta.outputs.sha }}" \
|
||||
.
|
||||
docker push "$IMAGE:qa"
|
||||
docker push "$IMAGE:${{ steps.meta.outputs.sha }}"
|
||||
|
||||
- name: Email that QA is ready to redeploy
|
||||
run: |
|
||||
cat > /tmp/mail.json <<JSON
|
||||
{
|
||||
"sender": { "email": "${{ vars.QA_NOTIFY_FROM }}", "name": "Redefined Designs CI" },
|
||||
"to": [ { "email": "${{ vars.QA_NOTIFY_TO }}" } ],
|
||||
"subject": "QA image ready — ${{ inputs.ref }} @ ${{ steps.meta.outputs.sha }}",
|
||||
"htmlContent": "<p>A QA image has been built and pushed.</p><ul><li><b>Ref:</b> ${{ inputs.ref }}</li><li><b>Commit:</b> ${{ steps.meta.outputs.sha }}</li><li><b>Subject:</b> ${{ steps.meta.outputs.subject }}</li></ul><p><b>To deploy it:</b> open the <code>redefined-designs-qa</code> stack in Portainer and redeploy with <i>Pull latest image</i> enabled.</p><p>Migrations run automatically as the container starts — check <code>docker logs redefined-designs-qa-syn</code> shows the migration output before <code>listening on 3000</code>, and that it appears only once.</p>"
|
||||
}
|
||||
JSON
|
||||
code=$(curl -sS -o /tmp/mail-response.json -w '%{http_code}' \
|
||||
-X POST https://api.brevo.com/v3/smtp/email \
|
||||
-H "api-key: ${{ secrets.BREVO_API_KEY }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @/tmp/mail.json)
|
||||
echo "Brevo responded $code"
|
||||
if [ "$code" -ge 300 ]; then
|
||||
cat /tmp/mail-response.json
|
||||
# The image is already pushed and usable at this point, so a failed
|
||||
# notification must not report the build as failed.
|
||||
echo "::warning::Image pushed successfully, but the notification email failed."
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "### QA image pushed"
|
||||
echo ""
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| Ref | \`${{ inputs.ref }}\` |"
|
||||
echo "| Commit | \`${{ steps.meta.outputs.full_sha }}\` |"
|
||||
echo "| Tags | \`$IMAGE:qa\`, \`$IMAGE:${{ steps.meta.outputs.sha }}\` |"
|
||||
echo ""
|
||||
echo "Redeploy the \`redefined-designs-qa\` stack in Portainer with **Pull latest image** enabled."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
Generated
+52
@@ -11,6 +11,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-pg-migrate": "^7.6.1",
|
||||
@@ -2865,6 +2866,48 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.6.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
|
||||
"integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -3333,6 +3376,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
+18
-17
@@ -19,30 +19,31 @@
|
||||
"migrate:create": "node-pg-migrate create --migration-file-language js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"pg": "^8.12.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"nodemailer": "^6.9.14",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-pg-migrate": "^7.6.1"
|
||||
"node-pg-migrate": "^7.6.1",
|
||||
"nodemailer": "^6.9.14",
|
||||
"pg": "^8.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.4",
|
||||
"tsx": "^4.16.5",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.15",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.14.15",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
"@types/supertest": "^6.0.2"
|
||||
"ts-jest": "^29.2.4",
|
||||
"tsx": "^4.16.5",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { sendMail } from '../mailer';
|
||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { passwordResetRequestLimiter } from '../rateLimit';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -93,6 +95,98 @@ router.post('/verify-email', async (req: Request, res: Response) => {
|
||||
res.json({ status: 'verified' });
|
||||
});
|
||||
|
||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
// Always answers 200, whether or not the address has an account. A response
|
||||
// that differed would let anyone test addresses for membership.
|
||||
//
|
||||
// Note /register still reveals existence via its 409 on a duplicate address,
|
||||
// so this protection is currently partial — closing that is its own change.
|
||||
router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(async (req: Request, res: Response) => {
|
||||
const email = String(req.body?.email || '').toLowerCase().trim();
|
||||
if (!email || !isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'a valid email is required' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]);
|
||||
const customer = rows[0];
|
||||
|
||||
if (customer) {
|
||||
// Supersede any outstanding token, so a link cannot be resurrected later
|
||||
// from an older message in the customer's inbox.
|
||||
await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'password_reset', $3)`,
|
||||
[token, customer.id, new Date(Date.now() + RESET_TOKEN_TTL_MS)]
|
||||
);
|
||||
|
||||
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
|
||||
sendMail(
|
||||
customer.email,
|
||||
'Reset your Redefined Designs password',
|
||||
`<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) => {
|
||||
const { email, password } = req.body;
|
||||
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);
|
||||
});
|
||||
});
|
||||
+12
-12
@@ -17,23 +17,23 @@
|
||||
# QA_DB_PASSWORD — deliberately not named DB_PASSWORD, so pasting the
|
||||
# production stack's variables here does nothing silently.
|
||||
#
|
||||
# BUILD THE IMAGE BEFORE DEPLOYING THIS STACK. redefined-designs:qa exists only
|
||||
# on the NAS and is never pushed to a registry, so deploying first makes Compose
|
||||
# fall back to pulling from Docker Hub and fail with a misleading
|
||||
# "pull access denied ... repository does not exist or may require docker login".
|
||||
# The image comes from the Gitea container registry, built by the manual
|
||||
# "Build QA Image" workflow. Redeploy this stack with Portainer's
|
||||
# "Pull latest image" toggle ON, or it will keep running the image it already
|
||||
# has and the redeploy will appear to do nothing.
|
||||
#
|
||||
# sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
|
||||
# Portainer needs registry credentials for gitea.bermudalamb.synology.me once
|
||||
# (Registries > Add registry, custom, with a Gitea token that has read:package).
|
||||
#
|
||||
# For the same reason, leave Portainer's "Pull latest image" toggle off.
|
||||
# `pull_policy: never` below makes a missing image report itself as missing
|
||||
# rather than as a registry authentication problem. If the Docker Compose
|
||||
# version on the NAS ever rejects that key, it is safe to delete the line — it
|
||||
# only improves the error message.
|
||||
# This replaced a locally-built `redefined-designs:qa` with `pull_policy: never`.
|
||||
# That arrangement meant the image existed only if someone had remembered to
|
||||
# build it, which produced two confusing deploy failures: a "pull access denied"
|
||||
# from Docker Hub when the tag was missing entirely, and a silent stale-image
|
||||
# deploy when the build had not been rerun.
|
||||
|
||||
services:
|
||||
redefined-designs-qa:
|
||||
image: redefined-designs:qa
|
||||
pull_policy: never
|
||||
image: gitea.bermudalamb.synology.me/bermudalamb/redefined-designs:qa
|
||||
container_name: redefined-designs-qa-syn
|
||||
environment:
|
||||
- TZ=America/Chicago
|
||||
|
||||
Generated
+191
@@ -19,9 +19,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.47.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"pg": "^8.23.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
@@ -1401,6 +1403,28 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
@@ -3231,6 +3255,103 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3295,6 +3416,49 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
||||
@@ -4317,6 +4481,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/string-convert": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
||||
@@ -4400,6 +4574,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
@@ -4642,6 +4823,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -8,21 +8,23 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@uiw/react-md-editor": "^4.0.4",
|
||||
"antd": "^5.20.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"antd": "^5.20.6",
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"@uiw/react-md-editor": "^4.0.4"
|
||||
"react-router-dom": "^6.26.0",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.47.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"pg": "^8.23.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0",
|
||||
"@playwright/test": "^1.47.0"
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<Text type="secondary">
|
||||
<Link to="/forgot-password">Forgot password?</Link>
|
||||
<br />
|
||||
No account yet? <Link to="/register">Create one</Link>
|
||||
</Text>
|
||||
</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 {
|
||||
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 PrivacyPolicy from './customer/PrivacyPolicy';
|
||||
import VerifyEmail from './customer/VerifyEmail';
|
||||
import ForgotPassword from './customer/ForgotPassword';
|
||||
import ResetPassword from './customer/ResetPassword';
|
||||
import Cart from './cart/Cart';
|
||||
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
|
||||
import { CartProvider } from './cart/CartContext';
|
||||
@@ -72,6 +74,8 @@ function Root() {
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicy />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</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