Scaffold Pwned Passwords clone: React/wouter frontend, Koa/pg backend, import CLI
- Backend: Koa + @koa/router serving the HIBP-style /range/:prefix k-anonymity API, backed by Postgres via pg, with migrate/seed scripts run through tsx. - Frontend: React + wouter, bundled with webpack (dev server + prod build), thin client that hashes passwords locally and only sends the hash prefix. - Import package: CLI to bulk-load a wordlist or hash list into the database, plus a separate script to pull the full dataset from the HIBP range API. Both support parallelism (worker threads for wordlist hashing, concurrency limiter for DB/HTTP fan-out). - Dev tooling: docker compose for a local Postgres, .env.local/.env.example, DATABASE_URL support as an alternative to discrete PG* vars.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
// DATABASE_URL (e.g. postgres://user:pass@host:5432/db) takes precedence over
|
||||
// the discrete PG* vars, for connecting to an externally hosted database.
|
||||
export const pool = new Pool(
|
||||
process.env.DATABASE_URL
|
||||
? { connectionString: process.env.DATABASE_URL }
|
||||
: {
|
||||
host: process.env.PGHOST,
|
||||
port: process.env.PGPORT ? Number(process.env.PGPORT) : undefined,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
database: process.env.PGDATABASE,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import Koa from "koa";
|
||||
import { rangeRouter } from "./routes/range.ts";
|
||||
|
||||
const app = new Koa();
|
||||
|
||||
app.use(async (ctx, next) => {
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
ctx.status = 500;
|
||||
ctx.body = { error: "internal server error" };
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(rangeRouter.routes());
|
||||
app.use(rangeRouter.allowedMethods());
|
||||
|
||||
const port = process.env.BACKEND_PORT ? Number(process.env.BACKEND_PORT) : 3001;
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`backend listening on http://localhost:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pool } from "./db.ts";
|
||||
|
||||
const schemaPath = fileURLToPath(new URL("./schema.sql", import.meta.url));
|
||||
const schema = readFileSync(schemaPath, "utf8");
|
||||
|
||||
await pool.query(schema);
|
||||
console.log("migration applied");
|
||||
await pool.end();
|
||||
@@ -0,0 +1,26 @@
|
||||
import Router from "@koa/router";
|
||||
import { pool } from "../db.ts";
|
||||
|
||||
const HEX_PREFIX = /^[0-9A-Fa-f]{5}$/;
|
||||
|
||||
export const rangeRouter = new Router();
|
||||
|
||||
// Mirrors the HaveIBeenPwned Pwned Passwords range API:
|
||||
// https://haveibeenpwned.com/API/v3#PwnedPasswords
|
||||
rangeRouter.get("/range/:prefix", async (ctx) => {
|
||||
const { prefix } = ctx.params;
|
||||
|
||||
if (!HEX_PREFIX.test(prefix)) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: "prefix must be 5 hex characters" };
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<{ suffix: string; count: string }>(
|
||||
"SELECT suffix, count FROM pwned_passwords WHERE prefix = $1",
|
||||
[prefix.toUpperCase()],
|
||||
);
|
||||
|
||||
ctx.type = "text/plain";
|
||||
ctx.body = rows.map((row) => `${row.suffix}:${row.count}`).join("\r\n");
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS pwned_passwords (
|
||||
prefix CHAR(5) NOT NULL,
|
||||
suffix CHAR(35) NOT NULL,
|
||||
count BIGINT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (prefix, suffix)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS pwned_passwords_prefix_idx ON pwned_passwords (prefix);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { pool } from "./db.ts";
|
||||
|
||||
// A handful of well-known weak passwords, seeded for local testing.
|
||||
// The real HIBP dataset has ~900M entries; this is just enough to exercise the API.
|
||||
const SAMPLE_PASSWORDS: Record<string, number> = {
|
||||
"123456": 30000000,
|
||||
password: 10000000,
|
||||
"123456789": 9000000,
|
||||
qwerty: 4000000,
|
||||
letmein: 500000,
|
||||
admin: 400000,
|
||||
welcome: 300000,
|
||||
"iloveyou": 250000,
|
||||
};
|
||||
|
||||
function sha1(value: string): string {
|
||||
return createHash("sha1").update(value).digest("hex").toUpperCase();
|
||||
}
|
||||
|
||||
for (const [password, count] of Object.entries(SAMPLE_PASSWORDS)) {
|
||||
const hash = sha1(password);
|
||||
const prefix = hash.slice(0, 5);
|
||||
const suffix = hash.slice(5);
|
||||
await pool.query(
|
||||
`INSERT INTO pwned_passwords (prefix, suffix, count)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (prefix, suffix) DO UPDATE SET count = EXCLUDED.count`,
|
||||
[prefix, suffix, count],
|
||||
);
|
||||
console.log(`seeded "${password}" -> ${hash} (${count})`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
Reference in New Issue
Block a user