session management

This commit is contained in:
mrjvs
2023-10-28 18:34:32 +02:00
parent 94e1f9ebe1
commit 8f503b9c5a
13 changed files with 448 additions and 13 deletions

View File

@@ -12,4 +12,7 @@ export const devFragment: FragmentSchema = {
postgres: {
syncSchema: true,
},
crypto: {
sessionSecret: 'aINCithRivERecKENdmANDRaNKenSiNi',
},
};

View File

@@ -38,4 +38,8 @@ export const configSchema = z.object({
// it is extremely destructive, do not use it EVER in production
syncSchema: z.coerce.boolean().default(false),
}),
crypto: z.object({
// session secret. used for signing session tokens
sessionSecret: z.string().min(32),
}),
});

46
src/db/models/Session.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
@Entity({ tableName: 'sessions' })
export class Session {
@PrimaryKey({ name: 'id', type: 'uuid' })
id: string = randomUUID();
@Property({ name: 'user', type: 'uuid' })
user!: string;
@Property({ type: 'date' })
createdAt: Date = new Date();
@Property({ type: 'date' })
accessedAt!: Date;
@Property({ type: 'date' })
expiresAt!: Date;
@Property({ type: 'text' })
device!: string;
@Property({ type: 'text' })
userAgent!: string;
}
export interface SessionDTO {
id: string;
user: string;
createdAt: string;
accessedAt: string;
device: string;
userAgent: string;
}
export function formatSession(session: Session): SessionDTO {
return {
id: session.id,
user: session.id,
createdAt: session.createdAt.toISOString(),
accessedAt: session.accessedAt.toISOString(),
device: session.device,
userAgent: session.userAgent,
};
}

View File

@@ -1,6 +1,8 @@
import { formatSession } from '@/db/models/Session';
import { User, formatUser } from '@/db/models/User';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { makeSession, makeSessionToken } from '@/services/session';
import { z } from 'zod';
const registerSchema = z.object({
@@ -12,13 +14,22 @@ export const manageAuthRouter = makeRouter((app) => {
app.post(
'/auth/register',
{ schema: { body: registerSchema } },
handle(({ em, body }) => {
handle(async ({ em, body, req }) => {
const user = new User();
user.name = body.name;
em.persistAndFlush(user);
const session = makeSession(
user.id,
body.device,
req.headers['user-agent'],
);
em.persist([user, session]);
await em.flush();
return {
user: formatUser(user),
session: formatSession(session),
token: makeSessionToken(session),
};
}),
);

View File

@@ -0,0 +1,30 @@
import { Session } from '@/db/models/Session';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const sessionRouter = makeRouter((app) => {
app.delete(
'/auth/session/:sid',
{
schema: {
params: z.object({
sid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
auth.assert();
const targetedSession = await em.findOne(Session, { id: params.sid });
if (!targetedSession) return true; // already deleted
if (targetedSession.user !== auth.user.id)
throw new StatusError('Cant delete sessions you dont own', 401);
await em.removeAndFlush(targetedSession);
return true;
}),
);
});

View File

@@ -1,5 +1,62 @@
import { Session } from '@/db/models/Session';
import { User } from '@/db/models/User';
import { Roles } from '@/services/access';
import { StatusError } from '@/services/error';
import { getSessionAndBump, verifySessionToken } from '@/services/session';
import { EntityManager } from '@mikro-orm/postgresql';
import { FastifyRequest } from 'fastify';
export function assertHasRole(_role: Roles) {
throw new Error('requires role');
export function makeAuthContext(manager: EntityManager, req: FastifyRequest) {
let userCache: User | null = null;
let sessionCache: Session | null = null;
const em = manager.fork();
return {
getSessionId(): string | null {
const header = req.headers.authorization;
if (!header) return null;
const [type, token] = header.split(' ', 2);
if (type.toLowerCase() !== 'Bearer')
throw new StatusError('Invalid auth', 400);
const payload = verifySessionToken(token);
if (!payload) throw new StatusError('Invalid auth', 400);
return payload.sid;
},
async getSession() {
if (sessionCache) return sessionCache;
const sid = this.getSessionId();
if (!sid) return null;
const session = await getSessionAndBump(em, sid);
if (session) return null;
sessionCache = session;
return session;
},
async getUser() {
if (userCache) return userCache;
const session = await this.getSession();
if (!session) return null;
const user = await em.findOne(User, { id: session.user });
if (!user) return null;
userCache = user;
return user;
},
async assert() {
const user = await this.getUser();
if (!user) throw new StatusError('Not logged in', 403);
return user;
},
get user() {
if (!userCache) throw new Error('call assert before getting user');
return userCache;
},
get session() {
if (!sessionCache) throw new Error('call assert before getting session');
return sessionCache;
},
async assertHasRole(role: Roles) {
const user = await this.assert();
const hasRole = user.roles.includes(role);
if (!hasRole) throw new StatusError('No permissions', 401);
},
};
}

9
src/services/error.ts Normal file
View File

@@ -0,0 +1,9 @@
export class StatusError extends Error {
errorStatusCode: number;
constructor(message: string, code: number) {
super(message);
this.errorStatusCode = code;
this.message = message;
}
}

View File

@@ -1,4 +1,5 @@
import { getORM } from '@/modules/mikro';
import { makeAuthContext } from '@/services/auth';
import { EntityManager } from '@mikro-orm/postgresql';
import {
ContextConfigDefault,
@@ -73,6 +74,7 @@ export type RequestContext<
Logger
>['query'];
em: EntityManager;
auth: ReturnType<typeof makeAuthContext>;
};
export function handle<
@@ -112,6 +114,7 @@ export function handle<
Logger
> {
const reqHandler: any = async (req: any, res: any) => {
const em = getORM().em.fork();
res.send(
await handler({
req,
@@ -119,7 +122,8 @@ export function handle<
body: req.body,
params: req.params,
query: req.query,
em: getORM().em.fork(),
em,
auth: makeAuthContext(em, req),
}),
);
};

69
src/services/session.ts Normal file
View File

@@ -0,0 +1,69 @@
import { conf } from '@/config';
import { Session } from '@/db/models/Session';
import { EntityManager } from '@mikro-orm/postgresql';
import { sign, verify } from 'jsonwebtoken';
// 21 days in ms
const SESSION_EXPIRY_MS = 21 * 24 * 60 * 60 * 1000;
export async function getSession(
em: EntityManager,
id: string,
): Promise<Session | null> {
const session = await em.findOne(Session, { id });
if (!session) return null;
if (session.expiresAt < new Date()) return null;
return session;
}
export async function getSessionAndBump(
em: EntityManager,
id: string,
): Promise<Session | null> {
const session = await getSession(em, id);
if (!session) return null;
em.assign(session, {
accessedAt: new Date(),
expiresAt: new Date(Date.now() + SESSION_EXPIRY_MS),
});
await em.flush();
return session;
}
export function makeSession(
user: string,
device: string,
userAgent?: string,
): Session {
if (!userAgent) throw new Error('No useragent provided');
const session = new Session();
session.accessedAt = new Date();
session.createdAt = new Date();
session.expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
session.userAgent = userAgent;
session.device = device;
session.user = user;
return session;
}
export function makeSessionToken(session: Session): string {
return sign({ sid: session.id }, conf.crypto.sessionSecret, {
algorithm: 'ES512',
});
}
export function verifySessionToken(token: string): { sid: string } | null {
try {
const payload = verify(token, conf.crypto.sessionSecret, {
algorithms: ['ES512'],
});
if (typeof payload === 'string') return null;
return payload as { sid: string };
} catch {
return null;
}
}