diff --git a/src/index.ts b/src/index.ts index 1e28a8c..250ef17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import "dotenv/config"; import "./handlebars.config"; import express from "express"; +import { createServer } from "http"; import { configCheck } from "./env.defaults"; configCheck(); @@ -37,7 +38,10 @@ dataSource.initialize().then(async () => { const app = express(); import router from "./routes/index"; router(app); -app.listen(process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000, () => { +const httpServer = createServer(app); +import SocketServer from "./websocket"; +SocketServer.init(httpServer); +httpServer.listen(process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000, () => { console.log( `${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000}` ); diff --git a/src/middleware/authenticateSocket.ts b/src/middleware/authenticateSocket.ts new file mode 100644 index 0000000..fe8af95 --- /dev/null +++ b/src/middleware/authenticateSocket.ts @@ -0,0 +1,54 @@ +import jwt from "jsonwebtoken"; +import BadRequestException from "../exceptions/badRequestException"; +import InternalException from "../exceptions/internalException"; +import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; +import { JWTHelper } from "../helpers/jwtHelper"; +import { SocketMap } from "../storage/socketMap"; +import { Socket } from "socket.io"; + +export default async function authenticateSocket(socket: Socket, next: Function) { + try { + const token = socket.handshake.auth.token; + + if (!token) { + throw new BadRequestException("Provide valid Authorization Header"); + } + + let decoded: string | jwt.JwtPayload; + await JWTHelper.validate(token) + .then((result) => { + decoded = result; + }) + .catch((err) => { + if (err == "jwt expired") { + throw new UnauthorizedRequestException("Token expired", err); + } else { + throw new BadRequestException("Failed Authorization Header decoding", err); + } + }); + + if (typeof decoded == "string" || !decoded) { + throw new InternalException("process failed"); + } + + if (decoded?.sub == "api_token_retrieve") { + throw new BadRequestException("This token is only authorized to get temporary access tokens via GET /api/webapi"); + } + + SocketMap.write(socket.id, { + socketId: socket.id, + userId: decoded.userId, + username: decoded.username, + firstname: decoded.firstname, + lastname: decoded.lastname, + isOwner: decoded.isOwner, + permissions: decoded.permissions, + isWebApiRequest: decoded?.sub == "webapi_access_token", + }); + socket.join("home"); + + next(); + } catch (err) { + next(err); + } +} diff --git a/src/middleware/checkSocketExists.ts b/src/middleware/checkSocketExists.ts new file mode 100644 index 0000000..db43554 --- /dev/null +++ b/src/middleware/checkSocketExists.ts @@ -0,0 +1,11 @@ +import { Event, Socket } from "socket.io"; +import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; +import { SocketMap } from "../storage/socketMap"; + +export default async (socket: Socket, [event, ...args]: Event, next: any) => { + if (SocketMap.exists(socket.id)) { + next(); + } else { + next(new UnauthorizedRequestException("not authorized for connection")); + } +}; diff --git a/src/storage/socketMap.ts b/src/storage/socketMap.ts new file mode 100644 index 0000000..e5bfeec --- /dev/null +++ b/src/storage/socketMap.ts @@ -0,0 +1,35 @@ +import { PermissionObject } from "../type/permissionTypes"; + +export interface socketStoreModel { + socketId: string; + userId: string; + username: string; + firstname: string; + lastname: string; + isOwner: string; + permissions: PermissionObject; + isWebApiRequest: boolean; +} + +/** + * @description store credentials to socket to prevent auth data change while connected + */ +export abstract class SocketMap { + private static store = new Map(); + + public static write(identifier: string, data: socketStoreModel, overwrite: boolean = false): void { + if (!this.exists(identifier) || overwrite) this.store.set(identifier, data); + } + + public static read(identifier: string): socketStoreModel { + return this.store.get(identifier); + } + + public static exists(identifier: string): boolean { + return this.store.has(identifier); + } + + public static delete(identifier: string): void { + this.store.delete(identifier); + } +} diff --git a/src/websocket/base.ts b/src/websocket/base.ts new file mode 100644 index 0000000..8243717 --- /dev/null +++ b/src/websocket/base.ts @@ -0,0 +1,35 @@ +import { Server, Socket } from "socket.io"; +import CustomRequestException from "../exceptions/customRequestException"; +import { SocketMap } from "../storage/socketMap"; + +export default (io: Server, socket: Socket) => { + socket.on("ping", (callback: Function) => { + callback(); + }); + + socket.on("error", (err) => { + let status = 500; + let msg = "Internal Server Error"; + + if (err instanceof CustomRequestException) { + status = err.statusCode; + msg = err.message; + } + + if (err instanceof CustomRequestException) { + console.log("WS Custom Handler", status, msg); + } else { + console.log("WS Error Handler", err); + } + + socket.emit("error", msg); + }); + + socket.on("disconnecting", () => { + console.log("socket disconnection: ", socket.id); + + SocketMap.delete(socket.id); + }); + + socket.on("disconnect", () => {}); +}; diff --git a/src/websocket/handleEvent.ts b/src/websocket/handleEvent.ts new file mode 100644 index 0000000..0a484f2 --- /dev/null +++ b/src/websocket/handleEvent.ts @@ -0,0 +1,72 @@ +import { Server, Socket } from "socket.io"; +import { PermissionObject, PermissionType, PermissionSection, PermissionModule } from "../type/permissionTypes"; +import PermissionHelper from "../helpers/permissionHelper"; +import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; +import { SocketMap } from "../storage/socketMap"; + +export type EventResponseType = { + answer: any; + type: + | `package-${string}` + | `status-${string}:${string}` + | "display" + | "warning" + | "reload" + | "deleted" + | "action required"; + room?: string; +}; + +type PermissionPass = + | { + type: PermissionType; + section: PermissionSection; + module?: PermissionModule; + } + | "admin"; + +export let handleEvent = ( + permissions: PermissionPass, + handler: (...args: any[]) => Promise, + socket: Socket +) => { + return async (...args: any[]) => { + try { + const socketData = SocketMap.read(socket.id); + if (permissions == "admin") { + if (!socketData.isOwner && !socketData.permissions.admin) { + throw new ForbiddenRequestException(`missing admin permission`); + } + } else { + if ( + !socketData.isOwner && + !PermissionHelper.can(socketData.permissions, permissions.type, permissions.section, permissions.module) + ) { + throw new ForbiddenRequestException(`missing required permission`); + } + } + + const { answer, type, room } = await handler(...args); + if (room === undefined || room == "") { + socket.emit(type, answer); + } else { + socket.to(room).emit(type, answer); + } + } catch (e) { + socket.emit("error", e.message); + } + }; +}; + +/** + socket.on( + "event", + { permissions } + handleResponse( + async (data:any) => { + throw new Error("failed") + }, + socket, + ) + ); + */ diff --git a/src/websocket/index.ts b/src/websocket/index.ts new file mode 100644 index 0000000..aac4c17 --- /dev/null +++ b/src/websocket/index.ts @@ -0,0 +1,33 @@ +import helmet from "helmet"; +import { Server as httpServerType } from "http"; +import { Server } from "socket.io"; +import authenticateSocket from "../middleware/authenticateSocket"; +import checkSocketExists from "../middleware/checkSocketExists"; +import base from "./base"; + +export default abstract class SocketServer { + private static io: Server; + + public static init(httpServer: httpServerType) { + this.io = new Server(httpServer, { + cors: { + origin: "*", + methods: ["GET", "POST"], + credentials: true, + }, + }); + + this.io.engine.use(helmet()); + + this.io + .of("/scanner") + .use(authenticateSocket) + .on("connection", (socket) => { + console.log("socket connection: ", socket.id); + socket.use((packet, next) => authenticateSocket(socket, next)); + socket.use((packet, next) => checkSocketExists(socket, packet, next)); + + base(this.io, socket); + }); + } +} diff --git a/src/websocket/scanner/index.ts b/src/websocket/scanner/index.ts new file mode 100644 index 0000000..c588e2c --- /dev/null +++ b/src/websocket/scanner/index.ts @@ -0,0 +1,7 @@ +import { Server, Socket } from "socket.io"; + +export default (io: Server, socket: Socket) => { + socket.on("disconnecting", () => { + // tell public client, that host left - connection will be closed + }); +};