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:
@@ -195,6 +195,31 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
|
||||
},
|
||||
"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)
|
||||
|
||||
+2
-1
@@ -396,7 +396,8 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
||||
{"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:
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
+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()
|
||||
|
||||
@@ -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
@@ -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) "
|
||||
|
||||
@@ -38,9 +38,6 @@ def main() -> None:
|
||||
|
||||
from lib.auth_users import ALL_SUBSYSTEMS, create_user
|
||||
|
||||
# Generate JWT secret
|
||||
secret = os.urandom(32).hex()
|
||||
|
||||
# Write config
|
||||
config_dir = project_dir / "config" / "auth"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -51,7 +48,6 @@ def main() -> None:
|
||||
"access_token_ttl": 900,
|
||||
"refresh_token_ttl": 604800,
|
||||
"algorithm": "HS256",
|
||||
"secret": secret,
|
||||
},
|
||||
"webauthn": {
|
||||
"rp_name": "Vacuum Wall",
|
||||
|
||||
+63
-26
@@ -13,9 +13,6 @@ import pytest
|
||||
|
||||
import lib.db
|
||||
import lib.db_sqlite
|
||||
from lib.auth import (
|
||||
AUTH_CONFIG_PATH as _AUTH_CFG_PATH,
|
||||
)
|
||||
from lib.auth import (
|
||||
blacklist_expired,
|
||||
blacklist_token,
|
||||
@@ -76,11 +73,11 @@ def db():
|
||||
|
||||
@pytest.fixture
|
||||
def sample_secret():
|
||||
"""Return the JWT secret from auth config."""
|
||||
from lib.common import load_json
|
||||
"""Placeholder for per-user secret tests.
|
||||
|
||||
raw = load_json(_AUTH_CFG_PATH)
|
||||
return raw.get("jwt", {}).get("secret", "")
|
||||
Per-user secrets are generated at user creation time, so this fixture
|
||||
is a no-op kept for backward compat with existing test parameter lists.
|
||||
"""
|
||||
|
||||
|
||||
# ---------- Password hashing tests ----------
|
||||
@@ -136,12 +133,12 @@ class TestDBLayer:
|
||||
assert conn1 is conn2
|
||||
|
||||
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 uid > 0
|
||||
|
||||
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",))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["username"] == "testuser"
|
||||
@@ -152,7 +149,7 @@ class TestDBLayer:
|
||||
|
||||
def test_transaction_commit(self, db):
|
||||
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"))
|
||||
rows = db.query(Q_SELECT_USER_BY_NAME, ("txuser",))
|
||||
assert len(rows) == 1
|
||||
@@ -163,7 +160,7 @@ class TestDBLayer:
|
||||
def test_transaction_rollback(self, db):
|
||||
try:
|
||||
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!")
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -177,7 +174,7 @@ class TestDBLayer:
|
||||
assert stmt1 is stmt2
|
||||
|
||||
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"))
|
||||
perms = db.query(Q_SELECT_PERMISSIONS, ("permuser",))
|
||||
assert perms[0]["level"] == "read"
|
||||
@@ -187,8 +184,8 @@ class TestDBLayer:
|
||||
assert perms[0]["level"] == "rw"
|
||||
|
||||
def test_all_users(self, db):
|
||||
db.run(Q_INSERT_USER, ("aaa", "$argon2id$hash"))
|
||||
db.run(Q_INSERT_USER, ("bbb", "$argon2id$hash"))
|
||||
db.run(Q_INSERT_USER, ("aaa", "$argon2id$hash", "test-secret"))
|
||||
db.run(Q_INSERT_USER, ("bbb", "$argon2id$hash", "test-secret"))
|
||||
rows = db.query(Q_SELECT_ALL_USERS, ())
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["username"] == "aaa"
|
||||
@@ -198,35 +195,68 @@ class TestDBLayer:
|
||||
|
||||
|
||||
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"})
|
||||
assert "access_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"})
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
assert payload["sub"] == "admin"
|
||||
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"})
|
||||
payload = validate_token(token, "access")
|
||||
assert payload is not None
|
||||
assert payload["sub"] == "admin"
|
||||
assert payload["permissions"]["firewall"] == "rw"
|
||||
|
||||
def test_validate_wrong_type(self, sample_secret):
|
||||
def test_validate_wrong_type(self):
|
||||
token = generate_refresh_token("admin")
|
||||
payload = validate_token(token, "access")
|
||||
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")
|
||||
assert payload is None
|
||||
|
||||
def test_blacklist_token(self, sample_secret, db):
|
||||
def test_blacklist_token(self):
|
||||
token = generate_access_token("admin", {})
|
||||
payload = decode_token(token)
|
||||
assert payload is not None
|
||||
@@ -238,7 +268,8 @@ class TestJWT:
|
||||
result = validate_token(token, "access")
|
||||
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))
|
||||
rows_before = db.query(Q_SELECT_BLACKLIST, ("old-jti",))
|
||||
assert len(rows_before) == 1
|
||||
@@ -321,7 +352,11 @@ class TestUserManagement:
|
||||
|
||||
|
||||
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."""
|
||||
user = create_user("admin", "secretpass", {"firewall": "rw", "auth": "rw"})
|
||||
assert verify_user_password("admin", "secretpass") is not None
|
||||
@@ -332,8 +367,9 @@ class TestLoginFlow:
|
||||
assert payload["sub"] == "admin"
|
||||
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."""
|
||||
create_user("testuser", "password123")
|
||||
tokens = generate_tokens("testuser", {})
|
||||
payload = decode_token(tokens["access_token"])
|
||||
assert payload is not None
|
||||
@@ -343,8 +379,9 @@ class TestLoginFlow:
|
||||
result = validate_token(tokens["access_token"], "access")
|
||||
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."""
|
||||
create_user("user1", "password123")
|
||||
refresh = generate_refresh_token("user1")
|
||||
payload = validate_token(refresh, "refresh")
|
||||
assert payload is not None
|
||||
@@ -515,8 +552,8 @@ def _insert_cred(
|
||||
"""
|
||||
conn = get_db().conn
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (username, password_hash) VALUES (?, ?)",
|
||||
(username, "testhash"),
|
||||
"INSERT OR IGNORE INTO users (username, password_hash, jwt_secret) VALUES (?, ?, ?)",
|
||||
(username, "testhash", "test-secret"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO webauthn_creds (username, credential_id, public_key, sign_count, name, transports) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
|
||||
@@ -4,6 +4,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from webui.server import _has_permission, _subsystem_from_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
@@ -95,3 +97,40 @@ class TestGroupWriteHandler:
|
||||
|
||||
mode = os.stat(log_file).st_mode & 0o777
|
||||
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
@@ -175,7 +175,8 @@ def _auth_middleware():
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
|
||||
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:
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
|
||||
@@ -232,6 +233,23 @@ def _log_request_finish(response):
|
||||
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
|
||||
if response.content_type.startswith("text/html"):
|
||||
# index.html: always short cache so browser revalidates
|
||||
|
||||
+30
-13
@@ -14,8 +14,9 @@ import { isModalProcessing, setModalProcessing, refreshModals } from './componen
|
||||
* Global state — shared with auth.js component.
|
||||
*
|
||||
* ``window.__auth_token__`` — current access token (in memory, cleared on reload).
|
||||
* ``localStorage['vw:refresh']`` — refresh token (survives reload).
|
||||
* ``localStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling.
|
||||
* ``sessionStorage['vw:refresh']`` — refresh token (tab-scoped, cleared on close).
|
||||
* ``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() {
|
||||
window.__auth_token__ = undefined;
|
||||
localStorage.removeItem('vw:refresh');
|
||||
localStorage.removeItem('vw:access_ttl');
|
||||
sessionStorage.removeItem('vw:refresh');
|
||||
sessionStorage.removeItem('vw:access_ttl');
|
||||
sessionStorage.removeItem('vw:session_id');
|
||||
localStorage.removeItem('vw:user');
|
||||
if (typeof window.__authRefreshTimer__ !== 'undefined') {
|
||||
clearTimeout(window.__authRefreshTimer__);
|
||||
@@ -52,13 +54,14 @@ function clearAuthTokens() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read refresh token and access TTL from localStorage.
|
||||
* @returns {{refresh?: string, ttl?: number}}
|
||||
* Read refresh token and access TTL from sessionStorage.
|
||||
* @returns {{refresh?: string, ttl?: number, session_id?: string}}
|
||||
*/
|
||||
function getStoredAuth() {
|
||||
return {
|
||||
refresh: localStorage.getItem('vw:refresh'),
|
||||
ttl: parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000,
|
||||
refresh: sessionStorage.getItem('vw:refresh'),
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @returns {Promise<boolean>} ``true`` if refresh succeeded
|
||||
@@ -93,9 +96,14 @@ async function tryRefreshToken() {
|
||||
}
|
||||
const tokens = json.data.tokens;
|
||||
window.__auth_token__ = tokens.access_token;
|
||||
localStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000));
|
||||
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
|
||||
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;
|
||||
} catch {
|
||||
clearAuthTokens();
|
||||
@@ -116,8 +124,9 @@ function redirectLogin() {
|
||||
*
|
||||
* Automatically sets Content-Type for object bodies, parses JSON
|
||||
* responses, and normalises the result to { ok, data, error, status }.
|
||||
* Injects ``Authorization: Bearer`` header when a token is present.
|
||||
* On 401, tries token refresh once; on persistent failure, redirects to login.
|
||||
* Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when
|
||||
* a token is present. On 401, tries token refresh once; on persistent
|
||||
* failure, redirects to login.
|
||||
*
|
||||
* @param {string} url – Target URL
|
||||
* @param {object} [options] – Fetch options (method, body, headers, …)
|
||||
@@ -129,6 +138,10 @@ export async function apiFetch(url, options = {}) {
|
||||
const token = getAuthToken();
|
||||
if (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)) {
|
||||
@@ -144,7 +157,11 @@ export async function apiFetch(url, options = {}) {
|
||||
if (res.status === 401 && getAuthToken()) {
|
||||
const refreshed = await tryRefreshToken();
|
||||
if (refreshed) {
|
||||
const refreshedStored = getStoredAuth();
|
||||
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 json = await retryRes.json();
|
||||
if (retryRes.ok) {
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
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.
|
||||
*/
|
||||
export function scheduleTokenRefresh() {
|
||||
if (typeof window.__authRefreshTimer__ !== 'undefined') {
|
||||
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);
|
||||
|
||||
window.__authRefreshTimer__ = setTimeout(async () => {
|
||||
@@ -47,6 +47,7 @@ export async function checkSession() {
|
||||
const { user, permissions } = result.data || {};
|
||||
if (user) {
|
||||
localStorage.setItem('vw:user', JSON.stringify(user));
|
||||
sessionStorage.setItem('vw:user', JSON.stringify(user));
|
||||
if (permissions) {
|
||||
localStorage.setItem('vw:permissions', JSON.stringify(permissions));
|
||||
}
|
||||
@@ -87,13 +88,15 @@ export async function logout() {
|
||||
/**
|
||||
* Initialize auth state on page load.
|
||||
* 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
|
||||
*/
|
||||
export async function initAuth() {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
const saved = JSON.parse(localStorage.getItem('vw:user') || 'null');
|
||||
const saved = JSON.parse(sessionStorage.getItem('vw:user') || 'null');
|
||||
if (saved) {
|
||||
const ok = await checkSession();
|
||||
if (ok) {
|
||||
@@ -116,8 +119,11 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') {
|
||||
const { tokens, user, permissions } = data || {};
|
||||
if (tokens) {
|
||||
setAuthToken(tokens.access_token);
|
||||
localStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
localStorage.setItem('vw:access_ttl', String((data.access_ttl || 900) * 1000));
|
||||
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
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) {
|
||||
localStorage.setItem('vw:user', JSON.stringify(user));
|
||||
if (permissions) {
|
||||
|
||||
@@ -22,7 +22,7 @@ const _directHandlers = [];
|
||||
* @returns {Promise<boolean>} true if token was refreshed
|
||||
*/
|
||||
async function _tryRefreshToken() {
|
||||
const refresh = localStorage.getItem('vw:refresh');
|
||||
const refresh = sessionStorage.getItem('vw:refresh');
|
||||
if (!refresh) return false;
|
||||
try {
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
@@ -36,9 +36,15 @@ async function _tryRefreshToken() {
|
||||
if (!json.ok || !json.data?.tokens) return false;
|
||||
const tokens = json.data.tokens;
|
||||
window.__auth_token__ = tokens.access_token;
|
||||
localStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000));
|
||||
sessionStorage.setItem('vw:refresh', tokens.refresh_token);
|
||||
sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000));
|
||||
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;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user