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
+19 -1
View File
@@ -175,7 +175,8 @@ def _auth_middleware():
return jsonify({"ok": False, "error": "unauthorized"}), 401
token_string = auth_header[7:] # strip "Bearer "
payload = validate_token(token_string, token_type="access")
session_header = request.headers.get("X-Session-Id")
payload = validate_token(token_string, token_type="access", session_id=session_header)
if payload is None:
return jsonify({"ok": False, "error": "unauthorized"}), 401
@@ -232,6 +233,23 @@ def _log_request_finish(response):
elapsed_ms,
)
# Content Security Policy — prevent inline script execution and XSS
if "Content-Security-Policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
# set X-Content-Type-Options to prevent MIME sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Set cache headers: short in dev, long with staleness tolerance in prod
if response.content_type.startswith("text/html"):
# index.html: always short cache so browser revalidates
+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) {
+11 -5
View File
@@ -7,14 +7,14 @@
import { apiFetch, setAuthToken, clearAuthTokens, redirectLogin, getAuthToken, tryRefreshToken, toast } from '../api.js?v=12';
/**
* Schedule a token refresh based on the access token TTL stored in localStorage.
* Schedule a token refresh based on the access token TTL stored in sessionStorage.
* The refresh fires at TTL - 60 seconds to allow the browser to refresh smoothly.
*/
export function scheduleTokenRefresh() {
if (typeof window.__authRefreshTimer__ !== 'undefined') {
clearTimeout(window.__authRefreshTimer__);
}
const ttl = parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000;
const ttl = parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000;
const delay = Math.max(ttl - 60000, 30000);
window.__authRefreshTimer__ = setTimeout(async () => {
@@ -47,6 +47,7 @@ export async function checkSession() {
const { user, permissions } = result.data || {};
if (user) {
localStorage.setItem('vw:user', JSON.stringify(user));
sessionStorage.setItem('vw:user', JSON.stringify(user));
if (permissions) {
localStorage.setItem('vw:permissions', JSON.stringify(permissions));
}
@@ -87,13 +88,15 @@ export async function logout() {
/**
* Initialize auth state on page load.
* Checks stored tokens, validates session, and schedules refresh.
* Since sessionStorage is cleared on tab close, a closed/reopened tab
* will always fall through to reauth.
*
* @returns {Promise<boolean>} true if authenticated
*/
export async function initAuth() {
const token = getAuthToken();
if (token) {
const saved = JSON.parse(localStorage.getItem('vw:user') || 'null');
const saved = JSON.parse(sessionStorage.getItem('vw:user') || 'null');
if (saved) {
const ok = await checkSession();
if (ok) {
@@ -116,8 +119,11 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') {
const { tokens, user, permissions } = data || {};
if (tokens) {
setAuthToken(tokens.access_token);
localStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((data.access_ttl || 900) * 1000));
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
sessionStorage.setItem('vw:access_ttl', String((data.access_ttl || 300) * 1000));
if (tokens.session_id) {
sessionStorage.setItem('vw:session_id', tokens.session_id);
}
if (user) {
localStorage.setItem('vw:user', JSON.stringify(user));
if (permissions) {
+10 -4
View File
@@ -22,7 +22,7 @@ const _directHandlers = [];
* @returns {Promise<boolean>} true if token was refreshed
*/
async function _tryRefreshToken() {
const refresh = localStorage.getItem('vw:refresh');
const refresh = sessionStorage.getItem('vw:refresh');
if (!refresh) return false;
try {
const res = await fetch('/api/auth/refresh', {
@@ -36,9 +36,15 @@ async function _tryRefreshToken() {
if (!json.ok || !json.data?.tokens) return false;
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) {
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
}
return true;
} catch {
return false;