diff --git a/README.md b/README.md index 282b970..e57cb8e 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ Backend for movie-web ## Todo list - [ ] standard endpoints: - - [ ] make account (PFP, account name) - - [ ] login + - [X] make account (PFP, account name) + - [X] login (Pending Actual Auth) - [X] logout a session - - [ ] read all sessions from logged in user - - [ ] edit current session device name - - [ ] edit account name and PFP - - [ ] delete logged in user - - [ ] backend meta (name and description) + - [X] read all sessions from logged in user + - [X] edit current session device name + - [X] edit account name and PFP + - [X] delete logged in user + - [X] backend meta (name and description) - [ ] upsert settings - [ ] upsert watched items - [ ] upsert bookmarks @@ -22,4 +22,6 @@ Backend for movie-web - [ ] ratelimits (stored in redis) - [ ] switch to pnpm - [ ] think of privacy centric method of auth + - [ ] Register + - [ ] Login - [ ] global namespacing (accounts are stored on a namespace) diff --git a/src/config/fragments/dev.ts b/src/config/fragments/dev.ts index 3e72304..58d0b92 100644 --- a/src/config/fragments/dev.ts +++ b/src/config/fragments/dev.ts @@ -15,4 +15,9 @@ export const devFragment: FragmentSchema = { crypto: { sessionSecret: 'aINCithRivERecKENdmANDRaNKenSiNi', }, + meta: { + name: 'movie-web development', + description: + "This backend is only used in development, do not create an account if you this isn't your own instance", + }, }; diff --git a/src/config/index.ts b/src/config/index.ts index 29a3c24..06d2868 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -8,6 +8,8 @@ const fragments = { dockerdev: dockerFragment, }; +export const version = '1.0.0'; + export const conf = createConfigLoader() .addFromEnvironment('MWB_') .addFromCLI('mwb-') diff --git a/src/config/schema.ts b/src/config/schema.ts index 3ae5569..69a8052 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -42,4 +42,10 @@ export const configSchema = z.object({ // session secret. used for signing session tokens sessionSecret: z.string().min(32), }), + meta: z.object({ + // name and description of this backend + // this is displayed to the client when making an account + name: z.string().min(1), + description: z.string().min(1).optional(), + }), }); diff --git a/src/db/models/Session.ts b/src/db/models/Session.ts index 51041a0..137f778 100644 --- a/src/db/models/Session.ts +++ b/src/db/models/Session.ts @@ -27,7 +27,7 @@ export class Session { export interface SessionDTO { id: string; - user: string; + userId: string; createdAt: string; accessedAt: string; device: string; @@ -37,7 +37,7 @@ export interface SessionDTO { export function formatSession(session: Session): SessionDTO { return { id: session.id, - user: session.id, + userId: session.user, createdAt: session.createdAt.toISOString(), accessedAt: session.accessedAt.toISOString(), device: session.device, diff --git a/src/db/models/User.ts b/src/db/models/User.ts index 1258507..931e1bf 100644 --- a/src/db/models/User.ts +++ b/src/db/models/User.ts @@ -1,6 +1,12 @@ import { Entity, PrimaryKey, Property, types } from '@mikro-orm/core'; import { randomUUID } from 'crypto'; +export type UserProfile = { + colorA: string; + colorB: string; + icon: string; +}; + @Entity({ tableName: 'users' }) export class User { @PrimaryKey({ name: 'id', type: 'uuid' }) @@ -14,18 +20,36 @@ export class User { @Property({ name: 'permissions', type: types.array }) roles: string[] = []; + + @Property({ + name: 'profile', + type: types.json, + }) + profile!: UserProfile; } export interface UserDTO { id: string; + name: string; roles: string[]; createdAt: string; + profile: { + colorA: string; + colorB: string; + icon: string; + }; } export function formatUser(user: User): UserDTO { return { id: user.id, + name: user.name, roles: user.roles, createdAt: user.createdAt.toISOString(), + profile: { + colorA: user.profile.colorA, + colorB: user.profile.colorB, + icon: user.profile.icon, + }, }; } diff --git a/src/main.ts b/src/main.ts index 2ba62f7..46cd827 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,8 @@ async function bootstrap(): Promise { evt: 'setup', }); - await setupFastify(); await setupMikroORM(); + await setupFastify(); log.info(`App setup, ready to accept connections`, { evt: 'success', diff --git a/src/modules/fastify/index.ts b/src/modules/fastify/index.ts index bdfd761..f1587cc 100644 --- a/src/modules/fastify/index.ts +++ b/src/modules/fastify/index.ts @@ -8,6 +8,7 @@ import { validatorCompiler, } from 'fastify-type-provider-zod'; import { ZodError } from 'zod'; +import { StatusError } from '@/services/error'; const log = scopedLogger('fastify'); @@ -31,8 +32,8 @@ export async function setupFastify(): Promise { return; } - if (err.statusCode) { - reply.status(err.statusCode).send({ + if (err instanceof StatusError) { + reply.status(err.errorStatusCode).send({ errorType: 'message', message: err.message, }); diff --git a/src/modules/fastify/routes.ts b/src/modules/fastify/routes.ts index 4ec5182..364fc6f 100644 --- a/src/modules/fastify/routes.ts +++ b/src/modules/fastify/routes.ts @@ -1,6 +1,18 @@ +import { loginAuthRouter } from '@/routes/auth/login'; import { manageAuthRouter } from '@/routes/auth/manage'; +import { metaRouter } from '@/routes/meta'; +import { sessionsRouter } from '@/routes/sessions'; +import { userDeleteRouter } from '@/routes/users/delete'; +import { userEditRouter } from '@/routes/users/edit'; +import { userSessionsRouter } from '@/routes/users/sessions'; import { FastifyInstance } from 'fastify'; export async function setupRoutes(app: FastifyInstance) { app.register(manageAuthRouter.register); + app.register(loginAuthRouter.register); + app.register(userSessionsRouter.register); + app.register(sessionsRouter.register); + app.register(userEditRouter.register); + app.register(userDeleteRouter.register); + app.register(metaRouter.register); } diff --git a/src/routes/auth/login.ts b/src/routes/auth/login.ts new file mode 100644 index 0000000..4425988 --- /dev/null +++ b/src/routes/auth/login.ts @@ -0,0 +1,39 @@ +import { formatSession } from '@/db/models/Session'; +import { User } from '@/db/models/User'; +import { StatusError } from '@/services/error'; +import { handle } from '@/services/handler'; +import { makeRouter } from '@/services/router'; +import { makeSession, makeSessionToken } from '@/services/session'; +import { z } from 'zod'; + +const loginSchema = z.object({ + id: z.string(), + device: z.string().max(500).min(1), +}); + +export const loginAuthRouter = makeRouter((app) => { + app.post( + '/auth/login', + { schema: { body: loginSchema } }, + handle(async ({ em, body, req }) => { + const user = await em.findOne(User, { id: body.id }); + + if (user == null) { + throw new StatusError('User cannot be found', 401); + } + + const session = makeSession( + user.id, + body.device, + req.headers['user-agent'], + ); + + await em.persistAndFlush(session); + + return { + session: formatSession(session), + token: makeSessionToken(session), + }; + }), + ); +}); diff --git a/src/routes/auth/manage.ts b/src/routes/auth/manage.ts index 614f97d..6b7fff5 100644 --- a/src/routes/auth/manage.ts +++ b/src/routes/auth/manage.ts @@ -8,6 +8,11 @@ import { z } from 'zod'; const registerSchema = z.object({ name: z.string().max(500).min(1), device: z.string().max(500).min(1), + profile: z.object({ + colorA: z.string(), + colorB: z.string(), + icon: z.string(), + }), }); export const manageAuthRouter = makeRouter((app) => { @@ -17,14 +22,15 @@ export const manageAuthRouter = makeRouter((app) => { handle(async ({ em, body, req }) => { const user = new User(); user.name = body.name; + user.profile = body.profile; + const session = makeSession( user.id, body.device, req.headers['user-agent'], ); - em.persist([user, session]); - await em.flush(); + await em.persistAndFlush([user, session]); return { user: formatUser(user), diff --git a/src/routes/auth/session.ts b/src/routes/auth/session.ts index 0b4b100..5909f70 100644 --- a/src/routes/auth/session.ts +++ b/src/routes/auth/session.ts @@ -4,9 +4,9 @@ import { handle } from '@/services/handler'; import { makeRouter } from '@/services/router'; import { z } from 'zod'; -export const sessionRouter = makeRouter((app) => { +export const authSessionRouter = makeRouter((app) => { app.delete( - '/auth/session/:sid', + '/sessions/:sid', { schema: { params: z.object({ @@ -15,16 +15,21 @@ export const sessionRouter = makeRouter((app) => { }, }, handle(async ({ auth, params, em }) => { - auth.assert(); + await auth.assert(); const targetedSession = await em.findOne(Session, { id: params.sid }); - if (!targetedSession) return true; // already deleted + if (!targetedSession) + return { + id: params.sid, + }; if (targetedSession.user !== auth.user.id) - throw new StatusError('Cant delete sessions you dont own', 401); + throw new StatusError('Cannot delete sessions you do not own', 401); await em.removeAndFlush(targetedSession); - return true; + return { + id: params.sid, + }; }), ); }); diff --git a/src/routes/meta.ts b/src/routes/meta.ts new file mode 100644 index 0000000..c55603f --- /dev/null +++ b/src/routes/meta.ts @@ -0,0 +1,28 @@ +import { conf } from '@/config'; +import { handle } from '@/services/handler'; +import { makeRouter } from '@/services/router'; + +export const metaRouter = makeRouter((app) => { + app.get( + '/healthcheck', + handle(async ({ em }) => { + const databaseConnected = await em.config + .getDriver() + .getConnection() + .isConnected(); + return { + healthy: databaseConnected, + databaseConnected, + }; + }), + ); + app.get( + '/meta', + handle(async () => { + return { + name: conf.meta.name, + description: conf.meta.description, + }; + }), + ); +}); diff --git a/src/routes/sessions.ts b/src/routes/sessions.ts new file mode 100644 index 0000000..ab022e1 --- /dev/null +++ b/src/routes/sessions.ts @@ -0,0 +1,65 @@ +import { Session, formatSession } 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 sessionsRouter = makeRouter((app) => { + app.patch( + '/sessions/:sid', + { + schema: { + params: z.object({ + sid: z.string(), + }), + body: z.object({ + name: z.string().max(500).min(1).optional(), + }), + }, + }, + handle(async ({ auth, params, em, body }) => { + await auth.assert(); + + const targetedSession = await em.findOne(Session, { id: params.sid }); + + if (!targetedSession) + throw new StatusError('Session cannot be found', 404); + + if (targetedSession.user !== auth.user.id) + throw new StatusError('Cannot modify sessions you do not own', 401); + + if (body.name) targetedSession.device = body.name; + + await em.persistAndFlush(targetedSession); + + return formatSession(targetedSession); + }), + ); + app.delete( + '/sessions/:sid', + { + schema: { + params: z.object({ + sid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + await auth.assert(); + + const targetedSession = await em.findOne(Session, { id: params.sid }); + if (!targetedSession) + return { + id: params.sid, + }; + + if (targetedSession.user !== auth.user.id) + throw new StatusError('Cannot delete sessions you do not own', 401); + + await em.removeAndFlush(targetedSession); + return { + id: params.sid, + }; + }), + ); +}); diff --git a/src/routes/sessions/session.ts b/src/routes/sessions/session.ts new file mode 100644 index 0000000..b8393e2 --- /dev/null +++ b/src/routes/sessions/session.ts @@ -0,0 +1,35 @@ +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( + '/sessions/:sid', + { + schema: { + params: z.object({ + sid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + await auth.assert(); + + const targetedSession = await em.findOne(Session, { id: params.sid }); + if (!targetedSession) + return { + id: params.sid, + }; + + if (targetedSession.user !== auth.user.id) + throw new StatusError('Cannot delete sessions you do not own', 401); + + await em.removeAndFlush(targetedSession); + return { + id: params.sid, + }; + }), + ); +}); diff --git a/src/routes/users/delete.ts b/src/routes/users/delete.ts new file mode 100644 index 0000000..969b755 --- /dev/null +++ b/src/routes/users/delete.ts @@ -0,0 +1,36 @@ +import { Session } from '@/db/models/Session'; +import { User } from '@/db/models/User'; +import { StatusError } from '@/services/error'; +import { handle } from '@/services/handler'; +import { makeRouter } from '@/services/router'; +import { z } from 'zod'; + +export const userDeleteRouter = makeRouter((app) => { + app.delete( + '/users/:uid', + { + schema: { + params: z.object({ + uid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + await auth.assert(); + + const user = await em.findOne(User, { id: params.uid }); + if (!user) throw new StatusError('User does not exist', 404); + + if (auth.user.id !== user.id) + throw new StatusError('Cannot delete user other than yourself', 403); + + const sessions = await em.find(Session, { user: user.id }); + + await em.remove([user, ...sessions]); + await em.flush(); + return { + id: user.id, + }; + }), + ); +}); diff --git a/src/routes/users/edit.ts b/src/routes/users/edit.ts new file mode 100644 index 0000000..d3eb093 --- /dev/null +++ b/src/routes/users/edit.ts @@ -0,0 +1,43 @@ +import { User, formatUser } from '@/db/models/User'; +import { StatusError } from '@/services/error'; +import { handle } from '@/services/handler'; +import { makeRouter } from '@/services/router'; +import { z } from 'zod'; + +export const userEditRouter = makeRouter((app) => { + app.patch( + '/users/:uid', + { + schema: { + params: z.object({ + uid: z.string(), + }), + body: z.object({ + profile: z + .object({ + colorA: z.string(), + colorB: z.string(), + icon: z.string(), + }) + .optional(), + name: z.string().max(500).min(1).optional(), + }), + }, + }, + handle(async ({ auth, params, body, em }) => { + await auth.assert(); + + const user = await em.findOne(User, { id: params.uid }); + if (!user) throw new StatusError('User does not exist', 404); + + if (auth.user.id !== user.id) + throw new StatusError('Cannot modify user other than yourself', 403); + + if (body.name) user.name = body.name; + if (body.profile) user.profile = body.profile; + + await em.persistAndFlush(user); + return formatUser(user); + }), + ); +}); diff --git a/src/routes/users/sessions.ts b/src/routes/users/sessions.ts new file mode 100644 index 0000000..2f2d4ef --- /dev/null +++ b/src/routes/users/sessions.ts @@ -0,0 +1,30 @@ +import { Session, formatSession } 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 userSessionsRouter = makeRouter((app) => { + app.get( + '/users/:uid/sessions', + { + schema: { + params: z.object({ + uid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + await auth.assert(); + + if (auth.user.id !== params.uid) + throw new StatusError('Cannot modify user other than yourself', 403); + + const sessions = await em.find(Session, { + user: params.uid, + }); + + return sessions.map(formatSession); + }), + ); +}); diff --git a/src/services/auth.ts b/src/services/auth.ts index 789ea26..547ccc7 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -16,10 +16,10 @@ export function makeAuthContext(manager: EntityManager, req: FastifyRequest) { 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); + if (type.toLowerCase() !== 'bearer') + throw new StatusError('Invalid authentication', 400); const payload = verifySessionToken(token); - if (!payload) throw new StatusError('Invalid auth', 400); + if (!payload) throw new StatusError('Invalid authentication', 400); return payload.sid; }, async getSession() { @@ -27,7 +27,7 @@ export function makeAuthContext(manager: EntityManager, req: FastifyRequest) { const sid = this.getSessionId(); if (!sid) return null; const session = await getSessionAndBump(em, sid); - if (session) return null; + if (!session) return null; sessionCache = session; return session; }, @@ -42,7 +42,7 @@ export function makeAuthContext(manager: EntityManager, req: FastifyRequest) { }, async assert() { const user = await this.getUser(); - if (!user) throw new StatusError('Not logged in', 403); + if (!user) throw new StatusError('Not logged in', 401); return user; }, get user() { @@ -56,7 +56,7 @@ export function makeAuthContext(manager: EntityManager, req: FastifyRequest) { async assertHasRole(role: Roles) { const user = await this.assert(); const hasRole = user.roles.includes(role); - if (!hasRole) throw new StatusError('No permissions', 401); + if (!hasRole) throw new StatusError('No permissions', 403); }, }; } diff --git a/src/services/session.ts b/src/services/session.ts index 1ad3a4b..1224123 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -28,7 +28,7 @@ export async function getSessionAndBump( accessedAt: new Date(), expiresAt: new Date(Date.now() + SESSION_EXPIRY_MS), }); - await em.flush(); + await em.persistAndFlush(session); return session; } @@ -52,14 +52,14 @@ export function makeSession( export function makeSessionToken(session: Session): string { return sign({ sid: session.id }, conf.crypto.sessionSecret, { - algorithm: 'ES512', + algorithm: 'HS256', }); } export function verifySessionToken(token: string): { sid: string } | null { try { const payload = verify(token, conf.crypto.sessionSecret, { - algorithms: ['ES512'], + algorithms: ['HS256'], }); if (typeof payload === 'string') return null; return payload as { sid: string }; diff --git a/tsconfig.json b/tsconfig.json index 4240e4c..5c83f77 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "experimentalDecorators": true, "isolatedModules": true, "emitDecoratorMetadata": true, + "resolveJsonModule": true, "strict": true, "paths": { "@/*": ["./*"]