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
+
+
+
+
+
+
+
+
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