Einladen, statt sich für jemanden ein Passwort auszudenken

Ein Konto von Hand anzulegen heisst: sich für einen anderen ein Passwort
ausdenken und es ihm durchsagen — über denselben Kanal, über den auch ein
Link gegangen wäre. Jetzt gibt es den Link. Wer ihn öffnet, wählt Namen
und Passwort selbst und ist danach angemeldet.

Gültigkeit und Anzahl wählst du beim Erstellen: einen Tag bis einen Monat,
einmal bis unbegrenzt. **Eine Rolle wählst du nicht** — wer eingeladen
wird, ist gewöhnlicher Benutzer. Ein Feld, über das ein Link Adminrechte
verschenken könnte, gibt es gar nicht, und damit auch keinen Weg, es
versehentlich zu setzen.

**Der Link steht genau einmal da.** Gespeichert ist nur sein
Fingerabdruck, wie beim Gerätetoken; aus der Liste lässt sich keiner mehr
bauen. Eine verlorene Einladung wird deshalb nicht wiedergefunden, sondern
neu gemacht. Und gebaut wird die Adresse im Browser: der Server weiss
nicht, unter welchem Namen er von aussen erreichbar ist, und hinter einem
Reverse Proxy rät er falsch.

**Das Rennen beim Einlösen.** Zwei Leute, die gleichzeitig denselben
einmaligen Link öffnen, läsen beide „null benutzt" und schrieben beide
„eins" — und hätten beide ein Konto. Der Zähler geht deshalb mit einer
Bedingung hoch, die die Gültigkeit noch einmal prüft; wer dabei keine
Zeile trifft, war der Zweite. Das Ganze in einer Transaktion, damit ein
Tippfehler im Namen den Link nicht verbrennt. Beides steht als Test da —
zwei gleichzeitige Einlösungen ergeben genau ein Konto.

**Angelegt wird, angemeldet wird danach gewöhnlich.** Derselbe Zweischritt
wie beim Einrichten, und der Grund ist nicht Bequemlichkeit: die Anmeldung
setzt den Sitzungskeks, und ohne den lädt kein Plakat — Bilder weisen sich
über ihn aus, nicht über das Token. Gemessen beim Durchspielen: nach dem
Einlösen kommt ein Bild mit 200 zurück.

**Die Weiche liegt im Hochfahren, nicht im Router.** Der Router läuft nie,
wenn niemand angemeldet ist — ein Ausgeloggter mit Einladungslink sähe
sonst die Anmeldekarte und landete nach dem Anmelden auf der Startseite,
ohne je erfahren zu haben, wozu er eingeladen war. Nach dem Einrichten,
damit ein nagelneuer Server zuerst seinen Administrator bekommt.

Wer schon angemeldet ist, bekommt kein Formular, sondern den Hinweis und
zwei Wege. Und die Gründe einer Ablehnung sind verschieden benannt:
abgelaufen, zurückgezogen, schon benutzt. Verschwiegener wäre ein
einheitliches „gilt nicht" — und dann fragt der Eingeladene nach, und der
Gastgeber rät mit.

Nebenbei korrigiert: die Erkundung meinte, dem Benutzernamen fehle ein
eindeutiger Index. Er hat einen, seit der ersten Migration, sogar mit
`NOCASE`. Das Namensrennen war also nie offen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-07 17:23:25 +02:00
co-authored by Claude Opus 5
parent c65356c16f
commit d5e21d663f
13 changed files with 2137 additions and 3 deletions
@@ -803,3 +803,39 @@ public sealed record Meldungseintrag(
string ReportedBy,
/// <summary><c>null</c> heisst: offen.</summary>
DateTimeOffset? ResolvedAt);
/* ---------- Einladungen ---------- */
/// <summary>Was beim Erstellen eines Einladungslinks gewählt wird.</summary>
public sealed record EinladungRequest(
/// <summary>Wie viele Tage er gilt.</summary>
int Days = 7,
/// <summary>Wie oft er sich einlösen lässt. <c>null</c> heisst: unbegrenzt.</summary>
int? MaxUses = 1);
/// <summary>Der frisch erstellte Link — der Token steht hier zum einzigen Mal.</summary>
///
/// <remarks>
/// <b>Die Adresse baut der Client.</b> Der Server weiss nicht, unter welchem
/// Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er
/// falsch, und ein falscher Einladungslink ist schlimmer als keiner.
/// </remarks>
public sealed record EinladungErstellt(Guid Id, string Token, DateTimeOffset ExpiresAt, int? MaxUses);
/// <summary>Eine Einladung in der Verwaltungsliste — ohne Token.</summary>
public sealed record Einladungseintrag(
Guid Id,
string CreatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt,
int? MaxUses,
int UsedCount,
DateTimeOffset? RevokedAt,
/// <summary>Ob sich damit jetzt noch ein Konto anlegen lässt.</summary>
bool Valid);
/// <summary>Was die Einladungsseite zeigt, bevor jemand etwas eintippt.</summary>
public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTimeOffset ExpiresAt);
/// <summary>Namen und Passwort, die sich der Eingeladene selbst gibt.</summary>
public sealed record EinloesenRequest(string UserName, string Password);