Parcourir la source

Beispielimplementierung "Chat" zur Veranschaulichung von XSRF-Token-Security

Christian Kahlau il y a 3 ans
Parent
commit
a004ede07d

+ 2 - 1
Readme.md

@@ -12,7 +12,8 @@ Der Server beinhaltet Grundfunktionalität, also:
   - -> Liefert die files aus dem Projektverzeichnis unter `public` per URL aus
 - 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
+- XSRF-Token-Security für `POST`, `PUT`, `PATCH` und `DELETE` -Endpoints
+  - -> Veranschaulicht im "Chat" des [/login/\*](http://localhost:8999/login/)-Bereichs
 
 _... behalte und erweitere, was du brauchst - schmeiß' raus, was nicht._
 

+ 143 - 0
private/index.html

@@ -14,6 +14,18 @@
       <h1>Privater Bereich</h1>
       <p class="alert alert-warning">Hier solltest du nur sein, wenn du dich davor eingeloggt hast</p>
 
+      <h2>Kleine Chat App</h2>
+      <p>(Inklusive XSRF-Token-Security)</p>
+      <div class="chat-box">
+        <div id="chat-messages"></div>
+        <div class="row">
+          <div class="col input-group">
+            <input type="text" id="chat-input" class="form-control" />
+            <button type="button" id="chat-submit" class="btn btn-outline-secondary" onclick="sendMessage()">Senden</button>
+          </div>
+        </div>
+      </div>
+
       <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">
@@ -25,5 +37,136 @@
         </table>
       </form>
     </div>
+
+    <script type="text/javascript" src="js/jquery.min.js"></script>
+    <script type="text/javascript">
+      var chat = {};
+      $(function () {
+        chat.container = $('#chat-messages');
+        chat.input = $('#chat-input');
+        chat.submitBtn = $('#chat-submit');
+
+        chat.input.on('keyup', function (e) {
+          if (e.keyCode === 13) {
+            // Enter
+            chat.submitBtn.click();
+          }
+        });
+
+        fetchMessages();
+      });
+
+      function fetchMessages() {
+        $.ajax({
+          url: 'chat',
+          method: 'GET',
+          dataType: 'json',
+          success: function (data, textStatus, xhr) {
+            if (data && data.length > 0) {
+              chat.messages = data;
+              renderMessages();
+            } else {
+              chat.container.append('<p>There are no messages yet to display</p>');
+              chat.messages = [];
+            }
+          },
+          error: function (xhr, textStatus, errorMsg) {
+            console.error(xhr, textStatus, errorMsg);
+          }
+        });
+      }
+
+      function renderMessages() {
+        chat.container.empty();
+        chat.messages.forEach((msg, i) => renderMessage(msg, i));
+      }
+
+      function renderMessage(msg, idx) {
+        chat.container.append(
+          '<div class="card chat-message">' +
+            '<div class="card-body">' +
+            '<h5 class="card-title">' +
+            msg.author +
+            '</h5>' +
+            '<button class="btn btn-outline-danger btn-sm" onclick="deleteMessage(' +
+            idx +
+            ')">Delete</button>' +
+            '<h6 class="card-subtitle mb-2 text-muted">' +
+            msg.time +
+            '</h6>' +
+            '<p class="card-text">' +
+            msg.text +
+            '</p>' +
+            '</div>' +
+            '</div>'
+        );
+      }
+
+      function sendMessage() {
+        var text = chat.input.val();
+        if (!text || text.trim().length == 0) return;
+
+        var token = getXsrfTokenFromCookie();
+
+        if (!token) {
+          alert('Konnte XSRF-TOKEN Cookie nicht auslesen');
+          return;
+        }
+
+        $.ajax({
+          url: 'chat',
+          method: 'POST',
+          dataType: 'json',
+          data: {
+            text
+          },
+          headers: {
+            'X-CSRF-Token': token
+          },
+          success: function (data, textStatus, xhr) {
+            renderMessage(data, chat.messages.length);
+            chat.input.val('');
+          },
+          error: function (xhr, textStatus, errorMsg) {
+            console.error(xhr, textStatus, errorMsg);
+            alert(errorMsg + ': ' + xhr.responseText);
+          }
+        });
+      }
+
+      function deleteMessage(idx) {
+        var token = getXsrfTokenFromCookie();
+        if (!token) {
+          alert('Konnte XSRF-TOKEN Cookie nicht auslesen');
+          return;
+        }
+
+        $.ajax({
+          url: 'chat/' + idx,
+          method: 'DELETE',
+          dataType: 'json',
+          headers: {
+            'X-CSRF-Token': token
+          },
+          success: function (data, textStatus, xhr) {
+            fetchMessages();
+          },
+          error: function (xhr, textStatus, errorMsg) {
+            console.error(xhr, textStatus, errorMsg);
+            alert(errorMsg + ': ' + xhr.responseText);
+          }
+        });
+      }
+
+      function getXsrfTokenFromCookie() {
+        var regMatch = /XSRF-TOKEN=([^;]+).*$/.exec(document.cookie);
+
+        if (!regMatch || !regMatch[1]) {
+          return null;
+        }
+
+        return regMatch[1];
+      }
+    </script>
   </body>
 </html>

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
private/js/jquery.min.js


+ 8 - 0
public/css/styles.css

@@ -22,3 +22,11 @@ a::before {
 a.back::before {
   content: '<';
 }
+
+.card.chat-message .card-title {
+  display: inline-block;
+}
+
+.card.chat-message button.btn.btn-outline-danger.btn-sm {
+  float: right;
+}

+ 42 - 0
src/controllers/chat-controller.class.ts

@@ -0,0 +1,42 @@
+import { ControllerBase } from './lib/controller-base.class';
+import { ControllerPool } from './lib/controller-pool.interface';
+
+export interface ChatMessage {
+  time: Date;
+  author: String;
+  text: string;
+}
+
+/** Example implementation of a data controller.
+ *
+ * Could also be a database client implementation of any kind to store and receive data.
+ */
+export class ChatController extends ControllerBase {
+  private _messages: ChatMessage[] = [];
+
+  constructor(ctrlPool: ControllerPool) {
+    super(ctrlPool);
+  }
+
+  public addMessage(author: string, text: string) {
+    const msg: ChatMessage = {
+      author,
+      text,
+      time: new Date()
+    };
+    this._messages.push(msg);
+    return msg;
+  }
+
+  public getMessages() {
+    return this._messages;
+  }
+
+  public deleteMessage(idx: number) {
+    if (this._messages.length > idx) {
+      this._messages.splice(idx, 1);
+      return true;
+    }
+    return false;
+  }
+}

+ 5 - 0
src/controllers/lib/controller-base.class.ts

@@ -0,0 +1,5 @@
+import { ControllerPool } from './controller-pool.interface';
+
+export abstract class ControllerBase {
+  constructor(protected ctrlPool: ControllerPool) {}
+}

+ 5 - 0
src/controllers/lib/controller-pool.interface.ts

@@ -0,0 +1,5 @@
+import { ChatController } from '../chat-controller.class';
+
+export interface ControllerPool {
+  chat: ChatController;
+}

+ 27 - 19
src/handlers/lib/handler-base.class.ts

@@ -3,6 +3,8 @@ import { createHash } from 'crypto';
 import { NextFunction, Request, Response, Router, RouterOptions, json as jsonBodyParser } from 'express';
 import moment from 'moment';
 
+import { ChatController } from '../../controllers/chat-controller.class';
+import { ControllerPool } from '../../controllers/lib/controller-pool.interface';
 import { AuthenticationException } from '../../model/err/authentication.exception';
 import { SessionHandler } from './session-handler.class';
 
@@ -10,14 +12,13 @@ const STATIC_USERS = {
   testuser: 'bc2d5cc456b81caa403661411cc72a309c39677d035b74b713a5ba02412d9eff' // pass1234
 };
 
-export abstract class HandlerBase {
+export abstract class HandlerBase implements ControllerPool {
   private _router: Router;
+  private _chatCtrl?: ChatController;
 
   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) {
@@ -26,12 +27,32 @@ export abstract class HandlerBase {
     }
   }
 
-  protected useCsrfMiddleware(options?: { ignorePath: string[] }) {
+  public get router(): Router {
+    return this._router;
+  }
+
+  public get chat(): ChatController {
+    if (!this._chatCtrl) {
+      this._chatCtrl = new ChatController(this);
+    }
+    return this._chatCtrl;
+  }
+
+  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();
+  };
+
+  protected csrf(options?: { ignorePath: string[] }) {
     options = {
       ignorePath: [],
       ...options
     };
-    this.router.use((req, res, next) => {
+    return (req, res, next) => {
       if (options.ignorePath.includes(req.path)) {
         return next();
       }
@@ -47,22 +68,9 @@ export abstract class HandlerBase {
         });
         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>>,

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

@@ -10,6 +10,40 @@ export class PrivateHandler extends HandlerBase {
   constructor(sessionHandler: SessionHandler) {
     super(sessionHandler, true);
 
+    /** Enable XSRF Token Security on this router path */
+    this.router.use(this.csrf());
+
+    this.router.get('/chat', (req, res, next) => {
+      try {
+        res.send(this.chat.getMessages());
+      } catch (err) {
+        next(err);
+      }
+    });
+
+    this.router.post('/chat', (req, res, next) => {
+      try {
+        const author = req.session.user;
+        const text = req.body.text;
+
+        const msg = this.chat.addMessage(author, text);
+        res.status(201).send(msg);
+      } catch (err) {
+        next(err);
+      }
+    });
+
+    this.router.delete('/chat/:idx', (req, res, next) => {
+      try {
+        const index = Number(req.params.idx);
+
+        const deleted = this.chat.deleteMessage(index);
+        res.status(200).send({ deleted });
+      } catch (err) {
+        next(err);
+      }
+    });
+
     this.router.use('/', express.static(PRIVATE_DIR));
   }
 }

+ 15 - 0
src/webserver.class.ts

@@ -35,6 +35,19 @@ export class Webserver {
         }
       });
 
+      // General Header Settings
+      this.app.use((req, res, next) => {
+        res.setHeader('X-Frame-Options', 'SAMEORIGIN');
+        res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload;');
+
+        res.setHeader('Access-Control-Allow-Credentials', 'true');
+        res.setHeader('Access-Control-Allow-Headers', 'X-CSRF-Token');
+        res.setHeader('Access-Control-Expose-Headers', 'x-csrf-token');
+        res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, OPTIONS, HEAD');
+
+        next();
+      });
+
       this.sessionHandler = new SessionHandler();
 
       /** Authentication endpoint /auth/ */
@@ -54,6 +67,8 @@ export class Webserver {
         try {
           if (err instanceof AuthenticationException) {
             res.status(err.statusCode).send(err.statusText);
+          } else if (Object.keys(err).includes('code') && err.code === 'EBADCSRFTOKEN') {
+            res.status(403).send(err.message);
           } else {
             console.error(err);
             res.status(500).send('INTERNAL SERVER ERROR');

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff