fix bugs + add a lot of endpoints

Co-authored-by: William Oldham <github@binaryoverload.co.uk>
This commit is contained in:
mrjvs 2023-10-28 21:08:01 +02:00
parent 8f503b9c5a
commit 542591342b
21 changed files with 369 additions and 29 deletions

View File

@ -3,14 +3,14 @@ Backend for movie-web
## Todo list ## Todo list
- [ ] standard endpoints: - [ ] standard endpoints:
- [ ] make account (PFP, account name) - [X] make account (PFP, account name)
- [ ] login - [X] login (Pending Actual Auth)
- [X] logout a session - [X] logout a session
- [ ] read all sessions from logged in user - [X] read all sessions from logged in user
- [ ] edit current session device name - [X] edit current session device name
- [ ] edit account name and PFP - [X] edit account name and PFP
- [ ] delete logged in user - [X] delete logged in user
- [ ] backend meta (name and description) - [X] backend meta (name and description)
- [ ] upsert settings - [ ] upsert settings
- [ ] upsert watched items - [ ] upsert watched items
- [ ] upsert bookmarks - [ ] upsert bookmarks
@ -22,4 +22,6 @@ Backend for movie-web
- [ ] ratelimits (stored in redis) - [ ] ratelimits (stored in redis)
- [ ] switch to pnpm - [ ] switch to pnpm
- [ ] think of privacy centric method of auth - [ ] think of privacy centric method of auth
- [ ] Register
- [ ] Login
- [ ] global namespacing (accounts are stored on a namespace) - [ ] global namespacing (accounts are stored on a namespace)

View File

@ -15,4 +15,9 @@ export const devFragment: FragmentSchema = {
crypto: { crypto: {
sessionSecret: 'aINCithRivERecKENdmANDRaNKenSiNi', 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",
},
}; };

View File

@ -8,6 +8,8 @@ const fragments = {
dockerdev: dockerFragment, dockerdev: dockerFragment,
}; };
export const version = '1.0.0';
export const conf = createConfigLoader() export const conf = createConfigLoader()
.addFromEnvironment('MWB_') .addFromEnvironment('MWB_')
.addFromCLI('mwb-') .addFromCLI('mwb-')

View File

@ -42,4 +42,10 @@ export const configSchema = z.object({
// session secret. used for signing session tokens // session secret. used for signing session tokens
sessionSecret: z.string().min(32), 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(),
}),
}); });

View File

@ -27,7 +27,7 @@ export class Session {
export interface SessionDTO { export interface SessionDTO {
id: string; id: string;
user: string; userId: string;
createdAt: string; createdAt: string;
accessedAt: string; accessedAt: string;
device: string; device: string;
@ -37,7 +37,7 @@ export interface SessionDTO {
export function formatSession(session: Session): SessionDTO { export function formatSession(session: Session): SessionDTO {
return { return {
id: session.id, id: session.id,
user: session.id, userId: session.user,
createdAt: session.createdAt.toISOString(), createdAt: session.createdAt.toISOString(),
accessedAt: session.accessedAt.toISOString(), accessedAt: session.accessedAt.toISOString(),
device: session.device, device: session.device,

View File

@ -1,6 +1,12 @@
import { Entity, PrimaryKey, Property, types } from '@mikro-orm/core'; import { Entity, PrimaryKey, Property, types } from '@mikro-orm/core';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
export type UserProfile = {
colorA: string;
colorB: string;
icon: string;
};
@Entity({ tableName: 'users' }) @Entity({ tableName: 'users' })
export class User { export class User {
@PrimaryKey({ name: 'id', type: 'uuid' }) @PrimaryKey({ name: 'id', type: 'uuid' })
@ -14,18 +20,36 @@ export class User {
@Property({ name: 'permissions', type: types.array }) @Property({ name: 'permissions', type: types.array })
roles: string[] = []; roles: string[] = [];
@Property({
name: 'profile',
type: types.json,
})
profile!: UserProfile;
} }
export interface UserDTO { export interface UserDTO {
id: string; id: string;
name: string;
roles: string[]; roles: string[];
createdAt: string; createdAt: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
} }
export function formatUser(user: User): UserDTO { export function formatUser(user: User): UserDTO {
return { return {
id: user.id, id: user.id,
name: user.name,
roles: user.roles, roles: user.roles,
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
profile: {
colorA: user.profile.colorA,
colorB: user.profile.colorB,
icon: user.profile.icon,
},
}; };
} }

View File

@ -9,8 +9,8 @@ async function bootstrap(): Promise<void> {
evt: 'setup', evt: 'setup',
}); });
await setupFastify();
await setupMikroORM(); await setupMikroORM();
await setupFastify();
log.info(`App setup, ready to accept connections`, { log.info(`App setup, ready to accept connections`, {
evt: 'success', evt: 'success',

View File

@ -8,6 +8,7 @@ import {
validatorCompiler, validatorCompiler,
} from 'fastify-type-provider-zod'; } from 'fastify-type-provider-zod';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import { StatusError } from '@/services/error';
const log = scopedLogger('fastify'); const log = scopedLogger('fastify');
@ -31,8 +32,8 @@ export async function setupFastify(): Promise<FastifyInstance> {
return; return;
} }
if (err.statusCode) { if (err instanceof StatusError) {
reply.status(err.statusCode).send({ reply.status(err.errorStatusCode).send({
errorType: 'message', errorType: 'message',
message: err.message, message: err.message,
}); });

View File

@ -1,6 +1,18 @@
import { loginAuthRouter } from '@/routes/auth/login';
import { manageAuthRouter } from '@/routes/auth/manage'; 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'; import { FastifyInstance } from 'fastify';
export async function setupRoutes(app: FastifyInstance) { export async function setupRoutes(app: FastifyInstance) {
app.register(manageAuthRouter.register); 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);
} }

39
src/routes/auth/login.ts Normal file
View File

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

View File

@ -8,6 +8,11 @@ import { z } from 'zod';
const registerSchema = z.object({ const registerSchema = z.object({
name: z.string().max(500).min(1), name: z.string().max(500).min(1),
device: 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) => { export const manageAuthRouter = makeRouter((app) => {
@ -17,14 +22,15 @@ export const manageAuthRouter = makeRouter((app) => {
handle(async ({ em, body, req }) => { handle(async ({ em, body, req }) => {
const user = new User(); const user = new User();
user.name = body.name; user.name = body.name;
user.profile = body.profile;
const session = makeSession( const session = makeSession(
user.id, user.id,
body.device, body.device,
req.headers['user-agent'], req.headers['user-agent'],
); );
em.persist([user, session]); await em.persistAndFlush([user, session]);
await em.flush();
return { return {
user: formatUser(user), user: formatUser(user),

View File

@ -4,9 +4,9 @@ import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router'; import { makeRouter } from '@/services/router';
import { z } from 'zod'; import { z } from 'zod';
export const sessionRouter = makeRouter((app) => { export const authSessionRouter = makeRouter((app) => {
app.delete( app.delete(
'/auth/session/:sid', '/sessions/:sid',
{ {
schema: { schema: {
params: z.object({ params: z.object({
@ -15,16 +15,21 @@ export const sessionRouter = makeRouter((app) => {
}, },
}, },
handle(async ({ auth, params, em }) => { handle(async ({ auth, params, em }) => {
auth.assert(); await auth.assert();
const targetedSession = await em.findOne(Session, { id: params.sid }); 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) 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); await em.removeAndFlush(targetedSession);
return true; return {
id: params.sid,
};
}), }),
); );
}); });

28
src/routes/meta.ts Normal file
View File

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

65
src/routes/sessions.ts Normal file
View File

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

View File

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

View File

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

43
src/routes/users/edit.ts Normal file
View File

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

View File

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

View File

@ -16,10 +16,10 @@ export function makeAuthContext(manager: EntityManager, req: FastifyRequest) {
const header = req.headers.authorization; const header = req.headers.authorization;
if (!header) return null; if (!header) return null;
const [type, token] = header.split(' ', 2); const [type, token] = header.split(' ', 2);
if (type.toLowerCase() !== 'Bearer') if (type.toLowerCase() !== 'bearer')
throw new StatusError('Invalid auth', 400); throw new StatusError('Invalid authentication', 400);
const payload = verifySessionToken(token); const payload = verifySessionToken(token);
if (!payload) throw new StatusError('Invalid auth', 400); if (!payload) throw new StatusError('Invalid authentication', 400);
return payload.sid; return payload.sid;
}, },
async getSession() { async getSession() {
@ -27,7 +27,7 @@ export function makeAuthContext(manager: EntityManager, req: FastifyRequest) {
const sid = this.getSessionId(); const sid = this.getSessionId();
if (!sid) return null; if (!sid) return null;
const session = await getSessionAndBump(em, sid); const session = await getSessionAndBump(em, sid);
if (session) return null; if (!session) return null;
sessionCache = session; sessionCache = session;
return session; return session;
}, },
@ -42,7 +42,7 @@ export function makeAuthContext(manager: EntityManager, req: FastifyRequest) {
}, },
async assert() { async assert() {
const user = await this.getUser(); 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; return user;
}, },
get user() { get user() {
@ -56,7 +56,7 @@ export function makeAuthContext(manager: EntityManager, req: FastifyRequest) {
async assertHasRole(role: Roles) { async assertHasRole(role: Roles) {
const user = await this.assert(); const user = await this.assert();
const hasRole = user.roles.includes(role); const hasRole = user.roles.includes(role);
if (!hasRole) throw new StatusError('No permissions', 401); if (!hasRole) throw new StatusError('No permissions', 403);
}, },
}; };
} }

View File

@ -28,7 +28,7 @@ export async function getSessionAndBump(
accessedAt: new Date(), accessedAt: new Date(),
expiresAt: new Date(Date.now() + SESSION_EXPIRY_MS), expiresAt: new Date(Date.now() + SESSION_EXPIRY_MS),
}); });
await em.flush(); await em.persistAndFlush(session);
return session; return session;
} }
@ -52,14 +52,14 @@ export function makeSession(
export function makeSessionToken(session: Session): string { export function makeSessionToken(session: Session): string {
return sign({ sid: session.id }, conf.crypto.sessionSecret, { return sign({ sid: session.id }, conf.crypto.sessionSecret, {
algorithm: 'ES512', algorithm: 'HS256',
}); });
} }
export function verifySessionToken(token: string): { sid: string } | null { export function verifySessionToken(token: string): { sid: string } | null {
try { try {
const payload = verify(token, conf.crypto.sessionSecret, { const payload = verify(token, conf.crypto.sessionSecret, {
algorithms: ['ES512'], algorithms: ['HS256'],
}); });
if (typeof payload === 'string') return null; if (typeof payload === 'string') return null;
return payload as { sid: string }; return payload as { sid: string };

View File

@ -14,6 +14,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"isolatedModules": true, "isolatedModules": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"resolveJsonModule": true,
"strict": true, "strict": true,
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]