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
+81
-10
@@ -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<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
|
||||
|
||||
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<void> {
|
||||
export async function anmelden(
|
||||
benutzer: string, passwort: string, bleiben = true,
|
||||
): Promise<void> {
|
||||
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<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;
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user