Auth: rate limiter, WebAuthn domain awareness, misc fixes

- Rate limiter tracks failures only; success resets counter
- Record failures/successes after password verification, not before
- WebAuthn rp_id/origin resolved dynamically from request domain
- Management domains auto-discovered from nginx backend config
- All WebAuthn operations validate domain against management list
- Add GET /api/auth/webauthn/capable endpoint for frontend checks
- Frontend checkWebAuthnCapable() function for domain-gated UI
- Timing side-channel fix: pre-compute dummy hash at module load
- Builtin admin seeded with random password (logged at WARNING)
- Logout handler returns consistent response shape
This commit is contained in:
2026-07-29 02:48:53 +00:00
parent 6d30f1387e
commit 48f8d0be18
11 changed files with 335 additions and 65 deletions
+27 -8
View File
@@ -429,11 +429,13 @@ 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
from lib.webauthn import get_management_domains, get_rp_name, is_enabled
assert get_rp_id() == "localhost"
assert is_enabled() is True
assert get_rp_name() == "Vacuum Wall"
assert get_origin() == "http://localhost"
# get_management_domains reads from nginx config
domains = get_management_domains()
assert isinstance(domains, list)
@patch.dict(
@@ -482,14 +484,18 @@ class TestWebAuthnRegistration:
def test_create_registration_options_basic(self) -> None:
from lib.webauthn import create_registration_options
options = create_registration_options("testuser")
options = create_registration_options(
"testuser",
origin="https://wall.example.com",
rp_id="wall.example.com",
)
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["rp"]["id"] == "wall.example.com"
assert options["user"]["name"] == "testuser"
assert len(options["pubKeyCredParams"]) >= 1
@@ -524,6 +530,8 @@ class TestWebAuthnRegistration:
},
{"challenge": b64u_encode(b"testchallenge123")},
"My Key",
origin="https://wall.example.com",
rp_id="wall.example.com",
)
assert "id" in result
@@ -542,7 +550,11 @@ class TestWebAuthnRegistration:
from lib.webauthn import create_registration_options
options = create_registration_options("testuser")
options = create_registration_options(
"testuser",
origin="https://wall.example.com",
rp_id="wall.example.com",
)
# Should contain the existing credential in exclude list
exclude_ids = [c["id"] for c in options.get("excludeCredentials", [])]
@@ -590,7 +602,9 @@ class TestWebAuthnAuthentication:
def test_create_auth_options_no_credentials(self) -> None:
from lib.webauthn import create_authentication_options
result = create_authentication_options("nonexistentuser")
result = create_authentication_options(
"nonexistentuser", rp_id="wall.example.com"
)
assert result is None
def test_create_auth_options_with_credentials(self) -> None:
@@ -598,7 +612,8 @@ class TestWebAuthnAuthentication:
from lib.webauthn import create_authentication_options
result = create_authentication_options("testuser")
result = create_authentication_options("testuser", rp_id="wall.example.com")
assert result is not None
assert result is not None
assert len(result["allowCredentials"]) == 1
@@ -618,6 +633,8 @@ class TestWebAuthnAuthentication:
},
},
{"challenge": _FAKE_CHALLENGE},
origin="https://wall.example.com",
rp_id="wall.example.com",
)
def test_verify_authentication_success(self) -> None:
@@ -643,6 +660,8 @@ class TestWebAuthnAuthentication:
},
},
{"challenge": _FAKE_CHALLENGE, "sign_count": 0},
origin="https://wall.example.com",
rp_id="wall.example.com",
)
assert result is True