A password reset link is a key to an account: whoever opens it can choose a new password. The starting code already does three things right: the token is random (secrets.token_urlsafe), RESETS keeps only its hash, and /forgot gives the same answer whether the email has an account or not.
The hole: a link works forever, and as many times as you like. An attacker who finds an old reset email, in a forwarded message or a shared inbox, sends its token with a password of their own and takes over the account.
A good reset link works once, and only for 30 minutes. Keep the second it stops working next to the email, and delete the entry as soon as the link comes back. Here is the same idea for a one-time login code sent by text message, valid for 5 minutes:
CODES[phone] = {"code": "4821", "expires": now() + 5 * 60}
entry = CODES.pop(phone, None)
if entry is None or now() >= entry["expires"]:
return jsonify({"error": "Code invalid or expired"}), 400
The check fixes the clock: now() returns the second each request names, so the times below are exact. It also reads the last email the shop sent, through a check-only route, to get the link's token: <token> and <late> stand for those tokens.
POST /forgot {"email": "ana@example.com"} at second 1000 → 200 {"message": "If that email has an account, a reset link is on its way"}
GET /check/last-mail → 200 {"emails": 1, "to": "ana@example.com", "token": "<token>"}
POST /forgot {"email": "nobody@example.com"} → 200 the same message (and no email)
POST /reset {"token": "<token>", "password": "new-pass-456"} at second 1600 → 200 {"message": "Password changed, you can log in"}
POST /reset {"token": "<token>", "password": "attacker-pass-1"} at second 1700 → 400 {"error": "This reset link is invalid or has expired"}
POST /login {"email": "ana@example.com", "password": "new-pass-456"} → 200 {"message": "Logged in"}
POST /forgot {"email": "ana@example.com"} at second 3000 → 200 the same message
GET /check/last-mail → 200 {"emails": 2, "to": "ana@example.com", "token": "<late>"}
POST /reset {"token": "<late>", "password": "late-pass-789"} at second 4800 → 400 {"error": "This reset link is invalid or has expired"}
Your task: add RESET_SECONDS (30 minutes), store the second each link expires in its entry, delete the entry from RESETS as soon as its link comes back, and refuse a link whose time is up with the same 400 error as a made-up one.
Assignment
Follow the Coding Tutorial to make each reset link work only once, and only for 30 minutes.
Hint
Delete the entry right below the if entry is None block, before the time check, so expired links are cleared out too. Use >=: at the expiry second itself, the link no longer works.
Free plan: 30 tutor messages. PRO is unlimited.
