using System.Security.Claims; using Flaro.Core.Auth; using Flaro.Core.Users; using Flaro.Data; using Microsoft.EntityFrameworkCore; namespace Flaro.Api.Endpoints; /// Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden. /// /// /// /// Warum. 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. /// /// /// Zwei Welten in einer Datei. 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. /// /// 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 Suchen(FlaroContext db, string token, CancellationToken ct) => db.Einladungen.FirstOrDefaultAsync( e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct); /// Der Grund, warum ein Link nicht gilt — oder null. /// /// /// Unterschiedliche Sätze, mit Absicht. 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. /// 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; }