mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 12:29:10 +00:00
feat: add auth to server requests
This commit is contained in:
committed by
Carlos Valente
parent
ce8d534953
commit
7fe5223af9
@@ -6,6 +6,8 @@
|
|||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { eventStore } from '../stores/EventStore.js';
|
|||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||||
import { generateId } from 'ontime-utils';
|
import { generateId } from 'ontime-utils';
|
||||||
|
import { authenticateSocket } from '../middleware/authenticate.js';
|
||||||
|
|
||||||
let instance: SocketServer | null = null;
|
let instance: SocketServer | null = null;
|
||||||
|
|
||||||
@@ -51,7 +52,12 @@ export class SocketServer implements IAdapter {
|
|||||||
this.shouldShowWelcome = showWelcome;
|
this.shouldShowWelcome = showWelcome;
|
||||||
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
|
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();
|
const clientId = generateId();
|
||||||
|
|
||||||
this.clients.set(clientId, {
|
this.clients.set(clientId, {
|
||||||
|
|||||||
+19
-18
@@ -5,16 +5,18 @@ import express from 'express';
|
|||||||
import http, { Server } from 'http';
|
import http, { Server } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import serverTiming from 'server-timing';
|
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 middleware configuration
|
||||||
import { bodyParser } from './middleware/bodyParser.js';
|
import { bodyParser } from './middleware/bodyParser.js';
|
||||||
import { compressedStatic } from './middleware/staticGZip.js';
|
import { compressedStatic } from './middleware/staticGZip.js';
|
||||||
|
import { loginRouter, makeAuthenticateMiddleware } from './middleware/authenticate.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 Routers
|
// Import Routers
|
||||||
import { appRouter } from './api-data/index.js';
|
import { appRouter } from './api-data/index.js';
|
||||||
@@ -73,19 +75,18 @@ if (!isProduction) {
|
|||||||
}
|
}
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
|
||||||
// setup cors for all routes
|
|
||||||
app.use(cors());
|
|
||||||
|
|
||||||
// enable pre-flight cors
|
|
||||||
app.options('*', cors());
|
|
||||||
|
|
||||||
// Implement middleware
|
// Implement middleware
|
||||||
|
app.use(cors()); // setup cors for all routes
|
||||||
|
app.options('*', cors()); // enable pre-flight cors
|
||||||
|
|
||||||
app.use(bodyParser);
|
app.use(bodyParser);
|
||||||
app.use(prefix, compressedStatic);
|
app.use(cookieParser());
|
||||||
|
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
|
||||||
|
|
||||||
// Implement route endpoints
|
// Implement route endpoints
|
||||||
app.use(`${prefix}/data`, appRouter); // router for application data
|
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||||
app.use(`${prefix}/api`, integrationRouter); // router for integrations
|
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||||
|
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||||
|
|
||||||
// serve static external files
|
// serve static external files
|
||||||
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
|
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.use(`${prefix}/user`, express.static(publicDir.userDir));
|
||||||
|
|
||||||
app.get(`${prefix}/*`, (_req, res) => {
|
// Base route for static files
|
||||||
res.sendFile(srcFiles.clientIndexHtml);
|
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
|
||||||
});
|
app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
|
||||||
|
|
||||||
// Implement catch all
|
// Implement catch all
|
||||||
app.use((_error, response) => {
|
app.use((_error, response) => {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
||||||
|
<meta name="description" content="Login page for Ontime application" />
|
||||||
|
<title>Ontime Login</title>
|
||||||
|
<style>
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana,
|
||||||
|
sans-serif;
|
||||||
|
background: #101010;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding-top: 30vh;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #ff7597;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
all: unset;
|
||||||
|
text-align: start;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
background-color: #fffffa;
|
||||||
|
color: black;
|
||||||
|
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid #779be7;
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
all: unset;
|
||||||
|
color: #779be7;
|
||||||
|
background: #2d2d2d;
|
||||||
|
height: 2rem;
|
||||||
|
padding-inline: 2rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #404040;
|
||||||
|
}
|
||||||
|
button:focus {
|
||||||
|
outline: 2px solid #779be7;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<form method="post" class="form" autocomplete="off">
|
||||||
|
<h1>Welcome to Ontime</h1>
|
||||||
|
<input type="hidden" name="redirect" value="" id="redirect" />
|
||||||
|
<input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const redirect = params.get('redirect');
|
||||||
|
if (redirect) {
|
||||||
|
// Validate redirect URL to prevent open redirect attacks
|
||||||
|
const isValidRedirect = redirect.startsWith('/') && !redirect.startsWith('//');
|
||||||
|
if (isValidRedirect) {
|
||||||
|
document.getElementById('redirect').value = redirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -87,6 +87,8 @@ export const srcFiles = {
|
|||||||
userReadme: join(srcDir.root, config.user, 'README.md'),
|
userReadme: join(srcDir.root, config.user, 'README.md'),
|
||||||
/** Path to bundled CSS readme */
|
/** Path to bundled CSS readme */
|
||||||
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
|
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
|
||||||
|
/** Path to login */
|
||||||
|
login: join(srcDir.root, 'html/login.html'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
Generated
+27
@@ -288,6 +288,12 @@ importers:
|
|||||||
'@googleapis/sheets':
|
'@googleapis/sheets':
|
||||||
specifier: ^5.0.5
|
specifier: ^5.0.5
|
||||||
version: 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:
|
cors:
|
||||||
specifier: ^2.8.5
|
specifier: ^2.8.5
|
||||||
version: 2.8.5
|
version: 2.8.5
|
||||||
@@ -2717,6 +2723,10 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
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:
|
cookie-signature@1.0.6:
|
||||||
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||||
|
|
||||||
@@ -2724,6 +2734,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
copy-to-clipboard@3.3.3:
|
||||||
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
|
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
|
||||||
|
|
||||||
@@ -7799,10 +7817,19 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
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-signature@1.0.6: {}
|
||||||
|
|
||||||
cookie@0.7.1: {}
|
cookie@0.7.1: {}
|
||||||
|
|
||||||
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
cookie@1.0.2: {}
|
||||||
|
|
||||||
copy-to-clipboard@3.3.3:
|
copy-to-clipboard@3.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
toggle-selection: 1.0.6
|
toggle-selection: 1.0.6
|
||||||
|
|||||||
Reference in New Issue
Block a user