UI server: add basic authentication support
authorJérôme Benoit <jerome.benoit@sap.com>
Sun, 4 Sep 2022 11:56:53 +0000 (13:56 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Sun, 4 Sep 2022 11:56:53 +0000 (13:56 +0200)
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
README.md
src/charging-station/Bootstrap.ts
src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/UIHttpServer.ts
src/charging-station/ui-server/UIServerFactory.ts
src/charging-station/ui-server/UIWebSocketServer.ts
src/types/ConfigurationData.ts
src/types/UIProtocol.ts
src/ui/web/src/types/ConfigurationType.ts

index d5a66a2606ebf8f19f3e6acd26a7ce9ec86e2d0c..d9b4579503d5d98f27e75d609d98d4efd18c6647 100644 (file)
--- 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                     |                                                  | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementStartDelay": 0,<br />"elementsPerWorker": 1,<br />"poolMinSize": 4,<br />"poolMaxSize": 16,<br />"poolStrategy": "ROUND_ROBBIN"<br />} | {<br />processType: WorkerProcessType;<br />startDelay: number;<br />elementStartDelay: number;<br />elementsPerWorker: number;<br />poolMinSize: number;<br />poolMaxSize: number;<br />poolStrategy: WorkerChoiceStrategy;<br />} | Worker configuration section:<br />- processType: worker threads process type (workerSet/staticPool/dynamicPool)<br />- startDelay: milliseconds to wait at worker threads startup (only for workerSet threads process type)<br />- elementStartDelay: milliseconds to wait at charging station startup<br />- elementsPerWorker: number of charging stations per worker threads for the `workerSet` process type<br />- poolMinSize: worker threads pool minimum number of threads</br >- poolMaxSize: worker threads pool maximum number of threads<br />- poolStrategy: worker threads pool [poolifier](https://github.com/poolifier/poolifier) worker choice strategy |
-| uiServer                   |                                                  | {<br />"enabled": true,<br />"type": "ws",<br />"options": {<br />"host: "localhost",<br />"port": 8080<br />}<br />}                                                                                          | {<br />enabled: boolean;<br />type: ApplicationProtocol;<br />options: ServerOptions;<br />}                                                                                                                                        | UI server configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
+| uiServer                   |                                                  | {<br />"enabled": true,<br />"type": "ws",<br />"options": {<br />"host: "localhost",<br />"port": 8080<br />}<br />}                                                                                          | {<br />enabled: boolean;<br />type: ApplicationProtocol;<br />options: ServerOptions;<br />authentication: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username: string;<br />password: string;<br />}<br />}      | UI server configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
 | performanceStorage         |                                                  | {<br />"enabled": false,<br />"type": "jsonfile",<br />"file:///performanceRecords.json"<br />}                                                                                                                | {<br />enabled: boolean;<br />type: string;<br />URI: string;<br />}<br />where type can be 'jsonfile' or 'mongodb'                                                                                                                 | performance storage configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
 | stationTemplateUrls        |                                                  | {}[]                                                                                                                                                                                                           | {<br />file: string;<br />numberOfStations: number;<br />}[]                                                                                                                                                                        | array of charging station configuration templates URIs configuration section (charging station configuration template file name and number of stations)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
 
index 5ed378c7894b99e29b754a2c0a98e24dec7d4dda..bc0f87d7fba0f7d20ea5cab784c7bd0fd369f3c4 100644 (file)
@@ -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,
index 9a19d39d9a6d29550c614b937f14fd18bcb514e3..a55d0070abb8fb6b4322203f608a8ea2d1a120cf 100644 (file)
@@ -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<string, ChargingStationData>;
-  protected server: WebSocket.Server | HttpServer;
+  protected httpServer: Server;
   protected readonly uiServices: Map<ProtocolVersion, AbstractUIService>;
 
-  public constructor() {
+  public constructor(protected readonly uiServerConfiguration: UIServerConfiguration) {
     this.chargingStations = new Map<string, ChargingStationData>();
     this.uiServices = new Map<ProtocolVersion, AbstractUIService>();
   }
@@ -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;
index a6a6313e02182b6dbd0e1e4c5aaaf417f9ee9728..280f1d38040d71ab7151d1c33fa69b93f0977189 100644 (file)
@@ -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<string, responseHandler>;
 
-  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<string, responseHandler>();
   }
 
   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,
index bbbb66b9c2fd67b01e2deb604345ae95314a7b36..392d1ee010f6ab1447cea880fdd311f8d19c21f7 100644 (file)
@@ -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;
     }
index 32f5262207873aaa62b71682ab92ac46d6fb316a..b973cf45b6663aa43be9fda38a2dd43ca04ad9a7 100644 (file)
@@ -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(
index 1681fcf53e19580f351a5d14b86cba7d3aadfe53..fb67bced00fdf3ac8f75f107a74f1383ff61a02e 100644 (file)
@@ -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 {
index 1f78671e2f6a4901c935b41736f1688459c2ff65..ab46f0355e717ffd018017357c871be44a2fed2d 100644 (file)
@@ -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',
 }
index e9eb729daf04098d07b65c30ea473f0f754032cb..f07f565e3315a5366ead7940e0681d8369097f6b 100644 (file)
@@ -6,4 +6,6 @@ interface UIServerConfig {
   host: string;
   port: number;
   protocol: string;
+  username?: string;
+  password?: string;
 }