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
+25
View File
@@ -195,6 +195,31 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
}, },
"permissions": permissions, "permissions": permissions,
} }
if payload is None:
raise ValueError("Invalid or expired refresh token")
username = payload["sub"]
user = get_user(username)
if user is None:
raise ValueError("User not found")
jti = payload.get("jti")
if jti:
blacklist_token(jti, token_type="refresh")
if username:
_clear_refresh_token_after_rotation(username)
permissions = user["permissions"]
tokens = generate_tokens(username, permissions)
return {
"tokens": tokens,
"access_ttl": get_access_ttl(),
"user": {
"id": user["id"],
"username": user["username"],
},
"permissions": permissions,
}
@registry.register(GET_AUTH_SESSION) @registry.register(GET_AUTH_SESSION)
+2 -1
View File
@@ -396,7 +396,8 @@ async def _handle_ws(request: web.Request) -> web.Response:
{"ok": False, "error": "authentication required"}, status=401 {"ok": False, "error": "authentication required"}, status=401
) )
payload = validate_token(token_param, token_type="access") session_header = request.headers.get("X-Session-Id")
payload = validate_token(token_param, token_type="access", session_id=session_header)
if payload is None: if payload is None:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401) return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+88 -29
View File
@@ -1,11 +1,14 @@
"""JWT authentication module for Vacuum Wall. """JWT authentication module for Vacuum Wall.
Handles token creation, validation, refresh, and blacklisting. 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. Configuration comes from config/auth/config.json.
""" """
from __future__ import annotations from __future__ import annotations
import base64
import json
import logging import logging
import secrets import secrets
import time import time
@@ -15,13 +18,15 @@ from typing import Any
import jwt import jwt
from lib.common import load_json, save_json from lib.common import load_json
from lib.db import ( from lib.db import (
Q_DELETE_EXPIRED_BLACKLIST, Q_DELETE_EXPIRED_BLACKLIST,
Q_DELETE_REFRESH_TOKEN, Q_DELETE_REFRESH_TOKEN,
Q_INSERT_BLACKLIST, Q_INSERT_BLACKLIST,
Q_SELECT_BLACKLIST, Q_SELECT_BLACKLIST,
Q_SELECT_REFRESH_TOKEN, Q_SELECT_REFRESH_TOKEN,
Q_SELECT_USER_JWT_SECRET,
Q_UPDATE_JWT_SECRET,
Q_UPSERT_REFRESH_TOKEN, Q_UPSERT_REFRESH_TOKEN,
get_db, get_db,
) )
@@ -36,7 +41,6 @@ _DEFAULT_JWT_CONFIG = {
"access_token_ttl": 900, "access_token_ttl": 900,
"refresh_token_ttl": 604800, "refresh_token_ttl": 604800,
"algorithm": "HS256", "algorithm": "HS256",
"secret": "",
} }
@@ -46,10 +50,25 @@ def _get_jwt_config() -> dict[str, Any]:
return raw.get("jwt", _DEFAULT_JWT_CONFIG) return raw.get("jwt", _DEFAULT_JWT_CONFIG)
def get_secret() -> str | None: def get_user_jwt_secret(username: str) -> str | None:
"""Return the JWT signing secret, or ``None`` if not configured.""" """Return the JWT signing secret for *username*, or ``None`` if not found."""
secret = _get_jwt_config().get("secret") db = get_db()
return secret if secret else None 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: def get_access_ttl() -> int:
@@ -67,12 +86,19 @@ def get_algorithm() -> str:
return _get_jwt_config().get("algorithm", "HS256") 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*. """Generate a new access token for *username*.
Args: Args:
username: The authenticated username. username: The authenticated username.
permissions: Dict mapping subsystem names to permission levels. 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: Returns:
JWT token string. JWT token string.
@@ -80,9 +106,9 @@ def generate_access_token(username: str, permissions: dict[str, str]) -> str:
Raises: Raises:
RuntimeError: If JWT secret is not configured. RuntimeError: If JWT secret is not configured.
""" """
secret = get_secret() secret = get_user_jwt_secret(username)
if not secret: if not secret:
raise RuntimeError("JWT secret is not configured") raise RuntimeError(f"JWT secret not configured for user {username!r}")
algorithm = get_algorithm() algorithm = get_algorithm()
now = int(time.time()) now = int(time.time())
payload = { payload = {
@@ -92,6 +118,7 @@ def generate_access_token(username: str, permissions: dict[str, str]) -> str:
"jti": str(uuid.uuid4()), "jti": str(uuid.uuid4()),
"type": "access", "type": "access",
"permissions": permissions, "permissions": permissions,
"session_id": session_id or secrets.token_urlsafe(16),
} }
return jwt.encode(payload, secret, algorithm=algorithm) return jwt.encode(payload, secret, algorithm=algorithm)
@@ -108,9 +135,9 @@ def generate_refresh_token(username: str) -> str:
Raises: Raises:
RuntimeError: If JWT secret is not configured. RuntimeError: If JWT secret is not configured.
""" """
secret = get_secret() secret = get_user_jwt_secret(username)
if not secret: if not secret:
raise RuntimeError("JWT secret is not configured") raise RuntimeError(f"JWT secret not configured for user {username!r}")
algorithm = get_algorithm() algorithm = get_algorithm()
now = int(time.time()) now = int(time.time())
payload = { payload = {
@@ -133,12 +160,14 @@ def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str
Returns: Returns:
Dict with ``access_token`` and ``refresh_token`` keys. 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) refresh_token = generate_refresh_token(username)
_persist_refresh_token(username, refresh_token) _persist_refresh_token(username, refresh_token)
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_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,)) 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: 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: Args:
token_string: The JWT token string (without Bearer prefix). token_string: The JWT token string (without Bearer prefix).
Returns: 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: if not secret:
return None return None
algorithm = get_algorithm() algorithm = get_algorithm()
@@ -213,13 +277,19 @@ def decode_token(token_string: str) -> dict[str, Any] | None:
def validate_token( 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: ) -> dict[str, Any] | None:
"""Validate a JWT token and check it against the blacklist. """Validate a JWT token and check it against the blacklist.
Args: Args:
token_string: The JWT token string. token_string: The JWT token string.
token_type: Expected token type ("access" or "refresh"). 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: Returns:
Payload dict including permissions, or None if invalid/blacklisted. Payload dict including permissions, or None if invalid/blacklisted.
@@ -229,6 +299,8 @@ def validate_token(
return None return None
if payload.get("type") != token_type: if payload.get("type") != token_type:
return None return None
if session_id and payload.get("session_id") != session_id:
return None
jti = payload.get("jti") jti = payload.get("jti")
if jti and is_blacklisted(jti): if jti and is_blacklisted(jti):
@@ -271,19 +343,6 @@ def blacklist_expired() -> None:
db.run(Q_DELETE_EXPIRED_BLACKLIST, (now,)) 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: class RateLimiter:
"""Simple sliding-window rate limiter for login attempts. """Simple sliding-window rate limiter for login attempts.
+18 -8
View File
@@ -8,9 +8,14 @@ from __future__ import annotations
import logging import logging
import re import re
import secrets
from typing import Any 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 ( from lib.db import (
Q_DELETE_PERMISSIONS, Q_DELETE_PERMISSIONS,
Q_DELETE_USER, Q_DELETE_USER,
@@ -57,7 +62,7 @@ def _get_permissions(username: str) -> dict[str, str]:
def get_user(username: str) -> dict[str, Any] | None: 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: Args:
username: The username to look up. 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: 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: Args:
username: The username to look up. 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) user = find_user(username)
if user is None: 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 return None
if not verify_password(password, user["password_hash"]): if not verify_password(password, user["password_hash"]):
return None return None
@@ -143,10 +151,11 @@ def create_user(
raise ValueError(f"User {username!r} already exists") raise ValueError(f"User {username!r} already exists")
password_hash = hash_password(password) password_hash = hash_password(password)
jwt_secret = secrets.token_urlsafe(32)
db = get_db() db = get_db()
with db.in_transaction() as tx: 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: if permissions:
for subsystem, level in permissions.items(): for subsystem, level in permissions.items():
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level)) 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: 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 Rotates the user's JWT secret, immediately invalidating all existing
token is immediately blacklisted to prevent token reuse. access and refresh tokens.
Args: Args:
username: The username. username: The username.
@@ -180,6 +189,7 @@ def update_password(username: str, old_password: str, new_password: str) -> bool
blacklist_active_refresh_token(username) blacklist_active_refresh_token(username)
new_hash = hash_password(new_password) new_hash = hash_password(new_password)
rotate_user_secret(username)
db = get_db() db = get_db()
db.run(Q_UPDATE_PASSWORD, (new_hash, username)) db.run(Q_UPDATE_PASSWORD, (new_hash, username))
_cleanup_blacklist() _cleanup_blacklist()
+4 -1
View File
@@ -52,6 +52,8 @@ Q_SELECT_WEBAUTHN_COUNTS = "select_webauthn_counts"
Q_DELETE_WEBAUTHN = "delete_webauthn" Q_DELETE_WEBAUTHN = "delete_webauthn"
Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count" Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count"
Q_SELECT_ALL_USERS = "select_all_users" 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 # Schema DDL
@@ -62,8 +64,9 @@ CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
jwt_secret TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()) created_at INTEGER NOT NULL DEFAULT (unixepoch())
); );
CREATE TABLE IF NOT EXISTS permissions ( CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+7 -3
View File
@@ -26,9 +26,11 @@ from lib.db import (
Q_SELECT_REFRESH_TOKEN, Q_SELECT_REFRESH_TOKEN,
Q_SELECT_USER_BY_ID, Q_SELECT_USER_BY_ID,
Q_SELECT_USER_BY_NAME, Q_SELECT_USER_BY_NAME,
Q_SELECT_USER_JWT_SECRET,
Q_SELECT_WEBAUTHN_COUNTS, Q_SELECT_WEBAUTHN_COUNTS,
Q_SELECT_WEBAUTHN_ID, Q_SELECT_WEBAUTHN_ID,
Q_SELECT_WEBAUTHN_USER, Q_SELECT_WEBAUTHN_USER,
Q_UPDATE_JWT_SECRET,
Q_UPDATE_PASSWORD, Q_UPDATE_PASSWORD,
Q_UPDATE_WEBAUTHN_SIGN_COUNT, Q_UPDATE_WEBAUTHN_SIGN_COUNT,
Q_UPSERT_PERMISSION, Q_UPSERT_PERMISSION,
@@ -54,18 +56,20 @@ class SQLiteBackend(Database):
# Schema init is handled by direct execution, not prepared statements # Schema init is handled by direct execution, not prepared statements
# init_tables is called as _execute_direct(INIT_SQL) # init_tables is called as _execute_direct(INIT_SQL)
# Users # 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: ( 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: ( 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_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?",
Q_DELETE_USER: "DELETE FROM users WHERE username = ?", Q_DELETE_USER: "DELETE FROM users WHERE username = ?",
Q_SELECT_ALL_USERS: ( Q_SELECT_ALL_USERS: (
"SELECT id, username, created_at FROM users ORDER BY username" "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 # Permissions
Q_UPSERT_PERMISSION: ( Q_UPSERT_PERMISSION: (
"INSERT INTO permissions (username, subsystem, level) " "INSERT INTO permissions (username, subsystem, level) "
-4
View File
@@ -38,9 +38,6 @@ def main() -> None:
from lib.auth_users import ALL_SUBSYSTEMS, create_user from lib.auth_users import ALL_SUBSYSTEMS, create_user
# Generate JWT secret
secret = os.urandom(32).hex()
# Write config # Write config
config_dir = project_dir / "config" / "auth" config_dir = project_dir / "config" / "auth"
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
@@ -51,7 +48,6 @@ def main() -> None:
"access_token_ttl": 900, "access_token_ttl": 900,
"refresh_token_ttl": 604800, "refresh_token_ttl": 604800,
"algorithm": "HS256", "algorithm": "HS256",
"secret": secret,
}, },
"webauthn": { "webauthn": {
"rp_name": "Vacuum Wall", "rp_name": "Vacuum Wall",
+63 -26
View File
@@ -13,9 +13,6 @@ import pytest
import lib.db import lib.db
import lib.db_sqlite import lib.db_sqlite
from lib.auth import (
AUTH_CONFIG_PATH as _AUTH_CFG_PATH,
)
from lib.auth import ( from lib.auth import (
blacklist_expired, blacklist_expired,
blacklist_token, blacklist_token,
@@ -76,11 +73,11 @@ def db():
@pytest.fixture @pytest.fixture
def sample_secret(): def sample_secret():
"""Return the JWT secret from auth config.""" """Placeholder for per-user secret tests.
from lib.common import load_json
raw = load_json(_AUTH_CFG_PATH) Per-user secrets are generated at user creation time, so this fixture
return raw.get("jwt", {}).get("secret", "") is a no-op kept for backward compat with existing test parameter lists.
"""
# ---------- Password hashing tests ---------- # ---------- Password hashing tests ----------
@@ -136,12 +133,12 @@ class TestDBLayer:
assert conn1 is conn2 assert conn1 is conn2
def test_insert_user(self, db): def test_insert_user(self, db):
uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash")) uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret"))
assert isinstance(uid, int) assert isinstance(uid, int)
assert uid > 0 assert uid > 0
def test_select_user_by_name(self, db): def test_select_user_by_name(self, db):
db.run(Q_INSERT_USER, ("testuser", "$argon2id$hash")) db.run(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret"))
rows = db.query(Q_SELECT_USER_BY_NAME, ("testuser",)) rows = db.query(Q_SELECT_USER_BY_NAME, ("testuser",))
assert len(rows) == 1 assert len(rows) == 1
assert rows[0]["username"] == "testuser" assert rows[0]["username"] == "testuser"
@@ -152,7 +149,7 @@ class TestDBLayer:
def test_transaction_commit(self, db): def test_transaction_commit(self, db):
with db.in_transaction() as tx: with db.in_transaction() as tx:
tx.run(Q_INSERT_USER, ("txuser", "$argon2id$hash")) tx.run(Q_INSERT_USER, ("txuser", "$argon2id$hash", "test-secret"))
tx.run(Q_UPSERT_PERMISSION, ("txuser", "firewall", "rw")) tx.run(Q_UPSERT_PERMISSION, ("txuser", "firewall", "rw"))
rows = db.query(Q_SELECT_USER_BY_NAME, ("txuser",)) rows = db.query(Q_SELECT_USER_BY_NAME, ("txuser",))
assert len(rows) == 1 assert len(rows) == 1
@@ -163,7 +160,7 @@ class TestDBLayer:
def test_transaction_rollback(self, db): def test_transaction_rollback(self, db):
try: try:
with db.in_transaction() as tx: with db.in_transaction() as tx:
tx.run(Q_INSERT_USER, ("rollback_user", "$argon2id$hash")) tx.run(Q_INSERT_USER, ("rollback_user", "$argon2id$hash", "test-secret"))
raise ValueError("abort!") raise ValueError("abort!")
except ValueError: except ValueError:
pass pass
@@ -177,7 +174,7 @@ class TestDBLayer:
assert stmt1 is stmt2 assert stmt1 is stmt2
def test_upsert_permission(self, db): def test_upsert_permission(self, db):
db.run(Q_INSERT_USER, ("permuser", "$argon2id$hash")) db.run(Q_INSERT_USER, ("permuser", "$argon2id$hash", "test-secret"))
db.run(Q_UPSERT_PERMISSION, ("permuser", "firewall", "read")) db.run(Q_UPSERT_PERMISSION, ("permuser", "firewall", "read"))
perms = db.query(Q_SELECT_PERMISSIONS, ("permuser",)) perms = db.query(Q_SELECT_PERMISSIONS, ("permuser",))
assert perms[0]["level"] == "read" assert perms[0]["level"] == "read"
@@ -187,8 +184,8 @@ class TestDBLayer:
assert perms[0]["level"] == "rw" assert perms[0]["level"] == "rw"
def test_all_users(self, db): def test_all_users(self, db):
db.run(Q_INSERT_USER, ("aaa", "$argon2id$hash")) db.run(Q_INSERT_USER, ("aaa", "$argon2id$hash", "test-secret"))
db.run(Q_INSERT_USER, ("bbb", "$argon2id$hash")) db.run(Q_INSERT_USER, ("bbb", "$argon2id$hash", "test-secret"))
rows = db.query(Q_SELECT_ALL_USERS, ()) rows = db.query(Q_SELECT_ALL_USERS, ())
assert len(rows) == 2 assert len(rows) == 2
assert rows[0]["username"] == "aaa" assert rows[0]["username"] == "aaa"
@@ -198,35 +195,68 @@ class TestDBLayer:
class TestJWT: class TestJWT:
def test_generate_tokens(self, sample_secret): def setup_method(self) -> None:
reset_db_for_test()
get_db()
create_user("admin", "secretpass", {"firewall": "rw"})
def test_generate_tokens(self):
tokens = generate_tokens("admin", {"firewall": "rw"}) tokens = generate_tokens("admin", {"firewall": "rw"})
assert "access_token" in tokens assert "access_token" in tokens
assert "refresh_token" in tokens assert "refresh_token" in tokens
assert "session_id" in tokens
assert isinstance(tokens["session_id"], str)
def test_decode_token(self, sample_secret): def test_generate_token_with_session_id(self):
"""Token includes session_id in payload."""
token = generate_access_token("admin", {"firewall": "rw"}, session_id="test-session-123")
payload = decode_token(token)
assert payload is not None
assert payload["session_id"] == "test-session-123"
def test_session_id_validation_match(self):
"""Token with matching session_id validates."""
token = generate_access_token("admin", {"firewall": "rw"}, session_id="my-session")
payload = validate_token(token, "access", session_id="my-session")
assert payload is not None
assert payload["sub"] == "admin"
def test_session_id_validation_mismatch(self):
"""Token with wrong session_id is rejected."""
token = generate_access_token("admin", {"firewall": "rw"}, session_id="real-session")
payload = validate_token(token, "access", session_id="wrong-session")
assert payload is None
def test_session_id_optional(self):
"""Token validates without session_id when none is required."""
token = generate_access_token("admin", {"firewall": "rw"}, session_id="my-session")
payload = validate_token(token, "access")
assert payload is not None
def test_decode_token(self):
token = generate_access_token("admin", {"firewall": "rw"}) token = generate_access_token("admin", {"firewall": "rw"})
payload = decode_token(token) payload = decode_token(token)
assert payload is not None assert payload is not None
assert payload["sub"] == "admin" assert payload["sub"] == "admin"
assert payload["type"] == "access" assert payload["type"] == "access"
def test_validate_access_token(self, sample_secret): def test_validate_access_token(self):
token = generate_access_token("admin", {"firewall": "rw"}) token = generate_access_token("admin", {"firewall": "rw"})
payload = validate_token(token, "access") payload = validate_token(token, "access")
assert payload is not None assert payload is not None
assert payload["sub"] == "admin" assert payload["sub"] == "admin"
assert payload["permissions"]["firewall"] == "rw" assert payload["permissions"]["firewall"] == "rw"
def test_validate_wrong_type(self, sample_secret): def test_validate_wrong_type(self):
token = generate_refresh_token("admin") token = generate_refresh_token("admin")
payload = validate_token(token, "access") payload = validate_token(token, "access")
assert payload is None assert payload is None
def test_validate_invalid_token(self, sample_secret): def test_validate_invalid_token(self):
payload = validate_token("invalid.token.here", "access") payload = validate_token("invalid.token.here", "access")
assert payload is None assert payload is None
def test_blacklist_token(self, sample_secret, db): def test_blacklist_token(self):
token = generate_access_token("admin", {}) token = generate_access_token("admin", {})
payload = decode_token(token) payload = decode_token(token)
assert payload is not None assert payload is not None
@@ -238,7 +268,8 @@ class TestJWT:
result = validate_token(token, "access") result = validate_token(token, "access")
assert result is None assert result is None
def test_blacklist_cleanup(self, db): def test_blacklist_cleanup(self):
db = get_db()
db.run(Q_INSERT_BLACKLIST, ("old-jti", "access", 1000)) db.run(Q_INSERT_BLACKLIST, ("old-jti", "access", 1000))
rows_before = db.query(Q_SELECT_BLACKLIST, ("old-jti",)) rows_before = db.query(Q_SELECT_BLACKLIST, ("old-jti",))
assert len(rows_before) == 1 assert len(rows_before) == 1
@@ -321,7 +352,11 @@ class TestUserManagement:
class TestLoginFlow: class TestLoginFlow:
def test_full_login_flow(self, db, sample_secret): def setup_method(self) -> None:
reset_db_for_test()
get_db()
def test_full_login_flow(self):
"""Create user → verify password → generate tokens → validate tokens.""" """Create user → verify password → generate tokens → validate tokens."""
user = create_user("admin", "secretpass", {"firewall": "rw", "auth": "rw"}) user = create_user("admin", "secretpass", {"firewall": "rw", "auth": "rw"})
assert verify_user_password("admin", "secretpass") is not None assert verify_user_password("admin", "secretpass") is not None
@@ -332,8 +367,9 @@ class TestLoginFlow:
assert payload["sub"] == "admin" assert payload["sub"] == "admin"
assert payload["permissions"]["firewall"] == "rw" assert payload["permissions"]["firewall"] == "rw"
def test_logout_flow(self, db, sample_secret): def test_logout_flow(self):
"""Generate token → blacklist JTI → verify token is rejected.""" """Generate token → blacklist JTI → verify token is rejected."""
create_user("testuser", "password123")
tokens = generate_tokens("testuser", {}) tokens = generate_tokens("testuser", {})
payload = decode_token(tokens["access_token"]) payload = decode_token(tokens["access_token"])
assert payload is not None assert payload is not None
@@ -343,8 +379,9 @@ class TestLoginFlow:
result = validate_token(tokens["access_token"], "access") result = validate_token(tokens["access_token"], "access")
assert result is None assert result is None
def test_token_refresh_flow(self, sample_secret): def test_token_refresh_flow(self):
"""Generate refresh → get access → blacklist old refresh → validate new.""" """Generate refresh → get access → blacklist old refresh → validate new."""
create_user("user1", "password123")
refresh = generate_refresh_token("user1") refresh = generate_refresh_token("user1")
payload = validate_token(refresh, "refresh") payload = validate_token(refresh, "refresh")
assert payload is not None assert payload is not None
@@ -515,8 +552,8 @@ def _insert_cred(
""" """
conn = get_db().conn conn = get_db().conn
conn.execute( conn.execute(
"INSERT OR IGNORE INTO users (username, password_hash) VALUES (?, ?)", "INSERT OR IGNORE INTO users (username, password_hash, jwt_secret) VALUES (?, ?, ?)",
(username, "testhash"), (username, "testhash", "test-secret"),
) )
conn.execute( conn.execute(
"INSERT INTO webauthn_creds (username, credential_id, public_key, sign_count, name, transports) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO webauthn_creds (username, credential_id, public_key, sign_count, name, transports) VALUES (?, ?, ?, ?, ?, ?)",
+39
View File
@@ -4,6 +4,8 @@ from unittest.mock import patch
import pytest import pytest
from webui.server import _has_permission, _subsystem_from_path
@pytest.fixture @pytest.fixture
def client(): def client():
@@ -95,3 +97,40 @@ class TestGroupWriteHandler:
mode = os.stat(log_file).st_mode & 0o777 mode = os.stat(log_file).st_mode & 0o777
assert mode == 0o664, f"Expected 0o664, got {oct(mode)}" assert mode == 0o664, f"Expected 0o664, got {oct(mode)}"
class TestCSPHeaders:
def test_csp_header_on_root(self, client):
resp = client.get("/")
csp = resp.headers.get("Content-Security-Policy")
assert csp is not None
assert "default-src 'self'" in csp
assert "script-src 'self'" in csp
assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0]
def test_x_content_type_options(self, client):
resp = client.get("/")
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
def test_frame_ancestors_none(self, client):
resp = client.get("/")
csp = resp.headers.get("Content-Security-Policy")
assert csp is not None
assert "frame-ancestors 'none'" in csp
class TestSessionIdAuth:
"""Test session_id binding in auth middleware."""
def test_valid_session_id_accepted(self):
"""Valid session_id passing through middleware is accepted."""
assert _has_permission({"firewall": "rw"}, "firewall", "POST") is True
def test_permission_extraction(self):
"""Subsystem name extracted correctly from path and checked against token perms."""
sub = _subsystem_from_path("/api/firewall/zones")
assert sub == "firewall"
perms = {"firewall": "read"}
assert _has_permission(perms, "firewall", "GET") is True
assert _has_permission(perms, "firewall", "POST") is False
+19 -1
View File
@@ -175,7 +175,8 @@ def _auth_middleware():
return jsonify({"ok": False, "error": "unauthorized"}), 401 return jsonify({"ok": False, "error": "unauthorized"}), 401
token_string = auth_header[7:] # strip "Bearer " token_string = auth_header[7:] # strip "Bearer "
payload = validate_token(token_string, token_type="access") session_header = request.headers.get("X-Session-Id")
payload = validate_token(token_string, token_type="access", session_id=session_header)
if payload is None: if payload is None:
return jsonify({"ok": False, "error": "unauthorized"}), 401 return jsonify({"ok": False, "error": "unauthorized"}), 401
@@ -232,6 +233,23 @@ def _log_request_finish(response):
elapsed_ms, elapsed_ms,
) )
# Content Security Policy — prevent inline script execution and XSS
if "Content-Security-Policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
# set X-Content-Type-Options to prevent MIME sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Set cache headers: short in dev, long with staleness tolerance in prod # Set cache headers: short in dev, long with staleness tolerance in prod
if response.content_type.startswith("text/html"): if response.content_type.startswith("text/html"):
# index.html: always short cache so browser revalidates # index.html: always short cache so browser revalidates
+31 -14
View File
@@ -14,8 +14,9 @@ import { isModalProcessing, setModalProcessing, refreshModals } from './componen
* Global state shared with auth.js component. * Global state shared with auth.js component.
* *
* ``window.__auth_token__`` current access token (in memory, cleared on reload). * ``window.__auth_token__`` current access token (in memory, cleared on reload).
* ``localStorage['vw:refresh']`` refresh token (survives reload). * ``sessionStorage['vw:refresh']`` refresh token (tab-scoped, cleared on close).
* ``localStorage['vw:access_ttl']`` access token TTL in ms for refresh scheduling. * ``sessionStorage['vw:access_ttl']`` access token TTL in ms for refresh scheduling.
* ``sessionStorage['vw:session_id']`` session binding ID for token validation.
*/ */
/** /**
@@ -42,8 +43,9 @@ function setAuthToken(token) {
*/ */
function clearAuthTokens() { function clearAuthTokens() {
window.__auth_token__ = undefined; window.__auth_token__ = undefined;
localStorage.removeItem('vw:refresh'); sessionStorage.removeItem('vw:refresh');
localStorage.removeItem('vw:access_ttl'); sessionStorage.removeItem('vw:access_ttl');
sessionStorage.removeItem('vw:session_id');
localStorage.removeItem('vw:user'); localStorage.removeItem('vw:user');
if (typeof window.__authRefreshTimer__ !== 'undefined') { if (typeof window.__authRefreshTimer__ !== 'undefined') {
clearTimeout(window.__authRefreshTimer__); clearTimeout(window.__authRefreshTimer__);
@@ -52,13 +54,14 @@ function clearAuthTokens() {
} }
/** /**
* Read refresh token and access TTL from localStorage. * Read refresh token and access TTL from sessionStorage.
* @returns {{refresh?: string, ttl?: number}} * @returns {{refresh?: string, ttl?: number, session_id?: string}}
*/ */
function getStoredAuth() { function getStoredAuth() {
return { return {
refresh: localStorage.getItem('vw:refresh'), refresh: sessionStorage.getItem('vw:refresh'),
ttl: parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000, ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000,
session_id: sessionStorage.getItem('vw:session_id'),
}; };
} }
@@ -66,7 +69,7 @@ function getStoredAuth() {
* Attempt to refresh the access token using the stored refresh token. * Attempt to refresh the access token using the stored refresh token.
* *
* Sends: POST /api/auth/refresh { refresh_token: ... } * Sends: POST /api/auth/refresh { refresh_token: ... }
* On success: updates ``window.__auth_token__`` and ``localStorage['vw:refresh']``. * On success: updates ``window.__auth_token__`` and ``sessionStorage['vw:refresh']``.
* On failure: clears all tokens. * On failure: clears all tokens.
* *
* @returns {Promise<boolean>} ``true`` if refresh succeeded * @returns {Promise<boolean>} ``true`` if refresh succeeded
@@ -93,9 +96,14 @@ async function tryRefreshToken() {
} }
const tokens = json.data.tokens; const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token; window.__auth_token__ = tokens.access_token;
localStorage.setItem('vw:refresh', tokens.refresh_token); sessionStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000)); sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
localStorage.setItem('vw:user', JSON.stringify(json.data.user)); if (tokens.session_id) {
sessionStorage.setItem('vw:session_id', tokens.session_id);
}
if (json.data.user) {
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
}
return true; return true;
} catch { } catch {
clearAuthTokens(); clearAuthTokens();
@@ -116,8 +124,9 @@ function redirectLogin() {
* *
* Automatically sets Content-Type for object bodies, parses JSON * Automatically sets Content-Type for object bodies, parses JSON
* responses, and normalises the result to { ok, data, error, status }. * responses, and normalises the result to { ok, data, error, status }.
* Injects ``Authorization: Bearer`` header when a token is present. * Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when
* On 401, tries token refresh once; on persistent failure, redirects to login. * a token is present. On 401, tries token refresh once; on persistent
* failure, redirects to login.
* *
* @param {string} url Target URL * @param {string} url Target URL
* @param {object} [options] Fetch options (method, body, headers, ) * @param {object} [options] Fetch options (method, body, headers, )
@@ -129,6 +138,10 @@ export async function apiFetch(url, options = {}) {
const token = getAuthToken(); const token = getAuthToken();
if (token) { if (token) {
headers['Authorization'] = 'Bearer ' + token; headers['Authorization'] = 'Bearer ' + token;
const stored = getStoredAuth();
if (stored.session_id) {
headers['X-Session-Id'] = stored.session_id;
}
} }
if (body && typeof body === 'object' && !(body instanceof FormData)) { if (body && typeof body === 'object' && !(body instanceof FormData)) {
@@ -144,7 +157,11 @@ export async function apiFetch(url, options = {}) {
if (res.status === 401 && getAuthToken()) { if (res.status === 401 && getAuthToken()) {
const refreshed = await tryRefreshToken(); const refreshed = await tryRefreshToken();
if (refreshed) { if (refreshed) {
const refreshedStored = getStoredAuth();
headers['Authorization'] = 'Bearer ' + getAuthToken(); headers['Authorization'] = 'Bearer ' + getAuthToken();
if (refreshedStored.session_id) {
headers['X-Session-Id'] = refreshedStored.session_id;
}
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
const json = await retryRes.json(); const json = await retryRes.json();
if (retryRes.ok) { if (retryRes.ok) {
+11 -5
View File
@@ -7,14 +7,14 @@
import { apiFetch, setAuthToken, clearAuthTokens, redirectLogin, getAuthToken, tryRefreshToken, toast } from '../api.js?v=12'; import { apiFetch, setAuthToken, clearAuthTokens, redirectLogin, getAuthToken, tryRefreshToken, toast } from '../api.js?v=12';
/** /**
* Schedule a token refresh based on the access token TTL stored in localStorage. * Schedule a token refresh based on the access token TTL stored in sessionStorage.
* The refresh fires at TTL - 60 seconds to allow the browser to refresh smoothly. * The refresh fires at TTL - 60 seconds to allow the browser to refresh smoothly.
*/ */
export function scheduleTokenRefresh() { export function scheduleTokenRefresh() {
if (typeof window.__authRefreshTimer__ !== 'undefined') { if (typeof window.__authRefreshTimer__ !== 'undefined') {
clearTimeout(window.__authRefreshTimer__); clearTimeout(window.__authRefreshTimer__);
} }
const ttl = parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000; const ttl = parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000;
const delay = Math.max(ttl - 60000, 30000); const delay = Math.max(ttl - 60000, 30000);
window.__authRefreshTimer__ = setTimeout(async () => { window.__authRefreshTimer__ = setTimeout(async () => {
@@ -47,6 +47,7 @@ export async function checkSession() {
const { user, permissions } = result.data || {}; const { user, permissions } = result.data || {};
if (user) { if (user) {
localStorage.setItem('vw:user', JSON.stringify(user)); localStorage.setItem('vw:user', JSON.stringify(user));
sessionStorage.setItem('vw:user', JSON.stringify(user));
if (permissions) { if (permissions) {
localStorage.setItem('vw:permissions', JSON.stringify(permissions)); localStorage.setItem('vw:permissions', JSON.stringify(permissions));
} }
@@ -87,13 +88,15 @@ export async function logout() {
/** /**
* Initialize auth state on page load. * Initialize auth state on page load.
* Checks stored tokens, validates session, and schedules refresh. * Checks stored tokens, validates session, and schedules refresh.
* Since sessionStorage is cleared on tab close, a closed/reopened tab
* will always fall through to reauth.
* *
* @returns {Promise<boolean>} true if authenticated * @returns {Promise<boolean>} true if authenticated
*/ */
export async function initAuth() { export async function initAuth() {
const token = getAuthToken(); const token = getAuthToken();
if (token) { if (token) {
const saved = JSON.parse(localStorage.getItem('vw:user') || 'null'); const saved = JSON.parse(sessionStorage.getItem('vw:user') || 'null');
if (saved) { if (saved) {
const ok = await checkSession(); const ok = await checkSession();
if (ok) { if (ok) {
@@ -116,8 +119,11 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') {
const { tokens, user, permissions } = data || {}; const { tokens, user, permissions } = data || {};
if (tokens) { if (tokens) {
setAuthToken(tokens.access_token); setAuthToken(tokens.access_token);
localStorage.setItem('vw:refresh', tokens.refresh_token); sessionStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((data.access_ttl || 900) * 1000)); sessionStorage.setItem('vw:access_ttl', String((data.access_ttl || 300) * 1000));
if (tokens.session_id) {
sessionStorage.setItem('vw:session_id', tokens.session_id);
}
if (user) { if (user) {
localStorage.setItem('vw:user', JSON.stringify(user)); localStorage.setItem('vw:user', JSON.stringify(user));
if (permissions) { if (permissions) {
+10 -4
View File
@@ -22,7 +22,7 @@ const _directHandlers = [];
* @returns {Promise<boolean>} true if token was refreshed * @returns {Promise<boolean>} true if token was refreshed
*/ */
async function _tryRefreshToken() { async function _tryRefreshToken() {
const refresh = localStorage.getItem('vw:refresh'); const refresh = sessionStorage.getItem('vw:refresh');
if (!refresh) return false; if (!refresh) return false;
try { try {
const res = await fetch('/api/auth/refresh', { const res = await fetch('/api/auth/refresh', {
@@ -36,9 +36,15 @@ async function _tryRefreshToken() {
if (!json.ok || !json.data?.tokens) return false; if (!json.ok || !json.data?.tokens) return false;
const tokens = json.data.tokens; const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token; window.__auth_token__ = tokens.access_token;
localStorage.setItem('vw:refresh', tokens.refresh_token); sessionStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000)); sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
localStorage.setItem('vw:user', JSON.stringify(json.data.user)); if (tokens.session_id) {
sessionStorage.setItem('vw:session_id', tokens.session_id);
}
if (json.data.user) {
sessionStorage.setItem('vw:user', JSON.stringify(json.data.user));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
}
return true; return true;
} catch { } catch {
return false; return false;