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:
+88
-29
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user