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
+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()