From cfdd6bafb742b5e6f5fe5134f87616f0fc41c468 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 27 Jun 2023 11:33:23 +0100 Subject: [PATCH] Rewrite how middlewares are ran inside sockets, and fix collaboration --- packages/server/src/websockets/grid.ts | 50 ++++++------ packages/server/src/websockets/middleware.ts | 85 ++++++++++++++++++++ packages/server/src/websockets/websocket.ts | 66 +++++---------- 3 files changed, 133 insertions(+), 68 deletions(-) create mode 100644 packages/server/src/websockets/middleware.ts diff --git a/packages/server/src/websockets/grid.ts b/packages/server/src/websockets/grid.ts index 2a797623cf..ffe26828bc 100644 --- a/packages/server/src/websockets/grid.ts +++ b/packages/server/src/websockets/grid.ts @@ -1,12 +1,15 @@ import authorized from "../middleware/authorized" +import currentApp from "../middleware/currentapp" import { BaseSocket } from "./websocket" -import { context, permissions } from "@budibase/backend-core" +import { auth, permissions } from "@budibase/backend-core" import http from "http" import Koa from "koa" import { getTableId } from "../api/controllers/row/utils" import { Row, Table } from "@budibase/types" import { Socket } from "socket.io" import { GridSocketEvent } from "@budibase/shared-core" +import { userAgent } from "koa-useragent" +import { createContext, runMiddlewares } from "./middleware" const { PermissionType, PermissionLevel } = permissions @@ -26,28 +29,27 @@ export default class GridSocket extends BaseSocket { return } - // Check if the user has permission to read this resource - const middleware = authorized( - PermissionType.TABLE, - PermissionLevel.READ - ) - const ctx = { - appId, + // Create context + const ctx = createContext(this.app, socket, { resourceId: tableId, - roleId: socket.data.roleId, - user: { _id: socket.data._id }, - isAuthenticated: socket.data.isAuthenticated, - request: { - url: "/fake", - }, - get: () => null, - throw: () => { - // If they don't have access, immediately disconnect them - socket.disconnect(true) - }, - } - await context.doInAppContext(appId, async () => { - await middleware(ctx, async () => { + appId, + }) + + // Construct full middleware chain to assess permissions + const middlewares = [ + userAgent, + auth.buildAuthMiddleware([], { + publicAllowed: true, + }), + currentApp, + authorized(PermissionType.TABLE, PermissionLevel.READ), + ] + + // Run all koa middlewares + try { + await runMiddlewares(ctx, middlewares, async () => { + // Middlewares are finished and we have permission + // Join room for this resource const room = `${appId}-${tableId}` await this.joinRoom(socket, room) @@ -55,7 +57,9 @@ export default class GridSocket extends BaseSocket { const sessions = await this.getRoomSessions(room) callback({ users: sessions }) }) - }) + } catch (error) { + socket.disconnect(true) + } } ) diff --git a/packages/server/src/websockets/middleware.ts b/packages/server/src/websockets/middleware.ts new file mode 100644 index 0000000000..2b6e7168af --- /dev/null +++ b/packages/server/src/websockets/middleware.ts @@ -0,0 +1,85 @@ +import { Socket } from "socket.io" +import Cookies from "cookies" +import http from "http" +import Koa from "koa" +import { Header } from "@budibase/backend-core" + +/** + * Constructs a fake Koa context to use for manually running middlewares in + * sockets + * @param app the Koa app + * @param socket the socket.io socket instance + * @param options additional metadata to populate the context with + */ +export const createContext = ( + app: Koa, + socket: Socket, + options?: WebsocketContextOptions +) => { + const res = new http.ServerResponse(socket.request) + const context: WebsocketContext = { + ...app.createContext(socket.request, res), + + // Additional overrides needed to make our middlewares work with this + // fake koa context + resourceId: options?.resourceId, + path: "/fake", + request: { + url: "/fake", + headers: { + [Header.APP_ID]: options?.appId, + }, + }, + cookies: new Cookies(socket.request, res), + get: (field: string) => socket.request.headers?.[field] as string, + throw: (...params: any[]) => { + // Throw has a bunch of different signatures, so we'll just stringify + // whatever params we get given + throw new Error( + ...(params?.join(" ") || "Unknown error in socket middleware") + ) + }, + + // Needed for koa-useragent middleware + headers: socket.request.headers, + header: socket.request.headers, + } + return context +} + +/** + * Runs a list of middlewares, nesting each callback inside each other mimic + * how the real middlewares run and ensuring that app or tenant context work + * as expected + * @param ctx the Koa context + * @param middlewares the array of middlewares to run + * @param callback a final callback for when all middlewares are completed + */ +export const runMiddlewares = async ( + ctx: any, + middlewares: any[], + callback: Function +) => { + if (!middlewares[0]) { + await callback() + } else { + await middlewares[0](ctx, async () => { + await runMiddlewares(ctx, middlewares.slice(1), callback) + }) + } +} + +export interface WebsocketContext extends Omit { + request: { + url: string + headers: { + [key: string]: string | undefined + } + } + cookies: Cookies +} + +export interface WebsocketContextOptions { + appId?: string + resourceId?: string +} diff --git a/packages/server/src/websockets/websocket.ts b/packages/server/src/websockets/websocket.ts index 52351aea36..94636010fa 100644 --- a/packages/server/src/websockets/websocket.ts +++ b/packages/server/src/websockets/websocket.ts @@ -1,7 +1,6 @@ import { Server } from "socket.io" import http from "http" import Koa from "koa" -import Cookies from "cookies" import { userAgent } from "koa-useragent" import { auth, Header, redis } from "@budibase/backend-core" import { createAdapter } from "@socket.io/redis-adapter" @@ -10,14 +9,17 @@ import { getSocketPubSubClients } from "../utilities/redis" import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core" import { SocketSession } from "@budibase/types" import { v4 as uuid } from "uuid" +import { createContext, runMiddlewares } from "./middleware" const anonUser = () => ({ _id: uuid(), email: "user@mail.com", firstName: "Anonymous", + tenantId: "default", }) export class BaseSocket { + app: Koa io: Server path: string redisClient?: redis.Client @@ -28,6 +30,7 @@ export class BaseSocket { path: string = "/", additionalMiddlewares?: any[] ) { + this.app = app this.path = path this.io = new Server(server, { path, @@ -45,52 +48,25 @@ export class BaseSocket { // Apply middlewares this.io.use(async (socket, next) => { - // Build fake koa context - const res = new http.ServerResponse(socket.request) - const ctx: any = { - ...app.createContext(socket.request, res), + const ctx = createContext(this.app, socket) - // Additional overrides needed to make our middlewares work with this - // fake koa context - cookies: new Cookies(socket.request, res), - get: (field: string) => socket.request.headers[field], - throw: (code: number, message: string) => { - throw new Error(message) - }, - - // Needed for koa-useragent middleware - headers: socket.request.headers, - header: socket.request.headers, - - // We don't really care about the path since it will never contain - // an app ID - path: "/socket", - } - - // Run all koa middlewares try { - for (let [idx, middleware] of middlewares.entries()) { - await middleware(ctx, () => { - if (idx === middlewares.length - 1) { - // Middlewares are finished - // Extract some data from our enriched koa context to persist - // as metadata for the socket - const user = ctx.user?._id ? ctx.user : anonUser() - const { _id, email, firstName, lastName } = user - socket.data = { - _id, - email, - firstName, - lastName, - sessionId: socket.id, - connectedAt: Date.now(), - isAuthenticated: ctx.isAuthenticated, - roleId: ctx.roleId, - } - next() - } - }) - } + await runMiddlewares(ctx, middlewares, () => { + // Middlewares are finished + // Extract some data from our enriched koa context to persist + // as metadata for the socket + const user = ctx.user?._id ? ctx.user : anonUser() + const { _id, email, firstName, lastName } = user + socket.data = { + _id, + email, + firstName, + lastName, + sessionId: socket.id, + connectedAt: Date.now(), + } + next() + }) } catch (error: any) { next(error) }