ソースを参照

Umbau auf Router/Handler Architektur; Basis-Implementierung Public/Private Page + Login/Logout; Readme updated

Christian Kahlau 3 年 前
コミット
307d48e9c8

+ 4 - 1
.env

@@ -1 +1,4 @@
-WEB_PORT=8999
+WEB_PORT=8999
+SESSION_TIMEOUT_SEC=1440
+ENABLE_BASIC_AUTH=1
+PASSWORD_SALT=Geh3im3s5@lt

+ 3 - 3
Readme.md

@@ -6,11 +6,11 @@ Dieses Projekt beinhaltet eine sog. Boilerplate, mit der sich schnell ein Webser
 
 Der Server beinhaltet Grundfunktionalität, also:
 
-- Ein einfacher `/echo`-Endpoint
+- Ein einfacher [/echo](http://localhost:8999/echo)-Endpoint
   - -> Schicke einen Request ab und erhalte als Antwort genau deine Request-Daten zurück
-- `(TODO)` Ein einfacher `fileserver` unter `/file/**`
+- Ein einfacher `fileserver` unter [`/*`](http://localhost:8999/)
   - -> Liefert die files aus dem Projektverzeichnis unter `public` per URL aus
-- `(TODO)` Ein einfacher `login`-Bereich
+- Ein einfacher `login`-Bereich unter [/login/\*](http://localhost:8999/login/)
   - -> Zeigt die Handhabung von Sessions und Authentifizierung in NodeJS+Express
 - `(TODO)` XSRF-Token-Security für `POST`, `PUT`, `PATCH` und `DELETE` -Endpoints
 

+ 5 - 0
package.json

@@ -16,12 +16,17 @@
   "author": "Christian Kahlau, sirius-net, ©2022",
   "license": "ISC",
   "dependencies": {
+    "csurf": "^1.11.0",
     "dotenv": "^16.0.1",
     "express": "^4.18.1",
+    "express-session": "^1.17.3",
+    "moment": "^2.29.3",
     "multiparty-express": "^0.1.9"
   },
   "devDependencies": {
+    "@types/csurf": "^1.11.2",
     "@types/express": "^4.17.13",
+    "@types/express-session": "^1.17.4",
     "typescript": "^4.6.4"
   }
 }

+ 29 - 0
private/index.html

@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Privater Bereich</title>
+
+    <link rel="stylesheet" href="../css/bootstrap.min.css" />
+    <link rel="stylesheet" href="../css/styles.css" />
+  </head>
+  <body>
+    <div class="container my-5">
+      <h1>Privater Bereich</h1>
+      <p class="alert alert-warning">Hier solltest du nur sein, wenn du dich davor eingeloggt hast</p>
+
+      <h2>Logout</h2>
+      <form method="POST" action="/auth/logout" enctype="application/x-www-form-urlencoded" class="login-form">
+        <table border="0" cellpadding="0" cellspacing="0">
+          <tr>
+            <td colspan="2">
+              <input type="submit" name="submit" value="Logout" />
+            </td>
+          </tr>
+        </table>
+      </form>
+    </div>
+  </body>
+</html>

+ 2 - 2
public/index.html

@@ -34,7 +34,7 @@
           <tr>
             <td>Benutzer:</td>
             <td>
-              <input type="text" name="username" value="testuser" />
+              <input type="text" name="user" value="testuser" />
             </td>
           </tr>
           <tr>
@@ -43,7 +43,7 @@
               <input type="password" name="password" value="pass1234" />
             </td>
           </tr>
-          <tr class="login">
+          <tr>
             <td colspan="2">
               <input type="submit" name="submit" value="Login" />
             </td>

+ 1 - 1
scripts/init.js

@@ -128,7 +128,7 @@ function readJsonFile(path, encoding = 'utf8') {
         dependencies = Object.keys(pkgJson.devDependencies).join('" "');
         yield exec(`npm install --save-dev "${dependencies}"`, process.stdout, process.stderr);
         console.log(cli.blue('[INFO]'), 'Copying project files...');
-        const copyFiles = ['.env', '.gitignore', '.prettierrc.js', 'tsconfig.json', '.vscode/settings.json', 'src', 'public'];
+        const copyFiles = ['.env', '.gitignore', '.prettierrc.js', 'tsconfig.json', '.vscode/settings.json', 'src', 'public', 'private'];
         for (const cpFile of copyFiles) {
             const sourceFile = path_1.default.resolve(cloneDir, cpFile);
             const targetFile = path_1.default.resolve(projectRootDir, cpFile);

+ 1 - 1
scripts/init.ts

@@ -116,7 +116,7 @@ async function readJsonFile<T>(path: string, encoding: BufferEncoding = 'utf8'):
     await exec(`npm install --save-dev "${dependencies}"`, process.stdout, process.stderr);
 
     console.log(cli.blue('[INFO]'), 'Copying project files...');
-    const copyFiles = ['.env', '.gitignore', '.prettierrc.js', 'tsconfig.json', '.vscode/settings.json', 'src', 'public'];
+    const copyFiles = ['.env', '.gitignore', '.prettierrc.js', 'tsconfig.json', '.vscode/settings.json', 'src', 'public', 'private'];
     for (const cpFile of copyFiles) {
       const sourceFile = path.resolve(cloneDir, cpFile);
       const targetFile = path.resolve(projectRootDir, cpFile);

+ 21 - 0
src/handlers/auth-handler.class.ts

@@ -0,0 +1,21 @@
+import { HandlerBase } from './handler-base.class';
+import { SessionHandler } from './helpers/session-handler.class';
+
+export class AuthHandler extends HandlerBase {
+  constructor(sessionHandler: SessionHandler) {
+    super(sessionHandler, true);
+
+    this.router.use(this.avoidCache);
+
+    this.router.post('/login', (req, res, next) => {
+      res.redirect('/login/');
+    });
+
+    this.router.post('/logout', (req, res, next) => {
+      req.session.destroy(err => {
+        if (err) return next(err);
+        res.redirect('/');
+      });
+    });
+  }
+}

+ 24 - 0
src/handlers/echo-handler.class.ts

@@ -0,0 +1,24 @@
+import { HandlerBase } from './handler-base.class';
+
+export class EchoHandler extends HandlerBase {
+  constructor() {
+    super();
+
+    this.router.use(this.avoidCache);
+
+    this.router.use('/', (req, res, next) => {
+      res.send({
+        method: req.method,
+        protocol: req.protocol,
+        hostname: req.hostname,
+        url: req.url,
+        originalUrl: req.originalUrl,
+        query: req.query,
+        headers: req.headers,
+        cookies: req.cookies,
+        body: req.body,
+        fields: req.fields
+      });
+    });
+  }
+}

+ 116 - 0
src/handlers/handler-base.class.ts

@@ -0,0 +1,116 @@
+import csurf from 'csurf';
+import { createHash } from 'crypto';
+import { NextFunction, Request, Response, Router, RouterOptions, json as jsonBodyParser } from 'express';
+import moment from 'moment';
+
+import { AuthenticationException } from '../model/err/authentication.exception';
+import { SessionHandler } from './helpers/session-handler.class';
+
+const STATIC_USERS = {
+  testuser: 'bc2d5cc456b81caa403661411cc72a309c39677d035b74b713a5ba02412d9eff' // pass1234
+};
+
+export abstract class HandlerBase {
+  private _router: Router;
+
+  constructor(private sessionHandler?: SessionHandler, auth?: boolean, options?: RouterOptions) {
+    this._router = Router(options);
+
+    this.router.use(jsonBodyParser());
+
+    if (this.sessionHandler) {
+      this._router.use(this.sessionHandler.handler);
+      if (auth) {
+        this._router.use(this.authHandler.bind(this));
+      }
+    }
+  }
+
+  protected useCsrfMiddleware(options?: { ignorePath: string[] }) {
+    options = {
+      ignorePath: [],
+      ...options
+    };
+    this.router.use((req, res, next) => {
+      if (options.ignorePath.includes(req.path)) {
+        return next();
+      }
+      csurf({
+        ignoreMethods: ['GET', 'HEAD', 'OPTIONS']
+      })(req, res, (err?: any) => {
+        if (err) return next(err);
+        const proto = req.get('x-forwarded-proto') || req.protocol;
+        res.cookie('XSRF-TOKEN', req.csrfToken(), {
+          httpOnly: false,
+          secure: proto === 'https',
+          sameSite: 'strict'
+        });
+        next();
+      });
+    });
+  }
+
+  public get router(): Router {
+    return this._router;
+  }
+
+  protected avoidCache = (req, res, next) => {
+    res.setHeader('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT');
+    res.setHeader('Last-Modified', `${moment().format('ddd, DD MMM YYYY HH:mm:ss')} CEST`);
+    res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate, no-store');
+    res.setHeader('Pragma', 'no-cache');
+
+    next();
+  };
+
+  private async authHandler(
+    req: Request<any, any, any, any, Record<string, any>>,
+    res: Response<any, Record<string, any>>,
+    next: NextFunction
+  ): Promise<void> {
+    // Is there already a recovered session available -> skip auth handling
+    if (req.session && req.session.user) {
+      return next();
+    }
+
+    // Login Requests Handling
+    let loginUser, loginPass;
+    if (req.method === 'POST' && req.body && req.body.user && req.body.password) {
+      // JSON Post Body Login
+      loginUser = req.body.user;
+      loginPass = req.body.password;
+    } else if (process.env.ENABLE_BASIC_AUTH && req.header('Authorization')?.substring(0, 5).toLowerCase() === 'basic') {
+      // Basic Auth Login ( ^- Enable only for DEV)
+      [loginUser, loginPass] = Buffer.from(req.header('Authorization').substring(6), 'base64').toString().split(':');
+      if (!process.env.UNIT_TEST_MODE) console.log('[INFO]', 'Authenticating via Basic Auth: ', loginUser);
+    }
+
+    if (loginUser && loginPass) {
+      try {
+        // --------------------------------------------- //
+        // TODO: Implement your "real" login here.
+        // This is just an example implementation based
+        // on a STATIC_USERS array defined above ;)
+        // --------------------------------------------- //
+
+        const pass = STATIC_USERS[loginUser];
+        if (pass && pass === HandlerBase.hashPassword(loginPass)) {
+          req.session.user = loginUser; // Hint: you can even store complex object types in a session, not just a string
+          req.session.save();
+          return next();
+        }
+      } catch (e) {
+        return next(e);
+      }
+    }
+
+    next(new AuthenticationException('No Session / Session Expired'));
+  }
+
+  public static hashPassword(password: string, salt?: string): string {
+    if (!salt) {
+      salt = process.env.PASSWORD_SALT;
+    }
+    return createHash('sha256').update(`${salt}${password}`).digest('hex');
+  }
+}

+ 33 - 0
src/handlers/helpers/session-handler.class.ts

@@ -0,0 +1,33 @@
+// nodejs / npm modules
+import { RequestHandler } from 'express';
+import session from 'express-session';
+
+declare module 'express-session' {
+  interface SessionData {
+    user?: string;
+  }
+}
+
+export class SessionHandler {
+  private requestHandler!: RequestHandler;
+
+  constructor() {
+    this.requestHandler = session({
+      secret: 'U&ert@)Ge^W]KLBzJH=GrF_N7gcQaQp>4Y<',
+      name: 'SESSIONID',
+      cookie: {
+        httpOnly: true,
+        sameSite: true,
+        maxAge: 1000 * Number(process.env.SESSION_TIMEOUT_SEC),
+        secure: 'auto'
+      },
+      resave: false,
+      rolling: true,
+      saveUninitialized: false
+    });
+  }
+
+  public get handler(): RequestHandler {
+    return this.requestHandler;
+  }
+}

+ 15 - 0
src/handlers/private-handler.class.ts

@@ -0,0 +1,15 @@
+import path from 'path';
+import express from 'express';
+
+import { HandlerBase } from './handler-base.class';
+import { SessionHandler } from './helpers/session-handler.class';
+
+const PRIVATE_DIR = path.resolve(process.cwd(), 'private');
+
+export class PrivateHandler extends HandlerBase {
+  constructor(sessionHandler: SessionHandler) {
+    super(sessionHandler, true);
+
+    this.router.use('/', express.static(PRIVATE_DIR));
+  }
+}

+ 14 - 0
src/handlers/public-handler.class.ts

@@ -0,0 +1,14 @@
+import path from 'path';
+import express from 'express';
+
+import { HandlerBase } from './handler-base.class';
+
+const PUBLIC_DIR = path.resolve(process.cwd(), 'public');
+
+export class PublicHandler extends HandlerBase {
+  constructor() {
+    super();
+
+    this.router.use('/', express.static(PUBLIC_DIR));
+  }
+}

+ 7 - 0
src/model/err/authentication.exception.ts

@@ -0,0 +1,7 @@
+import { HttpStatusException } from './http-status.exception';
+
+export class AuthenticationException extends HttpStatusException {
+  constructor(message: string) {
+    super(message, 401);
+  }
+}

+ 48 - 0
src/model/err/http-status.exception.ts

@@ -0,0 +1,48 @@
+export class HttpStatusException extends Error {
+  constructor(message: string, private code: number) {
+    super(message);
+  }
+
+  public get statusCode(): number {
+    return this.code;
+  }
+
+  public get statusText(): string {
+    return HttpStatusException.getStatusText(this.code);
+  }
+
+  public static getStatusText(code): string {
+    switch (code) {
+      case 301:
+        return 'Moved Permanently';
+      case 302:
+        return 'Found';
+      case 307:
+        return 'Temporary Redirect';
+      case 308:
+        return 'Permanent Redirect';
+      case 400:
+        return 'Bad Request';
+      case 401:
+        return 'Unauthorized';
+      case 403:
+        return 'Forbidden';
+      case 404:
+        return 'Not Found';
+      case 405:
+        return 'Method Not Allowed';
+      case 406:
+        return 'Not Acceptable';
+      case 500:
+        return 'Internal Server Error';
+      case 501:
+        return 'Not Implemented';
+      case 502:
+        return 'Bad Gateway';
+      case 503:
+        return 'Service Unavailable';
+      default:
+        return 'Unknown Error';
+    }
+  }
+}

+ 29 - 20
src/webserver.class.ts

@@ -1,13 +1,19 @@
 import express, { Express } from 'express';
 import multiparty from 'multiparty-express';
-import path from 'path';
 
-const PUBLIC_DIR = path.resolve(process.cwd(), 'public');
+import { AuthHandler } from './handlers/auth-handler.class';
+import { EchoHandler } from './handlers/echo-handler.class';
+import { SessionHandler } from './handlers/helpers/session-handler.class';
+import { PrivateHandler } from './handlers/private-handler.class';
+import { PublicHandler } from './handlers/public-handler.class';
+
+import { AuthenticationException } from './model/err/authentication.exception';
 
 const multipartMiddleware = multiparty();
 
 export class Webserver {
   private app!: Express;
+  private sessionHandler: SessionHandler;
 
   constructor(port: number) {
     try {
@@ -29,36 +35,39 @@ export class Webserver {
         }
       });
 
+      this.sessionHandler = new SessionHandler();
+
+      /** Authentication endpoint /auth/ */
+      const auth = new AuthHandler(this.sessionHandler);
+      this.app.use('/auth', auth.router);
+
       /** Send any request to /echo - receive your request data back */
-      this.app.use('/echo', (req, res, next) => {
-        res.send({
-          method: req.method,
-          protocol: req.protocol,
-          hostname: req.hostname,
-          url: req.url,
-          originalUrl: req.originalUrl,
-          query: req.query,
-          headers: req.headers,
-          cookies: req.cookies,
-          body: req.body,
-          fields: req.fields
-        });
-      });
+      const echo = new EchoHandler();
+      this.app.use('/echo', echo.router);
 
-      /** Serves files in /public via URL /file/~ */
-      this.app.use('/file', express.static(PUBLIC_DIR));
+      /** Serves files in /private via URL /login/~ */
+      const priv = new PrivateHandler(this.sessionHandler);
+      this.app.use('/login', priv.router);
 
       /** Global Error Handler - transforms exceptions into the right HTTP response */
       this.app.use((err, req, res, next) => {
         try {
-          console.error(err);
-          res.status(500).send('INTERNAL SERVER ERROR');
+          if (err instanceof AuthenticationException) {
+            res.status(err.statusCode).send(err.statusText);
+          } else {
+            console.error(err);
+            res.status(500).send('INTERNAL SERVER ERROR');
+          }
         } catch (error) {
           console.error(error);
           res.status(500).send(error);
         }
       });
 
+      /** Serves files in /public via root URL /~ */
+      const pub = new PublicHandler();
+      this.app.use('/', pub.router);
+
       this.app.listen(port, () => {
         console.log(`Example app listening on http://localhost:${port}`);
       });