Files
Flaro/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs
T
ScarriffleandClaude Opus 5 a7a4419626 Adminrecht geben und nehmen — und einen, dem es niemand nimmt
Ein Konto liess sich anlegen und danach nie wieder ändern: kein Weg zum
Administrator, keiner zurück, keiner zum Löschen. Mit Einladungslinks
füllte sich der Server also in genau eine Richtung.

**Warum es einen Besitzer braucht.** Sobald Administratoren einander das
Recht entziehen können, gibt es einen Zustand ohne jeden Administrator —
und aus dem führt kein Weg zurück ausser dem an der Datenbank. Ein Konto,
das ausgenommen ist, schliesst ihn aus. Es ist das erste, also das, mit
dem eingerichtet wurde; die Migration trägt das auf bestehenden Servern
nach, und weil die Kennungen zeitlich geordnet vergeben werden, ist der
Älteste schlicht der mit der kleinsten.

Weitergeben lässt er sich: vom Besitzer, an einen Administrator, mit
Rückfrage. Nur an einen Administrator, sonst wäre der Server einen Klick
von einem Besitzer entfernt, der die Verwaltung gar nicht sieht.

**Drei Türen sind zugemauert**, und jede hat ihren Test: dem Besitzer das
Recht nehmen, den Besitzer löschen, sich selbst das eigene Recht nehmen.
Das letzte ist die unauffälligste — wer sich im Eifer entmachtet, steht
vor einer Verwaltung, die er nicht mehr öffnen kann, und das Zurücknehmen
braucht genau die.

Gemessen und als Test festgehalten: ein frisch vergebenes Adminrecht gilt
**sofort**, ohne neue Anmeldung. Die Rolle wird je Anfrage aus der
Datenbank gelesen und nicht aus dem Token — ich hatte das Gegenteil
befürchtet.

Löschen nimmt mit, was am Konto hing: Geräte, Sehstände, Sehprotokoll,
Meldungen, offene Einladungen. Alles hängt mit `Cascade` daran. Zahlen
über jemanden zu führen, den es nicht mehr gibt, wäre die schlechtere
Hälfte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:31:05 +02:00

246 lines
11 KiB
C#

using System.Security.Claims;
using Flaro.Api.Auth;
using Flaro.Core.Auth;
using Flaro.Core.Users;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
public static class AuthEndpoints
{
/// <summary>Ein Hash, gegen den geprüft wird, wenn es den Benutzer gar nicht gibt.</summary>
///
/// <remarks>
/// <b>Gegen das Abfragen von Benutzernamen über die Uhr.</b> Ohne ihn
/// antwortet der Server bei einem unbekannten Namen sofort und bei einem
/// bekannten erst nach dem Argon2-Lauf — vierzig Millisekunden Unterschied,
/// und damit lässt sich eine Liste gültiger Konten zusammentragen.
/// </remarks>
private static readonly Lazy<string> Strohmann =
new(() => PasswordHasher.Hash(Secrets.Create()), LazyThreadSafetyMode.ExecutionAndPublication);
public static void MapAuth(this IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/auth/login", async (
LoginRequest anfrage, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
{
var name = anfrage.UserName.Trim();
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.UserName == name);
if (benutzer is null)
{
// Dieselbe Arbeit wie bei einem echten Konto, dann ablehnen.
PasswordHasher.Verify(anfrage.Password, Strohmann.Value);
return Abgelehnt();
}
if (!PasswordHasher.Verify(anfrage.Password, benutzer.PasswordHash)) return Abgelehnt();
// Gelegenheit genutzt: hier liegt das Passwort im Klartext vor, und
// nur hier lässt sich ein nach alten Kennwerten gebildeter Hash
// überhaupt erneuern.
if (PasswordHasher.NeedsRehash(benutzer.PasswordHash))
benutzer.PasswordHash = PasswordHasher.Hash(anfrage.Password);
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
await db.SaveChangesAsync();
CookieSetzen(zusammenhang, antwort.Token, anfrage.StaySignedIn);
return Results.Ok(antwort);
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
// **Abmelden scheitert nie.**
//
// Vorher verlangte dieser Weg eine gültige Anmeldung — und genau dann,
// wenn man ihn am dringendsten braucht, gibt es keine: nach einem
// Serverumzug, nach einem geleerten Datenbestand, nach einem
// zurückgezogenen Token. Das Cookie blieb dann im Browser stehen und
// ging bei **jedem** Bild erneut hinaus, und der Server lehnte es
// jedes Mal ab. Achtzig Ablehnungen in einer Stunde, und nichts davon
// liess sich von aussen beenden.
//
// Jetzt wird das Cookie immer gelöscht; widerrufen wird, was sich
// widerrufen lässt.
app.MapPost("/api/v1/auth/logout", async (
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
{
zusammenhang.Response.Cookies.Delete(FlaroTokenCookie);
if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id))
return Results.NoContent();
var eintrag = await db.DeviceTokens.FindAsync(id);
if (eintrag is not null)
{
eintrag.RevokedAt = clock.GetUtcNow();
await db.SaveChangesAsync();
}
return Results.NoContent();
}).AllowAnonymous();
// **Aus der Datenbank, nicht nur aus dem Token.** Name und Rolle stehen
// in den Ansprüchen; Sprache und Bild ändern sich, ohne dass ein neues
// Token ausgestellt wird. Wer sein Bild tauscht und die Seite neu lädt,
// sähe sonst das alte, bis er sich neu anmeldet.
app.MapGet("/api/v1/auth/me", async (ClaimsPrincipal wer, FlaroContext db) =>
{
if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id))
return Results.Unauthorized();
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
if (benutzer is null) return Results.Unauthorized();
return Results.Ok(new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
}).RequireAuthorization();
MapCodeFlow(app);
}
/// <summary>Anmelden mit einem Code auf dem Bildschirm.</summary>
private static void MapCodeFlow(IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/auth/code", async (CodeRequest anfrage, FlaroContext db, TimeProvider clock) =>
{
var jetzt = clock.GetUtcNow();
// Beiläufig aufräumen. Ein eigener Hintergrunddienst für ein paar
// Zeilen je Tag wäre Aufwand ohne Gegenwert.
await db.AuthCodes.Where(c => c.ExpiresAt < jetzt).ExecuteDeleteAsync();
var abholschein = Secrets.Create();
var code = new AuthCode
{
Code = AuthCode.NewCode(),
HandleFingerprint = Secrets.Fingerprint(abholschein),
DeviceName = Kürzen(anfrage.DeviceName, 128),
DeviceId = Kürzen(anfrage.DeviceId, 128),
CreatedAt = jetzt,
ExpiresAt = jetzt + AuthCode.Lifetime,
};
db.AuthCodes.Add(code);
await db.SaveChangesAsync();
return Results.Ok(new CodeResponse(code.Code, abholschein, code.ExpiresAt));
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
app.MapGet("/api/v1/auth/code/{handle}", async (
string handle, FlaroContext db, TimeProvider clock) =>
{
var jetzt = clock.GetUtcNow();
var fingerabdruck = Secrets.Fingerprint(handle);
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
return Results.Ok(new CodeState("expired", null));
if (code.ApprovedByUserId is not { } benutzerId)
return Results.Ok(new CodeState("pending", null));
var benutzer = await db.Users.FindAsync(benutzerId);
if (benutzer is null) return Results.Ok(new CodeState("expired", null));
// Einmal abgeholt ist abgeholt — vor dem Ausstellen gesetzt, damit
// zwei gleichzeitige Abholungen nicht zwei Token ergeben.
code.ConsumedAt = jetzt;
var antwort = await TokenAusstellen(db, clock, benutzer, code.DeviceName, code.DeviceId);
await db.SaveChangesAsync();
return Results.Ok(new CodeState("approved", antwort));
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.Polling);
app.MapPost("/api/v1/auth/code/{code}", async (
string code, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
{
if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var benutzerId))
return Results.Unauthorized();
var jetzt = clock.GetUtcNow();
var offen = await db.AuthCodes.FirstOrDefaultAsync(c =>
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
if (offen is null)
return Einwand.Fehlt("Dieser Code gilt nicht mehr.");
offen.ApprovedByUserId = benutzerId;
await db.SaveChangesAsync();
// Damit am Telefon steht, was man da gerade hereinlässt.
return Results.Ok(new { device = offen.DeviceName });
}).RequireAuthorization().RequireRateLimiting(FlaroRateLimits.SignIn);
}
/// <summary>Stellt ein Geräte-Token aus und löst ein älteres desselben Geräts ab.</summary>
///
/// <remarks>
/// Ohne das Ablösen füllt sich die Geräteliste mit Karteileichen: jede
/// erneute Anmeldung derselben App hinterliesse einen weiteren Eintrag,
/// und nach einem Jahr weiss niemand mehr, welches Telefon welches ist.
/// </remarks>
private static async Task<LoginResponse> TokenAusstellen(
FlaroContext db, TimeProvider clock, User benutzer, string geräteName, string geräteKennung)
{
var jetzt = clock.GetUtcNow();
var kennung = Kürzen(geräteKennung, 128);
await db.DeviceTokens
.Where(t => t.UserId == benutzer.Id && t.DeviceId == kennung && t.RevokedAt == null)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, jetzt));
var token = Secrets.Create();
db.DeviceTokens.Add(new DeviceToken
{
UserId = benutzer.Id,
TokenFingerprint = Secrets.Fingerprint(token),
DeviceName = Kürzen(geräteName, 128),
DeviceId = kennung,
CreatedAt = jetzt,
LastSeenAt = jetzt,
});
return new LoginResponse(token, new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
}
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
/// <summary>Legt dasselbe Token als Cookie ab — für den Browser.</summary>
///
/// <remarks>
/// <para>
/// <c>HttpOnly</c>: kein Skript kommt heran, also auch keines, das sich
/// über eine Lücke in der Oberfläche eingeschlichen hat. <c>Strict</c>:
/// eine fremde Seite kann damit nichts auslösen. <c>Secure</c> nur bei
/// HTTPS, sonst liesse sich die Oberfläche im Heimnetz über http gar
/// nicht benutzen.
/// </para>
/// <para>
/// <b>Mit oder ohne Verfallsdatum, und das entscheidet der Benutzer.</b>
/// Ohne <c>MaxAge</c> ist es ein Sitzungscookie: es verschwindet, wenn
/// der Browser zugeht. Das ist die richtige Vorgabe an einem fremden
/// Rechner — und die falsche am eigenen, wo niemand sich täglich neu
/// anmelden will. Deshalb wird gefragt statt geraten.
/// </para>
/// </remarks>
private static void CookieSetzen(HttpContext zusammenhang, string token, bool bleiben) =>
zusammenhang.Response.Cookies.Append(FlaroTokenCookie, token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = zusammenhang.Request.IsHttps,
Path = "/",
MaxAge = bleiben ? TimeSpan.FromDays(365) : null,
});
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
private static IResult Abgelehnt() =>
Results.Problem("Benutzername oder Passwort stimmt nicht.", statusCode: StatusCodes.Status401Unauthorized);
private static string Kürzen(string? wert, int höchstens)
{
var sauber = (wert ?? "").Trim();
return sauber.Length <= höchstens ? sauber : sauber[..höchstens];
}
}