enforce mandatory X-Session-Id header for access token validation
Session binding was bypassable: if the X-Session-Id header was absent, validate_token skipped the check entirely, allowing a stolen JWT to be used without the originating session. Server-side: reject 401 early in Flask middleware and daemon WebSocket handler when X-Session-Id is missing, before calling validate_token. Updated validate_token to always enforce session_id matching for access tokens (refresh tokens are unaffected as they carry no session_id claim). Frontend: removed dead if (stored.session_id) guards in api.js since the header is now always required. Added X-Session-Id to logout request headers and always store session_id on login/refresh.
This commit is contained in:
@@ -397,6 +397,8 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
session_header = request.headers.get("X-Session-Id")
|
session_header = request.headers.get("X-Session-Id")
|
||||||
|
if not session_header:
|
||||||
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||||
payload = validate_token(
|
payload = validate_token(
|
||||||
token_param, token_type="access", session_id=session_header
|
token_param, token_type="access", session_id=session_header
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-5
@@ -289,10 +289,10 @@ def validate_token(
|
|||||||
Args:
|
Args:
|
||||||
token_string: The JWT token string.
|
token_string: The JWT token string.
|
||||||
token_type: Expected token type ("access" or "refresh").
|
token_type: Expected token type ("access" or "refresh").
|
||||||
session_id: If provided, must match the ``session_id`` claim in the
|
session_id: Must match the ``session_id`` claim in the token payload.
|
||||||
token payload. Acts as session binding — prevents a stolen token
|
Required for access tokens — prevents a stolen token from being
|
||||||
from being used by an attacker who doesn't also possess the
|
usable without the originating session. Ignored for refresh tokens
|
||||||
matching session ID.
|
which carry no ``session_id`` claim.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Payload dict including permissions, or None if invalid/blacklisted.
|
Payload dict including permissions, or None if invalid/blacklisted.
|
||||||
@@ -302,7 +302,13 @@ def validate_token(
|
|||||||
return None
|
return None
|
||||||
if payload.get("type") != token_type:
|
if payload.get("type") != token_type:
|
||||||
return None
|
return None
|
||||||
if session_id and payload.get("session_id") != session_id:
|
if token_type == "access" and session_id != payload.get("session_id"):
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
token_type != "access"
|
||||||
|
and session_id
|
||||||
|
and payload.get("session_id") != session_id
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
jti = payload.get("jti")
|
jti = payload.get("jti")
|
||||||
|
|||||||
+19
-11
@@ -235,13 +235,13 @@ class TestJWT:
|
|||||||
payload = validate_token(token, "access", session_id="wrong-session")
|
payload = validate_token(token, "access", session_id="wrong-session")
|
||||||
assert payload is None
|
assert payload is None
|
||||||
|
|
||||||
def test_session_id_optional(self):
|
def test_session_id_required(self):
|
||||||
"""Token validates without session_id when none is required."""
|
"""Access token is rejected without session_id parameter."""
|
||||||
token = generate_access_token(
|
token = generate_access_token(
|
||||||
"admin", {"firewall": "rw"}, session_id="my-session"
|
"admin", {"firewall": "rw"}, session_id="my-session"
|
||||||
)
|
)
|
||||||
payload = validate_token(token, "access")
|
payload = validate_token(token, "access")
|
||||||
assert payload is not None
|
assert payload is None
|
||||||
|
|
||||||
def test_decode_token(self):
|
def test_decode_token(self):
|
||||||
token = generate_access_token("admin", {"firewall": "rw"})
|
token = generate_access_token("admin", {"firewall": "rw"})
|
||||||
@@ -251,23 +251,27 @@ class TestJWT:
|
|||||||
assert payload["type"] == "access"
|
assert payload["type"] == "access"
|
||||||
|
|
||||||
def test_validate_access_token(self):
|
def test_validate_access_token(self):
|
||||||
token = generate_access_token("admin", {"firewall": "rw"})
|
token = generate_access_token(
|
||||||
payload = validate_token(token, "access")
|
"admin", {"firewall": "rw"}, session_id="test-session"
|
||||||
|
)
|
||||||
|
payload = validate_token(token, "access", session_id="test-session")
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
assert payload["sub"] == "admin"
|
assert payload["sub"] == "admin"
|
||||||
assert payload["permissions"]["firewall"] == "rw"
|
assert payload["permissions"]["firewall"] == "rw"
|
||||||
|
|
||||||
def test_validate_wrong_type(self):
|
def test_validate_wrong_type(self):
|
||||||
token = generate_refresh_token("admin")
|
token = generate_refresh_token("admin")
|
||||||
payload = validate_token(token, "access")
|
payload = validate_token(token, "access", session_id="test-session")
|
||||||
assert payload is None
|
assert payload is None
|
||||||
|
|
||||||
def test_validate_invalid_token(self):
|
def test_validate_invalid_token(self):
|
||||||
payload = validate_token("invalid.token.here", "access")
|
payload = validate_token(
|
||||||
|
"invalid.token.here", "access", session_id="test-session"
|
||||||
|
)
|
||||||
assert payload is None
|
assert payload is None
|
||||||
|
|
||||||
def test_blacklist_token(self):
|
def test_blacklist_token(self):
|
||||||
token = generate_access_token("admin", {})
|
token = generate_access_token("admin", {}, session_id="test-session")
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
jti = payload["jti"]
|
jti = payload["jti"]
|
||||||
@@ -275,7 +279,7 @@ class TestJWT:
|
|||||||
blacklist_token(jti)
|
blacklist_token(jti)
|
||||||
assert is_blacklisted(jti) is True
|
assert is_blacklisted(jti) is True
|
||||||
|
|
||||||
result = validate_token(token, "access")
|
result = validate_token(token, "access", session_id="test-session")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_blacklist_cleanup(self):
|
def test_blacklist_cleanup(self):
|
||||||
@@ -373,7 +377,9 @@ class TestLoginFlow:
|
|||||||
assert verify_user_password("testadmin", "secretpass") is not None
|
assert verify_user_password("testadmin", "secretpass") is not None
|
||||||
|
|
||||||
tokens = generate_tokens("testadmin", user["permissions"])
|
tokens = generate_tokens("testadmin", user["permissions"])
|
||||||
payload = validate_token(tokens["access_token"], "access")
|
payload = validate_token(
|
||||||
|
tokens["access_token"], "access", session_id=tokens["session_id"]
|
||||||
|
)
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
assert payload["sub"] == "testadmin"
|
assert payload["sub"] == "testadmin"
|
||||||
assert payload["permissions"]["firewall"] == "rw"
|
assert payload["permissions"]["firewall"] == "rw"
|
||||||
@@ -387,7 +393,9 @@ class TestLoginFlow:
|
|||||||
|
|
||||||
blacklist_token(payload["jti"])
|
blacklist_token(payload["jti"])
|
||||||
|
|
||||||
result = validate_token(tokens["access_token"], "access")
|
result = validate_token(
|
||||||
|
tokens["access_token"], "access", session_id=tokens["session_id"]
|
||||||
|
)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_token_refresh_flow(self):
|
def test_token_refresh_flow(self):
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ def _auth_middleware():
|
|||||||
|
|
||||||
token_string = auth_header[7:] # strip "Bearer "
|
token_string = auth_header[7:] # strip "Bearer "
|
||||||
session_header = request.headers.get("X-Session-Id")
|
session_header = request.headers.get("X-Session-Id")
|
||||||
|
if not session_header:
|
||||||
|
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||||
payload = validate_token(
|
payload = validate_token(
|
||||||
token_string, token_type="access", session_id=session_header
|
token_string, token_type="access", session_id=session_header
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -107,9 +107,7 @@ async function tryRefreshToken() {
|
|||||||
window.__auth_token__ = tokens.access_token;
|
window.__auth_token__ = tokens.access_token;
|
||||||
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||||
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
|
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
|
||||||
if (tokens.session_id) {
|
sessionStorage.setItem('vw:session_id', tokens.session_id);
|
||||||
sessionStorage.setItem('vw:session_id', tokens.session_id);
|
|
||||||
}
|
|
||||||
if (json.data.user) {
|
if (json.data.user) {
|
||||||
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
|
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
|
||||||
}
|
}
|
||||||
@@ -151,9 +149,7 @@ export async function apiFetch(url, options = {}) {
|
|||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = 'Bearer ' + token;
|
headers['Authorization'] = 'Bearer ' + token;
|
||||||
const stored = getStoredAuth();
|
const stored = getStoredAuth();
|
||||||
if (stored.session_id) {
|
headers['X-Session-Id'] = stored.session_id;
|
||||||
headers['X-Session-Id'] = stored.session_id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body && typeof body === 'object' && !(body instanceof FormData)) {
|
if (body && typeof body === 'object' && !(body instanceof FormData)) {
|
||||||
@@ -171,9 +167,7 @@ export async function apiFetch(url, options = {}) {
|
|||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
const refreshedStored = getStoredAuth();
|
const refreshedStored = getStoredAuth();
|
||||||
headers['Authorization'] = 'Bearer ' + getAuthToken();
|
headers['Authorization'] = 'Bearer ' + getAuthToken();
|
||||||
if (refreshedStored.session_id) {
|
headers['X-Session-Id'] = 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 retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
|
||||||
if (retryRes.ok) {
|
if (retryRes.ok) {
|
||||||
const json = await retryRes.json().catch(() => null);
|
const json = await retryRes.json().catch(() => null);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export async function logout() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Authorization': 'Bearer ' + token,
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'X-Session-Id': sessionStorage.getItem('vw:session_id'),
|
||||||
};
|
};
|
||||||
const refresh = sessionStorage.getItem('vw:refresh');
|
const refresh = sessionStorage.getItem('vw:refresh');
|
||||||
await fetch('/api/auth/logout', {
|
await fetch('/api/auth/logout', {
|
||||||
@@ -120,9 +121,7 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') {
|
|||||||
setAuthToken(tokens.access_token);
|
setAuthToken(tokens.access_token);
|
||||||
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||||
sessionStorage.setItem('vw:access_ttl', String((data.access_ttl || 300) * 1000));
|
sessionStorage.setItem('vw:access_ttl', String((data.access_ttl || 300) * 1000));
|
||||||
if (tokens.session_id) {
|
sessionStorage.setItem('vw:session_id', tokens.session_id);
|
||||||
sessionStorage.setItem('vw:session_id', tokens.session_id);
|
|
||||||
}
|
|
||||||
if (user) {
|
if (user) {
|
||||||
sessionStorage.setItem('vw:user', JSON.stringify(user));
|
sessionStorage.setItem('vw:user', JSON.stringify(user));
|
||||||
if (permissions) {
|
if (permissions) {
|
||||||
|
|||||||
Reference in New Issue
Block a user