Abschnitt 14 · Lektion 2
Registrierung
Die beiden Funktionen aus 14.1 liegen bereit, jetzt kommt der Weg, auf dem ein Konto entsteht. Er sieht kurz aus und hat vier Stellen, an denen man danebengreifen kann.
Vier Schritte, und der dritte ist der interessante
Adresse und Passwort prüfen, nachsehen ob es die Adresse schon gibt, das Passwort hashen, Zeile
anlegen. In dieser Reihenfolge, denn eine Prüfung, die nach dem INSERT kommt, ist keine.
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 { hashePasswort } 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 app = express();
app.use(express.urlencoded({ extended: true }));
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>Konto anlegen</button></p>" +
"</form>"
)
);
app.post("/registrieren", (req, res) => {
const email = (req.body?.email ?? "").trim().toLowerCase();
const passwort = req.body?.passwort ?? "";
if (!email.includes("@")) return res.status(400).send("Adresse fehlt oder ist unvollstaendig.");
if (passwort.length < 10) return res.status(400).send("Das Passwort braucht mindestens 10 Zeichen.");
if (db.prepare("SELECT id FROM nutzer WHERE email = ?").get(email)) {
return res.status(409).send("Diese Adresse ist schon vergeben.");
}
db.prepare("INSERT INTO nutzer (email, passwort) VALUES (?, ?)").run(email, hashePasswort(passwort));
res.status(201).send(`Konto angelegt: ${email}`);
});
app.listen(3000); Rechts steht ein Formular. Schick es ab, dann noch einmal mit derselben Adresse, dann mit einem zu kurzen Passwort. Drei Versuche, drei verschiedene Statuscodes.
Beachte zwei Kleinigkeiten in den ersten beiden Zeilen der Route. Das ?. steht dort, weil
req.body in Express 5 undefined sein kann, wenn der Inhaltstyp nicht passt, und ein Zugriff
darauf ergäbe dann einen 500 statt einer ehrlichen Fehlermeldung. Das haben wir in 11.7 einmal
schmerzhaft gelernt. Und die Adresse wird trim() und toLowerCase() unterworfen, das Passwort
nicht. Warum, dazu gleich mehr.
Deine Prüfung reicht nicht, die Datenbank muss mitreden
Der dritte Schritt sieht wasserdicht aus: nachsehen, ob es die Adresse gibt, und nur anlegen, wenn
nicht. Er ist es nicht. Zwischen deinem SELECT und deinem INSERT liegt ein winziger Moment, und
in diesem Moment kann eine zweite Anfrage denselben SELECT machen.
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE nutzer (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)");
const frei = (email) => db.prepare("SELECT id FROM nutzer WHERE email = ?").get(email) === undefined;
// Zwei Anfragen, die kurz hintereinander hereinkommen. Beide sehen
// nach, beide finden nichts, und erst danach legt die erste an.
console.log(`Anfrage A haelt die Adresse fuer frei: ${frei("doppelt@beispiel.de")}`);
console.log(`Anfrage B haelt die Adresse fuer frei: ${frei("doppelt@beispiel.de")}`);
const anlegen = db.prepare("INSERT INTO nutzer (email) VALUES (?)");
anlegen.run("doppelt@beispiel.de");
console.log("Anfrage A hat angelegt.");
try {
anlegen.run("doppelt@beispiel.de");
console.log("Anfrage B hat auch angelegt.");
} catch (fehler) {
console.log(`Anfrage B wird abgewiesen: ${fehler.message}`);
}
console.log(`Zeilen in der Tabelle: ${db.prepare("SELECT count(*) AS n FROM nutzer").get().n}`); Beide halten die Adresse für frei, beide legen an, und am Ende steht die Adresse zweimal in der
Tabelle. Es sei denn, die Tabelle selbst lässt das nicht zu. Genau dafür steht UNIQUE in der
Spaltendefinition, und genau deshalb ist es kein Doppelt-gemoppelt neben deiner Prüfung, sondern die
einzige Stelle, an der die Regel wirklich gilt. Es ist derselbe Gedanke wie in 12.1, wo zwei
Schreiber sich gegenseitig die Datei überschrieben haben, nur mit Folgen, die man nicht mehr
zurückdrehen kann.
Praktisch heißt das: Du machst beides. Die Vorab-Prüfung, damit der Normalfall eine freundliche
Antwort bekommt, und den try-Block um das INSERT, damit der seltene Fall nicht als Serverfehler
beim Besucher landet. Beide antworten dasselbe.
Warum die Adresse kleingeschrieben wird und das Passwort nicht
UNIQUE vergleicht Zeichen für Zeichen, und für eine Datenbank sind Anna@Beispiel.de und
anna@beispiel.de zwei verschiedene Dinge.
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE nutzer (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)");
const anlegen = db.prepare("INSERT INTO nutzer (email) VALUES (?)");
// UNIQUE vergleicht Zeichen fuer Zeichen. Fuer die Datenbank sind das
// zwei verschiedene Adressen, fuer den Menschen dahinter ist es eine.
anlegen.run("Anna@Beispiel.de");
anlegen.run("anna@beispiel.de");
console.log(`Zeilen nach zweimal derselben Adresse: ${db.prepare("SELECT count(*) AS n FROM nutzer").get().n}`);
for (const zeile of db.prepare("SELECT id, email FROM nutzer ORDER BY id").all()) {
console.log(` ${zeile.id} ${zeile.email}`);
}
// Und die Gegenrichtung: beim Passwort darf nichts weggeschnitten werden.
const eingetippt = " geheim123 ";
console.log("");
console.log(`eingetippt: "${eingetippt}" mit ${eingetippt.length} Zeichen`);
console.log(`mit trim(): "${eingetippt.trim()}" mit ${eingetippt.trim().length} Zeichen`);
console.log("Wer beim Anlegen trimmt und beim Anmelden nicht, sperrt den Besitzer aus."); Für den Menschen dahinter ist es eine Adresse, und er wird sich beim nächsten Mal nicht daran
erinnern, wie er sie damals getippt hat. Deshalb wird sie kleingeschrieben gespeichert und
kleingeschrieben gesucht, und dann trägt UNIQUE auch das, wofür es da ist.
Beim Passwort ist es genau umgekehrt: Da wird nichts weggeschnitten und nichts umgeformt. Ein
Leerzeichen am Ende gehört dazu, wenn der Besitzer es so gewählt hat. Wer beim Anlegen trim()
aufruft und beim Anmelden nicht, sperrt genau die Leute aus, die es getan haben, und niemand versteht
warum. Aus demselben Grund gibt es keine Höchstlänge und kein Abschneiden nach 20 Zeichen: Gehasht
wird alles zur selben Länge, ein langes Passwort kostet also nichts.
Zu den Mindestanforderungen noch ein Wort, weil dort viel Unsinn kursiert. Länge zählt, alles andere
kaum. Erzwungene Sonderzeichen führen zu Passwort1! und zu Zetteln am Monitor, sie bringen also
weniger als die zehn Zeichen, die du ohnehin verlangst. Sinnvoll ist stattdessen, sehr lange
Passwörter zuzulassen und die bekanntesten abzulehnen.
Und was sagen wir dem, der schon ein Konto hat?
Die ehrliche Antwort lautet „diese Adresse ist schon vergeben”, und genau die verrät einem Fremden, dass es zu dieser Adresse ein Konto bei dir gibt. Bei einem Onlineshop ist das lästig, bei einer Partnerbörse oder einem Ärzteportal ist es ein Problem. Der Ausweg ist, immer dasselbe zu antworten und die Auskunft per E-Mail an die Adresse selbst zu schicken. Dieser Kurs bleibt beim offenen 409, weil er keinen E-Mail-Versand hat, aber die Überlegung dahinter kommt in 14.3 wieder, und dort ist sie nicht mehr optional.
Zum Mitnehmen
Prüfen, ob es die Adresse schon gibt, ist richtig und reicht nicht. Die Datenbank muss dasselbe noch einmal sagen, denn zwischen deiner Prüfung und deinem INSERT passt eine zweite Anfrage.
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.