Erstelle eine sichere RESTful API mit Node.JS in 30 Minuten

API’s (Application Programming Interface) sind heutezutage wichtiger Bestandteil einer jeden Anwendung welche auf Geräten laufen und Daten irgendeiner Art sammeln bzw. laden oder Webseiten die als Single Page Application ihre Daten aus einem Backend beziehen, sei es aus internen oder externen Systemen.

Sehr oft ist es wichtiger die API zuerst zu definieren und zu implementieren als die eigentliche Anwendung.

In diesem Artikel werde ich eine einfache Node.JS basierte RESTful API mit Tokenbasierten Authentifizierung beschreiben die ich für meine App „Puckify“ implementiert hatte um statistische Daten zu sammeln und In-App Einkäufe zu dokumentieren. Heute ist es mittlerweile sehr einfach eine einfache API zu erstellen, also zeige ich hier wie man das sogar mit einem Aufwand von 30 Minuten schafft.

Diese API, einmal fertiggestellt, kann dann per systemctl (oder auch anderen Tools in Linuxbasierten Systemen) integriert werden so dass diese automatisch beim Systemstart hochfahren. Auch bei Abstürzen kann die API automatisch neu gestartet werden.

Ich werde eine simple MC (Model-Controller) Architektur aufsetzen, eine versionsbasierte API mit einigen GET und POST Funktionen implementieren und MongoDB in der Cloud als Datenbankquelle im Hintergrund nutzen. Hierbei kann man auch gerne MongoDB als Lokalinstallation auf dem Server oder auch eine völlig andere Datenbank nutzen.

Im folgenden Bild kann man die finale Verzeichnisstruktur der API sehen die wir hier implementieren werden. Am Ende dieses Artikels gibt es auch eine ZIP Datei als Download welche die komplette API installierbar und ausführbar bereit hält. Voraussetzung ist natürlich die Installation von Node.JS.

Wie man sehen kann gibt es hier 2 Unterverzeichnis, einmal api, welche die Hauptlogik enthält und einmal config, diese enthält lediglich die Zugangsdaten zur Datenbank.

server.js ist die Hauptdatei mit der wir später die API starten, main.js enthält den eigentlichen Initialisierungsteil für die Hauptlogik.

Diese API wird mehrere Versionen unterstützen. Beachte das jede API die heute läuft, im Grund nie eine einzelne Version unterstützt, zumindest die welche öffentliche laufen. Wenn du mal deine API aktualisierst, werden alle Nutzer deiner API welche z.B. die Software auf ihren iOS oder Android basierten Geräten nutzen entsprechend die neue Version der Software herunterladen müssen. Da dies aber nicht garantiert werden kann, außer die betroffene Software wird von dir verwaltet und du blockierst die Software sofort mit der Info doch ein Update herunterzuladen. Besser ist es die alte Version deiner API ebenfalls auf der Produktionsumgebung aktiv zu belassen so das sogar Nutzer die monatelang deine App nicht aktualisieren, immer noch die Dienste deiner API abrufen können (aber eben noch von der alten Version der API entsprechend der App Version auf ihren Geräten). So sorgst du dafür das deine Nutzer nicht sofort ein Problem bekommen sobald du deine API aktualisierst.

Was die Versionierung angeht halte ich mich gerne an das Format [MAJOR].[MINOR].[PATCH] also z.B. 1.0.0. Wenn du einen Fehler korrigierst oder einfache Textanpassungen machst, solltest du in diesem Fall nur die letzte Nummer, also den PATCH Teil erhöhen, also z.B. von 1.0.0 auf die 1.0.1. Änderungen des MINOR Teils bedeuten hierbei größere Änderungen an der API wie z.B. eine neue Funktion oder eine bestehende Funktion wurde geändert. In dieser API die wir implementieren berücksichtigt die API nur den MAJOR und MINOR Teil, weil PATCHES niemals die Nutzer deiner API betreffen sollten, heisst, sie müssen nicht eine neue App herunterladen um die neue API nutzen zu können. Änderungen des MAJOR Teils sind, wie der Name schon deutlich macht, umfangreiche Änderungen an deiner API. Einen MAJOR zu erhöhen käme dann eher einer Veröffentlichung eines völlig neuen Funktionsumfangs gleich. In so einem Fall müssen also Nutzer zwangsläufig die neue App herunterladen welche diese neue API Version unterstützt. Solange du aber mehrere Versionen gleichzeitig betreibst, können deine Nutzer weiterhin entspannt die App nutzen. So könnte die API Version z.B. 3.2.1 LIVE stehen aber einige deiner Nutzer sind immer noch mit der z.B. 2.5.5 unterwegs.

Ein Patch deiner API von 1.2.3 auf die 1.2.4 sollte also auf keinen Fall deine Nutzer betreffen, da sie aktuell auf deine API 1.2 zugreifen. Genau deswegen werden wir die PATCH Version in der API nie abbilden, sondern nur den MAJOR und MINOR Teil.

Das heisst jetzt nicht das du gezwungen bist genau dieses Versionsformat zu befolgen. Deine Versionen könnten auch lauten 1.2.10.344.23. Du definierst für dich was jede einzelne Nummer in der Version repräsentiert. Mein Format entspricht dem üblichen Standard heute. Beachte das deine App im Einklang mit der API Version ist. So sollte deine App immer den neuesten Stand haben. So kann deine App aktuell die Version 1.3.33 haben, das bedeutet natürlich auch das mit der Einführung der App Version 1.3.0 zugleich die API Version 1.3 eingeführt werden muss. Beachte ebenfalls bei iOS und Android Apps, ZUERST deine API zu veröffentlichen bevor du deine App an Apple bzw. Google schickst, da diese deine App testen werden und natürlich der Zugriff auf die entsprechende API funktionieren muss.

Wir werden ebenfalls mehrere Umgebungen unterstützen. So wird diese API einen TEST Modus und einen PROD Modus unterstützen. Der Unterschied zwischen dieser liegt zum einen an der Bindung zum virtuellen Host und dem Laden der entsprechenden Zertifikate. So werden wir für TEST unter der URL api-test.eser-esen.de und unter PROD die URL api.eser-esen.de. Du kannst hier natürlich deine eigene Domain in den Beispielen verwenden.

Zusätzlich wollen wir die API vor unbefugtem Zugriff schützen. Hierbei wollen wir jede Abfrage mit einem Authentication Header schützen. Dabei muss der Client diese Header mit einem lesbaren Teil (nennen wir es einfach Username) und einem Hash der mit Hilfe des HMAC-SHA256 Algorithmus erzeugt wird, jedes Mal an die API schicken. Diese wird geprüft und bei gleich ermitteltem Hash bei gegebenem Usernamen der Zugang gewährt, ansonsten mit einem 403 geblockt.

Diese Anwendung ist Node.JS basiert, wir werden also alles in Javascript implementieren. Die Module die hier zur Verwendung kommen sind folgende:

  • mongo (MongoDB Treiber für die Datenbank)
  • https (Das Framework für das HTTP/HTTPS Protokoll)
  • express (Ein Web Framework welche die Request & Response Daten handhaben wird)
  • vhost (Zusammen mit express definieren wir mit vhost den virtuellen Host unter dem die API erreichbar sein wird, ähnlich dem VirtualHost Konfigurationen auf Webservern wie Apache oder Nginx)
  • fs (Zuständig für das Laden der Zertifikate)
  • crypto (Für das Tokenhandling mit HMAC-SHA256. Stellt Verschlüsselungsmethoden bereit)
  • Zusätzliche Dinge die wir brauchen sind:
    • moment (Weil eine unserer API Funktionen das aktuelle Datum und Zeit liefern wird, nutzen wir hier einfach moment)
    • body-parser (Ein Werkzeug um den Body bei Anfragen sauber zu interpretieren, macht uns das Leben einfacher)

server.js

Wir lassen alle Skripte im strict Modus laufen daher werden in allen Javascript Dateien das „use strict“; platziert. Das sorgt einfach dafür das du saubereren Code schreibst weil der strikte Modus z.B. keinen Aufruf von undefinierten Variablen zulässt.

"use strict";

var express = require("express");
var vhost = require("vhost");
var crypto = require("crypto");
var fs = require("fs");
var https = require("https");
var port = 9999;
var params = {};
var current_key = "";
var hash_key = "MySecretPassphrase";

Der Port wird in diesem Fall 9999 sein aber du kannst hier einen beliebigen Port wählen. Beachte aber das beim parallelen Betrieb einer anderen Webanwendung auf dem Server welche auf Port 80 oder 443 laufen deine API in diesem Fall beim Versuch diese Ports zu nutzen nicht starten wird. Weiter unten werde ich zeigen wie man deine Node.JS API und z.B. eine Webanwendung in Nginx parallel unter Port 80 oder 443 laufen lassen kann.

Die Variable params wird alle Werte enthalten die man als Eingabeparameter aus der Konsole beim Start der API übergeben kann.

current_key ist nur eine Hilfsvariable für den Teil weiter unten der die Parameter von der Konsole einliest, dabei wird current_key in einer Schleife den aktuellen Parameternamen halten der gerade von der Konsole eingelesen wird. Für diese API werden wir nur den „env“ Parameter unterstützen. Dieser wird dann entweder TEST oder PROD lauten.

hash_key dient als geheimes Passwort welche wir für unsere einfache Tokenbasierte Authorisierung aller Abfragen nutzen werden. Mit der HMAC-SHA256 Methode werden wir den klar lesbaren Teil den der Nutzer schickt mit diesem hash_key verschlüsseln und den daraus generierten Hash mit dem Hash vergleichen den der Nuter mit geschickt hat. Bei Gleichheit wird der Zugang gewährt, ansonsten blockiert. Eine einfache Methode aber definitiv gut genug um die API vor unbefugtem Zugriff zu schützen. Weiter unten erkläre ich noch weitere mögliche Schritte wie du den Schutz deiner API noch erhöhen kannst.

Jetzt der Teil der die Parameter aus der Konsole einliest:

// gehe durch alle Parameterteile und speichere diese in params
process.argv.forEach(function (val, index) {
  if( index >= 2) {
    if( index % 2 == 0 && val.startsWith("-")) {
      current_key = val.substr(1).toLowerCase();
      params[current_key] = "";
    } else {
      params[current_key] = val;
    }
  }
});

Da die ersten beiden Parameter in process.argv immer node selbst und das Startskript, in diesem Fall server.js, sein werden, müssen wir von der 3. Stelle anfangen, also Index 2. Das Startskript wird dann später wie folgt aufgerufen (für PROD):

node server.js -env PROD

Daher prüft der erste IF Block erst mal für einen Bindestrich und nimmt diesen erst mal in current_key auf bevor er das nächste dann als Werte für diesen key speichert.

Im folgenden definieren wir unsere HMAC-SHA256 Methode die mit Hilfe des crypto Moduls benutzt werden kann.

// main encrypt function using hmac256
function encrypt( text){
  var crypted = crypto.createHmac("sha256", hash_key).update(text).digest("hex");
  return crypted;
}

Jetzt müssen wir noch unsere params Variable prüfen und schauen ob der env Parameter existiert, wenn nicht, setzen wir diesen standardmäßig auf TEST. Wird etwas anderes als TEST oder PROD übergeben, werfen wir einen Fehler.

if( !params["env"]) {
  params["env"] = "TEST";
}

// env param ist Pflicht
if( params.env != "TEST" && params.env != "PROD") {
  console.log("Error: env Parameter ist Pflicht. Gültige Werte sind TEST und PROD.");
  process.exit(1);
}

Da wir mit HTTPS die API betreiben wollen, brauchen wir ein Zertifikat. Das kannst du mit eigens signierten (self-signed) oder gekauften oder mit Lets Encrypt (empfohlen) machen. Lets Encrypt ist kostenlos und ist sehr einfach zu benutzen. Ein Skript certbot-auto hilft hierbei mit der Einrichtung des Zertifikats.

Hinter diesem Link kannst du sehen wie dieses Skript zu beziehen und zu benutzen ist.

Die Schritte zum generieren deines Zertifikats für deine Domain mit certbot-auto sind wie folgt:

  1. Führe „certbot-auto certonly“ aus
  2. Wähle Nginx wenn du Nginx laufen hast oder webroot falls du ein Verzeichnis hast welche bereits auf deinem beliebigen Webserver läuft wo dann certbot dein Zertifikat einrichten und unter Verwendung dieses im Internet per URL erreichbaren Verzeichnisses deine Domain verifizieren kann (wichtiger Schritt).
  3. Gebe die Domain ein für die du ein Zertifikat einrichten willst.
  4. Am Endefindest du dein Zertifikat unter /etc/letsencrypt/live/xyz.deinedomain.de/privkey.pem

In meinem Fall habe ich ein Zertifikat für die Domain api-test.eser-esen.de eingerichtet. Damit muss jetzt folgender Code in die server.js:

var options = {
  key: fs.readFileSync("/etc/letsencrypt/live/api"+(params["env"] == "TEST" ? "-test" : "")+".eser-esen.de/privkey.pem"),
  cert: fs.readFileSync("/etc/letsencrypt/live/api"+(params["env"] == "TEST" ? "-test" : "")+".eser-esen.de/fullchain.pem")
};

Im Code wird noch der aktuelle Parameter env geprüft. Je nach Modus (TEST oder PROD) muss dann das Zertifikat geladen werden. Ich musste das Zertifikatsthema von Anfang an richtig angehen da iOS und Android Probleme bereiten beim Versuch auf URL zuzugreifen die nur eigens signierte Zertifikate haben. Es soll wohl Wege geben diese Sicherheit an den Geräte abzuschalten. Aber der Aufwand lohnte sich für mich nicht, da er einfach zu hoch war (nach langem probieren). Also warum nicht in 3 Minuten kostenlos wohlgemerkt ein Zertifikat generieren der problemlos von den Geräten akzeptiert wird?

Nun kommt der Hauptteil der server.js zum Tragen. Jetzt müssen wir nach all den Vorbereitungen die express Instanz erzeugen welche uns erlaubt die Regeln festzulegen und das (sehr wichtig) in bestimmter Reihenfolge.

Als erstes prüfen wir die Authorisierung, danach prüfen wir die Domain unter der die Abfragen rein kommen, am Ende machen wir eine Blockade falls keine der obigen Regeln zur Geltung kamen.

var app = express()
  .use(function(req, res, next) { // Authorisation zuerst
    var path = req.path && req.path.length > 0 && req.path.startsWith("/") ? req.path.substr(1) : "";
    var tokens = path.split("/");
    req.APIVERSION = tokens[0];

    var auth = req.get("Authorization");
    if( !auth || !auth.match(/:/)) {
      res.status(401).send({error: "001: Unauthorized"});
    } else {
      tokens = auth.split(":");
      if(!token || token.length < 3) { // keinen leeren Klartext erlauben und auch nicht wenn kürzer als 3
        res.status(401).send({error: "002: Unauthorized"});
        return;
      }
      var encrypted = encrypt(tokens[0]);
      if( encrypted != tokens[1]) {
        res.status(401).send({error: "003: Unauthorized"});
      } else {
        // OK
        next();
      }
    }
  })
  .use(vhost("api"+(params.env == "TEST" ? "-test" : "")+".eser-esen.de", require("./main.js").app))
  .use(function(req, res) { // wenn obiges nicht klappt dann 404
    res.status(403);
  });

Im ersten „use“ Aufruf holen wir den Authorization Header, dieser muss wie folgt formatiert sein:

Authorization: itsme:3b4852a227a9ffb99cb0e5480ff12cff6de88fd3410859f5c36499af839b4d58

Wir nehmen den Klartext Teil welcher in diesem Fall „itsme“ ist, benutzen die encrypt Funktion und generieren den Hash. Diesen vergleichen wir mit dem Hash von der Abfrage und werfen einen 401 (Not authorized) wenn diese nicht übereinstimmen. Die verschiedenen Fehlermeldungen beginnend mit 00.. dienen nur als Info für dich um zu sehen welche Fehlerfälle es geben kann.

Im zweiten „use“ Aufruf prüfen wir die Domain unter die aktuelle Abfrage dann läuft. Das entspricht dann dem virtuellen Host unter dem diese API gerade erreichbar ist. Wenn der Host nicht mit dem erwarteten übereinstimmt kommt es auch nicht zur Ausführung unserer Hauptlogik in main.js.

Wenn keine von beiden Regeln zur Geltung kommen, werfen wir eine 403 (Forbidden). Wenn wir den letzten Aufruf nicht machen, wird bei einem ungültigen Aufruf der Nutzer endlos lange auf eine Antwort von der API warten. Wir müssen hier also bei Problemfällen mit der 403 antworten.

Jetzt sind wir mit dem Hauptteil fertig und müssen nur noch de Server starten und zur Info eine kurze Log ausgeben das der Server auch läuft.

https.createServer( options, app).listen(port); // start listening
console.log("test api "+params.env+" gestartet auf port " + port);

main.js

Der Code in main.js ist relativ kurz gehalten. Dieser baut im Grunde nur die Verbindung zur MongoDB auf und initialisiert dann erst die Routen wodurch dann endgültig die API läuft und auf Anfragen wartet.

"use strict";

var express = require("express");
const MongoClient = require('mongodb').MongoClient;
var bodyParser = require("body-parser");
var app = express();
var config = require("./config/db");

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// mongo db initialisieren und dann routes festlegen
const client = new MongoClient(config.url, { useNewUrlParser: true });
client.connect(function(err, database) {
  if (err) return console.log(err);
  const db = client.db(config.dbname); // mit der DB "test" verbinden
  require("./api/routes").default(app, db);
});

exports.app = app;

db.js

db.js enthält nur die Zugangsdaten für unsere Datenbank und den Namen. Das was du in exports.url siehst habe ich aus meinem MongoDB Atlas Konto entnommen. Dieses entspricht dem Short SRV Connection String welche mit MongoDB in der Version 3.6 oder höher funktioniert. Abhängig davon wie du dich verbinden willst liefert dir MongoDB die notwendigen Infos. Wie das in Node.JS abläuft findest du bei MongoDB hier.

"use strict";

exports.url = "mongodb+srv://[USERNAME]:[PASSWORD]@clusterXXX-XYZ.mongodb.net?retryWrites=true";
exports.dbname = "test";

routes.js

 

In the routes.js file we initialise the controller for version 1.0 and define the routes our API will listen on. We map each route to a function call on the controller using the method functions the router in express is providing us. At the end of all valid routes, we need a wildcard one to catch up all invalid routes leading to a 404 with some basic error message, otherwise your client will wait endlessly until the client timeout triggers as the API does not respond at all without this final route.

In der routes.js definieren wir alle Routen auf die deine API reagieren soll und weisen die dann entsprechend einer Funktion zu die sich darum kümmert. Oben ist zu erkennen das eine Datei für den Controller in der Version 1.0 geladen wird. Am Ende MÜSSEN wir eine generische Route definieren. Diese kommt zur Geltung wenn obige Routen nicht passen, also der Nutzer eine Anfrage macht für die es keine Route gibt. In so einem Fall geben wir eine 404. Wenn wir diese Route nicht definieren, dann kommt es auch hier zu einem endlosen Laden auf der Nutzerseite weil die API einfach nicht weiß inwiefern es antworten soll, also antwortet es einfach gar nicht.

"use strict";

exports.default = function(app, db) {
  var v1_0 = require("./controllers/controller.1.0");

  v1_0.model.db = db;
  // Routen
  app.route("/1.0/datetime").get(v1_0.get_datetime);
  app.route("/1.0/book").post(v1_0.post_book);
  app.route("/1.0/book/:id").get(v1_0.get_book);

  // wichtig: um ungütige routen abzufangen, in so einem Fall 404 zurück geben
  app.get('*', function(req, res){
    res.status(404).json({error: "Unbekannte Route"});
  });
};

Nun stell dir vor du willst eine 2.0 deiner API implementieren in der es neue Funktionen gibt. Die routes.js würde in so einem Fall folgendermaßen aussehen:

"use strict";

exports.default = function(app, db) {
  var v1_0 = require("./controllers/controller.1.0");
  var v1_1 = require("./controllers/controller.1.1");
  var v2_0 = require("./controllers/controller.2.0");

  // die DB Instanz an alle Model der verschiedenen Versionen übergeben
  v1_0.model.db = db;
  v1_1.model.db = db;
  v2_0.model.db = db;

  // Routen für 1.0
  app.route("/1.0/datetime").get(v1_0.get_datetime);
  app.route("/1.0/book").post(v1_0.post_book);
  app.route("/1.0/book/:id").get(v1_0.get_book);

  // routen für 1.1, 2 neue Funktionen
  // GET book könnte hier in der Version 1.1 anders funktionieren als in 1.0
  app.route("/1.1/datetime").get(v1_1.get_datetime);
  app.route("/1.1/book").post(v1_1.post_book);
  app.route("/1.1/book/:id").get(v1_1.get_book);
  app.route("/1.1/article").post(v1_1.post_article);
  app.route("/1.1/articles").get(v1_1.get_articles);

  // routen for 2.0, zwei weitere Funktionen als in 1.1
  // und wieder, können hier bestehende Funktionen anders laufen als in 1.1
  // ältere Apps können mit der 2.0 nicht arbeiten und kennen nur die 1.0 und 1.1, daher bieten wir oben die alten Versionen weiter an
  app.route("/2.0/datetime").get(v2_0.get_datetime);
  app.route("/2.0/book").post(v2_0.post_book);
  app.route("/2.0/book/:id").get(v2_0.get_book);
  app.route("/2.0/article").post(v2_0.post_article);
  app.route("/2.0/articles").get(v2_0.get_articles);
  app.route("/2.0/video").post(v2_0.post_video);
  app.route("/2.0/video/:id").get(v2_0.get_video);

  // wichtig: um ungütige routen abzufangen, in so einem Fall 404 zurück geben
  app.get('*', function(req, res){
    res.status(404).json({error: "Unbekannte Route"});
  });
};

To ease the handling of your growing routes, you could split the routes.js file into version based files like routes.1.0.js and routes.1.1.js etc.

Damit du die Übersicht in deiner wachsenen routes.js nicht verlierst, kannst du gerne diese aufsplitten in jeweilige Versionen, also z.B. routes.1.0.js, routes.1.1.js usw.

So in main.js you simply add as many

Also würdest du dann in der main.js folgenden Eintrag für die Routen Javascriptdatei stattdessen machen.

require("./api/routes.X.Y.js").default(app, db);

Da du nun noch weitere Versionen haben wirst, musst du einfach jede Datei nach und nach laden. Der Vorteil ist das du bei Patches einfach nur die einzelne routes.X.Y.js in die produktive Umgebung laden musst ohne dabei die anderen Dateiversionen anzutasten. 

controller.1.0.js

Der Controller enthält alle Funktionen die deine API dann beim gültigen Routing aufrufen kann je nach Version. Hier machst du üblicherweise Validierungen und rufst dann Funktionen an der model auf die die eigentliche Arbeit machen. Einige machen Validierungen auch erst am Model, das kannst du frei definieren. Achte nur darauf das du deinen Code klar getrennt in die Dateien packst. Das ist der Controller Teil, also packe ich dort auch Kontrollangelegenheiten wie eben das Validieren (Kontrolle der Eingabe) in den Controller. Das kann ein anderer Entwickler evtl. anders sehen, aber das entscheidest du.

Aktuell haben wir eine simple datetime Funktion die einfach mehrere Datumsobjekte generiert und das mit Hilfe von momentJS. Dann haben wir eine Funktion zum anlegen eines Buches und eines zum abrufen dieser anhand einer MongoDB ID.

"use strict";
var model = require("../models/model.1.0");
var moment = require("moment");

exports.model = model;
exports.get_datetime = function(req, res) {
  var date = new Date();
  res.json({datetime: date.getTime(), datetime2: date.toUTCString(), datetime3: date.toISOString()});
};

exports.post_addbook = function(req, res) {
  var params = req.body;
  params.datetime = moment().format("DD.MM.YYYY HH:mm:ss");
  model.addBook(params, function(ok, details) {
  	if(ok) {
    	res.status(200).json({details: details});
  	} else {
    	res.status(400).json({error: "Fehler beim Speichern des Buches", details: details});
  	}

  });
};

exports.get_book = function(req, res) {
  console.log(req.params.id);
  var results = model.getBook(req.params.id, function(results) {
    res.json(results);
  });
};

In der Route oben für das Laden eines Buches stand am Ende der Route der Teil „:id“. Das bedeutet, was auch immer in der Abfrage URL nach dem /book/ dran hängst in der req Variable unter req.params.id verfügbar sein wird.

model.1.0.js

Die model Datei enthält die eigentliche Kernlogik der API. Diese macht die eigentliche Schreib und Lesearbeit wie z.B. das Schreiben und Lesen aus der MongoDB (oder was auch immer du als Datenbank nutzen willst).

Ich arbeite grundsätzlich mit callbacks weil im Grunde alle Datenbankaktionen asynchron laufen, vor allem weil hier die Verbindung zur Datenbank über das Netzwerk läuft. Wenn du Daten von der MongoDB laden willst und dieses auf eine sychrone Art machst während die Funktion aber asynchron arbeitet, wirst du hierbei evtl. Stunden verlieren und dich wundern warum deine Funktion einfach nichts zurück gibt. Man kann natürlich alles auch auf eine synchrone Art verarbeiten, das hat aber zur Folge das dein Aufruf solange blockiert bis der gewünschte Wert (oder das Dokument) verfügbar ist. Bei parallel laufenden Aktionen im selben Thread kann dies problematisch werden.

exports.test = function() {
  console.log("hi");
};

exports.addBook = function(params, callback) {
  exports.db.collection("books")
  .insertOne(params)
  .then(result => {
    console.log("book einfügen fertig: "+result.insertedCount+" eingefügt");
    console.log(result);
    callback(result.insertedCount >= 1, result.ops[0]);
  })
  .catch(err => {
    callback(false, err);
  });
};

exports.getBook = function(id, callback) {
  var mongo = require('mongodb');
  return exports.db.collection("books").find({_id: new mongo.ObjectID(id)}).toArray(function(err, results){
    console.log(results);
    callback(results[0]);
  });
};

Das wars. Jetzt führe dein Skript mit folgendem Befehl aus:

npm install
node server.js -env TEST

Du kannst die ZIP mit allen Dateien, installierbar und ausführbar hier finden: testapi.nodejs.tar.gz. Ich habe zusätzlich noch die certbot-auto in die ZIP eingefügt die du unter https://certbot.eff.org/docs/install.html (link) finden kannst.

Node.JS App auf Port 80 während ein Webserver auf Port 80 läuft?

Nginx

Du musst eine Serverkonfiguration aufsetzen welche deine API Domain abfängt. Unter Verwendung meiner api-test Domain (hier solltest du nun deine Domain nutzen) die du dann in sites-enabled platzierst (or sites-available und dann per symlink in die sites-enabled) mit einem anschließenden Neustart von Nginx.

server {
    listen 80;
    server_name api-test.eser-esen.de;

    location / {
        proxy_set_header   X-Forwarded-For $remote_addr;
        proxy_set_header   Host $http_host;
        proxy_pass         "http://127.0.0.1:9999";
    }
}

Für Port 443 (SSL) solltest du am besten die übliche Port 80 Konfiguration in Nginx aufsetzen und dann mit certbot-auto die Zertifikate einrichten. certbot-auto wird automatisch deine Konfiguration so anpassen das sie mit SSL läuft.

In diesem Fall brauchst du keine Zertifikate in deiner Node.JS App zu laden, weil Nginx nun den SSL Teil übernimmt. Die Verbindung zwischen Nginx und deiner API kann dann unverschlüsselt laufen solange beide Instanzen auf dem selben Server sind. Ist das nicht der Fall solltest du es bei den Zertifikaten belassen und die proxy_pass Direktive in der Nginx Konfiguration auf https umstellen. Achte hierbei auf die korrekten Zertifikate jeweils für Nginx und deine Node.JS App.

Apache

Hierfür brauchst du den mod_proxy eingerichtet und aktiv. Dann musst du hier den virtuellen Host einrichten (wieder unter Verwendung meiner api-test Domain, hier bitte deine Domain verwenden):

<VirtualHost *:80>
ServerName api-test.eser-esen.de
ProxyPreserveHost on
ProxyPass / http://localhost:9999/
</VirtualHost>

Das gleiche gilt hier wenn du SSL benötigst. Hier müssen bei Apache die Zertifikate geladen werden. Liegt auch hier die Node.JS App auf einem anderem Server, müssen die Zertifikate bei beiden berücksichtigt werden und die ProxyPass Direktive sollte auf https umgestellt werden.

Sicherheit deiner API erhöhen

Wenn deine API komplett offen im Internet liegen muss kann das zur Meisteraufgabe werden diese zu sichern, da du nie wissen kannst wer oder was eigentlich gerade auf deine API zugreift.

Es gibt verschiedene Fragen die du dir stellen und beantworten musst bevor du mit der eigentlichen Definition und Implementierung deiner API beginnst:

  • Wird die API vollständig offen sein?
    • Zum Beispiel eine App die völlig frei ist aber im Hintergrund ständig Daten von der API laden muss? Gutes Beispiel ist Google Maps auf Geräten (zumindest auf iOS), kein Loginzwang, aber du kannst es frei nutzen, Standorte suchen, Routen berechnen und das alles von Googles API.
  • Brauchen meine User Logindaten für die API?
    • Beispiel Facebook, kann nicht benutzt werden ohne Login.
  • Will ich meine API offen haben aber sicher stellen das ausnahmslos von meiner App nur benutzt werden kann?
    • Wieder Google Maps. Es ist frei, aber Nutzer können sich anmelden um mit ihren Profildaten (Gespeicherte Standorte, Historie der Standorte und Routen, etc.) Google Maps zu nutzen.

Abhängig von deinen Anforderungen musst du gewisse Regeln befolgen.

Wenn du glaubst den letzten Punkt 3 (freie API aber nur von deiner App zugänglich) oben umsetzen zu können dann liegst du falsch. Tatsache ist, man kann eine API nie wirklich vollständig absichern vor denen von denen du nicht willst das sie Zugriff auf deine API haben. Du kannst es letzten Endes nur so schwer wie möglich machen einem unbefugten Zugang zu deiner API zu beschaffen. Bedenke, hierbei geht es nicht um den Schutz der eigentlichen Nutzerabhängigen Daten sondern lediglich darum ob jemand oder etwas Daten in irgendeiner Form von deiner API abfragen kann.

Du kannst niemals den Anfragenden verifizieren, aber du kannst sein Verhalten verifizieren. Unabhängig davon kannst du noch folgende Schritte gehen um den Zugang zu deiner API weiter zu schützen oder zumindest den Zugang denen zu erschweren die es nicht sollten. Besonders wenn es um den Mißbrauch deiner API geht.

  • Benutze timestamps. Integriert in die HTTP header.
    • Den Timestamp in einem Request mitzuschicken und diesen dann gegen den aktuellen Timestamp des Servers zu vergleichen (mit einer Toleranz von wenigen Sekunden) schafft einen Schutz vor so genannten „Replay“ Attacken. Du musst die Toleranz einbauen weil du nicht erwarten kannst das jede im Internet aktive Entität auf dem Planeten Erde durchgehend in perfekt synchroner Zeit arbeitet.
    • Wenn irgenjemand böswillig eine Request kopiert und diese unverändert an deine API schickt, in der Hoffnung das er an Daten ran kommt, würde dieses in diesem Fall schief gehen für den Angreifer weil seine Anfrage mit dem alten Timestamp bereits zu alt ist und über die Toleranz gehen würde.
  • User logins.
    • Wenn möglich, erlaubt den Zugriff auf deine API nur mit Logindaten.
    • Damit wäre jeder gezwungen sich zu registrieren (was du vielleicht sogar nur über deine App erlauben könntest)
    • Wenn jemand gültige Zugangsdaten aber stehlen sollte, würde er damit immer noch auf deine API kommen können.
  •  Drosselungsmechanismen.
    • Wenn du oben beschriebene API so nutzt, wäre es jedem möglich deine API so oft wie er will aufzurufen. Auch eine Überflutung wäre möglich. Deine API würde versuchen jede Anfrage zu beantworten. Böswillige könnten auch per Brute Force verschiedene Anfragen mit verschiedenen (zufällig oder per Wörterbuch) gewählten Attacken durchführen in der Hoffnung an Daten ran zu kommen.
    • Die API wäre in so einem Fall überladen mit anstehenden Anfragen.
    • Ein Mechanismus hilft hierbei eine Frequenz an erlaubten Anfragen pro IP zu definieren. Wird diese überschritten, werden die Anfragen einfach blockiert oder im erste Schritt verlangsamt und dann geblockt.
    • Desweiteren kann man eine Quota definieren die pro App oder User gilt. Bei der „pro App“ Variante könnte man eine eindeutige ID erzeugen und diese mit der Request schicken. Die API blockiert dann Anfragen sobald für gegebenen ID eine Frequenz überschritten wird. Achtung: User die vom gleichen Netzwerk kommen und dabei immer die gleiche IP haben, können Nachteile davon haben wenn andere User im gleichen Netzwerk die Anfragefrequenz überschritten haben.
  • Keine Schlüssel in der URI.
    • Wenn du an deiner API weiter baust kann es sein das du weitere Sicherheitsmechanisment benötigst. Wenn du dabei Schlüsselelemente in die URI einbaust und so einliest schaffst du damit eine Sicherheitslücke. Schlüssel immer nur im HTTP Header oder in der Payload (Body) übertragen. Alles was in der URI steht wird von Serverlogs erfasst (Access Log) und ist somit für Menschen (Angreifer) einsehbar.
  • Benutze Apple’s DeviceCheck API und Google’s SafetyNet API.
    • Beide API’s helfen dir heraus zu finden ob der Anfragende von einem Gerät und DEINER App kommen die auf diesem Gerät (iOS und Android) installiert ist. Dabei wird mit Hilfe eines Tokens der zusätzlich bei der Anfrage mit geschickt wird eine Verifizierung auf deiner API gegen die Apple bzw. Google API durchgeführt welche letzten Endes die Integrität und Echtheit, das es von deiner App kommt, bestätigt.
    • Natürlich sind diese API’s bei Apple und Google auch nur API und damit angreifbar. Schickt jemand einen gültigen Token den er irgendwie unrechtmäßig bezogen hat, kann hierbei ein unberechtiger im schlimmsten Fall als valide eingestuft werden.

Achte immer darauf, deine Sicherheitsmaßnahmen und Mechanismen sollten nie die Benutzerfreundlichkeit deiner API beeinträchtigen. Nutzer sollten nicht aufgrund deiner Sicherheitsmechanismen den Kürzeren ziehen weil du jetzt unbedingt drei Mal prüfen musst ob der Nutzer auf deine API darf oder weil deine API durch die vielen Mechanismen sehr langsam geworden ist. Dieses kann im schlimmsten Fall den Erfolg deiner App und der API killen.

Sobald du deine iOS App oder Android App in die Welt frei gibst, gibst du im Grunde auch den Quellcode deiner App frei. Apps können dekompiliert werden und wenn es am Ende auch nur Assembler ist. Aber wenn du Passwörter und ähnliches im Quellcode speicherst, musst du davon ausgehen das auch dieses aus der App ausgelesen werden kann (schließlich läuft deine App auf dem Gerät eines Nutzers der damit machen kann was er will, also auch deine App extrahieren und dekompilieren). Daher musst du an der Sicherheitsregel: „So schwer wie möglich machen“ immer arbeiten.