feat(auth): setup/login/logout, argon2id, sessions, login rate limit
This commit is contained in:
23
server/src/app.ts
Normal file
23
server/src/app.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
const dataDir = process.env.DATA_DIR ?? "./data";
|
||||||
app.get("/api/health", (c) => c.json({ ok: true }));
|
const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir) });
|
||||||
|
|
||||||
export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch };
|
export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch };
|
||||||
|
|||||||
75
server/src/routes/auth.ts
Normal file
75
server/src/routes/auth.ts
Normal file
@@ -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<string, { count: number; resetAt: number }>();
|
||||||
|
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<boolean> {
|
||||||
|
if (!sid) return false;
|
||||||
|
const row = (await db.select().from(sessions).where(eq(sessions.id, sid))).at(0);
|
||||||
|
return !!row && row.expiresAt > Date.now();
|
||||||
|
}
|
||||||
55
server/test/auth.test.ts
Normal file
55
server/test/auth.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,3 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const HealthResponse = z.object({ ok: z.literal(true) });
|
export const HealthResponse = z.object({ ok: z.literal(true) });
|
||||||
export type HealthResponse = z.infer<typeof HealthResponse>;
|
export type HealthResponse = z.infer<typeof HealthResponse>;
|
||||||
|
|
||||||
|
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<typeof MeResponse>;
|
||||||
|
|||||||
Reference in New Issue
Block a user