mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 10:23:54 +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",
|
||||
"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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
+19
-18
@@ -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) => {
|
||||
|
||||
@@ -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'),
|
||||
/** 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'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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':
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user