v1.2.0 — Account security
Track A of production readiness: make the app safe to put real users on.
The headline gap was blunt — there was no password reset. A user who forgot their password was locked out permanently unless somebody with shell access ran a CLI command. That's fixed, along with the rest of the account-security floor.
Added
Password reset. Single-use, expiring tokens stored as Argon2 hashes. Requesting a new link invalidates the previous one, and completing a reset signs out every existing session — so a reset genuinely evicts an attacker rather than running alongside them.
Email, built on smtplib rather than an extension. Three backends: console (the development default — the message and its link go to the log, so a reset works with no mail infrastructure at all), smtp, and null for tests. Delivery failure is logged, never raised into the user's request. Invitations are now emailed rather than only surfacing a link.
Account lockout, counted per account with a growing backoff. Per-account rather than per-IP, because an IP limit does nothing against credential stuffing spread across addresses. A locked account fails before the password is checked and returns the identical message to every other failure — so the lockout can't be probed, and the endpoint still can't be used to enumerate addresses.
Session revocation. The session cookie carries a per-user epoch; bumping it invalidates every live session at once with no server-side session store. Surfaced as "sign out everywhere else" on the profile page, and triggered automatically by password changes and resets.
A loud startup warning when rate limiting uses in-memory storage in a multi-worker deployment, where configured limits are silently multiplied by the worker count. Redis is now wired into docker-compose.yml.
Fixed
Two bugs found by actually exercising this rather than reading it:
login_user(current_user)caused aRecursionError. Flask-Login stores whatever it's handed ong._login_user, andcurrent_useris a LocalProxy that reads that same slot — so passing the proxy made it resolve to itself. Both call sites now pass the concrete object.- Two forms on the profile page each had a field named
submit, so posting either one looked like a submission of both.
Changed
Production configuration now refuses to boot without a mail relay, for the same reason it already refuses to boot without a secret key: a deployment that cannot send a password reset is not a working deployment.
Upgrading
The migration adds NOT NULL columns to a populated users table, so it carries server defaults and drops them again afterwards — safe on a live database. Everyone is signed out once on upgrade: existing cookies have no session epoch and are rejected, which is the correct behaviour for the release that introduces session revocation.
Set MAIL_SERVER before deploying, or production will refuse to start.
267 tests, green on Python 3.11 and 3.12 with the PDF stack installed.