From 22683f358402db941301c4b9b4f88ec0169043bb Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 20:12:03 +0200 Subject: [PATCH] feat: sign in with a code emailed to you, offered beside the password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sign-in screen asks for an email first, then offers both ways in together: a six-digit code sent to that address, or the password. Beside rather than instead — a code depends on mail being delivered and a password does not, so neither may be the only route. "Use a different email" goes back a step, and creating an account stays where it was. What keeps it from being a second, weaker front door: - Only a bcrypt hash is stored, so a code read out of the database is not a working credential. - Ten minutes, single use, marked used before the session is issued so a replay cannot race it, and requesting a new one deletes the old. - Five wrong guesses burn it. Six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries. - Requesting a code answers identically whether or not the address exists, and every verify failure returns one message. A sign-in screen that says "no such account" is a way of finding out who has one. - Two-factor still applies: a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it. - Its own rate limits, tighter for requesting than for attempting, because requesting sends mail to someone else's address. These had to be separate limiters: Express matches app.use paths on segment boundaries, so /api/auth/login does not cover /api/auth/login-code — checked against a real router rather than assumed. Two bugs found while building it, both mine: authFetch keeps an allowlist of endpoints callable with no verified owner and rejects everything else before it is sent. The new endpoints were not on it, so the request never left the browser and surfaced as "Connection error". reveal() hid elements by appending 'hidden' to className and showed them with a non-global replace, so hiding twice left two copies and showing stripped one. The "use a different email" link never reappeared. It uses classList now, which is idempotent. Verified against the running server: correct code signs in, the same code again is refused, a superseded code is refused, five wrong guesses burn it, an expired one is refused, and the stored value is a hash. In the browser: requesting a code advances the screen, a wrong code is refused without losing the screen, and the password route still signs in. Not yet demonstrated: a correct code typed into the browser. The harness keeps racing the one-live-code rule — the page's own request supersedes whatever code the test holds, and with SMTP off the delivered one cannot be read. The same request reaches the server on the wrong-code path, and the endpoint itself is verified, but that last step is untested end to end. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- .env.example | 3 + migrations/1780700000000_login-codes.js | 29 +++++ public/index.html | 29 ++++- public/js/auth.js | 138 +++++++++++++++++++++++- public/js/authFetch.js | 10 +- server.js | 17 +++ src/routes/auth.js | 96 +++++++++++++++++ src/utils/loginCodes.js | 110 +++++++++++++++++++ test/login-codes.test.js | 96 +++++++++++++++++ 9 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 migrations/1780700000000_login-codes.js create mode 100644 src/utils/loginCodes.js create mode 100644 test/login-codes.test.js diff --git a/.env.example b/.env.example index a9e42d9c..87de1ff2 100644 --- a/.env.example +++ b/.env.example @@ -227,6 +227,9 @@ DB_PASSWORD=pedscribe_secret_change_me # SITE_NAME=Pediatric AI Scribe # API_RATE_LIMIT_MAX=200 # requests per window across /api # LOGIN_RATE_LIMIT_MAX=10 # login attempts per 15 minutes +# Codes emailed for sign-in, per IP per hour. Lower than the login limit +# because each request sends mail to somebody else's address (default 5). +#LOGIN_CODE_RATE_LIMIT_MAX=5 # NODE_ENV=production # with APP_URL, puts the app in production mode: # refuses to start without JWT_SECRET or a CORS origin # CORS_ORIGINS= # extra allowed origins, comma-separated, beyond APP_URL diff --git a/migrations/1780700000000_login-codes.js b/migrations/1780700000000_login-codes.js new file mode 100644 index 00000000..b5137a17 --- /dev/null +++ b/migrations/1780700000000_login-codes.js @@ -0,0 +1,29 @@ +// Signing in with a code emailed to you, instead of a password. +// +// Its own table rather than columns on users, because a code is a short-lived +// event with its own attempt count and it should be possible to delete every +// outstanding one without touching an account row. +// +// Only the hash is stored. A code read out of the database would otherwise be a +// working credential, which is the whole thing a login code must not become. + +exports.up = pgm => pgm.sql(` + CREATE TABLE IF NOT EXISTS login_codes ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + -- Guessing is bounded per code as well as per IP: six digits is a million + -- possibilities, which is plenty against a human and nothing against a + -- script that gets unlimited tries at one code. + attempts INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_login_codes_user ON login_codes (user_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_login_codes_expiry ON login_codes (expires_at); +`); + +exports.down = pgm => pgm.sql(` + DROP TABLE IF EXISTS login_codes; +`); diff --git a/public/index.html b/public/index.html index 133c391d..8c3a089d 100644 --- a/public/index.html +++ b/public/index.html @@ -70,15 +70,37 @@ -
+ + + + + + + + - +