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:
+18
-8
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user