mirror of
https://github.com/movie-web/backend.git
synced 2025-09-13 18:13:26 +00:00
session management
This commit is contained in:
@@ -12,4 +12,7 @@ export const devFragment: FragmentSchema = {
|
||||
postgres: {
|
||||
syncSchema: true,
|
||||
},
|
||||
crypto: {
|
||||
sessionSecret: 'aINCithRivERecKENdmANDRaNKenSiNi',
|
||||
},
|
||||
};
|
||||
|
@@ -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
46
src/db/models/Session.ts
Normal 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,
|
||||
};
|
||||
}
|
@@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
30
src/routes/auth/session.ts
Normal file
30
src/routes/auth/session.ts
Normal 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;
|
||||
}),
|
||||
);
|
||||
});
|
@@ -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
9
src/services/error.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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
69
src/services/session.ts
Normal 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user