diff --git a/src/web/api/session.test.ts b/src/web/api/session.test.ts new file mode 100644 index 0000000..6545e73 --- /dev/null +++ b/src/web/api/session.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import pino from "pino"; +import { createDatabase, type BotDatabase } from "../../data/database.js"; +import { createUserStore, type UserStore } from "../../data/users.js"; +import { createSessionStore, type SessionStore } from "../../data/sessions.js"; +import { createSessionRouter } from "./session.js"; +import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +function makeApp(users: UserStore, sessions: SessionStore) { + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use("/api/session", createSessionRouter(users, sessions, pino({ level: "silent" }))); + return app; +} + +function extractCookie(res: request.Response): string { + const header = res.headers["set-cookie"]; + const arr = Array.isArray(header) ? header : header ? [header] : []; + const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`)); + if (!found) throw new Error("no session cookie set"); + return found.split(";")[0]; // "tsmb_session=xxxx" +} + +describe("session router", () => { + let botDb: BotDatabase; + let users: UserStore; + let sessions: SessionStore; + let app: express.Express; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + sessions = createSessionStore(botDb.db); + app = makeApp(users, sessions); + }); + + afterEach(() => botDb.close()); + + it("GET /needs-setup returns true on an empty db", async () => { + const res = await request(app).get("/api/session/needs-setup"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ needsSetup: true }); + }); + + it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => { + const setupRes = await request(app) + .post("/api/session/setup") + .send({ username: "alice", password: "hunter2-hunter2" }); + expect(setupRes.status).toBe(200); + expect(setupRes.body.username).toBe("alice"); + extractCookie(setupRes); + + const needs = await request(app).get("/api/session/needs-setup"); + expect(needs.body).toEqual({ needsSetup: false }); + }); + + it("POST /setup returns 409 once a user already exists", async () => { + await users.createUser("admin", "pw"); + const res = await request(app) + .post("/api/session/setup") + .send({ username: "alice", password: "pw" }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: "already initialized" }); + }); + + it("POST /login returns 401 with constant-time delay on bad credentials", async () => { + await users.createUser("alice", "correct"); + const start = Date.now(); + const res = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "wrong" }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "invalid credentials" }); + expect(Date.now() - start).toBeGreaterThanOrEqual(200); + }, 10_000); + + it("POST /login sets a session cookie on success", async () => { + await users.createUser("alice", "pw"); + const res = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + expect(res.status).toBe(200); + expect(res.body.username).toBe("alice"); + extractCookie(res); + }); + + it("GET /me returns the current user when cookie is present, 401 otherwise", async () => { + await users.createUser("alice", "pw"); + const loginRes = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + const cookie = extractCookie(loginRes); + + const me = await request(app).get("/api/session/me").set("Cookie", cookie); + expect(me.status).toBe(200); + expect(me.body.username).toBe("alice"); + + const anon = await request(app).get("/api/session/me"); + expect(anon.status).toBe(401); + }); + + it("POST /logout deletes the session and clears the cookie", async () => { + await users.createUser("alice", "pw"); + const loginRes = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + const cookie = extractCookie(loginRes); + + const logout = await request(app).post("/api/session/logout").set("Cookie", cookie); + expect(logout.status).toBe(204); + + const me = await request(app).get("/api/session/me").set("Cookie", cookie); + expect(me.status).toBe(401); + }); + + it("POST /change-password requires old password and invalidates other sessions", async () => { + const u = await users.createUser("alice", "old"); + const cookieA = extractCookie( + await request(app).post("/api/session/login").send({ username: "alice", password: "old" }) + ); + const cookieB = extractCookie( + await request(app).post("/api/session/login").send({ username: "alice", password: "old" }) + ); + + const wrongOld = await request(app) + .post("/api/session/change-password") + .set("Cookie", cookieA) + .send({ oldPassword: "WRONG", newPassword: "new" }); + expect(wrongOld.status).toBe(401); + + const ok = await request(app) + .post("/api/session/change-password") + .set("Cookie", cookieA) + .send({ oldPassword: "old", newPassword: "newpassword" }); + expect(ok.status).toBe(204); + + const meA = await request(app).get("/api/session/me").set("Cookie", cookieA); + expect(meA.status).toBe(200); + + const meB = await request(app).get("/api/session/me").set("Cookie", cookieB); + expect(meB.status).toBe(401); + + expect(u.id).toBe(meA.body.id); + }); +}); diff --git a/src/web/api/session.ts b/src/web/api/session.ts new file mode 100644 index 0000000..9f302ce --- /dev/null +++ b/src/web/api/session.ts @@ -0,0 +1,150 @@ +import { Router } from "express"; +import type { Request, Response, NextFunction } from "express"; +import type { Logger } from "../../logger.js"; +import type { UserStore } from "../../data/users.js"; +import { UsernameTakenError } from "../../data/users.js"; +import type { SessionStore } from "../../data/sessions.js"; +import { SESSION_TTL_MS } from "../../data/sessions.js"; +import { SESSION_COOKIE_NAME, validateSessionFromHeaders } from "../auth/validateSession.js"; + +const FAILED_LOGIN_DELAY_MS = 250; + +function setSessionCookie(res: Response, token: string): void { + res.cookie(SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: res.req.secure, + path: "/", + maxAge: SESSION_TTL_MS, + }); +} + +function clearSessionCookie(res: Response): void { + res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isValidUsername(v: unknown): v is string { + return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v); +} + +function isValidPassword(v: unknown): v is string { + return typeof v === "string" && v.length >= 8 && v.length <= 200; +} + +function parseTokenFromCookie(cookieHeader: string | undefined): string | null { + if (!cookieHeader) return null; + const match = cookieHeader + .split(";") + .map((p) => p.trim()) + .find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`)); + if (!match) return null; + return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1)); +} + +export function createSessionRouter( + users: UserStore, + sessions: SessionStore, + logger: Logger +): Router { + const router = Router(); + + const requireAuthInline = (req: Request, res: Response, next: NextFunction) => { + const result = validateSessionFromHeaders(req.headers.cookie, sessions); + if (!result) { + clearSessionCookie(res); + res.status(401).json({ error: "unauthenticated" }); + return; + } + req.user = { id: result.userId, username: result.username }; + next(); + }; + + router.get("/needs-setup", (_req, res) => { + res.json({ needsSetup: users.countUsers() === 0 }); + }); + + router.post("/setup", async (req, res) => { + const { username, password } = req.body ?? {}; + if (users.countUsers() !== 0) { + res.status(409).json({ error: "already initialized" }); + return; + } + if (!isValidUsername(username) || !isValidPassword(password)) { + res.status(400).json({ error: "invalid username or password" }); + return; + } + try { + const user = await users.createUser(username, password); + const { token } = sessions.createSession(user.id); + setSessionCookie(res, token); + logger.info({ userId: user.id, username }, "First admin created"); + res.json({ id: user.id, username: user.username }); + } catch (err) { + if (err instanceof UsernameTakenError) { + res.status(409).json({ error: "already initialized" }); + return; + } + logger.error({ err }, "setup failed"); + res.status(500).json({ error: "internal" }); + } + }); + + router.post("/login", async (req, res) => { + const { username, password } = req.body ?? {}; + if (typeof username !== "string" || typeof password !== "string") { + res.status(400).json({ error: "invalid request" }); + return; + } + const user = users.findByUsername(username); + const ok = user ? await users.verifyPassword(password, user.passwordHash) : false; + if (!user || !ok) { + await delay(FAILED_LOGIN_DELAY_MS); + res.status(401).json({ error: "invalid credentials" }); + return; + } + const { token } = sessions.createSession(user.id); + setSessionCookie(res, token); + res.json({ id: user.id, username: user.username }); + }); + + router.post("/logout", (req, res) => { + const token = parseTokenFromCookie(req.headers.cookie); + if (token) { + sessions.deleteSession(token); + } + clearSessionCookie(res); + res.status(204).end(); + }); + + router.get("/me", requireAuthInline, (req, res) => { + res.json(req.user); + }); + + router.post("/change-password", requireAuthInline, async (req, res) => { + const { oldPassword, newPassword } = req.body ?? {}; + if (typeof oldPassword !== "string") { + res.status(400).json({ error: "invalid request" }); + return; + } + const u = users.findById(req.user!.id); + if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) { + await delay(FAILED_LOGIN_DELAY_MS); + res.status(401).json({ error: "invalid credentials" }); + return; + } + if (!isValidPassword(newPassword)) { + res.status(400).json({ error: "invalid request" }); + return; + } + await users.changePassword(u.id, newPassword); + const currentToken = parseTokenFromCookie(req.headers.cookie); + sessions.deleteAllForUser(u.id, currentToken ?? undefined); + res.status(204).end(); + }); + + return router; +}