learning-website-nextjs1-10 Exercise 1: Accounts Without a Framework: Passwords, Sessions, Lockout, Progress =================================================================================================================== The site is made of pages built in advance, so anything that must be REMEMBERED (who someone is, what they have finished) needs a small program and a small database. The options were: Option What it costs Used here Keep progress in the browser nothing to run; per browser, lost with its data no (a different device would start again) Our own small API + SQLite a Node process; we are responsible for the security YES A library such as Auth.js a large dependency, and still a database to run no A hosted login service another company holds the passwords no The decisions are in a package of plain functions with no web framework in it (@lw/accounts), so they can be tested hard without a browser. The database is SQLite through Node's own built-in "node:sqlite": nothing to install. (In Node 24 it is still marked experimental and prints a warning when it starts: its interface could change.) Save as packages/accounts/src/db.ts: import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; /** * Open (and if needed create) the accounts database: a single SQLite file, used through Node's own built-in `node:sqlite`, so there is * nothing to install. It holds users, login sessions, the finished pages of each user, and wrong-password counts. */ export function openDatabase(file: string): DatabaseSync { if (file !== ":memory:") mkdirSync(dirname(file), { recursive: true }); const db = new DatabaseSync(file); db.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( token_hash TEXT PRIMARY KEY, -- the token itself is never stored, only its hash user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS progress ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, path TEXT NOT NULL, -- the page's path, as in the content folder (it survives a rebuild) done_at INTEGER NOT NULL, PRIMARY KEY (user_id, path) ); CREATE TABLE IF NOT EXISTS login_failures ( name_hash TEXT NOT NULL, -- a hash of the user name that was typed, real or not at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS login_failures_by_name ON login_failures (name_hash, at); `); return db; } export type Db = DatabaseSync; Save as packages/accounts/src/passwords.ts: import { randomBytes, scrypt as scryptCallback, timingSafeEqual, type ScryptOptions } from "node:crypto"; /** * Passwords are never stored. What is stored is a scrypt hash: a slow, memory-hungry one-way function, with a random salt for each * password, written as "scrypt$N$r$p$salt$hash" so the settings travel with the hash and can be raised later. */ const N = 16384; // cost: 2^14 (about 16 MB of memory and a few tens of milliseconds) const R = 8; const P = 1; const KEY_LENGTH = 32; function derive(password: string, salt: Buffer, n: number, r: number, p: number): Promise { const options: ScryptOptions = { N: n, r, p, maxmem: 128 * n * r * 2 }; return new Promise((resolve, reject) => { scryptCallback(password.normalize("NFKC"), salt, KEY_LENGTH, options, (error, key) => (error ? reject(error) : resolve(key))); }); } export async function hashPassword(password: string): Promise { const salt = randomBytes(16); const key = await derive(password, salt, N, R, P); return ["scrypt", N, R, P, salt.toString("base64"), key.toString("base64")].join("$"); } /** True only if the password matches. The comparison takes the same time whatever the answer. */ export async function verifyPassword(stored: string, password: string): Promise { const [scheme, n, r, p, salt, key] = stored.split("$"); if (scheme !== "scrypt" || !n || !r || !p || !salt || !key) return false; const expected = Buffer.from(key, "base64"); const actual = await derive(password, Buffer.from(salt, "base64"), Number(n), Number(r), Number(p)); return expected.length === actual.length && timingSafeEqual(expected, actual); } /** * A hash of a password nobody has, for checking against when the user name does not exist. Without it, "no such user" would answer * faster than "wrong password", and the difference in time would tell an attacker which names are real. */ let decoy: Promise | undefined; export function decoyHash(): Promise { decoy ??= hashPassword(randomBytes(12).toString("hex")); return decoy; } Save as packages/accounts/src/accounts.ts: import { createHash, randomBytes } from "node:crypto"; import type { Db } from "./db.ts"; import { decoyHash, hashPassword, verifyPassword } from "./passwords.ts"; /** All times are seconds since 1970, passed in, so a test can move the clock. */ export type Seconds = number; export const nowSeconds = (): Seconds => Math.floor(Date.now() / 1000); export const SESSION_SECONDS = 30 * 24 * 60 * 60; // a login lasts 30 days export const MAX_FAILURES = 5; // wrong passwords for one user name ... export const LOCKOUT_SECONDS = 15 * 60; // ... lock that name out for this long const sha256 = (text: string) => createHash("sha256").update(text).digest("hex"); const nameKey = (username: string) => sha256(username.trim().toLowerCase()); export interface User { readonly id: number; readonly username: string } export class AccountError extends Error {} /** Accounts are made by the owner, never by a visitor: there is no sign-up. */ export async function createUser(db: Db, username: string, password: string, now: Seconds = nowSeconds()): Promise { const name = username.trim(); if (!/^[A-Za-z0-9._-]{3,40}$/.test(name)) throw new AccountError("A user name is 3 to 40 letters, digits, dots, dashes or underscores."); if (password.length < 12) throw new AccountError("A password needs at least 12 characters."); const result = db.prepare("INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)").run(name, await hashPassword(password), now); return { id: Number(result.lastInsertRowid), username: name }; } /** Is this user name locked out right now (too many wrong passwords lately)? A name that does not exist is counted too. */ export function isLocked(db: Db, username: string, now: Seconds = nowSeconds()): boolean { const row = db.prepare("SELECT count(*) AS n FROM login_failures WHERE name_hash = ? AND at > ?").get(nameKey(username), now - LOCKOUT_SECONDS) as { n: number }; return row.n >= MAX_FAILURES; } export type LoginResult = | { readonly ok: true; readonly token: string; readonly user: User } | { readonly ok: false; readonly reason: "locked" | "invalid" }; /** * Check a user name and password and, if right, start a session. The answer for "no such user" and "wrong password" is the same * ("invalid") and takes about the same time. After MAX_FAILURES wrong passwords for a name, that name is locked for 15 minutes, EVEN for * the right password. */ export async function login(db: Db, username: string, password: string, now: Seconds = nowSeconds()): Promise { if (isLocked(db, username, now)) return { ok: false, reason: "locked" }; const row = db.prepare("SELECT id, username, password_hash FROM users WHERE username = ?").get(username.trim()) as | { id: number; username: string; password_hash: string } | undefined; const correct = await verifyPassword(row ? row.password_hash : await decoyHash(), password); if (!row || !correct) { db.prepare("INSERT INTO login_failures (name_hash, at) VALUES (?, ?)").run(nameKey(username), now); return { ok: false, reason: "invalid" }; } db.prepare("DELETE FROM login_failures WHERE name_hash = ?").run(nameKey(username)); const token = randomBytes(32).toString("base64url"); db.prepare("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)").run(sha256(token), row.id, now + SESSION_SECONDS); return { ok: true, token, user: { id: row.id, username: row.username } }; } /** The user a session token belongs to, or null (unknown, or expired: an expired session is removed). */ export function userForSession(db: Db, token: string | undefined, now: Seconds = nowSeconds()): User | null { if (!token || token.length > 200) return null; const row = db.prepare("SELECT s.expires_at, u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?").get(sha256(token)) as | { expires_at: number; id: number; username: string } | undefined; if (!row) return null; if (row.expires_at <= now) { db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token)); return null; } return { id: row.id, username: row.username }; } export function logout(db: Db, token: string | undefined): void { if (token) db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256(token)); } /** Remove old sessions and old failure records. Safe to run any time. */ export function tidy(db: Db, now: Seconds = nowSeconds()): { sessions: number; failures: number } { const sessions = Number(db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(now).changes); const failures = Number(db.prepare("DELETE FROM login_failures WHERE at <= ?").run(now - LOCKOUT_SECONDS).changes); return { sessions, failures }; } /** Mark a page finished (or not). The path is whatever the caller has already checked to be a real page. */ export function setDone(db: Db, userId: number, path: string, done: boolean, now: Seconds = nowSeconds()): void { if (done) db.prepare("INSERT OR IGNORE INTO progress (user_id, path, done_at) VALUES (?, ?, ?)").run(userId, path, now); else db.prepare("DELETE FROM progress WHERE user_id = ? AND path = ?").run(userId, path); } export function finishedPaths(db: Db, userId: number): string[] { return (db.prepare("SELECT path FROM progress WHERE user_id = ? ORDER BY path").all(userId) as { path: string }[]).map((row) => row.path); } Save as packages/accounts/src/origin.ts: /** * Is this request really from our own pages? A page on ANOTHER site can make a visitor's browser send a request to ours, with the * visitor's cookie. Browsers always say where such a request came from, in the Origin header, so a request whose Origin is not our own * host is refused. (The session cookie is also SameSite=Lax, which stops most of this on its own: this is the second lock.) */ export function isSameOrigin(headers: { get(name: string): string | null }): boolean { const origin = headers.get("origin"); const host = headers.get("host"); if (!origin || !host) return false; // a browser always sends both on a POST; anything else is not a browser page try { return new URL(origin).host.toLowerCase() === host.toLowerCase(); } catch { return false; } } /** The request must declare JSON. A form posted from another site cannot (without the browser asking us first), which is one more lock. */ export function isJson(headers: { get(name: string): string | null }): boolean { return (headers.get("content-type") ?? "").toLowerCase().startsWith("application/json"); } The rules, and why: - There is NO SIGN-UP. The owner makes accounts (accounts-admin.mjs, below), so there is no registration form to attack or to fill with junk. - A password is never stored: only a scrypt hash with its own random salt, written with its settings. One check takes about 29 ms on this machine (the point of scrypt is that it is slow: it makes guessing slow too). - A login token is 32 random bytes. The database holds only a HASH of it, so someone who copies the database cannot log in as anyone. - "Wrong password" and "no such user" give the SAME answer in the SAME words, and take about the same time (a name that does not exist is checked against a decoy hash). Otherwise either the words or the delay would tell an attacker which names are real. - 5 wrong passwords for one NAME lock that name for 15 minutes, even for the right password. Names that do not exist are counted too. Cost: anyone who knows your user name can lock you out for 15 minutes (the Django project has the same trade-off). - Sessions last 30 days and are checked against the server every time, so logging out really ends them. - Progress is stored by page PATH, not by an internal id, so rebuilding the site never loses it (renaming a page file would). - Deleting a user deletes their sessions and progress (the database does it: foreign keys with ON DELETE CASCADE). Save as packages/accounts/src/accounts.test.ts: import assert from "node:assert/strict"; import { test } from "node:test"; import { AccountError, LOCKOUT_SECONDS, MAX_FAILURES, SESSION_SECONDS, createUser, finishedPaths, hashPassword, isJson, isLocked, isSameOrigin, login, logout, openDatabase, setDone, tidy, userForSession, verifyPassword, } from "./index.ts"; const PASSWORD = "correct-horse-battery-staple-9"; // a throwaway used only inside these tests const T0 = 1_800_000_000; async function fresh() { const db = openDatabase(":memory:"); const user = await createUser(db, "philip", PASSWORD, T0); return { db, user }; } test("a password hash is salted, carries its settings, and checks both ways", async () => { const a = await hashPassword(PASSWORD); const b = await hashPassword(PASSWORD); assert.match(a, /^scrypt\$16384\$8\$1\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/); assert.notEqual(a, b); // a different salt each time assert.ok(!a.includes(PASSWORD)); assert.equal(await verifyPassword(a, PASSWORD), true); assert.equal(await verifyPassword(a, PASSWORD + "x"), false); assert.equal(await verifyPassword(a, ""), false); for (const garbage of ["", "plain", "scrypt$1$2", "md5$a$b$c$d$e"]) assert.equal(await verifyPassword(garbage, PASSWORD), false, garbage); }); test("making a user: the rules, and no two with the same name in any capitals", async () => { const { db } = await fresh(); await assert.rejects(createUser(db, "ab", PASSWORD), AccountError); await assert.rejects(createUser(db, "has space", PASSWORD), AccountError); await assert.rejects(createUser(db, "someone", "short"), AccountError); await assert.rejects(createUser(db, "PHILIP", PASSWORD), /UNIQUE/); const stored = db.prepare("SELECT password_hash FROM users").get() as { password_hash: string }; assert.ok(!stored.password_hash.includes(PASSWORD)); }); test("the right password starts a session, and the token itself is never stored", async () => { const { db, user } = await fresh(); const result = await login(db, "philip", PASSWORD, T0 + 10); assert.ok(result.ok); if (!result.ok) return; assert.deepEqual(result.user, user); assert.ok(result.token.length >= 40); const rows = JSON.stringify(db.prepare("SELECT * FROM sessions").all()); assert.ok(!rows.includes(result.token)); assert.deepEqual(userForSession(db, result.token, T0 + 20), user); assert.equal((await login(db, "PhIlIp", PASSWORD, T0 + 11)).ok, true); // the name is not case-sensitive }); test("a wrong password, and a name that does not exist, get the same answer and both take real time", async () => { const { db } = await fresh(); const timed = async (name: string, password: string) => { const start = performance.now(); const result = await login(db, name, password, T0); return { result, ms: performance.now() - start }; }; const wrong = await timed("philip", "not-the-password"); const unknown = await timed("nobody-here", "not-the-password"); assert.deepEqual(wrong.result, { ok: false, reason: "invalid" }); assert.deepEqual(unknown.result, { ok: false, reason: "invalid" }); assert.ok(unknown.ms > wrong.ms / 4, `an unknown name answered much faster (${unknown.ms.toFixed(1)} ms against ${wrong.ms.toFixed(1)} ms)`); }); test("after five wrong passwords a name is locked for fifteen minutes, even for the right password", async () => { const { db } = await fresh(); for (let i = 0; i < MAX_FAILURES; i++) assert.equal((await login(db, "philip", "wrong-" + i, T0 + i)).ok, false); assert.equal(isLocked(db, "philip", T0 + 10), true); assert.deepEqual(await login(db, "philip", PASSWORD, T0 + 10), { ok: false, reason: "locked" }); assert.equal(isLocked(db, "PHILIP", T0 + 10), true); // capitals do not get round it assert.equal(isLocked(db, "someone-else", T0 + 10), false); assert.equal((await login(db, "philip", PASSWORD, T0 + LOCKOUT_SECONDS + 10)).ok, true); // the lock ends }); test("the policy itself, written out in plain numbers: 5 wrong passwords lock a name for 15 minutes, 30-day sessions", async () => { // These numbers are deliberately NOT read from the constants: a test that follows the constant cannot notice the constant being changed. assert.equal(MAX_FAILURES, 5); assert.equal(LOCKOUT_SECONDS, 15 * 60); assert.equal(SESSION_SECONDS, 30 * 24 * 60 * 60); const { db } = await fresh(); for (let i = 0; i < 4; i++) await login(db, "philip", "wrong", T0); assert.equal(isLocked(db, "philip", T0 + 1), false); // four wrong ones: not yet assert.equal((await login(db, "philip", "wrong", T0 + 2)).ok, false); // the fifth assert.equal(isLocked(db, "philip", T0 + 3), true); assert.equal(isLocked(db, "philip", T0 + 899), true); assert.equal(isLocked(db, "philip", T0 + 2 + 900), false); // 15 minutes after the last wrong one }); test("a name that does not exist is counted too, so the lock does not reveal which names are real", async () => { const { db } = await fresh(); for (let i = 0; i < MAX_FAILURES; i++) await login(db, "nobody-here", "x", T0); assert.deepEqual(await login(db, "nobody-here", "x", T0 + 1), { ok: false, reason: "locked" }); }); test("a good login clears the wrong ones", async () => { const { db } = await fresh(); for (let i = 0; i < MAX_FAILURES - 1; i++) await login(db, "philip", "wrong", T0); assert.equal((await login(db, "philip", PASSWORD, T0 + 1)).ok, true); for (let i = 0; i < MAX_FAILURES - 1; i++) await login(db, "philip", "wrong", T0 + 2); assert.equal((await login(db, "philip", PASSWORD, T0 + 3)).ok, true); // four more wrong ones do not add to the earlier four }); test("sessions end: by time, by logging out, and a made-up token is nothing", async () => { const { db } = await fresh(); const result = await login(db, "philip", PASSWORD, T0); assert.ok(result.ok); if (!result.ok) return; assert.ok(userForSession(db, result.token, T0 + SESSION_SECONDS - 1)); assert.equal(userForSession(db, result.token, T0 + SESSION_SECONDS), null); assert.equal((db.prepare("SELECT count(*) AS n FROM sessions").get() as { n: number }).n, 0); // the expired row was removed const again = await login(db, "philip", PASSWORD, T0); assert.ok(again.ok); if (!again.ok) return; logout(db, again.token); assert.equal(userForSession(db, again.token, T0 + 1), null); for (const bad of [undefined, "", "x", "A".repeat(300), result.token + "x", result.token.slice(1)]) assert.equal(userForSession(db, bad, T0), null); }); test("progress: marking is repeatable, undoable and private to each user", async () => { const { db, user } = await fresh(); const other = await createUser(db, "guest-user", PASSWORD, T0); setDone(db, user.id, "hungary/x/a.html", true); setDone(db, user.id, "hungary/x/a.html", true); // twice is still once setDone(db, user.id, "hungary/x/b.html", true); setDone(db, other.id, "hungary/x/c.html", true); assert.deepEqual(finishedPaths(db, user.id), ["hungary/x/a.html", "hungary/x/b.html"]); assert.deepEqual(finishedPaths(db, other.id), ["hungary/x/c.html"]); setDone(db, user.id, "hungary/x/a.html", false); setDone(db, other.id, "hungary/x/b.html", false); // undoing a page the user never finished changes nothing for anyone else assert.deepEqual(finishedPaths(db, user.id), ["hungary/x/b.html"]); }); test("deleting a user deletes their sessions and progress", async () => { const { db, user } = await fresh(); await login(db, "philip", PASSWORD, T0); setDone(db, user.id, "hungary/x/a.html", true); db.prepare("DELETE FROM users WHERE id = ?").run(user.id); assert.equal((db.prepare("SELECT count(*) AS n FROM sessions").get() as { n: number }).n, 0); assert.equal((db.prepare("SELECT count(*) AS n FROM progress").get() as { n: number }).n, 0); }); test("tidying removes old sessions and old failures only", async () => { const { db } = await fresh(); await login(db, "philip", PASSWORD, T0); await login(db, "philip", "wrong", T0); assert.deepEqual(tidy(db, T0 + 5), { sessions: 0, failures: 0 }); assert.deepEqual(tidy(db, T0 + SESSION_SECONDS + 1), { sessions: 1, failures: 1 }); }); test("a POST must come from our own pages and say it is JSON", () => { const headers = (h: Record) => ({ get: (name: string) => h[name.toLowerCase()] ?? null }); assert.equal(isSameOrigin(headers({ origin: "http://languages.localhost:3001", host: "languages.localhost:3001" })), true); assert.equal(isSameOrigin(headers({ origin: "HTTP://Languages.LOCALHOST:3001", host: "languages.localhost:3001" })), true); for (const bad of [ { origin: "http://evil.example", host: "languages.localhost:3001" }, { origin: "http://languages.localhost:3001.evil.example", host: "languages.localhost:3001" }, { origin: "http://systems.localhost:3004", host: "languages.localhost:3001" }, // another of our own sites is another origin { origin: "null", host: "languages.localhost:3001" }, { host: "languages.localhost:3001" }, { origin: "http://languages.localhost:3001" }, { origin: "not a url", host: "x" }, ]) assert.equal(isSameOrigin(headers(bad)), false, JSON.stringify(bad)); assert.equal(isJson(headers({ "content-type": "application/json" })), true); assert.equal(isJson(headers({ "content-type": "Application/JSON; charset=utf-8" })), true); for (const type of ["text/plain", "application/x-www-form-urlencoded", "multipart/form-data; boundary=x", ""]) assert.equal(isJson(headers({ "content-type": type })), false, type); }); npm test ℹ tests 90 ℹ pass 90 ℹ fail 0 (76 earlier, 13 here and 1 for the login redirect in Exercise 3) A WEAKNESS IN MY FIRST TESTS, found by changing the code on purpose: I changed MAX_FAILURES from 5 to 50 and ALL the tests still passed, because they imported the constant and so followed it. A test that follows a constant cannot notice the constant being changed. I added a test that writes the policy out in plain numbers (4 wrong passwords: not locked; the 5th: locked; 899 s later still locked; 900 s later free). With that test, the same change fails the suite. A second planted change (sessions never expire) was caught by the existing test. The owner's tool (the password comes from the environment, not the command line, so it is not left in the shell history): Save as accounts-admin.mjs: // The owner's tool for accounts: there is no sign-up on the site, so accounts are made here. // LW_DATA_DIR= LW_NEW_PASSWORD= node accounts-admin.mjs create // LW_DATA_DIR= node accounts-admin.mjs list (names and counts, never passwords) // LW_DATA_DIR= node accounts-admin.mjs tidy (remove expired sessions and old wrong-password records) // LW_DATA_DIR= node accounts-admin.mjs delete // The password comes from the environment, not from the command line, so it is not left in the shell history or the process list. import { AccountError, createUser, openDatabase, tidy } from "./packages/accounts/src/index.ts"; const [command, username] = process.argv.slice(2); const folder = process.env.LW_DATA_DIR; if (!folder) { console.error("Set LW_DATA_DIR to the folder that holds accounts.sqlite."); process.exit(2); } const db = openDatabase(`${folder}/accounts.sqlite`); if (command === "create" && username) { const password = process.env.LW_NEW_PASSWORD; if (!password) { console.error("Set LW_NEW_PASSWORD (at least 12 characters)."); process.exit(2); } try { const user = await createUser(db, username, password); console.log(`created user ${user.username}`); } catch (error) { console.error(error instanceof AccountError ? error.message : String(error).includes("UNIQUE") ? "That user name is taken." : error); process.exit(1); } } else if (command === "list") { const rows = db.prepare(`SELECT u.username, datetime(u.created_at, 'unixepoch') AS created, (SELECT count(*) FROM progress p WHERE p.user_id = u.id) AS finished, (SELECT count(*) FROM sessions s WHERE s.user_id = u.id) AS sessions FROM users u ORDER BY u.username`).all(); console.table(rows); } else if (command === "tidy") { console.log(tidy(db)); } else if (command === "delete" && username) { const result = db.prepare("DELETE FROM users WHERE username = ?").run(username); console.log(Number(result.changes) ? `deleted ${username} and their sessions and progress` : "no such user"); } else { console.error("usage: accounts-admin.mjs create | list | tidy | delete "); process.exit(2); } no password given -> "Set LW_NEW_PASSWORD (at least 12 characters)." a short one -> "A password needs at least 12 characters." create owner -> created user owner create OWNER (any capitals) -> "That user name is taken." list -> a table of names, creation time, pages finished, open sessions: never passwords or hashes tidy / delete owner -> { sessions: 0, failures: 0 } / deleted owner and their sessions and progress WHY THIS WORKS AS AN ANSWER --------------------------- Each security decision is a few lines in a small tested package, and the test that was too forgiving was found by changing the code and watching the tests fail to notice.