diff --git a/apps/server/package.json b/apps/server/package.json index b340daa92..835aa0b18 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,6 +6,8 @@ "exports": "./src/index.js", "dependencies": { "@googleapis/sheets": "^5.0.5", + "cookie": "^1.0.2", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.0.1", "express": "^4.21.1", diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index 9fc1a6c75..f90a8f22e 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -25,6 +25,7 @@ import { eventStore } from '../stores/EventStore.js'; import { logger } from '../classes/Logger.js'; import { dispatchFromAdapter } from '../api-integration/integration.controller.js'; import { generateId } from 'ontime-utils'; +import { authenticateSocket } from '../middleware/authenticate.js'; let instance: SocketServer | null = null; @@ -51,7 +52,12 @@ export class SocketServer implements IAdapter { this.shouldShowWelcome = showWelcome; this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD }); - this.wss.on('connection', (ws) => { + this.wss.on('connection', (ws, req) => { + authenticateSocket(ws, req, (error) => { + if (error) { + ws.close(1008, 'Unauthorized'); + } + }); const clientId = generateId(); this.clients.set(clientId, { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 18afb200a..a0884ca84 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -5,16 +5,18 @@ import express from 'express'; import http, { Server } from 'http'; import cors from 'cors'; import serverTiming from 'server-timing'; +import cookieParser from 'cookie-parser'; + +// import utils +import { publicDir, srcDir } from './setup/index.js'; +import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js'; +import { ONTIME_VERSION } from './ONTIME_VERSION.js'; +import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js'; // Import middleware configuration import { bodyParser } from './middleware/bodyParser.js'; import { compressedStatic } from './middleware/staticGZip.js'; - -// import utils -import { publicDir, srcDir, srcFiles } from './setup/index.js'; -import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js'; -import { ONTIME_VERSION } from './ONTIME_VERSION.js'; -import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js'; +import { loginRouter, makeAuthenticateMiddleware } from './middleware/authenticate.js'; // Import Routers import { appRouter } from './api-data/index.js'; @@ -73,19 +75,18 @@ if (!isProduction) { } app.disable('x-powered-by'); -// setup cors for all routes -app.use(cors()); - -// enable pre-flight cors -app.options('*', cors()); - // Implement middleware +app.use(cors()); // setup cors for all routes +app.options('*', cors()); // enable pre-flight cors + app.use(bodyParser); -app.use(prefix, compressedStatic); +app.use(cookieParser()); +const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix); // Implement route endpoints -app.use(`${prefix}/data`, appRouter); // router for application data -app.use(`${prefix}/api`, integrationRouter); // router for integrations +app.use(`${prefix}/data`, authenticate, appRouter); // router for application data +app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations +app.use(`${prefix}/login`, loginRouter); // router for login flow // serve static external files app.use(`${prefix}/external`, express.static(publicDir.externalDir)); @@ -95,9 +96,9 @@ app.use(`${prefix}/external`, (req, res) => { }); app.use(`${prefix}/user`, express.static(publicDir.userDir)); -app.get(`${prefix}/*`, (_req, res) => { - res.sendFile(srcFiles.clientIndexHtml); -}); +// Base route for static files +app.use(`${prefix}`, authenticateAndRedirect, compressedStatic); +app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic); // Implement catch all app.use((_error, response) => { diff --git a/apps/server/src/html/login.html b/apps/server/src/html/login.html new file mode 100644 index 000000000..3f8d3921a --- /dev/null +++ b/apps/server/src/html/login.html @@ -0,0 +1,82 @@ + + + + + + + + + Ontime Login + + + + +
+

Welcome to Ontime

+ + + +
+ + + diff --git a/apps/server/src/middleware/authenticate.ts b/apps/server/src/middleware/authenticate.ts new file mode 100644 index 000000000..0d03a6baf --- /dev/null +++ b/apps/server/src/middleware/authenticate.ts @@ -0,0 +1,141 @@ +import { LogOrigin } from 'ontime-types'; + +import express, { type Request, type Response, type NextFunction } from 'express'; +import type { IncomingMessage } from 'node:http'; +import type { WebSocket } from 'ws'; +import { parse as parseCookie } from 'cookie'; + +import { hashPassword } from '../utils/hash.js'; +import { srcFiles } from '../setup/index.js'; +import { logger } from '../classes/Logger.js'; +// import { password } from '../externals.js'; +const password = 'test'; +import { noopMiddleware } from './noop.js'; + +export const hasPassword = Boolean(password); +const hashedPassword = hasPassword ? hashPassword(password) : ''; + +/** + * List of public assets that can be accessed without authentication + * should match the files in client/public + */ +const publicAssets = ['/favicon.ico', '/manifest.json', '/ontime-logo.png', '/robots.txt', '/site.webmanifest']; + +export const loginRouter = express.Router(); + +// serve static files at root +loginRouter.use('/', express.static(srcFiles.login)); + +// verify password and set cookies + redirect appropriately +loginRouter.post('/', (req, res) => { + res.clearCookie('token'); + const { password: reqPassword, redirect } = req.body; + + if (!hasPassword) { + res.redirect(redirect || '/'); + return; + } + + if (!reqPassword) { + res.status(401).send('Unauthorized'); + return; + } + + if (hashPassword(reqPassword) === hashedPassword) { + res.cookie('token', hashedPassword, { + httpOnly: false, // allow websocket to access cookie + secure: true, + path: '/', // allow cookie to be accessed from any path + sameSite: 'strict', + }); + res.redirect(redirect || '/'); + return; + } + + res.status(401).send('Unauthorized'); +}); + +/** + * Express middleware to authenticate requests + * @param {string} prefix - Prefix is used for the client hashes in Ontime Cloud + */ +export function makeAuthenticateMiddleware(prefix: string) { + // we dont need to initialise the authenticate middleware if there is no password + if (!hasPassword) { + return { authenticate: noopMiddleware, authenticateAndRedirect: noopMiddleware }; + } + + function authenticate(req: Request, res: Response, next: NextFunction) { + const token = req.query.token || req.cookies?.token; + if (token && token === hashedPassword) { + return next(); + } + + res.status(401).send('Unauthorized'); + } + + function authenticateAndRedirect(req: Request, res: Response, next: NextFunction) { + // Allow access to specific public assets without authentication + if (publicAssets.includes(req.originalUrl)) { + return next(); + } + + if (req.originalUrl.startsWith('/login')) { + // cannot authenticate the login route + return next(); + } + + // we expect the token to be in the cookies + if (req.cookies?.token === hashedPassword) { + return next(); + } + + // we use query params for generating authenticated URLs and for clients like the companion module + // if the user gives is a token in the query params, we set the cookie to be used in further requests + if (req.query.token === hashedPassword) { + res.cookie('token', hashedPassword, { + httpOnly: false, // allow websocket to access cookie + secure: true, + path: '/', // allow cookie to be accessed from any path + sameSite: 'strict', + }); + return next(); + } + + const redirect = req.originalUrl.startsWith('/login') + ? `${prefix}/login` + : `${prefix}/login?redirect=${req.originalUrl}`; + + res.redirect(redirect); + } + + return { authenticate, authenticateAndRedirect }; +} + +/** + * Middleware to authenticate a WebSocket connection with a token in the cookie + */ +export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (error?: Error) => void) { + if (!hasPassword) { + return next(); + } + + // check if the token is in the cookie + const cookieString = req.headers.cookie; + if (typeof cookieString === 'string') { + const cookies = parseCookie(cookieString); + if (cookies.token === hashedPassword) { + return next(); + } + } + + // check if token is in the params + const url = new URL(req.url || '', `http://${req.headers.host}`); + const token = url.searchParams.get('token'); + if (token === hashedPassword) { + return next(); + } + + logger.warning(LogOrigin.Client, 'Unauthorized WebSocket connection attempt'); + return next(new Error('Unauthorized')); +} diff --git a/apps/server/src/middleware/noop.ts b/apps/server/src/middleware/noop.ts new file mode 100644 index 000000000..23c59b7d2 --- /dev/null +++ b/apps/server/src/middleware/noop.ts @@ -0,0 +1,5 @@ +import type { Request, Response, NextFunction } from 'express'; + +export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) { + next(); +} diff --git a/apps/server/src/setup/index.ts b/apps/server/src/setup/index.ts index 0990d69c0..63d969f66 100644 --- a/apps/server/src/setup/index.ts +++ b/apps/server/src/setup/index.ts @@ -87,6 +87,8 @@ export const srcFiles = { userReadme: join(srcDir.root, config.user, 'README.md'), /** Path to bundled CSS readme */ cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'), + /** Path to login */ + login: join(srcDir.root, 'html/login.html'), }; /** diff --git a/apps/server/src/utils/hash.ts b/apps/server/src/utils/hash.ts new file mode 100644 index 000000000..bfe1b1f5d --- /dev/null +++ b/apps/server/src/utils/hash.ts @@ -0,0 +1,9 @@ +import { createHash } from 'node:crypto'; + +/** + * Creates a hash of the password that is URL safe + * @link https://stackoverflow.com/questions/17639645/websafe-encoding-of-hashed-string-in-nodejs + */ +export function hashPassword(password: string) { + return createHash('sha256').update(password).digest('base64url'); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c223acb3a..664ebeabe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,12 @@ importers: '@googleapis/sheets': specifier: ^5.0.5 version: 5.0.5 + cookie: + specifier: ^1.0.2 + version: 1.0.2 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 cors: specifier: ^2.8.5 version: 2.8.5 @@ -2717,6 +2723,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -2724,6 +2734,14 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -7799,10 +7817,19 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + cookie-signature@1.0.6: {} cookie@0.7.1: {} + cookie@0.7.2: {} + + cookie@1.0.2: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6