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
+88 -29
View File
@@ -1,11 +1,14 @@
"""JWT authentication module for Vacuum Wall.
Handles token creation, validation, refresh, and blacklisting.
Each user has their own JWT signing secret stored in the database.
Configuration comes from config/auth/config.json.
"""
from __future__ import annotations
import base64
import json
import logging
import secrets
import time
@@ -15,13 +18,15 @@ from typing import Any
import jwt
from lib.common import load_json, save_json
from lib.common import load_json
from lib.db import (
Q_DELETE_EXPIRED_BLACKLIST,
Q_DELETE_REFRESH_TOKEN,
Q_INSERT_BLACKLIST,
Q_SELECT_BLACKLIST,
Q_SELECT_REFRESH_TOKEN,
Q_SELECT_USER_JWT_SECRET,
Q_UPDATE_JWT_SECRET,
Q_UPSERT_REFRESH_TOKEN,
get_db,
)
@@ -36,7 +41,6 @@ _DEFAULT_JWT_CONFIG = {
"access_token_ttl": 900,
"refresh_token_ttl": 604800,
"algorithm": "HS256",
"secret": "",
}
@@ -46,10 +50,25 @@ def _get_jwt_config() -> dict[str, Any]:
return raw.get("jwt", _DEFAULT_JWT_CONFIG)
def get_secret() -> str | None:
"""Return the JWT signing secret, or ``None`` if not configured."""
secret = _get_jwt_config().get("secret")
return secret if secret else None
def get_user_jwt_secret(username: str) -> str | None:
"""Return the JWT signing secret for *username*, or ``None`` if not found."""
db = get_db()
rows = db.query(Q_SELECT_USER_JWT_SECRET, (username,))
if not rows:
return None
return rows[0]["jwt_secret"]
def rotate_user_secret(username: str) -> None:
"""Rotate the JWT secret for *username*, invalidating all their existing tokens.
Used when a user's password is changed to ensure all prior sessions
are immediately terminated regardless of token expiration.
"""
new_secret = secrets.token_urlsafe(32)
db = get_db()
db.run(Q_UPDATE_JWT_SECRET, (new_secret, username))
logger.warning("JWT secret rotated for %r — their existing tokens are now invalid", username)
def get_access_ttl() -> int:
@@ -67,12 +86,19 @@ def get_algorithm() -> str:
return _get_jwt_config().get("algorithm", "HS256")
def generate_access_token(username: str, permissions: dict[str, str]) -> str:
def generate_access_token(
username: str,
permissions: dict[str, str],
session_id: str | None = None,
) -> str:
"""Generate a new access token for *username*.
Args:
username: The authenticated username.
permissions: Dict mapping subsystem names to permission levels.
session_id: Optional session binding ID. Included in the token payload
so the Flask middleware can tie the token to the browser session
that created it.
Returns:
JWT token string.
@@ -80,9 +106,9 @@ def generate_access_token(username: str, permissions: dict[str, str]) -> str:
Raises:
RuntimeError: If JWT secret is not configured.
"""
secret = get_secret()
secret = get_user_jwt_secret(username)
if not secret:
raise RuntimeError("JWT secret is not configured")
raise RuntimeError(f"JWT secret not configured for user {username!r}")
algorithm = get_algorithm()
now = int(time.time())
payload = {
@@ -92,6 +118,7 @@ def generate_access_token(username: str, permissions: dict[str, str]) -> str:
"jti": str(uuid.uuid4()),
"type": "access",
"permissions": permissions,
"session_id": session_id or secrets.token_urlsafe(16),
}
return jwt.encode(payload, secret, algorithm=algorithm)
@@ -108,9 +135,9 @@ def generate_refresh_token(username: str) -> str:
Raises:
RuntimeError: If JWT secret is not configured.
"""
secret = get_secret()
secret = get_user_jwt_secret(username)
if not secret:
raise RuntimeError("JWT secret is not configured")
raise RuntimeError(f"JWT secret not configured for user {username!r}")
algorithm = get_algorithm()
now = int(time.time())
payload = {
@@ -133,12 +160,14 @@ def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str
Returns:
Dict with ``access_token`` and ``refresh_token`` keys.
"""
access_token = generate_access_token(username, permissions)
session_id = secrets.token_urlsafe(16)
access_token = generate_access_token(username, permissions, session_id)
refresh_token = generate_refresh_token(username)
_persist_refresh_token(username, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"session_id": session_id,
}
@@ -192,16 +221,51 @@ def clear_active_refresh_token(username: str) -> None:
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
def _extract_unverified_sub(token_string: str) -> str | None:
"""Extract the ``sub`` claim from a JWT payload without signature verification.
The JWT payload is the second segment (dot-separated), base64url-encoded JSON.
This is safe because we are NOT trusting the claim value — we use it solely
to look up the user's secret for proper verification.
Args:
token_string: The JWT token string.
Returns:
The ``sub`` claim value, or ``None`` if the token is malformed.
"""
try:
parts = token_string.split(".")
if len(parts) != 3:
return None
payload_b64 = parts[1]
# Add padding
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload_json = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_json)
return payload.get("sub")
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
def decode_token(token_string: str) -> dict[str, Any] | None:
"""Decode and validate a JWT token.
"""Decode and validate a JWT token using the user's secret.
Extracts the ``sub`` claim from the unverified payload to look up the
correct per-user signing secret, then verifies the signature.
Args:
token_string: The JWT token string (without Bearer prefix).
Returns:
Payload dict if valid, None if invalid/expired or secret not configured.
Payload dict if valid, None if invalid/expired or user not found.
"""
secret = get_secret()
sub = _extract_unverified_sub(token_string)
if not sub:
return None
secret = get_user_jwt_secret(sub)
if not secret:
return None
algorithm = get_algorithm()
@@ -213,13 +277,19 @@ def decode_token(token_string: str) -> dict[str, Any] | None:
def validate_token(
token_string: str, token_type: str = "access"
token_string: str,
token_type: str = "access",
session_id: str | None = None,
) -> dict[str, Any] | None:
"""Validate a JWT token and check it against the blacklist.
Args:
token_string: The JWT token string.
token_type: Expected token type ("access" or "refresh").
session_id: If provided, must match the ``session_id`` claim in the
token payload. Acts as session binding — prevents a stolen token
from being used by an attacker who doesn't also possess the
matching session ID.
Returns:
Payload dict including permissions, or None if invalid/blacklisted.
@@ -229,6 +299,8 @@ def validate_token(
return None
if payload.get("type") != token_type:
return None
if session_id and payload.get("session_id") != session_id:
return None
jti = payload.get("jti")
if jti and is_blacklisted(jti):
@@ -271,19 +343,6 @@ def blacklist_expired() -> None:
db.run(Q_DELETE_EXPIRED_BLACKLIST, (now,))
def rotate_secret() -> None:
"""Rotate the JWT secret, invalidating all existing tokens.
Used when a user's password is changed to ensure all prior sessions
are immediately terminated regardless of token expiration.
"""
raw = load_json(AUTH_CONFIG_PATH)
jwt_config = raw.get("jwt", _DEFAULT_JWT_CONFIG)
jwt_config["secret"] = secrets.token_urlsafe(48)
raw["jwt"] = jwt_config
save_json(AUTH_CONFIG_PATH, raw)
logger.warning("JWT secret rotated — all existing tokens are now invalid")
class RateLimiter:
"""Simple sliding-window rate limiter for login attempts.