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
- [ ] 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<FastifyInstance> {
return;
}
if (err.statusCode) {
reply.status(err.statusCode).send({
if (err instanceof StatusError) {
reply.status(err.errorStatusCode).send({
errorType: 'message',
message: err.message,
});

View File

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

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({
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),

View File

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

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

View File

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

View File

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