Abschnitt 14 · Lektion 4
Geschützte Routen
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.
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.
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.
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.
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, kostenlosZu 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.