mitmario.dev

Registrierung

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

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.

Der Ablauf in einer Route
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.

Zwei Anfragen, die beide nichts finden
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.

Zwei Schreibweisen, zwei Konten
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, 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.