fix: harden auth with refresh token session binding, logging, and router state
- Add session_id to refresh tokens and enforce it during validation, preventing stolen refresh tokens from being usable without the originating browser session - Set router.isAuthenticated via auth:login event after successful login (previously only set at page load) - Add console.warn logging to WS message parse/handler errors - Improve _refreshPromise error handling in token refresh flow - Document rate limiter in-memory limitation and CSP connect-src same-origin requirement - Add 3 tests for session-bound refresh token validation
This commit is contained in:
@@ -259,6 +259,9 @@ def _log_request_finish(response):
|
||||
)
|
||||
|
||||
# Content Security Policy — prevent inline script execution and XSS
|
||||
# NOTE: connect-src 'self' is safe because all XHR/fetch/WS calls go through
|
||||
# nginx on the same origin. If WS or API routing ever changes to use a
|
||||
# different host/port directly, the CSP must be updated accordingly.
|
||||
if "Content-Security-Policy" not in response.headers:
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
|
||||
@@ -263,6 +263,14 @@ export async function initApp() {
|
||||
render(mainEl, MainContent);
|
||||
}
|
||||
|
||||
// Listen for login events to update router state after auth
|
||||
window.addEventListener('auth:login', () => {
|
||||
router.isAuthenticated = true;
|
||||
if (!router.state.path.startsWith('/login')) {
|
||||
fetchInitialData();
|
||||
}
|
||||
});
|
||||
|
||||
// Check auth state before connecting WS
|
||||
const ok = await initAuth();
|
||||
if (ok) {
|
||||
|
||||
+34
-31
@@ -83,41 +83,44 @@ let _refreshPromise = null;
|
||||
*
|
||||
* @returns {Promise<boolean>} ``true`` if refresh succeeded
|
||||
*/
|
||||
async function tryRefreshToken() {
|
||||
_refreshPromise = _refreshPromise || (async () => {
|
||||
try {
|
||||
const stored = getStoredAuth();
|
||||
if (!stored.refresh) return false;
|
||||
async function tryRefreshToken() {
|
||||
if (!_refreshPromise) {
|
||||
_refreshPromise = (async () => {
|
||||
try {
|
||||
const stored = getStoredAuth();
|
||||
if (!stored.refresh) return false;
|
||||
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: stored.refresh }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: stored.refresh, session_id: stored.session_id }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
clearAuthTokens();
|
||||
return false;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.ok || !json.data?.tokens) {
|
||||
clearAuthTokens();
|
||||
return false;
|
||||
}
|
||||
const tokens = json.data.tokens;
|
||||
setAuthToken(tokens.access_token);
|
||||
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
|
||||
sessionStorage.setItem('vw:session_id', tokens.session_id);
|
||||
if (json.data.user) {
|
||||
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[Auth] Token refresh failed:', err);
|
||||
clearAuthTokens();
|
||||
return false;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.ok || !json.data?.tokens) {
|
||||
clearAuthTokens();
|
||||
return false;
|
||||
}
|
||||
const tokens = json.data.tokens;
|
||||
setAuthToken(tokens.access_token);
|
||||
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
|
||||
sessionStorage.setItem('vw:session_id', tokens.session_id);
|
||||
if (json.data.user) {
|
||||
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
clearAuthTokens();
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}
|
||||
return _refreshPromise.finally(() => { _refreshPromise = null; });
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,13 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') {
|
||||
}
|
||||
scheduleTokenRefresh();
|
||||
}
|
||||
// Notify app.js that auth is established (used to set router.isAuthenticated)
|
||||
window.dispatchEvent(new CustomEvent('auth:login', {
|
||||
detail: {
|
||||
permissions: sessionStorage.getItem('vw:permissions') ?
|
||||
JSON.parse(sessionStorage.getItem('vw:permissions')) : {},
|
||||
},
|
||||
}));
|
||||
window.location.hash = redirectPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,9 @@ function _wsConnect() {
|
||||
try {
|
||||
const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
|
||||
handleMessage(msg);
|
||||
} catch (_) {}
|
||||
} catch (err) {
|
||||
console.warn('[WS] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,7 +126,7 @@ function handleMessage(msg) {
|
||||
for (const h of _directHandlers) {
|
||||
if (h.unsubscribed) continue;
|
||||
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
|
||||
try { h.handler(msg); } catch (_) {}
|
||||
try { h.handler(msg); } catch (err) { console.warn('[WS] Handler error:', err); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user