refactor: session handler

This commit is contained in:
Huskydog9988
2025-04-03 19:15:33 -04:00
parent 97043d6366
commit a9d1a442f6
13 changed files with 189 additions and 168 deletions

View File

@ -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 };
});

View File

@ -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;
});

View File

@ -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;

View File

@ -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;

View File

@ -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<T>(token: string) {
async getSession<T extends Session>(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,
},
},
});
},
};
}

View File

@ -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<T extends Session>(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<T>(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<T extends Session>(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();

View File

@ -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<T extends Session>(token: string): Promise<T | undefined> {
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);
}
},
};

View File

@ -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<boolean>;
updateSession: (token: string, key: string, data: any) => Promise<boolean>;
getSession: <T extends Session>(token: string) => Promise<T | undefined>;
clearSession: (token: string) => Promise<void>;
setSession: (token: string, data: Session) => Promise<boolean>;
updateSession: (token: string, data: Session) => Promise<boolean>;
removeSession: (token: string) => Promise<boolean>;
cleanupSessions: () => Promise<void>;
}

View File

@ -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)}`

View File

@ -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 };
},
});