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;