From 0b8e897e3130d7376b66cf613ed0b9d8cecd9775 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 14:11:00 -0700 Subject: [PATCH] feat(auth): setup/login/logout, argon2id, sessions, login rate limit --- server/src/app.ts | 23 ++++++++++++ server/src/index.ts | 8 +++-- server/src/routes/auth.ts | 75 +++++++++++++++++++++++++++++++++++++++ server/test/auth.test.ts | 55 ++++++++++++++++++++++++++++ shared/src/types.ts | 4 +++ 5 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 server/src/app.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/test/auth.test.ts diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..7e7c1ce --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,23 @@ +import { Hono } from "hono"; +import { getCookie } from "hono/cookie"; +import type { Db } from "./db"; +import { authRoutes, isAuthenticated } from "./routes/auth"; + +export type Deps = { db: Db; key: Buffer }; +const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]); + +export function createApp(deps: Deps) { + const app = new Hono(); + app.get("/api/health", (c) => c.json({ ok: true })); + app.use("/api/*", async (c, next) => { + if (PUBLIC.has(c.req.path)) return next(); + if (!(await isAuthenticated(deps.db, getCookie(c, "helios_session")))) { + return c.json({ error: "unauthenticated" }, 401); + } + return next(); + }); + app.route("/api", authRoutes({ db: deps.db })); + // Later route groups (settings, connectors, chat) mount here. + app.get("/api/settings", (c) => c.json({ error: "not implemented" }, 501)); // replaced in Task 5 + return app; +} diff --git a/server/src/index.ts b/server/src/index.ts index a6a1c4e..a589232 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,6 +1,8 @@ -import { Hono } from "hono"; +import { openDb } from "./db"; +import { loadOrCreateKey } from "./lib/crypto"; +import { createApp } from "./app"; -const app = new Hono(); -app.get("/api/health", (c) => c.json({ ok: true })); +const dataDir = process.env.DATA_DIR ?? "./data"; +const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir) }); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch }; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..8d5914b --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,75 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { getCookie, setCookie } from "hono/cookie"; +import { randomBytes } from "node:crypto"; +import { PasswordBody } from "@helios/shared"; +import type { Db } from "../db"; +import { sessions, settings } from "../db/schema"; + +const SESSION_MS = 30 * 24 * 3600 * 1000; +const WINDOW_MS = 15 * 60 * 1000; +const MAX_FAILURES = 10; + +export type AuthDeps = { db: Db }; + +export function authRoutes({ db }: AuthDeps) { + const failures = new Map(); + const app = new Hono(); + + const getHash = async () => + (await db.select().from(settings).where(eq(settings.key, "password_hash"))).at(0)?.value; + + app.get("/me", async (c) => { + const sid = getCookie(c, "helios_session"); + let authenticated = false; + if (sid) { + const row = (await db.select().from(sessions).where(eq(sessions.id, sid))).at(0); + authenticated = !!row && row.expiresAt > Date.now(); + } + return c.json({ needsSetup: !(await getHash()), authenticated }); + }); + + app.post("/setup", async (c) => { + if (await getHash()) return c.json({ error: "already set up" }, 400); + const body = PasswordBody.safeParse(await c.req.json().catch(() => null)); + if (!body.success) return c.json({ error: "password must be 8–200 chars" }, 400); + const hash = await Bun.password.hash(body.data.password, "argon2id"); + await db.insert(settings).values({ key: "password_hash", value: hash }); + return c.body(null, 204); + }); + + app.post("/login", async (c) => { + const ip = c.req.header("x-forwarded-for") ?? "local"; + const f = failures.get(ip); + if (f && f.count >= MAX_FAILURES && Date.now() < f.resetAt) return c.json({ error: "too many attempts" }, 429); + + const body = PasswordBody.safeParse(await c.req.json().catch(() => null)); + const hash = await getHash(); + const ok = body.success && !!hash && (await Bun.password.verify(body.data.password, hash)); + if (!ok) { + const cur = f && Date.now() < f.resetAt ? f : { count: 0, resetAt: Date.now() + WINDOW_MS }; + failures.set(ip, { count: cur.count + 1, resetAt: cur.resetAt }); + return c.json({ error: "invalid password" }, 401); + } + failures.delete(ip); + const id = randomBytes(32).toString("base64url"); + await db.insert(sessions).values({ id, createdAt: Date.now(), expiresAt: Date.now() + SESSION_MS }); + setCookie(c, "helios_session", id, { httpOnly: true, sameSite: "Lax", path: "/", maxAge: SESSION_MS / 1000 }); + return c.body(null, 204); + }); + + app.post("/logout", async (c) => { + const sid = getCookie(c, "helios_session"); + if (sid) await db.delete(sessions).where(eq(sessions.id, sid)); + setCookie(c, "helios_session", "", { httpOnly: true, path: "/", maxAge: 0 }); + return c.body(null, 204); + }); + + return app; +} + +export async function isAuthenticated(db: Db, sid: string | undefined): Promise { + if (!sid) return false; + const row = (await db.select().from(sessions).where(eq(sessions.id, sid))).at(0); + return !!row && row.expiresAt > Date.now(); +} diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts new file mode 100644 index 0000000..6fc5cb2 --- /dev/null +++ b/server/test/auth.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../src/app"; +import { openDb } from "../src/db"; +import { loadOrCreateKey } from "../src/lib/crypto"; + +function makeApp() { + const dir = mkdtempSync(join(tmpdir(), "helios-")); + return createApp({ db: openDb(dir), key: loadOrCreateKey(dir) }); +} +const json = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +describe("auth", () => { + test("fresh instance needs setup; setup then login yields a session", async () => { + const app = makeApp(); + let me = await (await app.request("/api/me")).json(); + expect(me).toEqual({ needsSetup: true, authenticated: false }); + + expect((await app.request("/api/setup", json({ password: "hunter2hunter2" }))).status).toBe(204); + expect((await app.request("/api/setup", json({ password: "again-not-allowed" }))).status).toBe(400); + + const login = await app.request("/api/login", json({ password: "hunter2hunter2" })); + expect(login.status).toBe(204); + const cookie = login.headers.get("set-cookie")!; + expect(cookie).toContain("helios_session="); + expect(cookie).toContain("HttpOnly"); + + me = await (await app.request("/api/me", { headers: { cookie } })).json(); + expect(me.authenticated).toBe(true); + }); + + test("wrong password 401; protected route 401 without cookie", async () => { + const app = makeApp(); + await app.request("/api/setup", json({ password: "hunter2hunter2" })); + expect((await app.request("/api/login", json({ password: "wrong-wrong-1" }))).status).toBe(401); + expect((await app.request("/api/settings")).status).toBe(401); + }); + + test("11th failed login from one IP is rate-limited", async () => { + const app = makeApp(); + await app.request("/api/setup", json({ password: "hunter2hunter2" })); + const hdrs = { "content-type": "application/json", "x-forwarded-for": "10.9.8.7" }; + let last = 0; + for (let i = 0; i < 11; i++) { + last = (await app.request("/api/login", { method: "POST", headers: hdrs, body: JSON.stringify({ password: "wrong-wrong-1" }) })).status; + } + expect(last).toBe(429); + }); +}); diff --git a/shared/src/types.ts b/shared/src/types.ts index f887e6f..c0b2eae 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -2,3 +2,7 @@ import { z } from "zod"; export const HealthResponse = z.object({ ok: z.literal(true) }); export type HealthResponse = z.infer; + +export const PasswordBody = z.object({ password: z.string().min(8).max(200) }); +export const MeResponse = z.object({ needsSetup: z.boolean(), authenticated: z.boolean() }); +export type MeResponse = z.infer;