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:
co-authored by
Claude Opus 5
parent
c65356c16f
commit
d5e21d663f
@@ -0,0 +1,195 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Auth;
|
||||
using Flaro.Core.Users;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum.</b> Ein Konto von Hand anzulegen heisst, sich ein Passwort für
|
||||
/// einen anderen auszudenken und es ihm durchzusagen — über denselben Kanal,
|
||||
/// über den er den Link bekommen hätte. Wer eingeladen wird, soll sich Namen
|
||||
/// und Passwort selbst geben.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Zwei Welten in einer Datei.</b> Erstellen, auflisten und zurückziehen
|
||||
/// ist Verwaltung. Ansehen und Einlösen muss jemand können, der noch gar kein
|
||||
/// Konto hat — also anonym, und damit gedrosselt: es gibt keine globale
|
||||
/// Drossel in diesem Server, ein anonymer Endpunkt ohne eigene wäre
|
||||
/// ungebremst.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class EinladungEndpoints
|
||||
{
|
||||
private const int HoechstensTage = 365;
|
||||
|
||||
public static void MapEinladungen(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var verwalten = app.MapGroup("/api/v1/invites")
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
verwalten.MapPost("", async (
|
||||
EinladungRequest anfrage, ClaimsPrincipal wer,
|
||||
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
|
||||
|
||||
var tage = Math.Clamp(anfrage.Days, 1, HoechstensTage);
|
||||
if (anfrage.MaxUses is { } anzahl && anzahl < 1)
|
||||
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
|
||||
|
||||
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
|
||||
// wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein
|
||||
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
|
||||
// wiedergefunden, sondern neu gemacht.
|
||||
var token = Secrets.Create();
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
var einladung = new Einladung
|
||||
{
|
||||
TokenFingerprint = Secrets.Fingerprint(token),
|
||||
CreatedByUserId = ich,
|
||||
CreatedAt = jetzt,
|
||||
ExpiresAt = jetzt.AddDays(tage),
|
||||
MaxUses = anfrage.MaxUses,
|
||||
};
|
||||
|
||||
db.Einladungen.Add(einladung);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok(new EinladungErstellt(
|
||||
einladung.Id, token, einladung.ExpiresAt, einladung.MaxUses));
|
||||
});
|
||||
|
||||
verwalten.MapGet("", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
var roh = await db.Einladungen.AsNoTracking()
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Join(db.Users.AsNoTracking(), e => e.CreatedByUserId, u => u.Id,
|
||||
(e, u) => new { E = e, u.UserName })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(roh.Select(z => new Einladungseintrag(
|
||||
z.E.Id, z.UserName, z.E.CreatedAt, z.E.ExpiresAt,
|
||||
z.E.MaxUses, z.E.UsedCount, z.E.RevokedAt, z.E.Gilt(jetzt))).ToList());
|
||||
});
|
||||
|
||||
// **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse,
|
||||
// hinterher nicht mehr zu wissen, dass es den Link je gab — und genau
|
||||
// das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht.
|
||||
verwalten.MapDelete("/{id:guid}", async (
|
||||
Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct);
|
||||
if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr.");
|
||||
|
||||
einladung.RevokedAt ??= uhr.GetUtcNow();
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
/* ---------- Was ohne Konto erreichbar sein muss ---------- */
|
||||
|
||||
app.MapGet("/api/v1/invites/{token}", async (
|
||||
string token, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var gefunden = await Suchen(db, token, ct);
|
||||
if (Pruefen(gefunden, uhr.GetUtcNow()) is { } einwand) return einwand;
|
||||
|
||||
var name = await db.Users.AsNoTracking()
|
||||
.Where(u => u.Id == gefunden!.CreatedByUserId)
|
||||
.Select(u => u.UserName).FirstOrDefaultAsync(ct) ?? "";
|
||||
|
||||
var servername = await db.Settings.AsNoTracking()
|
||||
.Where(s => s.Key == SystemEndpoints.NameSchlüssel)
|
||||
.Select(s => s.Value).FirstOrDefaultAsync(ct);
|
||||
|
||||
return Results.Ok(new Einladungsschau(
|
||||
string.IsNullOrWhiteSpace(servername) ? "Flaro" : servername,
|
||||
name, gefunden!.ExpiresAt));
|
||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||
|
||||
app.MapPost("/api/v1/invites/{token}/redeem", async (
|
||||
string token, EinloesenRequest anfrage,
|
||||
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
||||
{
|
||||
var name = anfrage.UserName?.Trim() ?? "";
|
||||
if (name.Length == 0 || (anfrage.Password?.Length ?? 0) < 8)
|
||||
{
|
||||
return Einwand.Bitte(
|
||||
"Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
|
||||
}
|
||||
|
||||
var jetzt = uhr.GetUtcNow();
|
||||
var gefunden = await Suchen(db, token, ct);
|
||||
if (Pruefen(gefunden, jetzt) is { } einwand) return einwand;
|
||||
|
||||
// **Verbrauchen und anlegen gehören zusammen.**
|
||||
//
|
||||
// Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen,
|
||||
// läsen beide `UsedCount = 0` und schrieben beide `1` — und hätten
|
||||
// beide ein Konto. Der Zähler wird deshalb mit einer Bedingung
|
||||
// hochgesetzt, die die Gültigkeit noch einmal prüft; wer dabei
|
||||
// keine Zeile trifft, war der Zweite.
|
||||
//
|
||||
// Und das Ganze in einer Transaktion: scheitert das Anlegen am
|
||||
// Namen, rollt der Verbrauch mit zurück. Sonst wäre ein einmaliger
|
||||
// Link an einem Tippfehler verbrannt.
|
||||
await using var geschaeft = await db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var verbraucht = await db.Einladungen
|
||||
.Where(e => e.Id == gefunden!.Id
|
||||
&& e.RevokedAt == null
|
||||
&& e.ExpiresAt > jetzt
|
||||
&& (e.MaxUses == null || e.UsedCount < e.MaxUses))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.UsedCount, e => e.UsedCount + 1), ct);
|
||||
|
||||
if (verbraucht != 1)
|
||||
return Einwand.Bitte("Dieser Einladungslink wurde schon benutzt.");
|
||||
|
||||
if (await db.Users.AnyAsync(u => u.UserName == name, ct))
|
||||
return Einwand.Belegt($"„{name}“ gibt es schon. Nimm einen anderen.");
|
||||
|
||||
db.Users.Add(new User
|
||||
{
|
||||
UserName = name,
|
||||
PasswordHash = PasswordHasher.Hash(anfrage.Password!),
|
||||
IsAdministrator = false,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await geschaeft.CommitAsync(ct);
|
||||
return Results.NoContent();
|
||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||
}
|
||||
|
||||
private static Task<Einladung?> Suchen(FlaroContext db, string token, CancellationToken ct) =>
|
||||
db.Einladungen.FirstOrDefaultAsync(
|
||||
e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct);
|
||||
|
||||
/// <summary>Der Grund, warum ein Link nicht gilt — oder <c>null</c>.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Unterschiedliche Sätze, mit Absicht.</b> Alles in ein „gilt nicht"
|
||||
/// zusammenzufassen wäre verschwiegener, aber hier ist Verschwiegenheit
|
||||
/// am falschen Platz: wer einen Link bekommen hat und abgewiesen wird,
|
||||
/// muss wissen, ob er zu spät ist oder ob jemand ihn zurückgezogen hat —
|
||||
/// sonst fragt er nach, und der Gastgeber rät mit.
|
||||
/// </remarks>
|
||||
private static IResult? Pruefen(Einladung? einladung, DateTimeOffset jetzt) => einladung switch
|
||||
{
|
||||
null => Einwand.Fehlt("Diesen Einladungslink gibt es nicht."),
|
||||
{ RevokedAt: not null } => Einwand.Bitte("Dieser Einladungslink wurde zurückgezogen."),
|
||||
var e when jetzt >= e.ExpiresAt => Einwand.Bitte("Dieser Einladungslink ist abgelaufen."),
|
||||
var e when e.MaxUses is { } h && e.UsedCount >= h =>
|
||||
Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||
}
|
||||
Reference in New Issue
Block a user