import { Hono } from "hono"; import { hashPassword, hashToken } from "./auth.js"; import { rateLimiter, clientIp, inWords } from "./ratelimit.js"; import { page, esc } from "./huisstijl.js"; import { sendMail } from "./mail.js"; import { brief, letterlijk } from "./mail-html.js"; import { PASSWORD_MIN, PASSWORD_MAX, GENERAL_CONTACT, PUBLIC_URL } from "../src/constants.js"; import * as db from "./queries.js"; /* * Wachtwoord vergeten. De app kent alleen het begin (een e-mailadres opgeven, POST /api/auth/forgot * in server/index.js); de rest gebeurt hier, op de webserver: de mail bevat een link naar * /wachtwoord en dat is een gewone pagina in de huisstijl van de site — geen kale API-tekst, * want dit is het enige stuk van de app dat iemand in zijn browser te zien krijgt. * * De link draagt een token van 256 bits; in de database staat alleen de sha256 ervan, één per * persoon, een uur geldig en eenmalig (db.resetPasswordByToken verzilvert en zet in één statement). * Wie de link opent nadat hij verlopen of gebruikt is, krijgt dat gewoon te lezen, met de weg * terug erbij: vraag in de app een nieuwe aan. */ export const RESET_TTL_MS = 60 * 60_000; // een uur — ruim voor wie zijn mail niet naast de app heeft const FROM = `Liefdesbrieven <${GENERAL_CONTACT}>`; /* * De basis van de link, en die staat vast — hij komt niet uit het verzoek. * * Hier stond eerder de Host-header (met X-Forwarded-Host ervóór). Dat is een adres dat de bezoeker * zelf opschrijft, en deze functie bouwt er de herstel-link mee die naar iemand ánders gemaild * wordt. Wie `POST /api/auth/forgot` deed met het adres van een vreemde en een eigen Host-header, * liet ons dus een geldig herstel-token naar dat slachtoffer sturen — in een mail die klopt, van het * juiste afzenderadres, met een link naar de site van de aanvaller. Eén klik is dan het account. * * Waar de app staat is geen vondst van het moment maar een gegeven (PUBLIC_URL in * src/constants.js, hetzelfde domein als de adressen daar). PUBLIC_BASE_URL in de omgeving wint, * voor een andere installatie of om de herstelmail lokaal een keer helemaal te doorlopen. */ export const linkBase = () => (process.env.PUBLIC_BASE_URL || PUBLIC_URL).trim().replace(/\/+$/, ""); /* * De brief. Tekst is de hoofdzaak (post van een mens); de HTML-versie staat op hetzelfde briefpapier * als de rest van de post (server/mail-html.js) en bestaat vooral om de link achter woorden te * zetten in plaats van een kale URL — precies waarvoor hij er is (zie server/mail.js). * * Twee versies, met opzet los van elkaar: in de tekstversie hóórt de URL voluit te staan, in de * HTML-versie hoort hij achter woorden. Een naam is niet van ons, dus die gaat langs `letterlijk`: * wie zich "[klik](…)" noemt krijgt geen link in zijn eigen brief. */ export async function stuurHerstelMail({ to, name, link }) { const text = `Beste ${name}, Je gaf aan dat je je wachtwoord kwijt bent. Dat geeft niets — zet hier een nieuw: ${link} De link werkt een uur, en één keer. Vroeg jij dit niet aan, gooi deze brief dan weg; je wachtwoord blijft gewoon staan. Hartelijke groet, Liefdesbrieven`; const html = brief(`Beste ${letterlijk(name)}, Je gaf aan dat je je wachtwoord kwijt bent. Dat geeft niets — [zet hier een nieuw](${link}). De link werkt een uur, en één keer. Vroeg jij dit niet aan, gooi deze brief dan weg; je wachtwoord blijft gewoon staan. Hartelijke groet, Liefdesbrieven`); await sendMail({ from: FROM, to, subject: "Een nieuw wachtwoord", text, html, stream: "outbound" }); } /* ---------- de pagina ---------- */ const knopStijl = `display:block;width:100%;padding:14px 0;font:inherit;font-size:16px;font-weight:700;border:none;background:var(--knop-alarm);color:var(--knop-ink);cursor:pointer`; const veldStijl = `display:block;width:100%;padding:10px 12px;font:inherit;font-size:15px;border:2px solid var(--ink);background:var(--paper);color:var(--ink);margin:6px 0 16px`; const formHtml = (token, fout = null) => page( "Een nieuw wachtwoord", `
Voor je account bij Liefdesbrieven. Vul hieronder een nieuw wachtwoord in; daarna log je ermee in in de app.
${fout ? `${esc(fout)}
` : ""} ` ); const wegHtml = () => page( "Deze link is verlopen", `Een herstel-link werkt een uur, en één keer — deze is op. Dat geeft niets: open de app, kies op het inlogscherm Wachtwoord vergeten? en er ligt zo een nieuwe link in je mail.
` ); const klaarHtml = (name) => page( "Je wachtwoord is vernieuwd", `Klaar, ${esc(name)}. Ga terug naar de app en log in met je nieuwe wachtwoord. Waar je nog ingelogd stond ben je uitgelogd — zo weet je zeker dat alleen jij er nog in kunt.
` ); /* Brute force op tokens afremmen; de tokens zelf zijn onraadbaar, dit houdt het ook onbeleefd verkeer af */ const resetByIp = rateLimiter({ windowMs: 15 * 60_000, max: 20 }); const wachtwoord = new Hono(); wachtwoord.get("/", async (c) => { const token = c.req.query("token") ?? ""; if (!token) return c.html(wegHtml(), 404); return (await db.peekPasswordReset(hashToken(token))) ? c.html(formHtml(token)) : c.html(wegHtml(), 404); }); wachtwoord.post("/", async (c) => { const lim = resetByIp.hit(`ip:${clientIp(c)}`); if (!lim.ok) return c.html(page("Even wachten", `Te veel pogingen — probeer het ${esc(inWords(lim.retryAfterSec))} opnieuw.
`), 429); const body = await c.req.parseBody(); const token = typeof body.token === "string" ? body.token : ""; const password = typeof body.password === "string" ? body.password : ""; if (!token) return c.html(wegHtml(), 404); if (password.length < PASSWORD_MIN) return c.html(formHtml(token, `Een wachtwoord is minimaal ${PASSWORD_MIN} tekens.`), 400); /* En een dak, want hieronder gaat het door scrypt — zie PASSWORD_MAX in src/constants.js */ if (password.length > PASSWORD_MAX) return c.html(formHtml(token, `Een wachtwoord is maximaal ${PASSWORD_MAX} tekens.`), 400); const wie = await db.resetPasswordByToken(hashToken(token), await hashPassword(password)); if (!wie) return c.html(wegHtml(), 404); return c.html(klaarHtml(wie.name)); }); export default wachtwoord;