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:
+19
-1
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user