From 1bc8a2ce2dd7eaa4f959c2d9dcbb3df982033ff3 Mon Sep 17 00:00:00 2001 From: Ricardo Costa Date: Tue, 25 Jun 2024 15:17:30 +0100 Subject: [PATCH] Implemented Socket Authentication & Authorization --- .../contexts/workspace/WorkspaceContext.tsx | 7 ++++++- .../socket/socketCommunication.ts | 2 +- .../src/ui/components/spinner/Spinner.scss | 4 ++-- code/client/src/ui/hooks/useEditing.tsx | 4 +++- code/client/src/ui/pages/document/Document.tsx | 2 +- .../commit-history/CommitHistory.tsx | 1 - .../document/components/commit/Commit.tsx | 1 - .../document/components/editor/Editor.tsx | 5 ++++- .../src/ui/pages/workspaces/Workspaces.tsx | 9 +++++---- code/server/package.json | 6 +++++- code/server/src/config.ts | 16 +++++++--------- .../http/middlewares/authMiddlewares.ts | 14 +++++++++----- code/server/src/controllers/ws/events.ts | 10 +++++----- .../ws/events/document/onJoinDocument.ts | 2 ++ .../ws/events/document/onOperation.ts | 2 +- .../ws/events/workspace/onJoinWorkspace.ts | 18 ++++++++++++++---- code/server/src/controllers/ws/rooms/rooms.ts | 9 +++------ code/server/src/controllers/ws/utils.ts | 6 ++++++ code/server/src/server.ts | 9 ++++++--- code/server/src/services/WorkspacesService.ts | 6 +++--- 20 files changed, 83 insertions(+), 50 deletions(-) create mode 100644 code/server/src/controllers/ws/utils.ts diff --git a/code/client/src/contexts/workspace/WorkspaceContext.tsx b/code/client/src/contexts/workspace/WorkspaceContext.tsx index 8d5f5448..b9a3a613 100644 --- a/code/client/src/contexts/workspace/WorkspaceContext.tsx +++ b/code/client/src/contexts/workspace/WorkspaceContext.tsx @@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom'; import useWorkspaceService from '@services/workspace/useWorkspaceService'; import useResources from '@domain/workspaces/useResources'; import { Resource, ResourceType } from '@notespace/shared/src/workspace/types/resource'; +import { useAuth } from '@/contexts/auth/useAuth'; export type Resources = Record; @@ -20,6 +21,7 @@ export type WorkspaceContextType = { workspace?: WorkspaceMeta; resources?: Resources; operations?: WorkspaceOperations; + isMember: boolean; }; export const WorkspaceContext = createContext({}); @@ -31,6 +33,8 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { const { setResources, ...otherOperations } = operations; const { socket } = useCommunication(); const { wid } = useParams(); + const { currentUser } = useAuth(); + const [isMember, setIsMember] = useState(false); useEffect(() => { if (!wid) return; @@ -39,6 +43,7 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { const { resources, ...workspace } = await services.getWorkspace(wid!); setWorkspace(workspace); setResources(resources); + setIsMember(workspace.members.includes(currentUser?.email || '')); } socket.connect(); socket.emit('joinWorkspace', wid); @@ -51,7 +56,7 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { }, [wid]); return ( - + {children} ); diff --git a/code/client/src/services/communication/socket/socketCommunication.ts b/code/client/src/services/communication/socket/socketCommunication.ts index 9d2c3959..f080a85f 100644 --- a/code/client/src/services/communication/socket/socketCommunication.ts +++ b/code/client/src/services/communication/socket/socketCommunication.ts @@ -15,7 +15,7 @@ export interface SocketCommunication { disconnect: ConnectionType; } -const socket = io(SERVER_URL, { autoConnect: false }); +const socket = io(SERVER_URL, { autoConnect: false, withCredentials: true }); const OPERATION_DELAY = 100; const operationEmitter = new OperationEmitter(socket, OPERATION_DELAY); diff --git a/code/client/src/ui/components/spinner/Spinner.scss b/code/client/src/ui/components/spinner/Spinner.scss index 014deb2b..d9866b98 100644 --- a/code/client/src/ui/components/spinner/Spinner.scss +++ b/code/client/src/ui/components/spinner/Spinner.scss @@ -2,8 +2,8 @@ border: 5px solid #f3f3f3; border-top: 5px solid lightgray; border-radius: 50%; - width: 30px!important; - height: 30px!important; + width: 30px !important; + height: 30px !important; animation: spin 1s linear infinite; margin: auto; } diff --git a/code/client/src/ui/hooks/useEditing.tsx b/code/client/src/ui/hooks/useEditing.tsx index 30f0ffe2..99141fe3 100644 --- a/code/client/src/ui/hooks/useEditing.tsx +++ b/code/client/src/ui/hooks/useEditing.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; +import useWorkspace from '@/contexts/workspace/useWorkspace'; function useEditing(initialValue: string, onEdit: (value: string) => void) { const [value, setValue] = useState(initialValue); const [isEditing, setIsEditing] = useState(false); + const { isMember } = useWorkspace(); // listen for changes in the initial value useEffect(() => { @@ -33,7 +35,7 @@ function useEditing(initialValue: string, onEdit: (value: string) => void) { return { component, isEditing, - setIsEditing, + setIsEditing: isMember ? setIsEditing : () => {}, }; } diff --git a/code/client/src/ui/pages/document/Document.tsx b/code/client/src/ui/pages/document/Document.tsx index aa362093..fa497194 100644 --- a/code/client/src/ui/pages/document/Document.tsx +++ b/code/client/src/ui/pages/document/Document.tsx @@ -22,7 +22,7 @@ function Document() { const connectors = useConnectors(fugue, communication); const navigate = useNavigate(); - // redirect to workspace if document is deleted + // redirect to workspace page if document is deleted connectors.service.on('deletedResource', rid => { if (id === rid) { publishError(Error('Document was deleted')); diff --git a/code/client/src/ui/pages/document/components/commit-history/CommitHistory.tsx b/code/client/src/ui/pages/document/components/commit-history/CommitHistory.tsx index 7181cff7..23fc03cf 100644 --- a/code/client/src/ui/pages/document/components/commit-history/CommitHistory.tsx +++ b/code/client/src/ui/pages/document/components/commit-history/CommitHistory.tsx @@ -36,7 +36,6 @@ function CommitHistory() { } async function fetchCommits() { const commits = await getCommits(); - console.log(commits); setCommits(commits); } startLoading(); diff --git a/code/client/src/ui/pages/document/components/commit/Commit.tsx b/code/client/src/ui/pages/document/components/commit/Commit.tsx index 18d12cea..2f830dd9 100644 --- a/code/client/src/ui/pages/document/components/commit/Commit.tsx +++ b/code/client/src/ui/pages/document/components/commit/Commit.tsx @@ -19,7 +19,6 @@ function Commit() { useEffect(() => { async function fetchCommit() { const commit = await getCommit(commitId!); - console.log(commit); setCommit(commit); fugue.applyOperations(commit.content, true); stopLoading(); diff --git a/code/client/src/ui/pages/document/components/editor/Editor.tsx b/code/client/src/ui/pages/document/components/editor/Editor.tsx index deb911d3..3ecc7279 100644 --- a/code/client/src/ui/pages/document/components/editor/Editor.tsx +++ b/code/client/src/ui/pages/document/components/editor/Editor.tsx @@ -14,6 +14,7 @@ import useCursors from '@ui/pages/document/components/editor/hooks/useCursors'; import getEventHandlers from '@domain/editor/slate/operations/getEventHandlers'; import useEditorSync from '@ui/pages/document/components/editor/hooks/useEditorSync'; import './Editor.scss'; +import useWorkspace from '@/contexts/workspace/useWorkspace'; type EditorProps = { title: string; @@ -29,6 +30,7 @@ function Editor({ title, connectors, fugue }: EditorProps) { const { renderElement, renderLeaf } = useRenderers(editor, fugue, connectors.service); const decorate = useDecorate(editor, cursors); const { syncEditor } = useEditorSync(fugue, setEditor); + const { isMember } = useWorkspace(); const { onInput, onShortcut, onCut, onPaste, onSelectionChange, onFormat, onBlur } = getEventHandlers( editor, connectors.input, @@ -40,7 +42,7 @@ function Editor({ title, connectors, fugue }: EditorProps) {
onSelectionChange()} initialValue={initialValue}> - + <Title title={title} placeholder="Untitled" connector={connectors.service} readOnly={!isMember} /> <Toolbar onApplyMark={onFormat} /> <Editable className="editable" @@ -56,6 +58,7 @@ function Editor({ title, connectors, fugue }: EditorProps) { onPaste={e => onPaste(e.nativeEvent)} onKeyDown={e => onShortcut(e.nativeEvent)} onBlur={onBlur} + readOnly={!isMember} /> </Slate> </div> diff --git a/code/client/src/ui/pages/workspaces/Workspaces.tsx b/code/client/src/ui/pages/workspaces/Workspaces.tsx index bb6a4303..89d49081 100644 --- a/code/client/src/ui/pages/workspaces/Workspaces.tsx +++ b/code/client/src/ui/pages/workspaces/Workspaces.tsx @@ -16,11 +16,12 @@ function Workspaces() { useEffect(() => { socket.connect(); + return () => socket.disconnect(); + }, [socket]); + + useEffect(() => { setRows(workspaces); - return () => { - socket.disconnect(); - }; - }, [socket, workspaces]); + }, [workspaces]); return ( <div className="workspaces"> diff --git a/code/server/package.json b/code/server/package.json index b34df74b..5fc97cad 100644 --- a/code/server/package.json +++ b/code/server/package.json @@ -15,6 +15,9 @@ }, "dependencies": { "@notespace/shared": "file:..\\shared", + "@types/cookie": "^0.6.0", + "@types/socket.io": "^3.0.2", + "cookie": "^0.6.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -29,11 +32,12 @@ "devDependencies": { "@babel/preset-env": "^7.24.5", "@babel/preset-typescript": "^7.24.1", + "@types/cookie-parser": "^1.4.7", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/lodash": "^4.17.1", - "@types/node": "^20.12.10", + "@types/node": "^20.14.8", "@types/pg": "^8.11.6", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", diff --git a/code/server/src/config.ts b/code/server/src/config.ts index 8dc6b62c..e6122543 100644 --- a/code/server/src/config.ts +++ b/code/server/src/config.ts @@ -1,25 +1,23 @@ import { config } from 'dotenv'; import * as process from 'node:process'; +import { ServerOptions } from 'socket.io'; config(); -const SERVER_PORT = parseInt(process.env.PORT || '8080'); -const CLIENT_PORT = parseInt(process.env.CLIENT_PORT || '5173'); -const ORIGIN = ['http://localhost:5173']; +const PORT = parseInt(process.env.PORT || '8080'); +const ORIGIN = 'http://localhost:5173'; -const SERVER_OPTIONS = { +const SERVER_OPTIONS: Partial<ServerOptions> = { cors: { origin: ORIGIN, - credentials: true, // allow credentials (cookies, authorization headers, etc.) - allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + credentials: true, // allow credentials (cookies) allowedHeaders: ['Authorization', 'Content-Type'], }, - connectionStateRecovery: {}, + connectionStateRecovery: {}, // enable connection state recovery }; export default { - SERVER_PORT, - CLIENT_PORT, + PORT, ORIGIN, SERVER_OPTIONS, }; diff --git a/code/server/src/controllers/http/middlewares/authMiddlewares.ts b/code/server/src/controllers/http/middlewares/authMiddlewares.ts index bb8d8591..b0ac16fc 100644 --- a/code/server/src/controllers/http/middlewares/authMiddlewares.ts +++ b/code/server/src/controllers/http/middlewares/authMiddlewares.ts @@ -1,8 +1,8 @@ -import admin from 'firebase-admin'; import { NextFunction, Request, Response } from 'express'; import { httpResponse } from '@controllers/http/utils/httpResponse'; import { LoggedUser } from '@notespace/shared/src/users/types'; import { ErrorLogger } from '@src/utils/logging'; +import admin from 'firebase-admin'; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -15,14 +15,12 @@ declare global { // middleware that injects the user object into the request if it has a valid session cookie export async function authMiddleware(req: Request, res: Response, next: NextFunction) { - const sessionCookie = req.cookies.session; + const sessionCookie = req.cookies?.session; if (!sessionCookie) { return next(); } try { - const idToken = await admin.auth().verifySessionCookie(sessionCookie, true); - const { uid, displayName, email } = await admin.auth().getUser(idToken.uid); - req.user = { id: uid, email: email!, name: displayName! }; + req.user = await verifySessionCookie(sessionCookie); next(); } catch (error) { ErrorLogger.logError('Request with invalid session token'); @@ -37,3 +35,9 @@ export async function enforceAuth(req: Request, res: Response, next: NextFunctio } next(); } + +async function verifySessionCookie(sessionCookie: string): Promise<LoggedUser> { + const idToken = await admin.auth().verifySessionCookie(sessionCookie, true); + const { uid, displayName, email } = await admin.auth().getUser(idToken.uid); + return { id: uid, email: email!, name: displayName! }; +} diff --git a/code/server/src/controllers/ws/events.ts b/code/server/src/controllers/ws/events.ts index d897915c..00a37a11 100644 --- a/code/server/src/controllers/ws/events.ts +++ b/code/server/src/controllers/ws/events.ts @@ -1,24 +1,24 @@ import { SocketHandler } from '@controllers/ws/types'; -import { DocumentsService } from '@services/DocumentsService'; import onOperation from '@controllers/ws/events/document/onOperation'; import onCursorChange from '@controllers/ws/events/document/onCursorChange'; import onJoinDocument from '@controllers/ws/events/document/onJoinDocument'; import onLeaveDocument from '@controllers/ws/events/document/onLeaveDocument'; import onJoinWorkspace from '@controllers/ws/events/workspace/onJoinWorkspace'; import onLeaveWorkspace from '@controllers/ws/events/workspace/onLeaveWorkspace'; +import { Services } from '@services/Services'; -export default function events(service: DocumentsService): Record<string, SocketHandler> { - if (!service) throw new Error('Service parameter is required'); +export default function events(services: Services): Record<string, SocketHandler> { + if (!services) throw new Error('Services parameter is required'); return { // document events - operations: onOperation(service), + operations: onOperation(services.documents), cursorChange: onCursorChange(), joinDocument: onJoinDocument(), leaveDocument: onLeaveDocument(), // workspace events - joinWorkspace: onJoinWorkspace(), + joinWorkspace: onJoinWorkspace(services.workspaces), leaveWorkspace: onLeaveWorkspace(), }; } diff --git a/code/server/src/controllers/ws/events/document/onJoinDocument.ts b/code/server/src/controllers/ws/events/document/onJoinDocument.ts index f1c7bc88..eee416ab 100644 --- a/code/server/src/controllers/ws/events/document/onJoinDocument.ts +++ b/code/server/src/controllers/ws/events/document/onJoinDocument.ts @@ -1,8 +1,10 @@ import { Socket } from 'socket.io'; import rooms from '@controllers/ws/rooms/rooms'; +import { InvalidParameterError } from '@domain/errors/errors'; function onJoinDocument() { return function (socket: Socket, documentId: string) { + if (!documentId) throw new InvalidParameterError('Document id is required'); rooms.document.join(socket, documentId); }; } diff --git a/code/server/src/controllers/ws/events/document/onOperation.ts b/code/server/src/controllers/ws/events/document/onOperation.ts index e0ff95df..11d05c30 100644 --- a/code/server/src/controllers/ws/events/document/onOperation.ts +++ b/code/server/src/controllers/ws/events/document/onOperation.ts @@ -10,7 +10,7 @@ function onOperation(service: DocumentsService) { if (!operations) throw new InvalidParameterError('Operations are required'); const { id, wid } = rooms.document.get(socket.id); - if (!id) throw new ForbiddenError('Client not in a room'); + if (!id) throw new ForbiddenError('Not in a room'); socket.broadcast.to(id).emit('operations', operations); await service.updateDocument(wid, id, operations); diff --git a/code/server/src/controllers/ws/events/workspace/onJoinWorkspace.ts b/code/server/src/controllers/ws/events/workspace/onJoinWorkspace.ts index b6e04153..ee02c5ee 100644 --- a/code/server/src/controllers/ws/events/workspace/onJoinWorkspace.ts +++ b/code/server/src/controllers/ws/events/workspace/onJoinWorkspace.ts @@ -1,10 +1,20 @@ -import { Socket } from 'socket.io'; import rooms from '@controllers/ws/rooms/rooms'; -import { InvalidParameterError } from '@domain/errors/errors'; +import { ForbiddenError, InvalidParameterError } from '@domain/errors/errors'; +import { WorkspacesService } from '@services/WorkspacesService'; +import { Socket } from 'socket.io'; +import { getUserFromSocket } from '@controllers/ws/utils'; -function onJoinWorkspace() { - return function (socket: Socket, id: string) { +function onJoinWorkspace(service: WorkspacesService) { + return async function (socket: Socket, id: string) { if (!id) throw new InvalidParameterError('Workspace id is required'); + + // ensure user is a member of the workspace + // this also prevents non-members from applying operations to documents inside the workspace + const { members } = await service.getWorkspace(id); + const user = getUserFromSocket(socket); + if (!user || !members.includes(user.email)) throw new ForbiddenError('Not a member of workspace'); + + // join the workspace room rooms.workspace.join(socket, id); }; } diff --git a/code/server/src/controllers/ws/rooms/rooms.ts b/code/server/src/controllers/ws/rooms/rooms.ts index edf8aa5a..89535f02 100644 --- a/code/server/src/controllers/ws/rooms/rooms.ts +++ b/code/server/src/controllers/ws/rooms/rooms.ts @@ -21,11 +21,9 @@ function leaveDocument(socket: Socket) { function getDocument(socketId: string) { const room = getRoom(documentRooms, socketId); - if (!room) throw new ForbiddenError('Client not in a document'); - + if (!room) throw new ForbiddenError('Not in a document'); const workspace = getWorkspace(socketId); - if (!workspace) throw new ForbiddenError('Client not in a workspace'); - + if (!workspace) throw new ForbiddenError('Not in a workspace'); return { id: room.id, wid: workspace.id }; } @@ -43,8 +41,7 @@ function leaveWorkspace(socket: Socket) { function getWorkspace(socketId: string) { const room = getRoom(workspaceRooms, socketId); - if (!room) throw new ForbiddenError('Client not in a workspace'); - + if (!room) throw new ForbiddenError('Not in a workspace'); return { id: room.id }; } diff --git a/code/server/src/controllers/ws/utils.ts b/code/server/src/controllers/ws/utils.ts new file mode 100644 index 00000000..3da962b4 --- /dev/null +++ b/code/server/src/controllers/ws/utils.ts @@ -0,0 +1,6 @@ +import { LoggedUser } from '@notespace/shared/src/users/types'; +import { Socket } from 'socket.io'; + +export function getUserFromSocket(socket: Socket) { + return (socket.request as any).user as LoggedUser | undefined; +} diff --git a/code/server/src/server.ts b/code/server/src/server.ts index fd2c69d7..a282ca86 100644 --- a/code/server/src/server.ts +++ b/code/server/src/server.ts @@ -11,6 +11,7 @@ import initSocketEvents from '@controllers/ws/initSocketEvents'; import { ServerLogger } from '@src/utils/logging'; import { TestDatabases } from '@databases/TestDatabases'; import { ProductionDatabases } from '@databases/ProductionDatabases'; +import { authMiddleware } from '@controllers/http/middlewares/authMiddlewares'; /** * Boot the server @@ -45,12 +46,14 @@ function bootServer(args: string[]): void { app.use('/', api); // setup event handlers - const events = eventsInit(services.documents); + io.engine.use(cookieParser()); + io.engine.use(authMiddleware); + const events = eventsInit(services); const socketEvents = initSocketEvents(events); io.on('connection', socketEvents); - server.listen(config.SERVER_PORT, () => { - ServerLogger.logSuccess(`Listening on port ${config.SERVER_PORT}`); + server.listen(config.PORT, () => { + ServerLogger.logSuccess(`Listening on port ${config.PORT}`); }); } diff --git a/code/server/src/services/WorkspacesService.ts b/code/server/src/services/WorkspacesService.ts index 690107e3..10f8d11e 100644 --- a/code/server/src/services/WorkspacesService.ts +++ b/code/server/src/services/WorkspacesService.ts @@ -1,6 +1,6 @@ import { Workspace, WorkspaceMeta } from '@notespace/shared/src/workspace/types/workspace'; import { Databases } from '@databases/types'; -import { ConflictError } from '@domain/errors/errors'; +import { InvalidParameterError } from '@domain/errors/errors'; import { validateEmail, validateId, validateName, validatePositiveNumber } from '@services/utils'; import { SearchParams } from '@src/utils/searchParams'; @@ -46,13 +46,13 @@ export class WorkspacesService { async addWorkspaceMember(wid: string, email: string) { const { userId, userInWorkspace } = await this.userInWorkspace(wid, email); - if (userInWorkspace) throw new ConflictError('User already in workspace'); + if (userInWorkspace) throw new InvalidParameterError('User already in workspace'); await this.databases.workspaces.addWorkspaceMember(wid, userId); } async removeWorkspaceMember(wid: string, email: string) { const { userId, userInWorkspace } = await this.userInWorkspace(wid, email); - if (!userInWorkspace) throw new ConflictError('User not in workspace'); + if (!userInWorkspace) throw new InvalidParameterError('User not in workspace'); await this.databases.workspaces.removeWorkspaceMember(wid, userId); }