Angemeldet bleiben — und eine 401 reisst nicht mehr die Sitzung ab
Erst die ehrliche Hälfte: dass es heute **andauernd** abgemeldet hat, geht auf mein Konto. Ich habe die Datenbank dreimal geleert, um mit frischen Metadaten zu bauen; jedes Mal verfallen alle Token. Kein Schalter der Welt hätte das verhindert. Die andere Hälfte ist ein echter Fehler, und er macht aus einem Aussetzer eine Abmeldung: **Eine einzelne 401 warf die Anmeldung weg** — und rief dabei `/auth/logout`, der Server zog das Token also *aktiv zurück*. War die Ablehnung nur ein Aussetzer, war eine intakte Anmeldung damit absichtlich zerstört, und zwar unwiderruflich. Jetzt wird einmal nachgefragt: gilt das Token wirklich nicht mehr, ist die Sitzung vorbei; antwortet der Server, war es diese eine Anfrage. Mehrere gleichzeitige Anfragen laufen in dieselbe Prüfung — sonst stellte eine Startseite mit acht Abfragen achtmal dieselbe Frage. Antwortet der Server **gar nicht**, ist das kein Beleg gegen das Token: jemanden abzumelden, weil das WLAN hakt, ist der falsche Schluss. **Abmelden scheitert nie mehr.** Vorher verlangte der Weg eine gültige Anmeldung — und genau dann, wenn man ihn am dringendsten braucht, gibt es keine. Nach dem Leeren des Datenbestands zeigte das Cookie im Browser auf ein Token, das es nicht mehr gab; es 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. --- **Und der Schalter, um den es ging.** „Angemeldet bleiben" steht jetzt im Anmeldefeld, mit Haken als Vorgabe — am eigenen Rechner will sich niemand täglich neu anmelden. Umgesetzt über die beiden Speicher, die der Browser ohnehin richtig aufräumt: mit Haken `localStorage` und ein Cookie für ein Jahr, ohne Haken `sessionStorage` und ein Sitzungscookie, das mit dem Fenster verschwindet. Ein eigenes Verfallsdatum zu verwalten wäre die schlechtere Variante — eine Uhr, die man stellen kann, gegen zwei Mechanismen, die es schon gibt. Das Token selbst läuft weiterhin **nie** ab; es wird widerrufen, nicht vergessen. Was hier entschieden wird, ist allein, wie lange der Browser sich erinnert. 372 Tests grün, vier davon neu. Am Container nachgemessen: mit Haken `max-age=31536000`, ohne Haken kein `max-age`, und ein Abmelden mit ungültigem Cookie antwortet 204 statt 401. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8c395ac051
commit
a7ec3a73ba
@@ -45,16 +45,28 @@ public static class AuthEndpoints
|
||||
|
||||
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
|
||||
await db.SaveChangesAsync();
|
||||
CookieSetzen(zusammenhang, antwort.Token);
|
||||
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.Unauthorized();
|
||||
return Results.NoContent();
|
||||
|
||||
var eintrag = await db.DeviceTokens.FindAsync(id);
|
||||
if (eintrag is not null)
|
||||
@@ -63,7 +75,7 @@ public static class AuthEndpoints
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization();
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
|
||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
|
||||
@@ -186,20 +198,29 @@ public static class AuthEndpoints
|
||||
/// <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) =>
|
||||
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 = TimeSpan.FromDays(365),
|
||||
MaxAge = bleiben ? TimeSpan.FromDays(365) : null,
|
||||
});
|
||||
|
||||
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
|
||||
|
||||
@@ -12,7 +12,19 @@ public sealed record ServerNameRequest(string Name);
|
||||
|
||||
public sealed record SetupRequest(string UserName, string Password);
|
||||
|
||||
public sealed record LoginRequest(string UserName, string Password, string DeviceName, string DeviceId);
|
||||
/// <param name="StaySignedIn">
|
||||
/// <b>Ob die Anmeldung einen geschlossenen Browser überlebt.</b> Vorgabe ist
|
||||
/// ja — am eigenen Rechner will niemand sich täglich neu anmelden. Wer an
|
||||
/// einem fremden sitzt, schaltet es ab, und das Cookie verfällt mit dem
|
||||
/// Fenster.
|
||||
///
|
||||
/// Das Token selbst läuft in keinem Fall ab; es wird widerrufen, nicht
|
||||
/// vergessen. Was hier entschieden wird, ist allein die Lebensdauer des
|
||||
/// Cookies im Browser.
|
||||
/// </param>
|
||||
public sealed record LoginRequest(
|
||||
string UserName, string Password, string DeviceName, string DeviceId,
|
||||
bool StaySignedIn = true);
|
||||
|
||||
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user