a365059976
- 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
1002 lines
34 KiB
Python
1002 lines
34 KiB
Python
"""Phase 1 auth tests: JWT lifecycle, DB layer, password hashing, login flow.
|
|
|
|
All subprocess calls are mocked. Uses in-memory SQLite.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import lib.db
|
|
import lib.db_sqlite
|
|
from lib.auth import (
|
|
blacklist_expired,
|
|
blacklist_token,
|
|
decode_token,
|
|
generate_access_token,
|
|
generate_refresh_token,
|
|
generate_tokens,
|
|
is_blacklisted,
|
|
validate_token,
|
|
)
|
|
from lib.auth_users import (
|
|
create_user,
|
|
delete_user,
|
|
get_user,
|
|
list_users,
|
|
update_password,
|
|
update_permissions,
|
|
verify_user_password,
|
|
)
|
|
from lib.db import (
|
|
Q_INSERT_BLACKLIST,
|
|
Q_INSERT_USER,
|
|
Q_SELECT_ALL_USERS,
|
|
Q_SELECT_BLACKLIST,
|
|
Q_SELECT_PERMISSIONS,
|
|
Q_SELECT_USER_BY_NAME,
|
|
Q_UPSERT_PERMISSION,
|
|
get_db,
|
|
reset_db_for_test,
|
|
)
|
|
from lib.password import hash_password, needs_rehash, verify_password
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _db_reset():
|
|
"""Reset DB singleton and env vars before each test."""
|
|
reset_db_for_test()
|
|
old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None)
|
|
old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None)
|
|
|
|
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
|
os.environ["VACUUM_WALL_DB_PATH"] = ":memory:"
|
|
|
|
yield
|
|
|
|
reset_db_for_test()
|
|
if old_backend is not None:
|
|
os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend
|
|
if old_path is not None:
|
|
os.environ["VACUUM_WALL_DB_PATH"] = old_path
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
"""Initialize the DB and return it."""
|
|
return get_db()
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_secret():
|
|
"""Placeholder for per-user secret tests.
|
|
|
|
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 ----------
|
|
|
|
|
|
class TestPasswordHashing:
|
|
def test_hash_starts_with_argon2id(self):
|
|
h = hash_password("test1234")
|
|
assert h.startswith("$argon2id$v=19$")
|
|
|
|
def test_hash_contains_parameters(self):
|
|
h = hash_password("test1234")
|
|
assert "m=65536" in h # 64 MiB
|
|
assert "t=3" in h
|
|
assert "p=4" in h
|
|
|
|
def test_unique_salt(self):
|
|
h1 = hash_password("same_password")
|
|
h2 = hash_password("same_password")
|
|
assert h1 != h2
|
|
|
|
def test_verify_correct_password(self):
|
|
h = hash_password("my_secret")
|
|
assert verify_password("my_secret", h) is True
|
|
|
|
def test_verify_wrong_password(self):
|
|
h = hash_password("my_secret")
|
|
assert verify_password("wrong_password", h) is False
|
|
|
|
def test_needs_rehash_no_change(self):
|
|
h = hash_password("test")
|
|
assert needs_rehash(h) is False
|
|
|
|
|
|
# ---------- DB layer tests ----------
|
|
|
|
|
|
class TestDBLayer:
|
|
def test_init_tables(self, db):
|
|
cur = db.conn.cursor()
|
|
tables = cur.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
).fetchall()
|
|
table_names = {t["name"] for t in tables}
|
|
assert "users" in table_names
|
|
assert "permissions" in table_names
|
|
assert "token_blacklist" in table_names
|
|
assert "webauthn_creds" in table_names
|
|
|
|
def test_connection_caching(self, db):
|
|
conn1 = db.conn
|
|
conn2 = db.conn
|
|
assert conn1 is conn2
|
|
|
|
def test_insert_user(self, db):
|
|
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", "test-secret"))
|
|
rows = db.query(Q_SELECT_USER_BY_NAME, ("testuser",))
|
|
assert len(rows) == 1
|
|
assert rows[0]["username"] == "testuser"
|
|
|
|
def test_query_unknown_id_raises(self, db):
|
|
with pytest.raises(KeyError):
|
|
db.query("nonexistent_query_id")
|
|
|
|
def test_transaction_commit(self, db):
|
|
with db.in_transaction() as tx:
|
|
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
|
|
perms = db.query(Q_SELECT_PERMISSIONS, ("txuser",))
|
|
assert len(perms) == 1
|
|
assert perms[0]["subsystem"] == "firewall"
|
|
|
|
def test_transaction_rollback(self, db):
|
|
try:
|
|
with db.in_transaction() as tx:
|
|
tx.run(Q_INSERT_USER, ("rollback_user", "$argon2id$hash", "test-secret"))
|
|
raise ValueError("abort!")
|
|
except ValueError:
|
|
pass
|
|
rows = db.query(Q_SELECT_USER_BY_NAME, ("rollback_user",))
|
|
assert len(rows) == 0
|
|
|
|
def test_prepared_statement_caching(self, db):
|
|
stmt1 = db._get_prepared(Q_SELECT_USER_BY_NAME)
|
|
stmt2 = db._get_prepared(Q_SELECT_USER_BY_NAME)
|
|
|
|
assert stmt1 is stmt2
|
|
|
|
def test_upsert_permission(self, db):
|
|
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"
|
|
|
|
db.run(Q_UPSERT_PERMISSION, ("permuser", "firewall", "rw"))
|
|
perms = db.query(Q_SELECT_PERMISSIONS, ("permuser",))
|
|
assert perms[0]["level"] == "rw"
|
|
|
|
def test_all_users(self, db):
|
|
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"
|
|
|
|
|
|
# ---------- JWT tests ----------
|
|
|
|
|
|
class TestJWT:
|
|
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_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):
|
|
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):
|
|
token = generate_refresh_token("admin")
|
|
payload = validate_token(token, "access")
|
|
assert payload is None
|
|
|
|
def test_validate_invalid_token(self):
|
|
payload = validate_token("invalid.token.here", "access")
|
|
assert payload is None
|
|
|
|
def test_blacklist_token(self):
|
|
token = generate_access_token("admin", {})
|
|
payload = decode_token(token)
|
|
assert payload is not None
|
|
jti = payload["jti"]
|
|
|
|
blacklist_token(jti)
|
|
assert is_blacklisted(jti) is True
|
|
|
|
result = validate_token(token, "access")
|
|
assert result is None
|
|
|
|
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
|
|
|
|
blacklist_expired()
|
|
|
|
rows_after = db.query(Q_SELECT_BLACKLIST, ("old-jti",))
|
|
assert len(rows_after) == 0
|
|
|
|
|
|
# ---------- User management tests ----------
|
|
|
|
|
|
class TestUserManagement:
|
|
def test_create_user(self, db):
|
|
user = create_user("testuser", "password123", {"firewall": "rw"})
|
|
assert user["username"] == "testuser"
|
|
assert user["permissions"]["firewall"] == "rw"
|
|
assert "id" in user
|
|
|
|
def test_create_user_invalid_username(self, db):
|
|
with pytest.raises(ValueError):
|
|
create_user("a", "password123")
|
|
|
|
def test_create_user_duplicate(self, db):
|
|
create_user("dupuser", "password123")
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
create_user("dupuser", "otherpassword")
|
|
|
|
def test_verify_password(self, db):
|
|
create_user("pwuser", "correctpass")
|
|
assert verify_user_password("pwuser", "correctpass") is not None
|
|
assert verify_user_password("pwuser", "wrongpass") is None
|
|
assert verify_user_password("nonexistent", "anything") is None
|
|
|
|
def test_get_user(self, db):
|
|
create_user("getuser", "password123", {"firewall": "rw"})
|
|
user = get_user("getuser")
|
|
assert user is not None
|
|
assert "password_hash" not in user
|
|
assert user["permissions"]["firewall"] == "rw"
|
|
|
|
def test_get_user_not_found(self, db):
|
|
assert get_user("nonexistent") is None
|
|
|
|
def test_update_password(self, db):
|
|
create_user("upwuser", "oldpass")
|
|
update_password("upwuser", "oldpass", "newpass123")
|
|
assert verify_user_password("upwuser", "newpass123") is not None
|
|
assert verify_user_password("upwuser", "oldpass") is None
|
|
|
|
def test_update_password_wrong_old(self, db):
|
|
create_user("upwfail", "realpass")
|
|
with pytest.raises(ValueError, match="incorrect"):
|
|
update_password("upwfail", "wrong_old", "newpass123")
|
|
|
|
def test_update_permissions(self, db):
|
|
create_user("permuser", "password123", {"firewall": "rw"})
|
|
update_permissions("permuser", {"firewall": "read", "network": "rw"})
|
|
user = get_user("permuser")
|
|
assert user["permissions"]["firewall"] == "read"
|
|
assert user["permissions"]["network"] == "rw"
|
|
|
|
def test_list_users(self, db):
|
|
create_user("listuser1", "pass1", {"firewall": "rw"})
|
|
create_user("listuser2", "pass2", {"network": "rw"})
|
|
users = list_users()
|
|
assert len(users) == 2
|
|
usernames = {u["username"] for u in users}
|
|
assert "listuser1" in usernames
|
|
|
|
def test_delete_user(self, db):
|
|
create_user("deluser", "password123")
|
|
assert get_user("deluser") is not None
|
|
delete_user("deluser")
|
|
assert get_user("deluser") is None
|
|
|
|
|
|
# ---------- Integration-style login flow ----------
|
|
|
|
|
|
class TestLoginFlow:
|
|
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
|
|
|
|
tokens = generate_tokens("admin", user["permissions"])
|
|
payload = validate_token(tokens["access_token"], "access")
|
|
assert payload is not None
|
|
assert payload["sub"] == "admin"
|
|
assert payload["permissions"]["firewall"] == "rw"
|
|
|
|
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
|
|
|
|
blacklist_token(payload["jti"])
|
|
|
|
result = validate_token(tokens["access_token"], "access")
|
|
assert result is None
|
|
|
|
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
|
|
|
|
blacklist_token(payload["jti"])
|
|
result = validate_token(refresh, "refresh")
|
|
assert result is None
|
|
|
|
new_refresh = generate_refresh_token("user1")
|
|
new_payload = validate_token(new_refresh, "refresh")
|
|
assert new_payload is not None
|
|
assert new_payload["jti"] != payload["jti"]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# WebAuthn tests (Phase 2)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
_FAKE_CRED_ID = base64.urlsafe_b64encode(b"fakecred012345").decode().rstrip("=")
|
|
_FAKE_CRED_ID_BYTES = b"fakecred012345"
|
|
_FAKE_PUBLIC_KEY = base64.urlsafe_b64encode(b"fakepubkey01234").decode().rstrip("=")
|
|
_FAKE_CHALLENGE = base64.urlsafe_b64encode(b"fakechallenge!!").decode().rstrip("=")
|
|
|
|
|
|
@patch.dict(
|
|
os.environ,
|
|
{
|
|
"VACUUM_WALL_DB_BACKEND": "sqlite",
|
|
"VACUUM_WALL_DB_PATH": ":memory:",
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
},
|
|
)
|
|
class TestWebAuthnConfig:
|
|
"""Test WebAuthn configuration helpers."""
|
|
|
|
def test_get_rp_defaults(self) -> None:
|
|
from lib.webauthn import get_origin, get_rp_id, get_rp_name
|
|
|
|
assert get_rp_id() == "localhost"
|
|
assert get_rp_name() == "Vacuum Wall"
|
|
assert get_origin() == "http://localhost"
|
|
|
|
|
|
@patch.dict(
|
|
os.environ,
|
|
{
|
|
"VACUUM_WALL_DB_BACKEND": "sqlite",
|
|
"VACUUM_WALL_DB_PATH": ":memory:",
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
},
|
|
)
|
|
class TestWebAuthnB64urlHelpers:
|
|
"""Test base64url encoding/decoding used by WebAuthn."""
|
|
|
|
def test_b64url_roundtrip(self) -> None:
|
|
from lib.webauthn import b64u_decode, b64u_encode
|
|
|
|
original = b"hello world 123 !@#"
|
|
encoded = b64u_encode(original)
|
|
decoded = b64u_decode(encoded)
|
|
assert decoded == original
|
|
|
|
def test_b64url_binary(self) -> None:
|
|
from lib.webauthn import b64u_decode, b64u_encode
|
|
|
|
original = bytes(range(256))
|
|
encoded = b64u_encode(original)
|
|
decoded = b64u_decode(encoded)
|
|
assert decoded == original
|
|
|
|
|
|
@patch.dict(
|
|
os.environ,
|
|
{
|
|
"VACUUM_WALL_DB_BACKEND": "sqlite",
|
|
"VACUUM_WALL_DB_PATH": ":memory:",
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
},
|
|
)
|
|
class TestWebAuthnRegistration:
|
|
"""Test WebAuthn registration flow with mocked library."""
|
|
|
|
def setup_method(self) -> None:
|
|
lib.db.reset_db_for_test()
|
|
get_db()
|
|
|
|
def test_create_registration_options_basic(self) -> None:
|
|
from lib.webauthn import create_registration_options
|
|
|
|
options = create_registration_options("testuser")
|
|
|
|
assert isinstance(options, dict)
|
|
assert "challenge" in options
|
|
assert "rp" in options
|
|
assert "user" in options
|
|
assert "pubKeyCredParams" in options
|
|
assert options["rp"]["id"] == "localhost"
|
|
assert options["user"]["name"] == "testuser"
|
|
assert len(options["pubKeyCredParams"]) >= 1
|
|
|
|
def test_verify_registration_stores_in_db(self) -> None:
|
|
from lib.webauthn import b64u_encode
|
|
|
|
with patch("lib.webauthn.verify_registration_response") as mock_verify:
|
|
mock_credential = MagicMock()
|
|
mock_credential.id = _FAKE_CRED_ID_BYTES
|
|
mock_credential.public_key = b"fakepubkey01234"
|
|
mock_credential.sign_count = 0
|
|
|
|
col = MagicMock()
|
|
col.credential = mock_credential
|
|
mock_verify.return_value = col
|
|
|
|
from lib.webauthn import verify_registration
|
|
|
|
_insert_cred("testuser")
|
|
|
|
result = verify_registration(
|
|
"testuser",
|
|
{
|
|
"id": _FAKE_CRED_ID,
|
|
"rawId": _FAKE_CRED_ID,
|
|
"type": "public-key",
|
|
"response": {
|
|
"clientDataJSON": b64u_encode(b"{}"),
|
|
"attestationObject": b64u_encode(b"dummy"),
|
|
"transports": [],
|
|
},
|
|
},
|
|
{"challenge": b64u_encode(b"testchallenge123")},
|
|
"My Key",
|
|
)
|
|
|
|
assert "id" in result
|
|
assert result["name"] == "My Key"
|
|
assert isinstance(result["transports"], list)
|
|
assert isinstance(result["sign_count"], int)
|
|
|
|
cursor = get_db().conn.execute(
|
|
"SELECT credential_id, name FROM webauthn_creds WHERE name = ?",
|
|
("My Key",),
|
|
)
|
|
assert len(cursor.fetchall()) == 1
|
|
|
|
def test_create_registration_excludes_existing(self) -> None:
|
|
_insert_cred("testuser", cred_id="existingcred")
|
|
|
|
from lib.webauthn import create_registration_options
|
|
|
|
options = create_registration_options("testuser")
|
|
|
|
# Should contain the existing credential in exclude list
|
|
exclude_ids = [c["id"] for c in options.get("excludeCredentials", [])]
|
|
assert "existingcred" in exclude_ids
|
|
|
|
|
|
def _insert_cred(
|
|
username,
|
|
cred_id="Y3JlZDE",
|
|
public_key="cGsx",
|
|
sign_count=0,
|
|
name="",
|
|
transports='["internal"]',
|
|
):
|
|
"""Convenience: insert a webauthn_creds row directly.
|
|
|
|
Also creates the user in the users table if they don't exist (FK constraint).
|
|
"""
|
|
conn = get_db().conn
|
|
conn.execute(
|
|
"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 (?, ?, ?, ?, ?, ?)",
|
|
(username, cred_id, public_key, sign_count, name, transports),
|
|
)
|
|
|
|
|
|
@patch.dict(
|
|
os.environ,
|
|
{
|
|
"VACUUM_WALL_DB_BACKEND": "sqlite",
|
|
"VACUUM_WALL_DB_PATH": ":memory:",
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
},
|
|
)
|
|
class TestWebAuthnAuthentication:
|
|
"""Test WebAuthn authentication flow with mocked library."""
|
|
|
|
def setup_method(self) -> None:
|
|
lib.db.reset_db_for_test()
|
|
get_db()
|
|
|
|
def test_create_auth_options_no_credentials(self) -> None:
|
|
from lib.webauthn import create_authentication_options
|
|
|
|
result = create_authentication_options("nonexistentuser")
|
|
assert result is None
|
|
|
|
def test_create_auth_options_with_credentials(self) -> None:
|
|
_insert_cred("testuser")
|
|
|
|
from lib.webauthn import create_authentication_options
|
|
|
|
result = create_authentication_options("testuser")
|
|
|
|
assert result is not None
|
|
assert len(result["allowCredentials"]) == 1
|
|
|
|
def test_verify_authentication_not_found(self) -> None:
|
|
from lib.webauthn import verify_authentication
|
|
|
|
with pytest.raises(ValueError, match="Credential not found"):
|
|
verify_authentication(
|
|
"testuser",
|
|
{
|
|
"id": "nonexistent",
|
|
"response": {
|
|
"clientDataJSON": "",
|
|
"authenticatorData": "",
|
|
"signature": "",
|
|
},
|
|
},
|
|
{"challenge": _FAKE_CHALLENGE},
|
|
)
|
|
|
|
def test_verify_authentication_success(self) -> None:
|
|
from lib.webauthn import b64u_encode
|
|
|
|
_insert_cred("testuser", _FAKE_CRED_ID, _FAKE_PUBLIC_KEY, 0)
|
|
|
|
with patch("lib.webauthn.verify_authentication_response") as mock_verify:
|
|
mock_col = MagicMock()
|
|
mock_col.credential_sign_count = 1
|
|
mock_verify.return_value = mock_col
|
|
|
|
from lib.webauthn import verify_authentication
|
|
|
|
result = verify_authentication(
|
|
"testuser",
|
|
{
|
|
"id": _FAKE_CRED_ID,
|
|
"response": {
|
|
"clientDataJSON": b64u_encode(b"{}"),
|
|
"authenticatorData": b64u_encode(b"aa"),
|
|
"signature": b64u_encode(b"sig"),
|
|
},
|
|
},
|
|
{"challenge": _FAKE_CHALLENGE, "sign_count": 0},
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
@patch.dict(
|
|
os.environ,
|
|
{
|
|
"VACUUM_WALL_DB_BACKEND": "sqlite",
|
|
"VACUUM_WALL_DB_PATH": ":memory:",
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
},
|
|
)
|
|
class TestWebAuthnCredentials:
|
|
"""Test credential listing and removal."""
|
|
|
|
def setup_method(self) -> None:
|
|
lib.db.reset_db_for_test()
|
|
get_db()
|
|
|
|
def test_list_credentials_empty(self) -> None:
|
|
from lib.webauthn import list_credentials
|
|
|
|
assert list_credentials("testuser") == []
|
|
|
|
def test_list_credentials_with_data(self) -> None:
|
|
_insert_cred("testuser", name="My Key", transports='["internal", "hybrid"]')
|
|
|
|
from lib.webauthn import list_credentials
|
|
|
|
result = list_credentials("testuser")
|
|
assert len(result) == 1
|
|
assert result[0]["name"] == "My Key"
|
|
assert result[0]["sign_count"] == 0
|
|
assert result[0]["transports"] == ["internal", "hybrid"]
|
|
|
|
def test_remove_credential_success(self) -> None:
|
|
_insert_cred("testuser")
|
|
|
|
from lib.webauthn import list_credentials, remove_credential
|
|
|
|
result = remove_credential("testuser", "Y3JlZDE")
|
|
assert result is True
|
|
|
|
assert list_credentials("testuser") == []
|
|
|
|
def test_remove_credential_not_found(self) -> None:
|
|
from lib.webauthn import remove_credential
|
|
|
|
with pytest.raises(ValueError, match="not found"):
|
|
remove_credential("testuser", "nonexistent")
|
|
|
|
def test_remove_credential_wrong_user(self) -> None:
|
|
_insert_cred("otheruser", cred_id=_FAKE_CRED_ID)
|
|
|
|
from lib.webauthn import remove_credential
|
|
|
|
with pytest.raises(ValueError, match="does not belong"):
|
|
remove_credential("testuser", _FAKE_CRED_ID)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Multi-user tests (Phase 3)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@patch.dict(
|
|
os.environ,
|
|
{
|
|
"VACUUM_WALL_DB_BACKEND": "sqlite",
|
|
"VACUUM_WALL_DB_PATH": ":memory:",
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
},
|
|
)
|
|
class TestMultiUserAdmin:
|
|
"""Test multi-user admin features: CRUD, permissions, self-deletion."""
|
|
|
|
def setup_method(self) -> None:
|
|
reset_db_for_test()
|
|
get_db()
|
|
|
|
def test_create_user_with_mixed_permissions(self) -> None:
|
|
"""User created with different permission levels per subsystem."""
|
|
create_user(
|
|
"mixeduser",
|
|
"password123",
|
|
{
|
|
"firewall": "rw",
|
|
"network": "read",
|
|
"logs": "rw",
|
|
"dhcp": "rw",
|
|
},
|
|
)
|
|
user = get_user("mixeduser")
|
|
assert user is not None
|
|
assert user["permissions"]["firewall"] == "rw"
|
|
assert user["permissions"]["network"] == "read"
|
|
assert user["permissions"]["logs"] == "rw"
|
|
assert user["permissions"]["dhcp"] == "rw"
|
|
assert "prox" not in user["permissions"]
|
|
|
|
def test_read_only_permissions(self) -> None:
|
|
"""Verify read-only user can be created and queried."""
|
|
create_user(
|
|
"readonly",
|
|
"password123",
|
|
{
|
|
"firewall": "read",
|
|
"network": "read",
|
|
"dhcp": "read",
|
|
"proxy": "read",
|
|
},
|
|
)
|
|
user = get_user("readonly")
|
|
assert user is not None
|
|
for _sub, level in user["permissions"].items():
|
|
assert level == "read"
|
|
|
|
def test_update_permissions_replaces_all(self) -> None:
|
|
"""Updating permissions replaces existing set entirely."""
|
|
create_user(
|
|
"permchange",
|
|
"password123",
|
|
{
|
|
"firewall": "rw",
|
|
"network": "rw",
|
|
},
|
|
)
|
|
user = get_user("permchange")
|
|
assert "firewall" in user["permissions"]
|
|
assert "network" in user["permissions"]
|
|
assert "dhcp" not in user["permissions"]
|
|
|
|
update_permissions(
|
|
"permchange",
|
|
{
|
|
"dhcp": "rw",
|
|
"logs": "read",
|
|
},
|
|
)
|
|
user = get_user("permchange")
|
|
assert "dhcp" in user["permissions"]
|
|
assert "logs" in user["permissions"]
|
|
assert "firewall" not in user["permissions"]
|
|
assert "network" not in user["permissions"]
|
|
|
|
def test_create_user_no_permissions(self) -> None:
|
|
"""User created without permissions gets empty permission set."""
|
|
perms = {"firewall": "rw"}
|
|
user = create_user("noperm", "password123", perms)
|
|
assert user["permissions"]["firewall"] == "rw"
|
|
|
|
update_permissions("noperm", {})
|
|
user = get_user("noperm")
|
|
assert not user["permissions"]
|
|
|
|
def test_delete_user_cascades_permissions(self) -> None:
|
|
"""Deleting a user also removes their permission rows."""
|
|
create_user(
|
|
"cscduser",
|
|
"password123",
|
|
{
|
|
"firewall": "rw",
|
|
"network": "read",
|
|
"auth": "rw",
|
|
},
|
|
)
|
|
user = get_user("cscduser")
|
|
assert len(user["permissions"]) == 3
|
|
|
|
delete_user("cscduser")
|
|
assert get_user("cscduser") is None
|
|
# Verify permissions are cascaded-deleted
|
|
from lib.db import get_db as _get_db
|
|
|
|
rows = _get_db().query(Q_SELECT_PERMISSIONS, ("cscduser",))
|
|
assert len(rows) == 0
|
|
|
|
def test_delete_user_cascades_webauthn_creds(self) -> None:
|
|
"""Deleting a user also removes their WebAuthn credentials."""
|
|
_insert_cred("wgscduser", cred_id="test-cred-id-123")
|
|
|
|
from lib.webauthn import list_credentials
|
|
|
|
creds = list_credentials("wgscduser")
|
|
assert len(creds) == 1
|
|
|
|
delete_user("wgscduser")
|
|
creds = list_credentials("wgscduser")
|
|
assert len(creds) == 0
|
|
|
|
def test_self_deletion_prevention(self) -> None:
|
|
"""Self-deletion is prevented at the Flask blueprint layer.
|
|
|
|
The daemon handler accepts the delete request but trusts the Flask
|
|
middleware (which has _user_ctx) to block self-deletion first.
|
|
|
|
This test verifies the handler still works for normal deletion.
|
|
The Flask blueprint test verifies self-deletion blocking.
|
|
"""
|
|
from daemon.handlers.auth import auth_delete_user
|
|
|
|
create_user("admin", "password123", {"auth": "rw"})
|
|
create_user("target", "password123", {"firewall": "read"})
|
|
|
|
# Handler can delete other users
|
|
mock_request = MagicMock()
|
|
result = auth_delete_user(mock_request, {"username": "target"})
|
|
assert result["ok"] is True
|
|
assert get_user("target") is None
|
|
|
|
def test_flask_self_deletion_logic(self) -> None:
|
|
"""Flask blueprint blocks self-deletion: verify the guard logic."""
|
|
# The blueprint check is a simple comparison:
|
|
# user_ctx.get("username") == username
|
|
# We verify this logic directly.
|
|
|
|
# Self-deletion scenario: same user
|
|
user_ctx = {"username": "alice"}
|
|
username = "alice"
|
|
# When user_ctx is not None and username matches, blueprint blocks
|
|
assert user_ctx is not None
|
|
assert user_ctx.get("username") == username # Would trigger 403
|
|
|
|
# Different user scenario
|
|
user_ctx2 = {"username": "admin"}
|
|
username2 = "alice"
|
|
# When usernames differ, deletion proceeds
|
|
assert user_ctx2.get("username") != username2
|
|
|
|
# No context scenario
|
|
user_ctx3 = None
|
|
# When user_ctx is None, delete proceeds (no guard)
|
|
assert user_ctx3 is None # No guard triggered
|
|
|
|
def test_self_deletion_prevention_no_context(self) -> None:
|
|
"""When _user_ctx is not set, deletion proceeds (direct daemon call)."""
|
|
from daemon.handlers.auth import auth_delete_user
|
|
|
|
create_user("directuser", "password123", {"firewall": "read"})
|
|
|
|
# No _user_ctx — daemon called directly (e.g., batch API)
|
|
mock_request = MagicMock()
|
|
mock_request._user_ctx = None
|
|
result = auth_delete_user(mock_request, {"username": "directuser"})
|
|
assert result["ok"] is True
|
|
assert get_user("directuser") is None
|
|
|
|
def test_multiple_users_independent_states(self) -> None:
|
|
"""Multiple users maintain independent password and permission state."""
|
|
create_user("user_a", "pass_a", {"firewall": "rw"})
|
|
create_user("user_b", "pass_b", {"dhcp": "read"})
|
|
|
|
# Passwords are independent
|
|
assert verify_user_password("user_a", "pass_a") is not None
|
|
assert verify_user_password("user_a", "pass_b") is None
|
|
assert verify_user_password("user_b", "pass_b") is not None
|
|
assert verify_user_password("user_b", "pass_a") is None
|
|
|
|
# Permissions are independent
|
|
perms_a = get_user("user_a")["permissions"]
|
|
perms_b = get_user("user_b")["permissions"]
|
|
assert "firewall" in perms_a
|
|
assert "dhcp" not in perms_a
|
|
assert "dhcp" in perms_b
|
|
assert "firewall" not in perms_b
|
|
|
|
# Updating one user doesn't affect the other
|
|
update_permissions("user_a", {"logs": "rw"})
|
|
perms_b_after = get_user("user_b")["permissions"]
|
|
assert "dhcp" in perms_b_after
|
|
assert "logs" not in perms_b_after
|
|
|
|
def test_permission_update_nonexistent_user(self) -> None:
|
|
"""Updating permissions for a nonexistent user raises ValueError."""
|
|
with pytest.raises(ValueError, match="not found"):
|
|
update_permissions("nonexistent", {"firewall": "rw"})
|
|
|
|
def test_list_users_with_permissions(self) -> None:
|
|
"""list_users returns all users with their full permission dicts."""
|
|
create_user("alice", "pass1", {"firewall": "rw", "network": "read"})
|
|
create_user("bob", "pass2", {"dhcp": "rw", "auth": "rw"})
|
|
create_user("charlie", "pass3", {})
|
|
|
|
users = list_users()
|
|
assert len(users) == 3
|
|
|
|
by_name = {u["username"]: u for u in users}
|
|
|
|
assert "firewall" in by_name["alice"]["permissions"]
|
|
assert "network" in by_name["alice"]["permissions"]
|
|
assert "dhcp" not in by_name["alice"]["permissions"]
|
|
|
|
assert "dhcp" in by_name["bob"]["permissions"]
|
|
assert "auth" in by_name["bob"]["permissions"]
|
|
|
|
assert not by_name["charlie"]["permissions"]
|
|
|
|
def test_all_subsystems_constant(self) -> None:
|
|
"""ALL_SUBSYSTEMS contains expected subsystem names."""
|
|
from lib.auth_users import ALL_SUBSYSTEMS
|
|
|
|
expected = {
|
|
"firewall",
|
|
"network",
|
|
"dhcp",
|
|
"proxy",
|
|
"certs",
|
|
"wireguard",
|
|
"logs",
|
|
"status",
|
|
"auth",
|
|
}
|
|
assert set(ALL_SUBSYSTEMS) == expected
|
|
|
|
|
|
class TestPermissionMiddleware:
|
|
"""Test Flask middleware permission enforcement logic.
|
|
|
|
These tests verify the permission checking logic that would be applied
|
|
by the Flask before_request middleware (server._auth_middleware).
|
|
"""
|
|
|
|
def test_permission_check_read_allowed(self) -> None:
|
|
"""GET requests allowed with 'read' or 'rw' for the subsystem."""
|
|
from webui.server import _has_permission
|
|
|
|
perms = {"firewall": "read"}
|
|
assert _has_permission(perms, "firewall", "GET") is True
|
|
|
|
perms_rw = {"firewall": "rw"}
|
|
assert _has_permission(perms_rw, "firewall", "GET") is True
|
|
|
|
def test_permission_check_read_denied_write(self) -> None:
|
|
"""Non-GET requests denied with 'read' permission."""
|
|
from webui.server import _has_permission
|
|
|
|
perms = {"firewall": "read"}
|
|
assert _has_permission(perms, "firewall", "POST") is False
|
|
assert _has_permission(perms, "firewall", "DELETE") is False
|
|
|
|
def test_permission_check_rw_allowed(self) -> None:
|
|
"""'rw' permission allows all HTTP methods."""
|
|
from webui.server import _has_permission
|
|
|
|
perms = {"firewall": "rw"}
|
|
assert _has_permission(perms, "firewall", "GET") is True
|
|
assert _has_permission(perms, "firewall", "POST") is True
|
|
assert _has_permission(perms, "firewall", "DELETE") is True
|
|
|
|
def test_permission_check_no_access(self) -> None:
|
|
"""Missing subsystem permission denies all access."""
|
|
from webui.server import _has_permission
|
|
|
|
perms = {"logs": "read"}
|
|
assert _has_permission(perms, "firewall", "GET") is False
|
|
assert _has_permission(perms, "firewall", "POST") is False
|
|
|
|
def test_subsystem_extraction(self) -> None:
|
|
"""Subsystem name correctly extracted from API path."""
|
|
from webui.server import _subsystem_from_path
|
|
|
|
assert _subsystem_from_path("/api/firewall/config") == "firewall"
|
|
assert _subsystem_from_path("/api/auth/users") == "auth"
|
|
assert _subsystem_from_path("/api/dhcp/leases/subpath") == "dhcp"
|
|
assert _subsystem_from_path("/") is None
|
|
assert _subsystem_from_path("/static/app.js") is None
|