diff --git a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs index d26792a..9980c1d 100644 --- a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs @@ -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 /// Legt dasselbe Token als Cookie ab — für den Browser. /// /// + /// /// HttpOnly: kein Skript kommt heran, also auch keines, das sich /// über eine Lücke in der Oberfläche eingeschlichen hat. Strict: /// eine fremde Seite kann damit nichts auslösen. Secure nur bei /// HTTPS, sonst liesse sich die Oberfläche im Heimnetz über http gar /// nicht benutzen. + /// + /// + /// Mit oder ohne Verfallsdatum, und das entscheidet der Benutzer. + /// Ohne MaxAge 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. + /// /// - 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, }); /// Eine Antwort, die nicht verrät, welche Hälfte falsch war. diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index a7099f2..ff3939b 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -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); +/// +/// Ob die Anmeldung einen geschlossenen Browser überlebt. 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. +/// +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); diff --git a/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs b/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs index 83b40bc..b9d137d 100644 --- a/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs +++ b/server/tests/Flaro.Api.Tests/WebInterfaceTests.cs @@ -215,7 +215,95 @@ public sealed class WebInterfaceTests } } + /// „Angemeldet bleiben" entscheidet über die Lebensdauer. + /// + /// + /// Ohne max-age ist es ein Sitzungscookie und verschwindet mit dem + /// Browserfenster — richtig an einem fremden Rechner, falsch am eigenen. + /// Deshalb wird gefragt statt geraten. + /// + [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); + }); + } + + /// Die Vorgabe ist „bleiben" — ein Client darf es weglassen. + [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); + } + + /// Abmelden scheitert nie — auch nicht ohne gültiges Token. + /// + /// + /// Der Fehler dahinter. 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. + /// + [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)); + }); + } + + /// Ein gültiges Token wird dabei trotzdem widerrufen. + [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)); + } + /// Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile. private static string Keks(HttpResponseMessage antwort) => - antwort.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; + GanzerKeks(antwort).Split(';')[0]; + + /// Die ganze Kopfzeile — samt Lebensdauer und Schutzangaben. + private static string GanzerKeks(HttpResponseMessage antwort) => + antwort.Headers.GetValues("Set-Cookie").Single(); } diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 60231e3..78364fb 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -105,15 +105,38 @@ export const sitzung: { token: string | null; wer: Rolle | 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 { - const roh = localStorage.getItem(SCHLUESSEL); - if (roh) Object.assign(sitzung, JSON.parse(roh)); + // Erst der dauerhafte, dann der fürs Fenster. Liegt in beiden etwas, gilt + // 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. */ } function merken(): void { try { - if (sitzung.token) localStorage.setItem(SCHLUESSEL, JSON.stringify(sitzung)); - else localStorage.removeItem(SCHLUESSEL); + // Immer aus beiden entfernen, bevor geschrieben wird: sonst bliebe nach + // 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. */ } } @@ -133,9 +156,18 @@ async function anfrage(pfad: string, opts: RequestInit = {}): Promise { const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf }); - // 401 heisst: das Token gilt nicht mehr. Weiterzumachen, als wäre nichts, - // führt zu einer Oberfläche, die bei jedem Klick stumm scheitert. - if (antwort.status === 401 && sitzung.token) { abmelden(); location.reload(); } + // **Eine 401 ist noch kein Grund, die Anmeldung wegzuwerfen.** + // + // 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) { let text = `${antwort.status}`; @@ -171,25 +203,64 @@ export const einrichten = (benutzer: string, passwort: string) => method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }), }); -export async function anmelden(benutzer: string, passwort: string): Promise { +export async function anmelden( + benutzer: string, passwort: string, bleiben = true, +): Promise { const antwort = await anfrage<{ token: string; user: Rolle }>('/auth/login', { method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort, deviceName: geraetename(), deviceId: geraetekennung(), + staySignedIn: bleiben, }), }); + bleibenAngemeldet = bleiben; sitzung.token = antwort.token; sitzung.wer = antwort.user; merken(); } -export function abmelden(): void { - if (sitzung.token) anfrage('/auth/logout', { method: 'POST' }).catch(() => {}); +/** Prüft einmal, ob das Token wirklich nicht mehr gilt. + * + * Mehrere gleichzeitige Anfragen laufen in dieselbe Prüfung — sonst stellte + * eine Startseite mit acht Abfragen acht Mal dieselbe Frage. */ +let laufendePruefung: Promise | null = null; + +function sitzungTot(): Promise { + 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; 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 { const u = navigator.userAgent; const was = /iPhone/.test(u) ? 'iPhone' : /iPad/.test(u) ? 'iPad' diff --git a/web/app/src/style.css b/web/app/src/style.css index 863d46a..184279b 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -161,6 +161,11 @@ svg { width: 20px; height: 20px; display: block; flex: none; } border-radius: var(--r-control); padding: var(--s-section); } .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 ---------- */ diff --git a/web/app/src/views/login.ts b/web/app/src/views/login.ts index a932383..6af49ec 100644 --- a/web/app/src/views/login.ts +++ b/web/app/src/views/login.ts @@ -12,15 +12,26 @@ export function anmeldung(fertig: () => void): HTMLElement { name.autocomplete = 'username'; const passwort = feld('Passwort', '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'); - karte.append(titel, name, passwort, los); + karte.append(titel, name, passwort, bleiben, los); async function absenden(): Promise { - karte.querySelector('.error')?.remove(); + karte.querySelector('.fehler')?.remove(); los.disabled = true; try { - await anmelden(name.value.trim(), passwort.value); + await anmelden(name.value.trim(), passwort.value, haken.checked); fertig(); } catch (fehler) { karte.appendChild(fehlerzeile((fehler as Error).message)); @@ -30,6 +41,7 @@ export function anmeldung(fertig: () => void): HTMLElement { } los.onclick = () => void absenden(); + name.onkeydown = e => { if (e.key === 'Enter') passwort.focus(); }; passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); }; setTimeout(() => name.focus(), 50); return karte;