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.
+18 -8
View File
@@ -8,9 +8,14 @@ from __future__ import annotations
import logging
import re
import secrets
from typing import Any
from lib.auth import blacklist_active_refresh_token, blacklist_expired
from lib.auth import (
blacklist_active_refresh_token,
blacklist_expired,
rotate_user_secret,
)
from lib.db import (
Q_DELETE_PERMISSIONS,
Q_DELETE_USER,
@@ -57,7 +62,7 @@ def _get_permissions(username: str) -> dict[str, str]:
def get_user(username: str) -> dict[str, Any] | None:
"""Get a user by username (without password hash).
"""Get a user by username (without password hash or JWT secret).
Args:
username: The username to look up.
@@ -76,9 +81,9 @@ def get_user(username: str) -> dict[str, Any] | None:
def find_user(username: str) -> dict[str, Any] | None:
"""Find a user by username, including password hash.
"""Find a user by username, including password hash and JWT secret.
Used for password verification. Not returned through APIs.
Used for password verification and token operations. Not returned through APIs.
Args:
username: The username to look up.
@@ -105,6 +110,9 @@ def verify_user_password(username: str, password: str) -> dict[str, Any] | None:
"""
user = find_user(username)
if user is None:
# Run a dummy Argon2id verification to prevent timing-based user enumeration.
# The timing for both paths is now equivalent.
verify_password(password, hash_password(secrets.token_hex(32)))
return None
if not verify_password(password, user["password_hash"]):
return None
@@ -143,10 +151,11 @@ def create_user(
raise ValueError(f"User {username!r} already exists")
password_hash = hash_password(password)
jwt_secret = secrets.token_urlsafe(32)
db = get_db()
with db.in_transaction() as tx:
tx.run_one(Q_INSERT_USER, (username, password_hash))
tx.run_one(Q_INSERT_USER, (username, password_hash, jwt_secret))
if permissions:
for subsystem, level in permissions.items():
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
@@ -159,10 +168,10 @@ def create_user(
def update_password(username: str, old_password: str, new_password: str) -> bool:
"""Update a user's password and invalidate all active refresh tokens.
"""Update a user's password and invalidate all active tokens.
Old access tokens expire naturally (15 min TTL). The active refresh
token is immediately blacklisted to prevent token reuse.
Rotates the user's JWT secret, immediately invalidating all existing
access and refresh tokens.
Args:
username: The username.
@@ -180,6 +189,7 @@ def update_password(username: str, old_password: str, new_password: str) -> bool
blacklist_active_refresh_token(username)
new_hash = hash_password(new_password)
rotate_user_secret(username)
db = get_db()
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
_cleanup_blacklist()
+4 -1
View File
@@ -52,6 +52,8 @@ Q_SELECT_WEBAUTHN_COUNTS = "select_webauthn_counts"
Q_DELETE_WEBAUTHN = "delete_webauthn"
Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count"
Q_SELECT_ALL_USERS = "select_all_users"
Q_SELECT_USER_JWT_SECRET = "select_user_jwt_secret"
Q_UPDATE_JWT_SECRET = "update_jwt_secret"
# ---------------------------------------------------------------------------
# Schema DDL
@@ -62,8 +64,9 @@ CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
jwt_secret TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
);
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+7 -3
View File
@@ -26,9 +26,11 @@ from lib.db import (
Q_SELECT_REFRESH_TOKEN,
Q_SELECT_USER_BY_ID,
Q_SELECT_USER_BY_NAME,
Q_SELECT_USER_JWT_SECRET,
Q_SELECT_WEBAUTHN_COUNTS,
Q_SELECT_WEBAUTHN_ID,
Q_SELECT_WEBAUTHN_USER,
Q_UPDATE_JWT_SECRET,
Q_UPDATE_PASSWORD,
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
Q_UPSERT_PERMISSION,
@@ -54,18 +56,20 @@ class SQLiteBackend(Database):
# Schema init is handled by direct execution, not prepared statements
# init_tables is called as _execute_direct(INIT_SQL)
# Users
Q_INSERT_USER: ("INSERT INTO users (username, password_hash) VALUES (?, ?)"),
Q_INSERT_USER: ("INSERT INTO users (username, password_hash, jwt_secret) VALUES (?, ?, ?)"),
Q_SELECT_USER_BY_NAME: (
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?"
"SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE username = ?"
),
Q_SELECT_USER_BY_ID: (
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?"
"SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE id = ?"
),
Q_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?",
Q_DELETE_USER: "DELETE FROM users WHERE username = ?",
Q_SELECT_ALL_USERS: (
"SELECT id, username, created_at FROM users ORDER BY username"
),
Q_SELECT_USER_JWT_SECRET: ("SELECT jwt_secret FROM users WHERE username = ?"),
Q_UPDATE_JWT_SECRET: ("UPDATE users SET jwt_secret = ? WHERE username = ?"),
# Permissions
Q_UPSERT_PERMISSION: (
"INSERT INTO permissions (username, subsystem, level) "