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 @@ -
+ + + + + + + + - +