// 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; `);