From 150cb82f5cd7fe7dd398250b95357db31636fd54 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 7 Mar 2025 12:41:57 +0100 Subject: [PATCH] Basic account and session system Provide a basic account system with login and server side session store identified by a cookie. Upon successful login a signed session cookie is set by the server with the session stored on the server identifying which account it is logged in as. The client uses a shared useFetch on the session endpoint to identify if it's logged in and which account it is logged in as, and refreshes this when loggin in or out. --- app.vue | 1 + components/Header.vue | 64 +++++++++++++++++++++++++++++++ composables/session.ts | 1 + pages/login.vue | 33 ++++++++++++++++ server/api/auth/login.post.ts | 18 +++++++++ server/api/auth/session.delete.ts | 3 ++ server/api/auth/session.get.ts | 14 +++++++ server/database.ts | 29 +++++++++++++- server/generate-demo-schedule.ts | 43 ++++++++++++++++++++- server/utils/session.ts | 57 +++++++++++++++++++++++++++ shared/types/account.d.ts | 17 ++++++++ 11 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 components/Header.vue create mode 100644 composables/session.ts create mode 100644 pages/login.vue create mode 100644 server/api/auth/login.post.ts create mode 100644 server/api/auth/session.delete.ts create mode 100644 server/api/auth/session.get.ts create mode 100644 server/utils/session.ts diff --git a/app.vue b/app.vue index 18e77f8..80429cd 100644 --- a/app.vue +++ b/app.vue @@ -1,4 +1,5 @@ diff --git a/components/Header.vue b/components/Header.vue new file mode 100644 index 0000000..a41a5cc --- /dev/null +++ b/components/Header.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/composables/session.ts b/composables/session.ts new file mode 100644 index 0000000..1e45d39 --- /dev/null +++ b/composables/session.ts @@ -0,0 +1 @@ +export const useAccountSession = () => useFetch("/api/auth/session"); diff --git a/pages/login.vue b/pages/login.vue new file mode 100644 index 0000000..f0a96f4 --- /dev/null +++ b/pages/login.vue @@ -0,0 +1,33 @@ + + + diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts new file mode 100644 index 0000000..6a0a771 --- /dev/null +++ b/server/api/auth/login.post.ts @@ -0,0 +1,18 @@ +import { readAccounts } from "~/server/database"; + +export default defineEventHandler(async (event) => { + const { name } = await readBody(event); + + if (!name) { + return new Response(undefined, { status: 400 }) + } + + const accounts = await readAccounts(); + const account = accounts.find(a => a.name === name); + + if (!account) { + return new Response(undefined, { status: 403 }) + } + + await setAccountSession(event, account.id); +}) diff --git a/server/api/auth/session.delete.ts b/server/api/auth/session.delete.ts new file mode 100644 index 0000000..c3fbf4e --- /dev/null +++ b/server/api/auth/session.delete.ts @@ -0,0 +1,3 @@ +export default defineEventHandler(async (event) => { + await clearAccountSession(event); +}) diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts new file mode 100644 index 0000000..f25b263 --- /dev/null +++ b/server/api/auth/session.get.ts @@ -0,0 +1,14 @@ +import { readAccounts } from "~/server/database"; +import { AccountSession } from "~/shared/types/account"; + +export default defineEventHandler(async (event) => { + const session = await getAccountSession(event); + if (!session) + return; + const accounts = await readAccounts(); + + return { + id: session.id, + account: accounts.find(account => account.id === session.accountId)!, + } satisfies AccountSession; +}) diff --git a/server/database.ts b/server/database.ts index 299b44a..16e65eb 100644 --- a/server/database.ts +++ b/server/database.ts @@ -1,13 +1,16 @@ import { readFile, writeFile } from "node:fs/promises"; import { Schedule } from "~/shared/types/schedule"; -import { generateDemoSchedule } from "./generate-demo-schedule"; -import { Subscription } from "~/shared/types/account"; +import { Account, Subscription, Session } from "~/shared/types/account"; +import { generateDemoSchedule, generateDemoAccounts } from "./generate-demo-schedule"; // For this demo I'm just storing the runtime data in JSON files. When making // this into proper application this should be replaced with an actual database. const schedulePath = "data/schedule.json"; const subscriptionsPath = "data/subscriptions.json"; +const accountsPath = "data/accounts.json"; +const sessionsPath = "data/sessions.json"; +const nextSessionIdPath = "data/next-session-id.json"; async function readJson(filePath: string, fallback: T) { let data: T extends () => infer R ? R : T; @@ -41,3 +44,25 @@ export async function readSubscriptions() { export async function writeSubscriptions(subscriptions: Subscription[]) { await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8"); } + +export async function readAccounts() { + return await readJson(accountsPath, generateDemoAccounts); +} + +export async function writeAccounts(accounts: Account[]) { + await writeFile(accountsPath, JSON.stringify(accounts, undefined, "\t") + "\n", "utf-8"); +} + +export async function nextSessionId() { + const nextId = await readJson(nextSessionIdPath, 0); + await writeFile(nextSessionIdPath, String(nextId + 1), "utf-8"); + return nextId; +} + +export async function readSessions() { + return await readJson(sessionsPath, []); +} + +export async function writeSessions(sessions: Session[]) { + await writeFile(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8"); +} diff --git a/server/generate-demo-schedule.ts b/server/generate-demo-schedule.ts index 716e126..08bef53 100644 --- a/server/generate-demo-schedule.ts +++ b/server/generate-demo-schedule.ts @@ -1,3 +1,4 @@ +import { Account } from "~/shared/types/account"; import { Schedule, TimeSlot } from "~/shared/types/schedule"; const locations = [ @@ -112,7 +113,7 @@ function toSlot(origin: Date, id: string, shorthand: string, index: number): Tim start: toIso(startDate), end: toIso(endDate), locations: [location], - } + }; } export function generateDemoSchedule(): Schedule { @@ -136,5 +137,43 @@ export function generateDemoSchedule(): Schedule { locations: locations.map( ({ name, description }) => ({ id: toId(name), name, description }) ), - } + }; +} + +const names = [ + "Leo", "Lisa", + "Jack", "Emily", + "Roy", "Sofia", + "Adam", "Eve", + "Max", "Rose", + "Hugo", "Maria", + "David", "Zoe", + "Hunter", "Ria", + "Sonny", "Amy", + "Kai", "Megan", + "Toby", "Katie", + "Bob", "Lucy", +]; + +// MINSTD random implementation for reproducible random numbers. +let seed = 1; +function random() { + const a = 48271; + const c = 0; + const m = 2 ** 31 -1; + return (seed = (a * seed + c) % m | 0) / 2 ** 31; +} + +export function generateDemoAccounts(): Account[] { + seed = 1; + const accounts: Account[] = []; + + for (const name of names) { + accounts.push({ + id: accounts.length, + name, + type: (["regular", "crew", "admin"] as const)[Math.floor(random() ** 5 * 3)], + }); + } + return accounts; } diff --git a/server/utils/session.ts b/server/utils/session.ts new file mode 100644 index 0000000..4278ce0 --- /dev/null +++ b/server/utils/session.ts @@ -0,0 +1,57 @@ +import type { H3Event } from "h3"; +import { nextSessionId, readSessions, writeSessions } from "~/server/database"; +import { Session } from "~/shared/types/account"; + +async function clearAccountSessionInternal(event: H3Event, sessions: Session[]) { + const existingSessionCookie = await getSignedCookie(event, "session"); + if (existingSessionCookie) { + const sessionId = parseInt(existingSessionCookie, 10); + const sessionIndex = sessions.findIndex(session => session.id === sessionId); + if (sessionIndex !== -1) { + sessions.splice(sessionIndex, 1); + return true; + } + } + return false; +} + +export async function clearAccountSession(event: H3Event) { + const sessions = await readSessions(); + if (await clearAccountSessionInternal(event, sessions)) { + await writeSessions(sessions); + } + setCookie(event, "session", "") +} + +export async function setAccountSession(event: H3Event, accountId: number) { + const sessions = await readSessions(); + await clearAccountSessionInternal(event, sessions); + + const newSession: Session = { + accountId, + id: await nextSessionId(), + }; + + sessions.push(newSession); + await writeSessions(sessions); + await setSignedCookie(event, "session", String(newSession.id)) +} + +export async function getAccountSession(event: H3Event) { + const sessionCookie = await getSignedCookie(event, "session"); + if (sessionCookie) { + const sessionId = parseInt(sessionCookie, 10); + const sessions = await readSessions(); + return sessions.find(session => session.id === sessionId); + } +} + +export async function requireAccountSession(event: H3Event) { + const session = await getAccountSession(event); + if (!session) + throw createError({ + status: 401, + message: "Account session required", + }); + return session; +} diff --git a/shared/types/account.d.ts b/shared/types/account.d.ts index b441e25..c2da1ee 100644 --- a/shared/types/account.d.ts +++ b/shared/types/account.d.ts @@ -1,4 +1,21 @@ +export interface Account { + id: number, + type: "anonymous" | "regular" | "crew" | "admin", + /** Name of the account. Not present on anonymous accounts */ + name?: string, +} + export interface Subscription { type: "push", push: PushSubscriptionJSON, } + +export interface Session { + id: number, + accountId: number, +} + +export interface AccountSession { + id: number, + account: Account, +}