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>
246 lines
11 KiB
C#
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];
|
|
}
|
|
}
|