security: harden JWT auth with session binding, CSP headers, and sessionStorage

- Reduce access_token_ttl from 900s to 300s (5 min) to shrink XSS exploit window
- Add session_id claim to JWT tokens tied to browser session (X-Session-Id header)
- Flask middleware validates session_id matches header on every request
- CSP headers: default-src/script-src 'self', no unsafe-inline/eval, frame-ancestors none
- X-Content-Type-Options: nosniff on all responses
- Move refresh token from localStorage to sessionStorage (tab-scoped, cleared on close)
- Timing-safe password verification (dummy Argon2id for unknown users)
- WebSocket auth also validates session_id header
- Add 5 session_id tests and 3 CSP header tests
This commit is contained in:
2026-07-24 02:51:54 +00:00
parent 56b200d233
commit a365059976
13 changed files with 317 additions and 96 deletions
+31 -14
View File
@@ -14,8 +14,9 @@ import { isModalProcessing, setModalProcessing, refreshModals } from './componen
* Global state — shared with auth.js component.
*
* ``window.__auth_token__`` — current access token (in memory, cleared on reload).
* ``localStorage['vw:refresh']`` — refresh token (survives reload).
* ``localStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling.
* ``sessionStorage['vw:refresh']`` — refresh token (tab-scoped, cleared on close).
* ``sessionStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling.
* ``sessionStorage['vw:session_id']`` — session binding ID for token validation.
*/
/**
@@ -42,8 +43,9 @@ function setAuthToken(token) {
*/
function clearAuthTokens() {
window.__auth_token__ = undefined;
localStorage.removeItem('vw:refresh');
localStorage.removeItem('vw:access_ttl');
sessionStorage.removeItem('vw:refresh');
sessionStorage.removeItem('vw:access_ttl');
sessionStorage.removeItem('vw:session_id');
localStorage.removeItem('vw:user');
if (typeof window.__authRefreshTimer__ !== 'undefined') {
clearTimeout(window.__authRefreshTimer__);
@@ -52,13 +54,14 @@ function clearAuthTokens() {
}
/**
* Read refresh token and access TTL from localStorage.
* @returns {{refresh?: string, ttl?: number}}
* Read refresh token and access TTL from sessionStorage.
* @returns {{refresh?: string, ttl?: number, session_id?: string}}
*/
function getStoredAuth() {
return {
refresh: localStorage.getItem('vw:refresh'),
ttl: parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000,
refresh: sessionStorage.getItem('vw:refresh'),
ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000,
session_id: sessionStorage.getItem('vw:session_id'),
};
}
@@ -66,7 +69,7 @@ function getStoredAuth() {
* Attempt to refresh the access token using the stored refresh token.
*
* Sends: POST /api/auth/refresh { refresh_token: ... }
* On success: updates ``window.__auth_token__`` and ``localStorage['vw:refresh']``.
* On success: updates ``window.__auth_token__`` and ``sessionStorage['vw:refresh']``.
* On failure: clears all tokens.
*
* @returns {Promise<boolean>} ``true`` if refresh succeeded
@@ -93,9 +96,14 @@ async function tryRefreshToken() {
}
const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token;
localStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
if (tokens.session_id) {
sessionStorage.setItem('vw:session_id', tokens.session_id);
}
if (json.data.user) {
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
}
return true;
} catch {
clearAuthTokens();
@@ -116,8 +124,9 @@ function redirectLogin() {
*
* Automatically sets Content-Type for object bodies, parses JSON
* responses, and normalises the result to { ok, data, error, status }.
* Injects ``Authorization: Bearer`` header when a token is present.
* On 401, tries token refresh once; on persistent failure, redirects to login.
* Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when
* a token is present. On 401, tries token refresh once; on persistent
* failure, redirects to login.
*
* @param {string} url Target URL
* @param {object} [options] Fetch options (method, body, headers, …)
@@ -129,6 +138,10 @@ export async function apiFetch(url, options = {}) {
const token = getAuthToken();
if (token) {
headers['Authorization'] = 'Bearer ' + token;
const stored = getStoredAuth();
if (stored.session_id) {
headers['X-Session-Id'] = stored.session_id;
}
}
if (body && typeof body === 'object' && !(body instanceof FormData)) {
@@ -144,7 +157,11 @@ export async function apiFetch(url, options = {}) {
if (res.status === 401 && getAuthToken()) {
const refreshed = await tryRefreshToken();
if (refreshed) {
const refreshedStored = getStoredAuth();
headers['Authorization'] = 'Bearer ' + getAuthToken();
if (refreshedStored.session_id) {
headers['X-Session-Id'] = refreshedStored.session_id;
}
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
const json = await retryRes.json();
if (retryRes.ok) {