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:
Scarriffle
2026-10-04 07:40:19 +02:00
co-authored by Claude Opus 5
parent 8c395ac051
commit a7ec3a73ba
6 changed files with 229 additions and 20 deletions
@@ -45,16 +45,28 @@ public static class AuthEndpoints
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId); var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
CookieSetzen(zusammenhang, antwort.Token); CookieSetzen(zusammenhang, antwort.Token, anfrage.StaySignedIn);
return Results.Ok(antwort); return Results.Ok(antwort);
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn); }).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 ( app.MapPost("/api/v1/auth/logout", async (
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) => ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
{ {
zusammenhang.Response.Cookies.Delete(FlaroTokenCookie); zusammenhang.Response.Cookies.Delete(FlaroTokenCookie);
if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id)) if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id))
return Results.Unauthorized(); return Results.NoContent();
var eintrag = await db.DeviceTokens.FindAsync(id); var eintrag = await db.DeviceTokens.FindAsync(id);
if (eintrag is not null) if (eintrag is not null)
@@ -63,7 +75,7 @@ public static class AuthEndpoints
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
return Results.NoContent(); return Results.NoContent();
}).RequireAuthorization(); }).AllowAnonymous();
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) => app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) 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> /// <summary>Legt dasselbe Token als Cookie ab — für den Browser.</summary>
/// ///
/// <remarks> /// <remarks>
/// <para>
/// <c>HttpOnly</c>: kein Skript kommt heran, also auch keines, das sich /// <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>: /// ü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 /// 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 /// HTTPS, sonst liesse sich die Oberfläche im Heimnetz über http gar
/// nicht benutzen. /// 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> /// </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 zusammenhang.Response.Cookies.Append(FlaroTokenCookie, token, new CookieOptions
{ {
HttpOnly = true, HttpOnly = true,
SameSite = SameSiteMode.Strict, SameSite = SameSiteMode.Strict,
Secure = zusammenhang.Request.IsHttps, Secure = zusammenhang.Request.IsHttps,
Path = "/", Path = "/",
MaxAge = TimeSpan.FromDays(365), MaxAge = bleiben ? TimeSpan.FromDays(365) : null,
}); });
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary> /// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
+13 -1
View File
@@ -12,7 +12,19 @@ public sealed record ServerNameRequest(string Name);
public sealed record SetupRequest(string UserName, string Password); 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); public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
@@ -215,7 +215,95 @@ public sealed class WebInterfaceTests
} }
} }
/// <summary>„Angemeldet bleiben" entscheidet über die Lebensdauer.</summary>
///
/// <remarks>
/// Ohne <c>max-age</c> ist es ein Sitzungscookie und verschwindet mit dem
/// Browserfenster — richtig an einem fremden Rechner, falsch am eigenen.
/// Deshalb wird gefragt statt geraten.
/// </remarks>
[Test]
public async Task Angemeldet_bleiben_setzt_ein_dauerhaftes_Cookie()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var client = server.OhneGedaechtnis();
var dauerhaft = await client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1", StaySignedIn: true));
var nurJetzt = await client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1", StaySignedIn: false));
Assert.Multiple(() =>
{
Assert.That(GanzerKeks(dauerhaft), Does.Contain("max-age=31536000").IgnoreCase);
Assert.That(GanzerKeks(nurJetzt), Does.Not.Contain("max-age").IgnoreCase);
});
}
/// <summary>Die Vorgabe ist „bleiben" — ein Client darf es weglassen.</summary>
[Test]
public async Task Ohne_Angabe_bleibt_man_angemeldet()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var antwort = await server.OhneGedaechtnis().PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1"));
Assert.That(GanzerKeks(antwort), Does.Contain("max-age=31536000").IgnoreCase);
}
/// <summary>Abmelden scheitert nie — auch nicht ohne gültiges Token.</summary>
///
/// <remarks>
/// <b>Der Fehler dahinter.</b> Vorher verlangte dieser Weg eine gültige
/// Anmeldung, und genau dann, wenn man ihn am dringendsten braucht, gibt
/// es keine: nach einem geleerten Datenbestand zeigt das Cookie im
/// Browser auf ein Token, das es nicht mehr gibt. Es ging bei jedem Bild
/// erneut hinaus, der Server lehnte es jedes Mal ab — achtzig
/// Ablehnungen in einer Stunde —, und nichts davon liess sich von aussen
/// beenden.
/// </remarks>
[Test]
public async Task Abmelden_loescht_das_Cookie_auch_ohne_gueltiges_Token()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var client = server.OhneGedaechtnis();
var anfrage = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
anfrage.Headers.Add("Cookie", $"{TokenAuthenticationHandler.CookieName}=laengst-ungueltig");
var antwort = await client.SendAsync(anfrage);
Assert.Multiple(() =>
{
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
// Ein gelöschtes Cookie ist eines mit abgelaufenem Datum.
Assert.That(antwort.Headers.GetValues("Set-Cookie").Single(),
Does.Contain(TokenAuthenticationHandler.CookieName));
});
}
/// <summary>Ein gültiges Token wird dabei trotzdem widerrufen.</summary>
[Test]
public async Task Abmelden_zieht_ein_gueltiges_Token_weiterhin_zurueck()
{
using var server = new FlaroServer();
var anmeldung = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(anmeldung.Token);
(await client.PostAsync("/api/v1/auth/logout", null)).EnsureSuccessStatusCode();
Assert.That((await client.GetAsync("/api/v1/auth/me")).StatusCode,
Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile.</summary> /// <summary>Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile.</summary>
private static string Keks(HttpResponseMessage antwort) => private static string Keks(HttpResponseMessage antwort) =>
antwort.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; GanzerKeks(antwort).Split(';')[0];
/// <summary>Die ganze Kopfzeile — samt Lebensdauer und Schutzangaben.</summary>
private static string GanzerKeks(HttpResponseMessage antwort) =>
antwort.Headers.GetValues("Set-Cookie").Single();
} }
+81 -10
View File
@@ -105,15 +105,38 @@ export const sitzung: { token: string | null; wer: Rolle | null } = {
token: null, wer: null, token: null, wer: null,
}; };
/** Ob die Anmeldung einen geschlossenen Browser überleben soll. */
let bleibenAngemeldet = true;
/** Wo die Sitzung liegt — und das ist die ganze Umsetzung von „bleiben".
*
* `localStorage` überlebt das Schliessen des Browsers, `sessionStorage`
* nicht. Ein Verfallsdatum selbst zu verwalten wäre die schlechtere
* Variante: eine Uhr, die man stellen kann, gegen zwei Speicher, die der
* Browser ohnehin richtig aufräumt. */
function ablage(dauerhaft: boolean): Storage | null {
try { return dauerhaft ? localStorage : sessionStorage; } catch { return null; }
}
try { try {
const roh = localStorage.getItem(SCHLUESSEL); // Erst der dauerhafte, dann der fürs Fenster. Liegt in beiden etwas, gilt
if (roh) Object.assign(sitzung, JSON.parse(roh)); // der dauerhafte — er ist der zuletzt ausdrücklich gewählte.
const roh = ablage(true)?.getItem(SCHLUESSEL) ?? ablage(false)?.getItem(SCHLUESSEL);
if (roh) {
Object.assign(sitzung, JSON.parse(roh));
bleibenAngemeldet = ablage(true)?.getItem(SCHLUESSEL) != null;
}
} catch { /* Privates Fenster oder gesperrter Speicher — dann eben anmelden. */ } } catch { /* Privates Fenster oder gesperrter Speicher — dann eben anmelden. */ }
function merken(): void { function merken(): void {
try { try {
if (sitzung.token) localStorage.setItem(SCHLUESSEL, JSON.stringify(sitzung)); // Immer aus beiden entfernen, bevor geschrieben wird: sonst bliebe nach
else localStorage.removeItem(SCHLUESSEL); // einem Wechsel von „bleiben" auf „nicht bleiben" die alte Kopie im
// dauerhaften Speicher liegen — und überlebte genau das, was sie nicht
// überleben sollte.
ablage(true)?.removeItem(SCHLUESSEL);
ablage(false)?.removeItem(SCHLUESSEL);
if (sitzung.token) ablage(bleibenAngemeldet)?.setItem(SCHLUESSEL, JSON.stringify(sitzung));
} catch { /* Nicht schlimm: die Sitzung gilt dann nur bis zum Neuladen. */ } } catch { /* Nicht schlimm: die Sitzung gilt dann nur bis zum Neuladen. */ }
} }
@@ -133,9 +156,18 @@ async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf }); const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
// 401 heisst: das Token gilt nicht mehr. Weiterzumachen, als wäre nichts, // **Eine 401 ist noch kein Grund, die Anmeldung wegzuwerfen.**
// führt zu einer Oberfläche, die bei jedem Klick stumm scheitert. //
if (antwort.status === 401 && sitzung.token) { abmelden(); location.reload(); } // Hier stand vorher ein sofortiges `abmelden()` — und das rief
// `/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.
if (antwort.status === 401 && sitzung.token && !pfad.startsWith('/auth/')) {
if (await sitzungTot()) { verwerfen(); location.reload(); }
}
if (!antwort.ok) { if (!antwort.ok) {
let text = `${antwort.status}`; let text = `${antwort.status}`;
@@ -171,25 +203,64 @@ export const einrichten = (benutzer: string, passwort: string) =>
method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }), method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }),
}); });
export async function anmelden(benutzer: string, passwort: string): Promise<void> { export async function anmelden(
benutzer: string, passwort: string, bleiben = true,
): Promise<void> {
const antwort = await anfrage<{ token: string; user: Rolle }>('/auth/login', { const antwort = await anfrage<{ token: string; user: Rolle }>('/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
userName: benutzer, password: passwort, userName: benutzer, password: passwort,
deviceName: geraetename(), deviceId: geraetekennung(), deviceName: geraetename(), deviceId: geraetekennung(),
staySignedIn: bleiben,
}), }),
}); });
bleibenAngemeldet = bleiben;
sitzung.token = antwort.token; sitzung.token = antwort.token;
sitzung.wer = antwort.user; sitzung.wer = antwort.user;
merken(); merken();
} }
export function abmelden(): void { /** Prüft einmal, ob das Token wirklich nicht mehr gilt.
if (sitzung.token) anfrage('/auth/logout', { method: 'POST' }).catch(() => {}); *
* Mehrere gleichzeitige Anfragen laufen in dieselbe Prüfung — sonst stellte
* eine Startseite mit acht Abfragen acht Mal dieselbe Frage. */
let laufendePruefung: Promise<boolean> | null = null;
function sitzungTot(): Promise<boolean> {
laufendePruefung ??= fetch('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${sitzung.token}` },
})
.then(a => a.status === 401)
// Antwortet der Server gar nicht, ist das kein Beleg gegen das Token.
// Jemanden abzumelden, weil das WLAN hakt, ist der falsche Schluss.
.catch(() => false)
.finally(() => { window.setTimeout(() => { laufendePruefung = null; }, 2000); });
return laufendePruefung;
}
/** Wirft die Sitzung hier weg — ohne den Server zu fragen. */
function verwerfen(): void {
sitzung.token = null; sitzung.wer = null; sitzung.token = null; sitzung.wer = null;
merken(); merken();
} }
/** Meldet ab und zieht das Token am Server zurück.
*
* **Nur auf ausdrücklichen Wunsch.** Der Weg hierher führt über den Knopf,
* nicht über eine abgelehnte Anfrage.
*/
export function abmelden(): void {
// Auch ohne gültiges Token aufrufen: der Server löscht dabei das Cookie,
// und genau das bleibt sonst im Browser stehen und geht bei jedem Bild
// erneut hinaus.
void fetch('/api/v1/auth/logout', {
method: 'POST',
headers: sitzung.token ? { Authorization: `Bearer ${sitzung.token}` } : {},
}).catch(() => {});
verwerfen();
}
function geraetename(): string { function geraetename(): string {
const u = navigator.userAgent; const u = navigator.userAgent;
const was = /iPhone/.test(u) ? 'iPhone' : /iPad/.test(u) ? 'iPad' const was = /iPhone/.test(u) ? 'iPhone' : /iPad/.test(u) ? 'iPad'
+5
View File
@@ -161,6 +161,11 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
border-radius: var(--r-control); padding: var(--s-section); border-radius: var(--r-control); padding: var(--s-section);
} }
.login-card h1 { margin: 0; font-size: 26px; letter-spacing: -.02em; color: var(--accent); } .login-card h1 { margin: 0; font-size: 26px; letter-spacing: -.02em; color: var(--accent); }
.bleiben {
display: flex; align-items: center; gap: var(--s-snug);
color: var(--text-dim); font-size: 13px; cursor: pointer; user-select: none;
}
.bleiben input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
/* ---------- Kopf einer Mediathek ---------- */ /* ---------- Kopf einer Mediathek ---------- */
+15 -3
View File
@@ -12,15 +12,26 @@ export function anmeldung(fertig: () => void): HTMLElement {
name.autocomplete = 'username'; name.autocomplete = 'username';
const passwort = feld('Passwort', 'password'); const passwort = feld('Passwort', 'password');
passwort.autocomplete = 'current-password'; passwort.autocomplete = 'current-password';
// **Vorgabe ist „bleiben".** Am eigenen Rechner will sich niemand täglich
// neu anmelden; wer an einem fremden sitzt, schaltet es ab. Ohne Haken
// verfällt die Anmeldung mit dem Browserfenster.
const bleiben = document.createElement('label');
bleiben.className = 'bleiben';
const haken = document.createElement('input');
haken.type = 'checkbox';
haken.checked = true;
bleiben.append(haken, document.createTextNode(' Angemeldet bleiben'));
const los = knopf('Anmelden', 'btn btn-primary'); const los = knopf('Anmelden', 'btn btn-primary');
karte.append(titel, name, passwort, los); karte.append(titel, name, passwort, bleiben, los);
async function absenden(): Promise<void> { async function absenden(): Promise<void> {
karte.querySelector('.error')?.remove(); karte.querySelector('.fehler')?.remove();
los.disabled = true; los.disabled = true;
try { try {
await anmelden(name.value.trim(), passwort.value); await anmelden(name.value.trim(), passwort.value, haken.checked);
fertig(); fertig();
} catch (fehler) { } catch (fehler) {
karte.appendChild(fehlerzeile((fehler as Error).message)); karte.appendChild(fehlerzeile((fehler as Error).message));
@@ -30,6 +41,7 @@ export function anmeldung(fertig: () => void): HTMLElement {
} }
los.onclick = () => void absenden(); los.onclick = () => void absenden();
name.onkeydown = e => { if (e.key === 'Enter') passwort.focus(); };
passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); }; passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); };
setTimeout(() => name.focus(), 50); setTimeout(() => name.focus(), 50);
return karte; return karte;