diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 6943709..8b6f4cc 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,10 +1,25 @@ -import { withAuth, withCors } from "./auth/middleware"; +import { withAuth, withCors, withCSRF } from "./auth/middleware"; import { testDB } from "./db/client"; +import { cleanupExpiredSessions } from "./db/queries"; import { routes } from "./routes"; const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null; const PORT = process.argv.find((arg) => arg.toLowerCase().startsWith("--port="))?.split("=")[1] || 0; +const SESSION_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour in ms + +const startSessionCleanup = () => { + const cleanup = async () => { + const count = await cleanupExpiredSessions(); + if (count > 0) { + console.log(`cleaned up ${count} expired sessions`); + } + }; + + cleanup(); + setInterval(cleanup, SESSION_CLEANUP_INTERVAL); +}; + const main = async () => { const server = Bun.serve({ port: Number(PORT), @@ -12,35 +27,39 @@ const main = async () => { "/": withCors(() => new Response(`title: eussi\ndev-mode: ${DEV}\nport: ${PORT}`)), "/health": withCors(() => new Response("OK")), + // routes that modify state require withCSRF middleware "/auth/register": withCors(routes.authRegister), "/auth/login": withCors(routes.authLogin), + "/auth/logout": withCors(withAuth(withCSRF(routes.authLogout))), "/auth/me": withCors(withAuth(routes.authMe)), "/user/by-username": withCors(withAuth(routes.userByUsername)), - "/user/update": withCors(withAuth(routes.userUpdate)), - "/user/upload-avatar": withCors(routes.userUploadAvatar), + "/user/update": withCors(withAuth(withCSRF(routes.userUpdate))), + "/user/upload-avatar": withCors(withAuth(withCSRF(routes.userUploadAvatar))), - "/issue/create": withCors(withAuth(routes.issueCreate)), - "/issue/update": withCors(withAuth(routes.issueUpdate)), - "/issue/delete": withCors(withAuth(routes.issueDelete)), + "/issue/create": withCors(withAuth(withCSRF(routes.issueCreate))), + "/issue/update": withCors(withAuth(withCSRF(routes.issueUpdate))), + "/issue/delete": withCors(withAuth(withCSRF(routes.issueDelete))), "/issues/by-project": withCors(withAuth(routes.issuesByProject)), "/issues/all": withCors(withAuth(routes.issues)), - "/organisation/create": withCors(withAuth(routes.organisationCreate)), + "/organisation/create": withCors(withAuth(withCSRF(routes.organisationCreate))), "/organisation/by-id": withCors(withAuth(routes.organisationById)), - "/organisation/update": withCors(withAuth(routes.organisationUpdate)), - "/organisation/delete": withCors(withAuth(routes.organisationDelete)), - "/organisation/add-member": withCors(withAuth(routes.organisationAddMember)), + "/organisation/update": withCors(withAuth(withCSRF(routes.organisationUpdate))), + "/organisation/delete": withCors(withAuth(withCSRF(routes.organisationDelete))), + "/organisation/add-member": withCors(withAuth(withCSRF(routes.organisationAddMember))), "/organisation/members": withCors(withAuth(routes.organisationMembers)), - "/organisation/remove-member": withCors(withAuth(routes.organisationRemoveMember)), - "/organisation/update-member-role": withCors(withAuth(routes.organisationUpdateMemberRole)), + "/organisation/remove-member": withCors(withAuth(withCSRF(routes.organisationRemoveMember))), + "/organisation/update-member-role": withCors( + withAuth(withCSRF(routes.organisationUpdateMemberRole)), + ), "/organisations/by-user": withCors(withAuth(routes.organisationsByUser)), - "/project/create": withCors(withAuth(routes.projectCreate)), - "/project/update": withCors(withAuth(routes.projectUpdate)), - "/project/delete": withCors(withAuth(routes.projectDelete)), + "/project/create": withCors(withAuth(withCSRF(routes.projectCreate))), + "/project/update": withCors(withAuth(withCSRF(routes.projectUpdate))), + "/project/delete": withCors(withAuth(withCSRF(routes.projectDelete))), "/project/with-creator": withCors(withAuth(routes.projectWithCreator)), "/projects/by-creator": withCors(withAuth(routes.projectsByCreator)), @@ -52,6 +71,7 @@ const main = async () => { console.log(`eussi (issue server) listening on ${server.url}`); await testDB(); + startSessionCleanup(); }; main(); diff --git a/packages/backend/src/routes/auth/login.ts b/packages/backend/src/routes/auth/login.ts index 522f48c..cd2971c 100644 --- a/packages/backend/src/routes/auth/login.ts +++ b/packages/backend/src/routes/auth/login.ts @@ -1,6 +1,6 @@ import type { BunRequest } from "bun"; -import { generateToken, verifyPassword } from "../../auth/utils"; -import { getUserByUsername } from "../../db/queries"; +import { buildAuthCookie, generateToken, verifyPassword } from "../../auth/utils"; +import { createSession, getUserByUsername } from "../../db/queries"; const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; @@ -37,10 +37,24 @@ export default async function login(req: BunRequest) { return new Response("invalid credentials", { status: 401 }); } - const token = generateToken(user.id); + const session = await createSession(user.id); + if (!session) { + return new Response("failed to create session", { status: 500 }); + } - return Response.json({ - user: { id: user.id, name: user.name, username: user.username }, - token, - }); + const token = generateToken(session.id, user.id); + + return new Response( + JSON.stringify({ + user: { id: user.id, name: user.name, username: user.username, avatarURL: user.avatarURL }, + csrfToken: session.csrfToken, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + "Set-Cookie": buildAuthCookie(token), + }, + }, + ); } diff --git a/packages/backend/src/routes/auth/logout.ts b/packages/backend/src/routes/auth/logout.ts new file mode 100644 index 0000000..10a3c95 --- /dev/null +++ b/packages/backend/src/routes/auth/logout.ts @@ -0,0 +1,19 @@ +import type { AuthedRequest } from "../../auth/middleware"; +import { buildClearAuthCookie } from "../../auth/utils"; +import { deleteSession } from "../../db/queries"; + +export default async function logout(req: AuthedRequest) { + if (req.method !== "POST") { + return new Response("method not allowed", { status: 405 }); + } + + await deleteSession(req.sessionId); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { + "Content-Type": "application/json", + "Set-Cookie": buildClearAuthCookie(), + }, + }); +} diff --git a/packages/backend/src/routes/auth/me.ts b/packages/backend/src/routes/auth/me.ts index fb90995..8e980cf 100644 --- a/packages/backend/src/routes/auth/me.ts +++ b/packages/backend/src/routes/auth/me.ts @@ -8,5 +8,10 @@ export default async function me(req: AuthedRequest) { return new Response("user not found", { status: 404 }); } - return Response.json(user as UserRecord); + const { passwordHash: _, ...safeUser } = user; + + return Response.json({ + user: safeUser as Omit, + csrfToken: req.csrfToken, + }); } diff --git a/packages/backend/src/routes/auth/register.ts b/packages/backend/src/routes/auth/register.ts index a36c8e1..b8c4f2a 100644 --- a/packages/backend/src/routes/auth/register.ts +++ b/packages/backend/src/routes/auth/register.ts @@ -1,6 +1,6 @@ import type { BunRequest } from "bun"; -import { generateToken, hashPassword } from "../../auth/utils"; -import { createUser, getUserByUsername } from "../../db/queries"; +import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils"; +import { createSession, createUser, getUserByUsername } from "../../db/queries"; const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; @@ -54,10 +54,24 @@ export default async function register(req: BunRequest) { return new Response("failed to create user", { status: 500 }); } - const token = generateToken(user.id); + const session = await createSession(user.id); + if (!session) { + return new Response("failed to create session", { status: 500 }); + } - return Response.json({ - token, - user, - }); + const token = generateToken(session.id, user.id); + + return new Response( + JSON.stringify({ + user: { id: user.id, name: user.name, username: user.username, avatarURL: user.avatarURL }, + csrfToken: session.csrfToken, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + "Set-Cookie": buildAuthCookie(token), + }, + }, + ); } diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 8336166..d8d3411 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -1,4 +1,5 @@ import authLogin from "./auth/login"; +import authLogout from "./auth/logout"; import authMe from "./auth/me"; import authRegister from "./auth/register"; import issueCreate from "./issue/create"; @@ -30,6 +31,7 @@ import userUploadAvatar from "./user/upload-avatar"; export const routes = { authRegister, authLogin, + authLogout, authMe, userByUsername,