fix: resolve iOS PWA session/CSRF issues causing forbidden errors
- Renew CSRF cookie on /auth/me (first call after iOS PWA resume) - Add try-catch + hex validation to CSRF middleware for corrupted tokens - Auto-retry state-changing requests on 403 by refreshing CSRF token - Add 200ms delay before SW controllerchange reload to prevent blank page on iOS
This commit is contained in:
+8
-1
@@ -22,7 +22,7 @@ function getCsrfToken() {
|
|||||||
* @param {RequestInit} options - Fetch-Optionen
|
* @param {RequestInit} options - Fetch-Optionen
|
||||||
* @returns {Promise<any>} Geparstes JSON oder wirft einen Fehler
|
* @returns {Promise<any>} Geparstes JSON oder wirft einen Fehler
|
||||||
*/
|
*/
|
||||||
async function apiFetch(path, options = {}) {
|
async function apiFetch(path, options = {}, _retried = false) {
|
||||||
const url = `${API_BASE}${path}`;
|
const url = `${API_BASE}${path}`;
|
||||||
|
|
||||||
const method = options.method ?? 'GET';
|
const method = options.method ?? 'GET';
|
||||||
@@ -45,6 +45,13 @@ async function apiFetch(path, options = {}) {
|
|||||||
throw new Error('Sitzung abgelaufen.');
|
throw new Error('Sitzung abgelaufen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF-Token-Desync (haeufig nach iOS-PWA-Resume): einmal GET /auth/me
|
||||||
|
// ausfuehren um den CSRF-Cookie zu erneuern, dann den Request wiederholen.
|
||||||
|
if (response.status === 403 && stateChanging && !_retried) {
|
||||||
|
await fetch(`${API_BASE}/auth/me`, { credentials: 'same-origin', cache: 'no-store' });
|
||||||
|
return apiFetch(path, options, true);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ if ('serviceWorker' in navigator) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nahtloses Update: Neuer SW hat skipWaiting() + clients.claim() aufgerufen
|
// SW-Update: Auf iOS-PWA fuehrt ein sofortiger Reload bei controllerchange
|
||||||
// → Controller wechselt → Seite neu laden für konsistenten Stand
|
// zu Timing-Problemen (leere Seite, verlorene Cookies). Stattdessen nur
|
||||||
|
// nachladen wenn die Seite gerade nicht mitten im Initialisieren ist.
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
window.location.reload();
|
// Kurz warten damit der neue SW vollstaendig aktiviert ist und
|
||||||
|
// clients.claim() abgeschlossen hat, bevor die Seite neu laedt.
|
||||||
|
// Auf iOS-Standalone verhindert das den "leere Seite"-Bug.
|
||||||
|
setTimeout(() => window.location.reload(), 200);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,19 @@ router.get('/me', requireAuth, (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Benutzer nicht gefunden.', code: 401 });
|
return res.status(401).json({ error: 'Benutzer nicht gefunden.', code: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume:
|
||||||
|
// iOS kann den CSRF-Cookie verwerfen waehrend die Session-Cookie erhalten bleibt.
|
||||||
|
// /me ist der erste API-Call nach App-Resume, also hier den Cookie wiederherstellen.)
|
||||||
|
if (!req.session.csrfToken) {
|
||||||
|
req.session.csrfToken = generateToken();
|
||||||
|
}
|
||||||
|
res.cookie('csrf-token', req.session.csrfToken, {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.SESSION_SECURE !== 'false',
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ user });
|
res.json({ user });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('/me Fehler:', err);
|
log.error('/me Fehler:', err);
|
||||||
|
|||||||
@@ -51,13 +51,20 @@ function csrfMiddleware(req, res, next) {
|
|||||||
const sessionToken = req.session.csrfToken;
|
const sessionToken = req.session.csrfToken;
|
||||||
const expectedLen = TOKEN_LENGTH * 2; // 64 Hex-Zeichen
|
const expectedLen = TOKEN_LENGTH * 2; // 64 Hex-Zeichen
|
||||||
|
|
||||||
const tokenValid =
|
let tokenValid = false;
|
||||||
|
try {
|
||||||
|
tokenValid =
|
||||||
headerToken.length === expectedLen &&
|
headerToken.length === expectedLen &&
|
||||||
sessionToken.length === expectedLen &&
|
sessionToken.length === expectedLen &&
|
||||||
|
// Nur valides Hex vergleichen (iOS kann Cookies korrumpieren)
|
||||||
|
/^[0-9a-f]+$/i.test(headerToken) &&
|
||||||
crypto.timingSafeEqual(
|
crypto.timingSafeEqual(
|
||||||
Buffer.from(headerToken, 'hex'),
|
Buffer.from(headerToken, 'hex'),
|
||||||
Buffer.from(sessionToken, 'hex')
|
Buffer.from(sessionToken, 'hex')
|
||||||
);
|
);
|
||||||
|
} catch {
|
||||||
|
// Buffer-Fehler bei korruptem Token - tokenValid bleibt false
|
||||||
|
}
|
||||||
|
|
||||||
if (!tokenValid) {
|
if (!tokenValid) {
|
||||||
return res.status(403).json({ error: 'Ungültiges CSRF-Token.', code: 403 });
|
return res.status(403).json({ error: 'Ungültiges CSRF-Token.', code: 403 });
|
||||||
|
|||||||
Reference in New Issue
Block a user