From eb3abc4fe41400debcf894e53f91937498e77571 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 4 Sep 2022 13:56:53 +0200 Subject: [PATCH] UI server: add basic authentication support MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Signed-off-by: Jérôme Benoit --- README.md | 2 +- src/charging-station/Bootstrap.ts | 14 ++--- .../ui-server/AbstractUIServer.ts | 32 +++++++++-- .../ui-server/UIHttpServer.ts | 20 ++++--- .../ui-server/UIServerFactory.ts | 10 ++-- .../ui-server/UIWebSocketServer.ts | 55 ++++++++++++++++--- src/types/ConfigurationData.ts | 11 +++- src/types/UIProtocol.ts | 4 ++ src/ui/web/src/types/ConfigurationType.ts | 2 + 9 files changed, 112 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index d5a66a26..d9b45795 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ But the modifications to test have to be done to the files in the build target d | logFile | | combined.log | string | log file relative path | | logErrorFile | | error.log | string | error log file relative path | | worker | | {
"processType": "workerSet",
"startDelay": 500,
"elementStartDelay": 0,
"elementsPerWorker": 1,
"poolMinSize": 4,
"poolMaxSize": 16,
"poolStrategy": "ROUND_ROBBIN"
} | {
processType: WorkerProcessType;
startDelay: number;
elementStartDelay: number;
elementsPerWorker: number;
poolMinSize: number;
poolMaxSize: number;
poolStrategy: WorkerChoiceStrategy;
} | Worker configuration section:
- processType: worker threads process type (workerSet/staticPool/dynamicPool)
- startDelay: milliseconds to wait at worker threads startup (only for workerSet threads process type)
- elementStartDelay: milliseconds to wait at charging station startup
- elementsPerWorker: number of charging stations per worker threads for the `workerSet` process type
- poolMinSize: worker threads pool minimum number of threads
- poolMaxSize: worker threads pool maximum number of threads
- poolStrategy: worker threads pool [poolifier](https://github.com/poolifier/poolifier) worker choice strategy | -| uiServer | | {
"enabled": true,
"type": "ws",
"options": {
"host: "localhost",
"port": 8080
}
} | {
enabled: boolean;
type: ApplicationProtocol;
options: ServerOptions;
} | UI server configuration section | +| uiServer | | {
"enabled": true,
"type": "ws",
"options": {
"host: "localhost",
"port": 8080
}
} | {
enabled: boolean;
type: ApplicationProtocol;
options: ServerOptions;
authentication: {
enabled: boolean;
type: AuthenticationType;
username: string;
password: string;
}
} | UI server configuration section | | performanceStorage | | {
"enabled": false,
"type": "jsonfile",
"file:///performanceRecords.json"
} | {
enabled: boolean;
type: string;
URI: string;
}
where type can be 'jsonfile' or 'mongodb' | performance storage configuration section | | stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
}[] | array of charging station configuration templates URIs configuration section (charging station configuration template file name and number of stations) | diff --git a/src/charging-station/Bootstrap.ts b/src/charging-station/Bootstrap.ts index 5ed378c7..bc0f87d7 100644 --- a/src/charging-station/Bootstrap.ts +++ b/src/charging-station/Bootstrap.ts @@ -26,7 +26,6 @@ import type WorkerAbstract from '../worker/WorkerAbstract'; import WorkerFactory from '../worker/WorkerFactory'; import { ChargingStationUtils } from './ChargingStationUtils'; import type { AbstractUIServer } from './ui-server/AbstractUIServer'; -import { UIServiceUtils } from './ui-server/ui-services/UIServiceUtils'; import UIServerFactory from './ui-server/UIServerFactory'; const moduleName = 'Bootstrap'; @@ -54,12 +53,13 @@ export class Bootstrap { 'ChargingStationWorker' + path.extname(fileURLToPath(import.meta.url)) ); this.initialize(); - Configuration.getUIServer().enabled && - (this.uiServer = UIServerFactory.getUIServerImplementation(Configuration.getUIServer().type, { - ...Configuration.getUIServer().options, - handleProtocols: UIServiceUtils.handleProtocols, - })); - Configuration.getPerformanceStorage().enabled && + if (Configuration.getUIServer().enabled === true) { + this.uiServer = UIServerFactory.getUIServerImplementation( + Configuration.getUIServer().type, + Configuration.getUIServer() + ); + } + Configuration.getPerformanceStorage().enabled === true && (this.storage = StorageFactory.getStorage( Configuration.getPerformanceStorage().type, Configuration.getPerformanceStorage().uri, diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index 9a19d39d..a55d0070 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -1,9 +1,9 @@ -import type { Server as HttpServer } from 'http'; - -import type WebSocket from 'ws'; +import type { IncomingMessage, Server } from 'http'; import type { ChargingStationData } from '../../types/ChargingStationWorker'; -import type { +import type { UIServerConfiguration } from '../../types/ConfigurationData'; +import { + AuthenticationType, ProcedureName, ProtocolRequest, ProtocolResponse, @@ -15,10 +15,10 @@ import type AbstractUIService from './ui-services/AbstractUIService'; export abstract class AbstractUIServer { public readonly chargingStations: Map; - protected server: WebSocket.Server | HttpServer; + protected httpServer: Server; protected readonly uiServices: Map; - public constructor() { + public constructor(protected readonly uiServerConfiguration: UIServerConfiguration) { this.chargingStations = new Map(); this.uiServices = new Map(); } @@ -35,6 +35,26 @@ export abstract class AbstractUIServer { return [id, responsePayload]; } + protected isBasicAuthEnabled(): boolean { + return ( + this.uiServerConfiguration.authentication?.enabled === true && + this.uiServerConfiguration.authentication?.type === AuthenticationType.BASIC_AUTH + ); + } + + protected isValidBasicAuth(req: IncomingMessage): boolean { + const authorizationHeader = req.headers.authorization ?? ''; + const authorizationToken = authorizationHeader.split(/\s+/).pop() ?? ''; + const authentication = Buffer.from(authorizationToken, 'base64').toString(); + const authenticationParts = authentication.split(/:/); + const username = authenticationParts.shift(); + const password = authenticationParts.join(':'); + return ( + this.uiServerConfiguration.authentication?.username === username && + this.uiServerConfiguration.authentication?.password === password + ); + } + public abstract start(): void; public abstract stop(): void; public abstract sendRequest(request: ProtocolRequest): void; diff --git a/src/charging-station/ui-server/UIHttpServer.ts b/src/charging-station/ui-server/UIHttpServer.ts index a6a6313e..280f1d38 100644 --- a/src/charging-station/ui-server/UIHttpServer.ts +++ b/src/charging-station/ui-server/UIHttpServer.ts @@ -3,7 +3,7 @@ import { IncomingMessage, RequestListener, Server, ServerResponse } from 'http'; import { StatusCodes } from 'http-status-codes'; import BaseError from '../../exception/BaseError'; -import type { ServerOptions } from '../../types/ConfigurationData'; +import type { UIServerConfiguration } from '../../types/ConfigurationData'; import { ProcedureName, Protocol, @@ -13,7 +13,6 @@ import { RequestPayload, ResponseStatus, } from '../../types/UIProtocol'; -import Configuration from '../../utils/Configuration'; import logger from '../../utils/Logger'; import Utils from '../../utils/Utils'; import { AbstractUIServer } from './AbstractUIServer'; @@ -27,15 +26,15 @@ type responseHandler = { procedureName: ProcedureName; res: ServerResponse }; export default class UIHttpServer extends AbstractUIServer { private readonly responseHandlers: Map; - public constructor(private options?: ServerOptions) { - super(); - this.server = new Server(this.requestListener.bind(this) as RequestListener); + public constructor(protected readonly uiServerConfiguration: UIServerConfiguration) { + super(uiServerConfiguration); + this.httpServer = new Server(this.requestListener.bind(this) as RequestListener); this.responseHandlers = new Map(); } public start(): void { - if ((this.server as Server).listening === false) { - (this.server as Server).listen(this.options ?? Configuration.getUIServer().options); + if (this.httpServer.listening === false) { + this.httpServer.listen(this.uiServerConfiguration.options); } } @@ -72,6 +71,13 @@ export default class UIHttpServer extends AbstractUIServer { } private requestListener(req: IncomingMessage, res: ServerResponse): void { + if (this.isBasicAuthEnabled() === true && this.isValidBasicAuth(req) === false) { + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('WWW-Authenticate', 'Basic realm=users'); + res.writeHead(StatusCodes.UNAUTHORIZED); + res.end(`${StatusCodes.UNAUTHORIZED} Unauthorized`); + return; + } // Expected request URL pathname: /ui/:version/:procedureName const [protocol, version, procedureName] = req.url?.split('/').slice(1) as [ Protocol, diff --git a/src/charging-station/ui-server/UIServerFactory.ts b/src/charging-station/ui-server/UIServerFactory.ts index bbbb66b9..392d1ee0 100644 --- a/src/charging-station/ui-server/UIServerFactory.ts +++ b/src/charging-station/ui-server/UIServerFactory.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; -import type { ServerOptions } from '../../types/ConfigurationData'; +import type { UIServerConfiguration } from '../../types/ConfigurationData'; import { ApplicationProtocol } from '../../types/UIProtocol'; import Configuration from '../../utils/Configuration'; import type { AbstractUIServer } from './AbstractUIServer'; @@ -15,9 +15,9 @@ export default class UIServerFactory { public static getUIServerImplementation( applicationProtocol: ApplicationProtocol, - options?: ServerOptions + uiServerConfiguration?: UIServerConfiguration ): AbstractUIServer | null { - if (!UIServiceUtils.isLoopback(options?.host)) { + if (UIServiceUtils.isLoopback(uiServerConfiguration.options?.host) === false) { console.warn( chalk.magenta( 'Loopback address not detected in UI server configuration. This is not recommended.' @@ -26,9 +26,9 @@ export default class UIServerFactory { } switch (applicationProtocol) { case ApplicationProtocol.WS: - return new UIWebSocketServer(options ?? Configuration.getUIServer().options); + return new UIWebSocketServer(uiServerConfiguration ?? Configuration.getUIServer()); case ApplicationProtocol.HTTP: - return new UIHttpServer(options ?? Configuration.getUIServer().options); + return new UIHttpServer(uiServerConfiguration ?? Configuration.getUIServer()); default: return null; } diff --git a/src/charging-station/ui-server/UIWebSocketServer.ts b/src/charging-station/ui-server/UIWebSocketServer.ts index 32f52622..b973cf45 100644 --- a/src/charging-station/ui-server/UIWebSocketServer.ts +++ b/src/charging-station/ui-server/UIWebSocketServer.ts @@ -1,12 +1,13 @@ -import type { IncomingMessage } from 'http'; +import { IncomingMessage, createServer } from 'http'; +import type internal from 'stream'; -import WebSocket, { RawData } from 'ws'; +import { StatusCodes } from 'http-status-codes'; +import WebSocket, { RawData, WebSocketServer } from 'ws'; import BaseError from '../../exception/BaseError'; -import type { ServerOptions } from '../../types/ConfigurationData'; +import type { UIServerConfiguration } from '../../types/ConfigurationData'; import type { ProtocolRequest, ProtocolResponse } from '../../types/UIProtocol'; import { WebSocketCloseEventStatusCode } from '../../types/WebSocket'; -import Configuration from '../../utils/Configuration'; import logger from '../../utils/Logger'; import Utils from '../../utils/Utils'; import { AbstractUIServer } from './AbstractUIServer'; @@ -16,13 +17,19 @@ import { UIServiceUtils } from './ui-services/UIServiceUtils'; const moduleName = 'UIWebSocketServer'; export default class UIWebSocketServer extends AbstractUIServer { - public constructor(options?: ServerOptions) { - super(); - this.server = new WebSocket.Server(options ?? Configuration.getUIServer().options); + private readonly webSocketServer: WebSocketServer; + + public constructor(protected readonly uiServerConfiguration: UIServerConfiguration) { + super(uiServerConfiguration); + this.httpServer = createServer(); + this.webSocketServer = new WebSocketServer({ + handleProtocols: UIServiceUtils.handleProtocols, + noServer: true, + }); } public start(): void { - this.server.on('connection', (ws: WebSocket, request: IncomingMessage): void => { + this.webSocketServer.on('connection', (ws: WebSocket, req: IncomingMessage): void => { const [protocol, version] = UIServiceUtils.getProtocolAndVersion(ws.protocol); if (UIServiceUtils.isProtocolAndVersionSupported(protocol, version) === false) { logger.error( @@ -59,6 +66,24 @@ export default class UIWebSocketServer extends AbstractUIServer { ); }); }); + this.httpServer.on( + 'upgrade', + (req: IncomingMessage, socket: internal.Duplex, head: Buffer): void => { + this.authenticate(req, (err) => { + if (err) { + socket.write(`HTTP/1.1 ${StatusCodes.UNAUTHORIZED} Unauthorized\r\n\r\n`); + socket.destroy(); + return; + } + this.webSocketServer.handleUpgrade(req, socket, head, (ws: WebSocket) => { + this.webSocketServer.emit('connection', ws, req); + }); + }); + } + ); + if (this.httpServer.listening === false) { + this.httpServer.listen(this.uiServerConfiguration.options); + } } public stop(): void { @@ -84,13 +109,25 @@ export default class UIWebSocketServer extends AbstractUIServer { } private broadcastToClients(message: string): void { - for (const client of (this.server as WebSocket.Server).clients) { + for (const client of this.webSocketServer.clients) { if (client?.readyState === WebSocket.OPEN) { client.send(message); } } } + private authenticate(req: IncomingMessage, next: (err: Error) => void): void { + if (this.isBasicAuthEnabled() === true) { + if (this.isValidBasicAuth(req) === false) { + next(new Error('Unauthorized')); + } else { + next(undefined); + } + } else { + next(undefined); + } + } + private validateRawDataRequest(rawData: RawData): ProtocolRequest { // logger.debug( // `${this.logPrefix( diff --git a/src/types/ConfigurationData.ts b/src/types/ConfigurationData.ts index 1681fcf5..fb67bced 100644 --- a/src/types/ConfigurationData.ts +++ b/src/types/ConfigurationData.ts @@ -1,13 +1,12 @@ import type { ListenOptions } from 'net'; import type { WorkerChoiceStrategy } from 'poolifier'; -import type { ServerOptions as WSServerOptions } from 'ws'; import type { StorageType } from './Storage'; -import type { ApplicationProtocol } from './UIProtocol'; +import type { ApplicationProtocol, AuthenticationType } from './UIProtocol'; import type { WorkerProcessType } from './Worker'; -export type ServerOptions = WSServerOptions & ListenOptions; +export type ServerOptions = ListenOptions; export enum SupervisionUrlDistribution { ROUND_ROBIN = 'round-robin', @@ -24,6 +23,12 @@ export interface UIServerConfiguration { enabled?: boolean; type?: ApplicationProtocol; options?: ServerOptions; + authentication?: { + enabled: boolean; + type: AuthenticationType; + username?: string; + password?: string; + }; } export interface StorageConfiguration { diff --git a/src/types/UIProtocol.ts b/src/types/UIProtocol.ts index 1f78671e..ab46f035 100644 --- a/src/types/UIProtocol.ts +++ b/src/types/UIProtocol.ts @@ -9,6 +9,10 @@ export enum ApplicationProtocol { WS = 'ws', } +export enum AuthenticationType { + BASIC_AUTH = 'basic-auth', +} + export enum ProtocolVersion { '0.0.1' = '0.0.1', } diff --git a/src/ui/web/src/types/ConfigurationType.ts b/src/ui/web/src/types/ConfigurationType.ts index e9eb729d..f07f565e 100644 --- a/src/ui/web/src/types/ConfigurationType.ts +++ b/src/ui/web/src/types/ConfigurationType.ts @@ -6,4 +6,6 @@ interface UIServerConfig { host: string; port: number; protocol: string; + username?: string; + password?: string; } -- 2.34.1