mitmario.dev

Synthese: der geschützte Bereich

Node.js Sandbox 3 Min Lesezeit 3 BeispieleLektion 6 von 7

Fünf Lektionen, fünf Bausteine, und jetzt liegen sie nebeneinander: Hashen, Registrieren, Anmelden, Schützen, Abmelden. In dieser Synthese kommt kein neues Mittel dazu. Neu ist nur, dass alles an seinem Platz stehen muss, damit es trägt.

Der Ausweis wechselt an genau zwei Stellen

Am besten siehst du das, indem du die Reise einmal selbst machst. Rechts stehen die drei Knöpfe und der Link dazu.

Die ganze Reise, einmal selbst gelaufen
import express from "express";
import { DatabaseSync } from "node:sqlite";
import { randomBytes } from "node:crypto";
import { hashePasswort, pruefePasswort } from "./passwoerter.js";

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE nutzer (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, passwort TEXT NOT NULL)");
const sitzungen = new Map();

const app = express();
app.use(express.urlencoded({ extended: true }));
app.use((req, res, next) => {
  const sid = (req.headers.cookie ?? "").split("=")[1];
  req.sitzung = sitzungen.get(sid);
  next();
});

app.get("/", (req, res) =>
  res.type("html").send(
    '<form method="post" action="/registrieren">' +
      '<p><input name="email" value="anna@beispiel.de"></p>' +
      '<p><input name="passwort" value="einlangesgeheimnis"></p>' +
      "<p><button>Registrieren</button></p>" +
      "</form>" +
      '<form method="post" action="/login">' +
      '<p><input name="email" value="anna@beispiel.de"></p>' +
      '<p><input name="passwort" value="einlangesgeheimnis"></p>' +
      "<p><button>Anmelden</button></p>" +
      "</form>" +
      '<p><a href="/intern">/intern</a></p>' +
      '<form method="post" action="/logout"><p><button>Abmelden</button></p></form>'
  )
);

app.post("/registrieren", (req, res) => {
  db.prepare("INSERT INTO nutzer (email, passwort) VALUES (?, ?)")
    .run(req.body.email.toLowerCase(), hashePasswort(req.body.passwort));
  res.status(201).send("angelegt");
});

app.post("/login", (req, res) => {
  const nutzer = db.prepare("SELECT id, passwort FROM nutzer WHERE email = ?").get(req.body.email);
  if (!nutzer || !pruefePasswort(req.body.passwort, nutzer.passwort)) return res.status(401).send("Anmeldung fehlgeschlagen.");
  if (req.sitzung) sitzungen.delete(req.sitzung.id);
  const id = randomBytes(16).toString("hex");
  sitzungen.set(id, { id, nutzerId: nutzer.id });
  res.cookie("sid", id, { httpOnly: true, sameSite: "lax" });
  res.redirect(303, "/intern");
});

app.get("/intern", (req, res) => {
  if (!req.sitzung?.nutzerId) return res.status(401).send("Nicht angemeldet");
  res.send(`Angemeldet als ${db.prepare("SELECT email FROM nutzer WHERE id = ?").get(req.sitzung.nutzerId).email}`);
});

app.post("/logout", (req, res) => {
  if (req.sitzung) sitzungen.delete(req.sitzung.id);
  res.clearCookie("sid", { httpOnly: true, sameSite: "lax" });
  res.redirect(303, "/");
});

app.listen(3000);

Geh sie in dieser Reihenfolge durch: erst /intern (nicht angemeldet), dann Registrieren, dann /intern (immer noch nicht), dann Anmelden, dann /intern (jetzt), dann Abmelden, dann /intern (wieder nicht).

Verfolge dabei den Ausweis, das ist die eigentliche Lektion. Er entsteht beim Anmelden und verschwindet beim Abmelden, dazwischen ändert er sich nicht. Registrieren gibt keinen aus, denn ein Konto anzulegen heißt nicht, angemeldet zu sein. Und der erste und der letzte Aufruf von /intern sehen absichtlich gleich aus: Nach dem Abmelden ist der Zustand wieder genau der vom Anfang, und zwar auf beiden Seiten.

Beachte auch, dass /intern am Anfang und am Ende denselben Statuscode liefert und trotzdem zwei verschiedene Dinge meint. Beim ersten Mal war noch nie jemand angemeldet, beim letzten Mal hat sich jemand abgemeldet. Für den Server ist das dasselbe, und das ist richtig so: Er kennt nur gültige und ungültige Ausweise.

Reihenfolge ist kein Stil, sondern Funktion

Die vier Routen sind schnell geschrieben. Was in einer Synthese schiefgeht, ist etwas anderes: Zwei Middlewares stehen an der falschen Stelle, und plötzlich funktioniert etwas nicht, das für sich genommen richtig aussieht.

Zwei Stücke an der falschen Stelle
import express from "express";

// Express' eingebauter Fehler-Logger schreibt sonst absolute Pfade auf
// stderr. Gelesen wird der Wert erst beim Erzeugen der Anwendung, die
// Zeile wirkt also auch hier oben schon. Derselbe Griff wie in 10.5.
process.env.NODE_ENV = "test";

// Dreimal dieselbe Anwendung, zweimal steht ein Stueck falsch.
function baue(variante) {
  const app = express();
  const sitzungen = new Map([["TESTID", { nutzerId: 1 }]]);

  const sitzungAnhaengen = (req, res, next) => {
    req.sitzung = sitzungen.get((req.headers.cookie ?? "").split("=")[1]);
    next();
  };

  if (variante !== "Parser fehlt") app.use(express.urlencoded({ extended: true }));
  if (variante !== "Sitzung zu spaet") app.use(sitzungAnhaengen);

  app.post("/login", (req, res) => {
    res.type("text/plain").send(`angemeldet als ${req.body.email}`);
  });
  app.get("/intern", (req, res) => {
    if (!req.sitzung) return res.status(401).type("text/plain").send("Nicht angemeldet");
    res.type("text/plain").send("Intern");
  });

  // Zu spaet: die Routen darueber haben req.sitzung nie gesehen.
  if (variante === "Sitzung zu spaet") app.use(sitzungAnhaengen);

  return app;
}

async function probiere(variante, port) {
  // Jede Variante bekommt einen eigenen Port. Mit einem gemeinsamen
  // griffe fetch beim naechsten Durchgang auf die Verbindung zum
  // vorigen, schon geschlossenen Server zurueck: close() wartet auf
  // offene Verbindungen, und eine offene ist genau das.
  const server = baue(variante).listen(port);
  await new Promise((fertig) => server.once("listening", fertig));

  console.log(variante);
  const anmelden = await fetch(`http://127.0.0.1:${port}/login`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: "email=anna@beispiel.de",
  });
  // Bei 500 antwortet Express mit einer Seite, die den Stacktrace zeigt.
  const text = anmelden.ok ? await anmelden.text() : "(Fehlerseite)";
  console.log(`  POST /login   ${anmelden.status}  ${text}`);

  const intern = await fetch(`http://127.0.0.1:${port}/intern`, { headers: { Cookie: "sid=TESTID" } });
  console.log(`  GET  /intern  ${intern.status}  ${await intern.text()}`);
  console.log("");

  server.close();
  await new Promise((fertig) => server.once("close", fertig));
}

let port = 3000;

for (const variante of ["Parser fehlt", "Sitzung zu spaet", "alles an seinem Platz"]) {
  await probiere(variante, port++);
}

Fehlt express.urlencoded, ist req.body schlicht undefined, und der Zugriff darauf wirft. Das kennst du aus 10.3, hier ist es dieselbe Sache. Steht die Sitzungs-Middleware unter den Routen, läuft sie zwar, aber erst danach: Die Anfrage ist längst beantwortet, und req.sitzung war die ganze Zeit undefined. Der Ausweis stimmt, die Sitzung gibt es, und trotzdem kommt niemand hinein.

Die Regel dahinter ist die aus Abschnitt 10: Middleware läuft in der Reihenfolge, in der sie registriert wurde. Alles, was Routen voraussetzen, gehört über sie.

Was hier bewusst fehlt

Was du in der Challenge baust, ist ein vollständiger Anmeldebereich und trotzdem nicht das, was du in eine echte Anwendung stellen würdest. Vier Dinge fehlen: Passwort vergessen, Bestätigung der E-Mail-Adresse, ein zweiter Faktor und eine Bremse gegen Durchprobieren.

Die letzte kommt in 15.5, die anderen drei haben alle dasselbe Problem.

Der Link, den dieser Kurs nicht verschicken kann
import { randomBytes, createHash } from "node:crypto";

// Ein Ruecksetz-Token ist schnell gebaut. Er ist ein Passwortersatz auf
// Zeit und wird deshalb genauso behandelt: lang, zufaellig, nur gehasht
// gespeichert, mit Ablauf und nur einmal verwendbar.
const token = randomBytes(32).toString("hex");
const abgelegt = {
  nutzerId: 1,
  tokenHash: createHash("sha256").update(token).digest("hex"),
  gueltigBis: "in 30 Minuten",
  benutzt: false,
};

console.log(`Token fuer den Link:  ${token.length} Zeichen`);
console.log(`in der Tabelle:       tokenHash mit ${abgelegt.tokenHash.length} Zeichen`);
console.log(`gueltig:              ${abgelegt.gueltigBis}`);
console.log(`schon benutzt:        ${abgelegt.benutzt}`);
console.log("");

// Und hier hoert dieser Kurs auf.
console.log("Der Link muss jetzt an die Adresse gehen, und ausschliesslich dorthin.");
console.log("Genau das ist der Schritt, den eine Sandbox ohne Mailversand nicht tun kann.");

Jedes dieser Verfahren braucht einen zweiten Kanal, also einen Weg zum Nutzer, der nicht über denselben Browser läuft. Eine E-Mail, eine SMS, eine App. Der Code davor ist überschaubar, das Beispiel zeigt ihn fast vollständig, und die Sicherheitsregeln sind dieselben wie beim Passwort: Der Token ist lang und zufällig, er wird nur gehasht gespeichert, er läuft ab, und er gilt genau einmal. Was diese Sandbox nicht kann, ist das Verschicken. Deshalb ist es keine Lücke im Kurs, sondern seine Grenze, und sie liegt an einer ehrlichen Stelle.

Wenn du das in echt brauchst: Der Weg ist ein Maildienst mit einer Schnittstelle, und die Regeln dazu stehen in derselben Ecke der Dokumentation wie alles andere hier. Der Teil, den du jetzt kannst, ist der schwierigere.

Zum Mitnehmen

Vier Routen, ein Ausweis, und er wechselt an genau zwei Stellen: beim Anmelden entsteht er, beim Abmelden verschwindet er. Alles dazwischen hängt daran.

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 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.