Abschnitt 14 · Lektion 3
Anmelden
Das Konto steht, jetzt soll jemand hineinkommen. Der Code dafür ist kurz: Nutzer suchen, Passwort prüfen, Sitzung anlegen, Cookie setzen. Die Lektion steckt nicht im Ablauf, sondern in dem, was die Antwort nebenbei verrät.
Der Ablauf, und er hält keine Überraschungen bereit
Die Sitzung aus 13.4 bekommt jetzt einen Inhalt: die Kennung des angemeldeten Nutzers. Mehr braucht sie nicht, alles Weitere steht in der Datenbank.
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
// Gegeben, aus 14.1.
export function hashePasswort(passwort) {
const salt = randomBytes(16);
const hash = scryptSync(passwort, salt, 64, { N: 16384 });
return ["scrypt", 16384, salt.toString("hex"), hash.toString("hex")].join("$");
}
export function pruefePasswort(passwort, gespeichert) {
const [verfahren, kosten, saltHex, hashHex] = gespeichert.split("$");
if (verfahren !== "scrypt") return false;
const erwartet = Buffer.from(hashHex, "hex");
const berechnet = scryptSync(passwort, Buffer.from(saltHex, "hex"), erwartet.length, { N: Number(kosten) });
return timingSafeEqual(berechnet, erwartet);
} 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)");
db.prepare("INSERT INTO nutzer (email, passwort) VALUES (?, ?)").run("anna@beispiel.de", hashePasswort("einlangesgeheimnis"));
const sitzungen = new Map();
const app = express();
app.use(express.urlencoded({ extended: true }));
app.get("/", (req, res) =>
res.type("html").send(
'<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="/konto">/konto</a></p>'
)
);
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.");
}
const sid = randomBytes(16).toString("hex");
sitzungen.set(sid, { nutzerId: nutzer.id });
res.cookie("sid", sid, { httpOnly: true, sameSite: "lax" });
res.redirect(303, "/konto");
});
app.get("/konto", (req, res) => {
const sid = (req.headers.cookie ?? "").split("=")[1];
const sitzung = sitzungen.get(sid);
if (!sitzung) return res.send("Nicht angemeldet");
res.send(`Angemeldet als ${db.prepare("SELECT email FROM nutzer WHERE id = ?").get(sitzung.nutzerId).email}`);
});
app.listen(3000); Melde dich rechts an. Der Browser landet auf /konto und trägt den Ausweis von da an mit. Ändere das
Passwort im Formular auf etwas Falsches, und es bleibt bei der Absage.
Beachte die Antwort auf den erfolgreichen POST: ein 303 auf eine Seite, die per GET erreichbar
ist, genau wie in 13.2. Wer sich anmeldet und dann F5 drückt, soll seine Zugangsdaten nicht ein
zweites Mal abschicken.
Und jetzt sieh dir die Anfrage an statt der Antwort. Im Reiter Netzwerk steht der
POST /login, und aufgeklappt liest du dein eigenes Passwort im Anfrage-Rumpf im Klartext,
genau so, wie es über die Leitung ging. In den Kopfzeilen steht davon nichts. Wegprogrammieren
lässt sich das nicht, ein Formular schickt eben, was jemand eingetippt hat; dagegen hilft nur
eine verschlüsselte Verbindung. Das ist derselbe Punkt wie bei Secure in 13.3, diesmal von
der anderen Seite.
Eine Meldung für beide Fälle
Jetzt zu dem Teil, den die meisten falsch machen, und zwar aus einem sympathischen Grund: Man will hilfreich sein. „Diese Adresse kennen wir nicht” ist eine nützliche Auskunft, wenn man sich vertippt hat. Sie ist leider genauso nützlich für jemanden, der wissen will, wer bei dir Kunde ist.
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
// Gegeben, aus 14.1.
export function hashePasswort(passwort) {
const salt = randomBytes(16);
const hash = scryptSync(passwort, salt, 64, { N: 16384 });
return ["scrypt", 16384, salt.toString("hex"), hash.toString("hex")].join("$");
}
export function pruefePasswort(passwort, gespeichert) {
const [verfahren, kosten, saltHex, hashHex] = gespeichert.split("$");
if (verfahren !== "scrypt") return false;
const erwartet = Buffer.from(hashHex, "hex");
const berechnet = scryptSync(passwort, Buffer.from(saltHex, "hex"), erwartet.length, { N: Number(kosten) });
return timingSafeEqual(berechnet, erwartet);
} import { DatabaseSync } from "node:sqlite";
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)");
db.prepare("INSERT INTO nutzer (email, passwort) VALUES (?, ?)").run("anna@beispiel.de", hashePasswort("einlangesgeheimnis"));
const BLINDWERT = hashePasswort("nur-damit-hier-immer-gerechnet-wird");
let rechnungen = 0;
function pruefe(passwort, gespeichert) {
rechnungen += 1;
return pruefePasswort(passwort, gespeichert);
}
const finde = (email) => db.prepare("SELECT id, passwort FROM nutzer WHERE email = ?").get(email);
// So nicht: jede Absage sagt genau, woran es lag.
function gespraechig(email, passwort) {
const nutzer = finde(email);
if (!nutzer) return "404 Diese Adresse kennen wir nicht.";
if (!pruefe(passwort, nutzer.passwort)) return "401 Falsches Passwort.";
return "303 angemeldet";
}
// So: eine Antwort fuer beide Faelle, und gerechnet wird auch dann,
// wenn es die Adresse gar nicht gibt.
function verschwiegen(email, passwort) {
const nutzer = finde(email);
const stimmt = pruefe(passwort, nutzer?.passwort ?? BLINDWERT);
if (!nutzer || !stimmt) return "401 Anmeldung fehlgeschlagen.";
return "303 angemeldet";
}
console.log("mit getrennten Meldungen");
rechnungen = 0;
console.log(` unbekannte Adresse ${gespraechig("gibtsnicht@beispiel.de", "egal")}`);
console.log(` falsches Passwort ${gespraechig("anna@beispiel.de", "falsch")}`);
console.log(` Hash-Rechnungen: ${rechnungen} von 2 Versuchen`);
console.log("");
console.log("mit einer Meldung");
rechnungen = 0;
console.log(` unbekannte Adresse ${verschwiegen("gibtsnicht@beispiel.de", "egal")}`);
console.log(` falsches Passwort ${verschwiegen("anna@beispiel.de", "falsch")}`);
console.log(` Hash-Rechnungen: ${rechnungen} von 2 Versuchen`); Mit getrennten Meldungen wird deine Anmeldemaske zum Auskunftsschalter: Adresse eintippen, irgendein Passwort dazu, und die Antwort sagt, ob es das Konto gibt. Das automatisiert sich in wenigen Zeilen, und wer eine geleakte Adressliste hat, weiß danach, welche davon bei dir ein Konto haben. Bei einem Onlineshop ist das unangenehm, bei einem Portal, bei dem die bloße Mitgliedschaft etwas über Menschen aussagt, ist es ernst.
Der gemessene Zeitunterschied ist der zweite, feinere Verrat. Wenn es die Adresse nicht gibt,
gibt es auch nichts nachzurechnen, und scrypt ist absichtlich langsam. Die Absage kommt also
spürbar schneller, und damit sagt die Zeit dasselbe wie die Meldung. Der Gegenzug ist, auch dann zu
rechnen: gegen einen festen Blindwert, dessen Ergebnis man wegwirft. Das kostet dich einen
Sekundenbruchteil bei einem Versuch, der ohnehin scheitert.
Nach der Anmeldung gibt es einen neuen Ausweis
Bleibt der letzte Punkt, und er hat einen Namen: Session Fixation. In 13.4 stand schon die Regel, dass man einen mitgebrachten Ausweis niemals übernimmt. Beim Anmelden bekommt sie ihre Bedeutung.
import { randomBytes } from "node:crypto";
// Der Angreifer hat sich vorher einen Ausweis besorgt und dem Opfer
// einen Link mit genau diesem Ausweis untergeschoben.
const UNTERGESCHOBEN = "ausweis-vom-angreifer";
function wasDerAngreiferSieht(sitzungen) {
const sitzung = sitzungen.get(UNTERGESCHOBEN);
return sitzung?.nutzer ? `Angemeldet als ${sitzung.nutzer}` : "Nicht angemeldet";
}
// So nicht: die mitgebrachte Sitzung wird einfach weiterverwendet.
const schlecht = new Map([[UNTERGESCHOBEN, { nutzer: null }]]);
schlecht.get(UNTERGESCHOBEN).nutzer = "opfer@beispiel.de";
console.log("Sitzung uebernommen");
console.log(` Ausweis nach der Anmeldung: ${UNTERGESCHOBEN}`);
console.log(` Der Angreifer sieht: ${wasDerAngreiferSieht(schlecht)}`);
// So: der alte Ausweis verfaellt, die Anmeldung bekommt einen neuen.
const gut = new Map([[UNTERGESCHOBEN, { nutzer: null }]]);
gut.delete(UNTERGESCHOBEN);
const neuerAusweis = randomBytes(16).toString("hex");
gut.set(neuerAusweis, { nutzer: "opfer@beispiel.de" });
console.log("");
console.log("neue Sitzung");
console.log(` Ausweis nach der Anmeldung: ein neuer mit ${neuerAusweis.length} Zeichen`);
console.log(` Der Angreifer sieht: ${wasDerAngreiferSieht(gut)}`); Der Ablauf des Angriffs: Der Angreifer holt sich auf deiner Seite ganz normal eine Sitzung, merkt sich den Ausweis und bringt das Opfer dazu, mit genau diesem Ausweis loszulegen, etwa über einen präparierten Link. Solange niemand angemeldet ist, ist das harmlos. Meldet sich das Opfer dann aber an und dein Server hängt die Anmeldung an die bestehende Sitzung, ist der Angreifer mit angemeldet. Er kennt den Ausweis ja.
Der Gegenzug ist eine Zeile: Beim Anmelden wird die alte Sitzung verworfen und eine neue vergeben. Der Ausweis, den der Angreifer kennt, gehört danach zu nichts mehr. Dieselbe Überlegung gilt übrigens bei jeder Rechteänderung, etwa wenn jemand Admin wird.
Was hier bewusst offen bleibt
Eine Anmelderoute ohne Bremse lässt sich durchprobieren, so oft man will. Das ist ein eigenes Thema und kommt in 15.5, zusammen mit den anderen Grenzen, die ein Server braucht. Und die Frage, was passiert, wenn jemand sein Passwort vergisst, hängt an einem E-Mail-Versand, den dieser Kurs nicht hat. In 14.6 steht, was davon in einer echten Anwendung noch dazugehört.
Zum Mitnehmen
Unbekannte Adresse und falsches Passwort bekommen dieselbe Antwort, und sie bekommen sie nach derselben Zeit. Sonst wird deine Anmeldemaske zum Verzeichnis deiner Kunden.
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 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.