From a9d1a442f675eb73c9cf1eec458f137320c9d1d4 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 3 Apr 2025 19:15:33 -0400 Subject: [PATCH] refactor: session handler --- nuxt.config.ts | 2 +- prisma/schema/auth.prisma | 9 +- prisma/schema/user.prisma | 3 +- server/api/v1/auth/signin/simple.post.ts | 33 ++-- .../api/v1/client/auth/callback/index.get.ts | 10 +- .../api/v1/client/auth/callback/index.post.ts | 4 +- server/internal/acls/index.ts | 12 +- server/internal/session/db.ts | 72 +++----- server/internal/session/index.ts | 159 +++++++++--------- server/internal/session/memory.ts | 22 ++- server/internal/session/types.d.ts | 15 +- server/plugins/redirect.ts | 4 +- server/tasks/cleanup/sessions.ts | 12 ++ 13 files changed, 189 insertions(+), 168 deletions(-) create mode 100644 server/tasks/cleanup/sessions.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 9b4f574..edb39c8 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -45,7 +45,7 @@ export default defineNuxtConfig({ }, scheduledTasks: { - "0 * * * *": ["cleanup:invitations"], + "0 * * * *": ["cleanup:invitations", "cleanup:sessions"], }, compressPublicAssets: true, diff --git a/prisma/schema/auth.prisma b/prisma/schema/auth.prisma index 1f38aa7..e8505f9 100644 --- a/prisma/schema/auth.prisma +++ b/prisma/schema/auth.prisma @@ -53,6 +53,11 @@ model Certificate { } model Session { - token String @id - data Json + token String @id + expiresAt DateTime + + userId String + user User? @relation(fields: [userId], references: [id]) + + data Json // misc extra data } diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index b450163..2b3905f 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -14,7 +14,8 @@ model User { collections Collection[] articles Article[] - tokens APIToken[] + tokens APIToken[] + sessions Session[] } model Notification { diff --git a/server/api/v1/auth/signin/simple.post.ts b/server/api/v1/auth/signin/simple.post.ts index dcc2238..c3358f4 100644 --- a/server/api/v1/auth/signin/simple.post.ts +++ b/server/api/v1/auth/signin/simple.post.ts @@ -8,24 +8,30 @@ import { } from "~/server/internal/security/simple"; import sessionHandler from "~/server/internal/session"; -export default defineEventHandler(async (h3) => { - const body = await readBody(h3); +const signinValidator = type({ + username: "string", + password: "string", + "rememberMe?": "boolean | undefined", +}); + +export default defineEventHandler(async (h3) => { + const body = signinValidator(await readBody(h3)); + if (body instanceof type.errors) { + // hover out.summary to see validation errors + console.error(body.summary); - const username = body.username; - const password = body.password; - const rememberMe = body.rememberMe ?? false; - if (username === undefined || password === undefined) throw createError({ - statusCode: 403, - statusMessage: "Username or password missing from request.", + statusCode: 400, + statusMessage: body.summary, }); + } const authMek = await prisma.linkedAuthMec.findFirst({ where: { mec: AuthMec.Simple, enabled: true, user: { - username, + username: body.username, }, }, include: { @@ -62,14 +68,14 @@ export default defineEventHandler(async (h3) => { "Invalid password state. Please contact the server administrator.", }); - if (!(await checkHashBcrypt(password, hash))) + if (!(await checkHashBcrypt(body.password, hash))) throw createError({ statusCode: 401, statusMessage: "Invalid username or password.", }); // TODO: send user to forgot password screen or something to force them to change their password to new system - await sessionHandler.setUserId(h3, authMek.userId, rememberMe); + await sessionHandler.signin(h3, authMek.userId, body.rememberMe); return { result: true, userId: authMek.userId }; } @@ -82,13 +88,12 @@ export default defineEventHandler(async (h3) => { "Invalid password state. Please contact the server administrator.", }); - if (!(await checkHashArgon2(password, hash))) + if (!(await checkHashArgon2(body.password, hash))) throw createError({ statusCode: 401, statusMessage: "Invalid username or password.", }); - await sessionHandler.setUserId(h3, authMek.userId, rememberMe); - + await sessionHandler.signin(h3, authMek.userId, body.rememberMe); return { result: true, userId: authMek.userId }; }); diff --git a/server/api/v1/client/auth/callback/index.get.ts b/server/api/v1/client/auth/callback/index.get.ts index ea8e4b3..d954534 100644 --- a/server/api/v1/client/auth/callback/index.get.ts +++ b/server/api/v1/client/auth/callback/index.get.ts @@ -2,8 +2,8 @@ import clientHandler from "~/server/internal/clients/handler"; import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { - const userId = await sessionHandler.getUserId(h3); - if (!userId) throw createError({ statusCode: 403 }); + const user = await sessionHandler.getSession(h3); + if (!user) throw createError({ statusCode: 403 }); const query = getQuery(h3); const providedClientId = query.id?.toString(); @@ -13,16 +13,14 @@ export default defineEventHandler(async (h3) => { statusMessage: "Provide client ID in request params as 'id'", }); - const data = await clientHandler.fetchClientMetadata( - providedClientId - ); + const data = await clientHandler.fetchClientMetadata(providedClientId); if (!data) throw createError({ statusCode: 404, statusMessage: "Request not found.", }); - await clientHandler.attachUserId(providedClientId, userId); + await clientHandler.attachUserId(providedClientId, user.userId); return data; }); diff --git a/server/api/v1/client/auth/callback/index.post.ts b/server/api/v1/client/auth/callback/index.post.ts index 46eb0b3..b08a57b 100644 --- a/server/api/v1/client/auth/callback/index.post.ts +++ b/server/api/v1/client/auth/callback/index.post.ts @@ -2,8 +2,8 @@ import clientHandler from "~/server/internal/clients/handler"; import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { - const userId = await sessionHandler.getUserId(h3); - if (!userId) throw createError({ statusCode: 403 }); + const user = await sessionHandler.getSession(h3); + if (!user) throw createError({ statusCode: 403 }); const body = await readBody(h3); const clientId = await body.id; diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 089bb8f..b75f07a 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -81,8 +81,8 @@ class ACLManager { if (!request) throw new Error("Native web requests not available - weird deployment?"); // Sessions automatically have all ACLs - const userId = await sessionHandler.getUserId(request); - if (userId) return userId; + const user = await sessionHandler.getSession(request); + if (user) return user.userId; const authorizationToken = this.getAuthorizationToken(request); if (!authorizationToken) return undefined; @@ -116,9 +116,11 @@ class ACLManager { ) { if (!request) throw new Error("Native web requests not available - weird deployment?"); - const userId = await sessionHandler.getUserId(request); - if (userId) { - const user = await prisma.user.findUnique({ where: { id: userId } }); + const userSession = await sessionHandler.getSession(request); + if (userSession) { + const user = await prisma.user.findUnique({ + where: { id: userSession.userId }, + }); if (!user) return false; if (user.admin) return true; return false; diff --git a/server/internal/session/db.ts b/server/internal/session/db.ts index d514791..12ce90b 100644 --- a/server/internal/session/db.ts +++ b/server/internal/session/db.ts @@ -10,8 +10,8 @@ export default function createDBSessionHandler(): SessionProvider { }); return { - async setSession(token, data) { - cache.set(token, data); + async setSession(token, session) { + cache.set(token, session); // const strData = JSON.stringify(data); await prisma.session.upsert({ @@ -20,54 +20,16 @@ export default function createDBSessionHandler(): SessionProvider { }, create: { token, - data, - }, - update: { - data, + ...session, }, + update: session, }); return true; }, - async updateSession(token, key, data) { - const newObj: { [key: string]: any } = {}; - newObj[key] = data; - cache.set(token, newObj); - - const session = await prisma.session.upsert({ - where: { - token, - }, - create: { - token, - data: newObj, - }, - update: {}, - }); - - // if json object and not arrary, update session - if ( - typeof session.data === "object" && - !Array.isArray(session.data) && - session.data !== null - ) { - // means we set it above - if (session.data[key] === data) return true; - - // else we need to set it ourselves - (session.data as Prisma.JsonObject)[key] = data; - await prisma.session.update({ - where: { - token, - }, - data: { - data: session.data, - }, - }); - return true; - } - return false; + async updateSession(token, data) { + return await this.setSession(token, data); }, - async getSession(token: string) { + async getSession(token: string) { const cached = cache.get(token); if (cached !== undefined) return cached as T; @@ -77,15 +39,31 @@ export default function createDBSessionHandler(): SessionProvider { }, }); if (result === null) return undefined; - return result.data as T; + + // i hate casting + // need to cast to unknown since result.data can be an N deep json object technically + // ts doesn't like that be cast down to the more constraining session type + return result as unknown as T; }, - async clearSession(token) { + async removeSession(token) { cache.delete(token); await prisma.session.delete({ where: { token, }, }); + return true; + }, + async cleanupSessions() { + const now = new Date(); + + await prisma.session.deleteMany({ + where: { + expiresAt: { + lt: now, + }, + }, + }); }, }; } diff --git a/server/internal/session/index.ts b/server/internal/session/index.ts index d4d1057..cda80f0 100644 --- a/server/internal/session/index.ts +++ b/server/internal/session/index.ts @@ -1,8 +1,7 @@ -import { H3Event, Session } from "h3"; +import { H3Event } from "h3"; import createMemorySessionProvider from "./memory"; -import { SessionProvider } from "./types"; -import prisma from "../db/database"; -import { v4 as uuidv4 } from "uuid"; +import { Session, SessionProvider } from "./types"; +import { randomUUID } from "node:crypto"; import moment from "moment"; import { parse as parseCookies } from "cookie-es"; import { MinimumRequestObject } from "~/server/h3"; @@ -14,9 +13,7 @@ This implementation may need work. It exposes an API that should stay static, but there are plenty of opportunities for optimisation/organisation under the hood */ -const userSessionKey = "_userSession"; -const userIdKey = "_userId"; -const dropTokenCookie = "drop-token"; +const dropTokenCookieName = "drop-token"; const normalSessionLength = [31, "days"]; const extendedSessionLength = [1, "year"]; @@ -29,86 +26,94 @@ export class SessionHandler { // this.sessionProvider = createMemorySessionProvider(); } - private getSessionToken(request: MinimumRequestObject | undefined) { + async signin(h3: H3Event, userId: string, rememberMe: boolean = false) { + const expiresAt = this.createExipreAt(rememberMe); + const token = this.createSessionCookie(h3, expiresAt); + return await this.sessionProvider.setSession(token, { + userId, + expiresAt, + data: {}, + }); + } + + /** + * Get a session associated with a request + * @returns session + */ + async getSession(request: MinimumRequestObject) { + const token = this.getSessionToken(request); + if (!token) return undefined; + // TODO: should validate if session is expired or not here, not in application code + + const data = await this.sessionProvider.getSession(token); + return data; + } + + /** + * Signout session associated with request and deauthenticates it + * @param request + * @returns + */ + async signout(h3: H3Event) { + const token = this.getSessionToken(h3); + if (!token) return false; + const res = await this.sessionProvider.removeSession(token); + if (!res) return false; + deleteCookie(h3, dropTokenCookieName); + return true; + } + + async cleanupSessions() { + await this.sessionProvider.cleanupSessions(); + } + + /** + * Update session info + * @param token session token + * @param data new session data + * @returns success or not + */ + private async updateSession(token: string, data: Session) { + return await this.sessionProvider.updateSession(token, data); + } + + // ---------------------- Private API Below ------------------------ + + /** + * Get session token on a request + * @param request + * @returns session token + */ + private getSessionToken( + request: MinimumRequestObject | undefined + ): string | undefined { if (!request) throw new Error("Native web request not available"); const cookieHeader = request.headers.get("Cookie"); if (!cookieHeader) return undefined; const cookies = parseCookies(cookieHeader); - const cookie = cookies[dropTokenCookie]; + const cookie = cookies[dropTokenCookieName]; return cookie; } - private async createSession(h3: H3Event, extend = false) { - const token = uuidv4(); - const expiry = moment().add( - ...(extend ? extendedSessionLength : normalSessionLength) - ); - - setCookie(h3, dropTokenCookie, token, { expires: expiry.toDate() }); - - this.sessionProvider.setSession(dropTokenCookie, {}); + private createExipreAt(rememberMe: boolean) { + return moment() + .add(...(rememberMe ? extendedSessionLength : normalSessionLength)) + .toDate(); + } + /** + * Creates cookie that represents user session + * @param h3 + * @param extend + * @returns + */ + private createSessionCookie(h3: H3Event, expiresAt: Date) { + const token = randomUUID(); + // TODO: we should probably switch to jwts to minimize possibility of someone + // trying to guess a session id (jwts let us sign + encrypt stuff in a std way) + setCookie(h3, dropTokenCookieName, token, { expires: expiresAt }); return token; } - - getDropTokenCookie() { - return dropTokenCookie; - } - - async getSession(request: MinimumRequestObject) { - const token = this.getSessionToken(request); - if (!token) return undefined; - const data = await this.sessionProvider.getSession<{ [userSessionKey]: T }>( - token - ); - if (!data) return undefined; - - return data[userSessionKey]; - } - async setSession(h3: H3Event, data: any, extend = false) { - const token = - this.getSessionToken(h3) ?? (await this.createSession(h3, extend)); - const result = await this.sessionProvider.updateSession( - token, - userSessionKey, - data - ); - - return result; - } - async clearSession(request: MinimumRequestObject) { - const token = this.getSessionToken(request); - if (!token) return false; - await this.sessionProvider.clearSession(token); - return true; - } - - async getUserId(h3: MinimumRequestObject) { - const token = this.getSessionToken(h3); - if (!token) return undefined; - - return await this.getUserIdRaw(token); - } - async getUserIdRaw(token: string) { - const session = await this.sessionProvider.getSession<{ - [userIdKey]: string | undefined; - }>(token); - - if (!session) return undefined; - - return session[userIdKey]; - } - - async setUserId(h3: H3Event, userId: string, extend = false) { - const token = - this.getSessionToken(h3) ?? (await this.createSession(h3, extend)); - - const result = await this.sessionProvider.updateSession( - token, - userIdKey, - userId - ); - } } export const sessionHandler = new SessionHandler(); diff --git a/server/internal/session/memory.ts b/server/internal/session/memory.ts index f4ad75e..2b35d48 100644 --- a/server/internal/session/memory.ts +++ b/server/internal/session/memory.ts @@ -8,15 +8,23 @@ export default function createMemorySessionHandler() { sessions[token] = data; return true; }, - async updateSession(token, key, data) { - sessions[token] = Object.assign({}, sessions[token], { [key]: data }); + async getSession(token: string): Promise { + const session = sessions[token]; + return session ? (session as T) : undefined; // Ensure undefined is returned if session is not found + }, + async updateSession(token, data) { + return this.setSession(token, data); + }, + async removeSession(token) { + delete sessions[token]; return true; }, - async getSession(token) { - return sessions[token] as any; // Wild type cast because we let the user specify types if they want - }, - async clearSession(token) { - delete sessions[token]; + async cleanupSessions() { + const now = new Date(); + for (let token in sessions) { + // if expires at time is before now, the session is expired + if (sessions[token].expiresAt < now) await this.removeSession(token); + } }, }; diff --git a/server/internal/session/types.d.ts b/server/internal/session/types.d.ts index 5977e7c..c832b41 100644 --- a/server/internal/session/types.d.ts +++ b/server/internal/session/types.d.ts @@ -1,10 +1,17 @@ import { H3Event } from "h3"; -export type Session = { [key: string]: any }; +export type Session = { + userId: string; + expiresAt: Date; + data: { + [key: string]: any; + }; +}; export interface SessionProvider { - setSession: (token: string, data: Session) => Promise; - updateSession: (token: string, key: string, data: any) => Promise; getSession: (token: string) => Promise; - clearSession: (token: string) => Promise; + setSession: (token: string, data: Session) => Promise; + updateSession: (token: string, data: Session) => Promise; + removeSession: (token: string) => Promise; + cleanupSessions: () => Promise; } diff --git a/server/plugins/redirect.ts b/server/plugins/redirect.ts index a875153..a531b54 100644 --- a/server/plugins/redirect.ts +++ b/server/plugins/redirect.ts @@ -14,8 +14,8 @@ export default defineNitroPlugin((nitro) => { switch (error.statusCode) { case 401: case 403: - const userId = await sessionHandler.getUserId(event); - if (userId) break; + const user = await sessionHandler.getSession(event); + if (user) break; return sendRedirect( event, `/auth/signin?redirect=${encodeURIComponent(event.path)}` diff --git a/server/tasks/cleanup/sessions.ts b/server/tasks/cleanup/sessions.ts new file mode 100644 index 0000000..07cbf8b --- /dev/null +++ b/server/tasks/cleanup/sessions.ts @@ -0,0 +1,12 @@ +import sessionHandler from "~/server/internal/session"; + +export default defineTask({ + meta: { + name: "cleanup:invitations", + }, + async run({}) { + await sessionHandler.cleanupSessions(); + + return { result: true }; + }, +});