Skip to content

Commit

Permalink
Implemented Socket Authentication & Authorization
Browse files Browse the repository at this point in the history
  • Loading branch information
R1c4rdCo5t4 committed Jun 25, 2024
1 parent 0de312f commit 1bc8a2c
Show file tree
Hide file tree
Showing 20 changed files with 83 additions and 50 deletions.
7 changes: 6 additions & 1 deletion code/client/src/contexts/workspace/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Resource>;

Expand All @@ -20,6 +21,7 @@ export type WorkspaceContextType = {
workspace?: WorkspaceMeta;
resources?: Resources;
operations?: WorkspaceOperations;
isMember: boolean;
};

export const WorkspaceContext = createContext<WorkspaceContextType>({});
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -51,7 +56,7 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
}, [wid]);

return (
<WorkspaceContext.Provider value={{ workspace, resources, operations: otherOperations }}>
<WorkspaceContext.Provider value={{ workspace, resources, operations: otherOperations, isMember }}>
{children}
</WorkspaceContext.Provider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions code/client/src/ui/components/spinner/Spinner.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion code/client/src/ui/hooks/useEditing.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -33,7 +35,7 @@ function useEditing(initialValue: string, onEdit: (value: string) => void) {
return {
component,
isEditing,
setIsEditing,
setIsEditing: isMember ? setIsEditing : () => {},
};
}

Expand Down
2 changes: 1 addition & 1 deletion code/client/src/ui/pages/document/Document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ function CommitHistory() {
}
async function fetchCommits() {
const commits = await getCommits();
console.log(commits);
setCommits(commits);
}
startLoading();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -40,7 +42,7 @@ function Editor({ title, connectors, fugue }: EditorProps) {
<div className="editor">
<div className="container">
<Slate editor={editor} onChange={() => onSelectionChange()} initialValue={initialValue}>
<Title title={title} placeholder="Untitled" connector={connectors.service} />
<Title title={title} placeholder="Untitled" connector={connectors.service} readOnly={!isMember} />
<Toolbar onApplyMark={onFormat} />
<Editable
className="editable"
Expand All @@ -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>
Expand Down
9 changes: 5 additions & 4 deletions code/client/src/ui/pages/workspaces/Workspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
6 changes: 5 additions & 1 deletion code/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 7 additions & 9 deletions code/server/src/config.ts
Original file line number Diff line number Diff line change
@@ -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,
};
14 changes: 9 additions & 5 deletions code/server/src/controllers/http/middlewares/authMiddlewares.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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');
Expand All @@ -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! };
}
10 changes: 5 additions & 5 deletions code/server/src/controllers/ws/events.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
}
Original file line number Diff line number Diff line change
@@ -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);
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 14 additions & 4 deletions code/server/src/controllers/ws/events/workspace/onJoinWorkspace.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
Expand Down
9 changes: 3 additions & 6 deletions code/server/src/controllers/ws/rooms/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

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

Expand Down
6 changes: 6 additions & 0 deletions code/server/src/controllers/ws/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 6 additions & 3 deletions code/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
});
}

Expand Down
Loading

0 comments on commit 1bc8a2c

Please sign in to comment.