mitmario.dev

Geschützte Routen

Node.js Sandbox 4 Min Lesezeit 4 BeispieleLektion 4 von 7

Anmelden funktioniert. Damit das einen Zweck hat, muss es Bereiche geben, in die man nur angemeldet hineinkommt. Der Code dafür ist eine Zeile, die Frage ist nur, an welcher Stelle sie steht.

An den Router, nicht an jede Route

Die naheliegende Stelle ist die erste Zeile der Route: nachsehen, ob jemand angemeldet ist, und sonst abbrechen. Das funktioniert und hält genau so lange, bis jemand eine Route dazuschreibt und die Zeile vergisst.

Die vergessene erste Zeile
import express from "express";

const angemeldet = (req) => req.headers.cookie === "sid=TESTID";

function nurAngemeldet(req, res, next) {
  if (!angemeldet(req)) return res.status(401).send("Nicht angemeldet");
  next();
}

const app = express();

// So nicht: die Pruefung als erste Zeile in jeder Route. Bei der
// dritten ist sie vergessen worden, und man sieht es der Datei nicht an.
const vonHand = express.Router();
vonHand.get("/profil", (req, res) => {
  if (!angemeldet(req)) return res.status(401).send("Nicht angemeldet");
  res.send("Profil");
});
vonHand.get("/rechnungen", (req, res) => {
  if (!angemeldet(req)) return res.status(401).send("Nicht angemeldet");
  res.send("Rechnungen");
});
vonHand.get("/notizen", (req, res) => {
  res.send("Notizen");
});
app.use("/vonhand", vonHand);

// So: einmal am Router, und alles darunter ist zu.
const amRouter = express.Router();
amRouter.get("/profil", (req, res) => res.send("Profil"));
amRouter.get("/rechnungen", (req, res) => res.send("Rechnungen"));
amRouter.get("/notizen", (req, res) => res.send("Notizen"));
app.use("/amrouter", nurAngemeldet, amRouter);

app.listen(3000);

Rechts steht /vonhand/notizen, und es steht dort offen. Vergleich das mit /vonhand/profil und mit /amrouter/notizen: Dieselbe Anwendung, dieselben drei Seiten, und nur eine Schreibweise hält.

Und das Tückische ist, dass nichts kaputtgeht. Die vergessene Route funktioniert prächtig, sie ist nur offen. Kein Test schlägt fehl, kein Fehler im Protokoll, niemandem fällt es auf, bis es jemandem auffällt.

Deshalb kommt die Prüfung eine Ebene höher, an den Router. app.use("/intern", nurAngemeldet, internRouter) heißt: Alles, was unter /intern liegt, geht durch diesen Wächter, und zwar auch das, was du nächsten Monat dazuschreibst. Das ist der Grund, warum 10.4 die Router eingeführt hat, denn ein Router ist genau die Klammer, die man dafür braucht.

Der Satz dahinter lautet: Standard ist zu, Ausnahmen sind offen. Wer es andersherum baut, muss sich jedes Mal daran erinnern, dass etwas geschützt gehört. Und daran erinnert man sich nicht.

Beide Ausgänge nebeneinander sieht man aber erst, wenn einer davon wirklich ein Browser ist. Das nächste Beispiel lässt seinen Server stehen, und der Reiter „Browser” lädt ihn.

Und wie sich das für einen Browser anfühlt
import express from "express";

const app = express();

function seite(titel, absatz) {
  return [
    "<!doctype html>",
    '<meta charset="utf-8">',
    `<title>${titel}</title>`,
    `<h1>${titel}</h1>`,
    `<p>${absatz}</p>`,
    '<p><a href="/">Startseite</a> · <a href="/intern">Interner Bereich</a></p>',
  ].join("\n");
}

function nurAngemeldet(req, res, next) {
  // Niemand ist angemeldet, es gibt in diesem Beispiel gar keinen Login.
  // Darum geht es hier auch nicht, sondern um die zwei Ausgaenge.
  if (req.accepts(["json", "html"]) === "html") return res.redirect(303, "/login");
  return res.status(401).json({ fehler: "Nicht angemeldet" });
}

app.get("/", (req, res) => {
  res.send(seite("Offen für alle", "Von hier führt ein Link in den internen Bereich."));
});

app.get("/login", (req, res) => {
  res.send(seite("Bitte anmelden", "Hierher hat dich der Wächter geschickt, weil du HTML wolltest."));
});

app.get("/intern", nurAngemeldet, (req, res) => {
  res.send(seite("Intern", "Hierhin kommt in diesem Beispiel niemand."));
});

app.listen(process.env.PORT ?? 3000);

Klick auf „Interner Bereich”. Statt einer Fehlermeldung landest du auf der Anmeldeseite, und in der Adresszeile steht, wohin es ging. Dasselbe /intern beantwortet ein Programm mit einem nackten 401, und beides ist richtig. Der Unterschied steht in einer einzigen Kopfzeile, die dein Browser mitschickt, ohne dass du je davon wusstest.

Beide Antworten nebeneinander bekommst du im Terminal. Solange das Beispiel läuft, antwortet curl -si http://localhost:3000/intern mit HTTP/1.1 401 Unauthorized und einem JSON-Rumpf, denn curl fragt von sich aus mit */*. Derselbe Aufruf mit dem Zusatz -H "Accept: text/html" antwortet dagegen mit HTTP/1.1 303 See Other und Location: /login. Express hängt dann noch ein Vary: Accept an, und das ist die ehrlichste Zeile von allen: Sie sagt jedem Zwischenspeicher, dass diese Antwort vom Header abhängt und man sie nicht für die nächste Anfrage wiederverwenden darf.

401 heißt „wer bist du”, 403 heißt „das darfst du nicht”

Die beiden Codes werden ständig verwechselt, dabei ist der Unterschied einfach.

Wer bist du und was darfst du
import express from "express";

// Drei Besucher: einer ohne Ausweis, ein Kunde, eine Chefin.
const nutzer = new Map([
  ["sid=KUNDE", { email: "anna@beispiel.de", rolle: "kunde" }],
  ["sid=CHEFIN", { email: "chefin@beispiel.de", rolle: "admin" }],
]);

const app = express();

app.use((req, res, next) => {
  req.nutzer = nutzer.get(req.headers.cookie);
  next();
});

app.get("/intern/profil", (req, res) => {
  if (!req.nutzer) return res.status(401).send("Ich weiss nicht, wer du bist.");
  res.send(`Profil von ${req.nutzer.email}`);
});

app.get("/intern/admin", (req, res) => {
  if (!req.nutzer) return res.status(401).send("Ich weiss nicht, wer du bist.");
  if (req.nutzer.rolle !== "admin") return res.status(403).send("Ich weiss wer du bist, und du darfst das nicht.");
  res.send("Adminbereich");
});

app.listen(3000);

Rechts steht der 401, denn dein Browser hat keinen Ausweis. Im Terminal kannst du dir einen geben:

curl -i -H "Cookie: sid=TESTID" http://localhost:3000/intern/profil

curl -i -H "Cookie: sid=TESTID" http://localhost:3000/intern/admin

401 sagt: Ich weiß nicht, wer du bist. Melde dich an, dann reden wir weiter. 403 sagt: Ich weiß, wer du bist, und mit dieser Antwort ändert sich nichts daran, dass du hier nicht hindarfst. Ein erneutes Anmelden hilft also bei 401 und bei 403 nicht.

Praktisch heißt das zwei Wächter statt einem. Der erste beantwortet die Frage „angemeldet?”, hängt den gefundenen Nutzer an die Anfrage und gibt weiter. Der zweite sieht sich dessen Rolle an. Der zweite kann sich dabei darauf verlassen, dass der erste schon gelaufen ist, denn sonst käme die Anfrage gar nicht erst bei ihm an.

Eine Seite leitet weiter, eine Schnittstelle antwortet

Bleibt eine Frage, die im Browser sofort auffällt: Ein 401 mit JSON ist für ein Programm richtig, aber ein Mensch, der auf einen Link klickt, will zur Anmeldeseite geschickt werden statt eine Fehlermeldung anzustarren. Beides ist derselbe Wächter, und was er tut, entscheidet der Accept-Header der Anfrage.

Seite oder Schnittstelle
import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.type("text/plain").send([
    `Accept-Kopfzeile:              ${req.headers.accept}`,
    `req.accepts(["json", "html"]): ${req.accepts(["json", "html"])}`,
    `req.accepts("html"):           ${req.accepts("html")}`,
  ].join("\n"));
});

app.listen(3000);

Beachte den Aufruf mit */* im ersten Block, denn dort liegt die Falle. req.accepts("html") fragt „geht HTML für dich in Ordnung?”, und auf */* lautet die Antwort ja. Damit würdest du jedes Programm auf die Anmeldeseite umleiten, auch curl und auch dein eigenes Frontend. Die Frage, die du wirklich stellen willst, ist eine andere: „Was von beidem passt besser?” Und genau die stellt req.accepts(["json", "html"]) mit einer Liste. Bei */* gewinnt der erste Eintrag, du entscheidest also über die Reihenfolge, was im Zweifel herauskommt. Ein Browser mit seiner langen Liste bekommt html, alle anderen json.

Damit hat der Wächter drei Ausgänge: durchlassen, weiterleiten oder 401. In der Challenge baust du genau ihn, zusammen mit dem zweiten für die Rolle.

Zum Mitnehmen

Standard ist zu, Ausnahmen sind offen. Eine Prüfung, die als erste Zeile in jeder Route steht, vergisst man irgendwann, und dann merkt es niemand.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 4 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.