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
+69 -18
View File
@@ -73,9 +73,10 @@ def _get_webauthn_config() -> dict[str, Any]:
return raw.get("webauthn", {})
def get_rp_id() -> str:
"""Return the Relying Party ID from config."""
return _get_webauthn_config().get("rp_id", "localhost")
def is_enabled() -> bool:
"""Return whether WebAuthn is enabled in config."""
cfg = _get_webauthn_config()
return cfg.get("enabled", True)
def get_rp_name() -> str:
@@ -83,9 +84,39 @@ def get_rp_name() -> str:
return _get_webauthn_config().get("rp_name", "Vacuum Wall")
def get_origin() -> str:
"""Return the WebAuthn origin from config."""
return _get_webauthn_config().get("origin", "http://localhost")
def get_management_domains() -> list[str]:
"""Return domain names eligible for WebAuthn.
Delegates to lib.nginx.get_management_domains() to read the
live proxy config and discover which domains serve the management UI.
"""
from lib.nginx import get_management_domains as _resolve
return _resolve()
def is_domain_valid(domain: str) -> bool:
"""Check if *domain* is eligible for WebAuthn."""
return domain in get_management_domains()
def check_webauthn_config() -> None:
"""Validate WebAuthn config at startup."""
enabled = is_enabled()
if not enabled:
logger.info("WebAuthn is disabled in config")
return
try:
domains = get_management_domains()
if not domains:
logger.warning(
"WebAuthn is enabled but no management domains are configured. "
"Add a domain with backend 'webui' to nginx config, or disable WebAuthn."
)
else:
logger.info("WebAuthn enabled for domains: %s", domains)
except Exception as exc:
logger.error("Failed to resolve management domains for WebAuthn: %s", exc)
# ---------------------------------------------------------------------------
@@ -93,9 +124,18 @@ def get_origin() -> str:
# ---------------------------------------------------------------------------
def create_registration_options(username: str) -> dict[str, Any]:
def create_registration_options(
username: str,
origin: str,
rp_id: str,
) -> dict[str, Any]:
"""Create WebAuthn registration options for a new credential.
Args:
username: The user registering the credential.
origin: WebAuthn origin (must match the request origin).
rp_id: Relying Party ID (must match the request domain).
Returns a dict serializable to JSON, matching the format expected by
``navigator.credentials.create()``.
"""
@@ -113,7 +153,7 @@ def create_registration_options(username: str) -> dict[str, Any]:
user_id = user_id + b"\x00" * (8 - len(user_id))
options = generate_registration_options(
rp_id=get_rp_id(),
rp_id=rp_id,
rp_name=get_rp_name(),
user_name=username,
user_display_name=username,
@@ -136,6 +176,8 @@ def verify_registration(
credential_response: dict[str, Any],
registration_options: dict[str, Any],
credential_name: str = "",
origin: str = "",
rp_id: str = "",
) -> dict[str, Any]:
"""Verify a registration response and persist the credential.
@@ -144,12 +186,12 @@ def verify_registration(
credential_response: Browser response from ``credentials.create()``.
registration_options: The options dict from ``create_registration_options``.
credential_name: Optional human-readable label.
origin: WebAuthn origin for verification.
rp_id: Relying Party ID for verification.
Returns:
Dict with ``id``, ``name``, ``transports``, ``sign_count``.
"""
expected_origin = get_origin()
expected_rp_id = get_rp_id()
challenge = b64u_decode(registration_options["challenge"])
# The library accepts the credential response as a JSON-serializable dict
@@ -157,8 +199,8 @@ def verify_registration(
col = verify_registration_response(
credential=credential_response,
expected_challenge=challenge,
expected_origin=expected_origin,
expected_rp_id=expected_rp_id,
expected_origin=origin,
expected_rp_id=rp_id,
require_user_verification=False,
)
@@ -218,9 +260,16 @@ def verify_registration(
# ---------------------------------------------------------------------------
def create_authentication_options(username: str) -> dict[str, Any] | None:
def create_authentication_options(
username: str,
rp_id: str,
) -> dict[str, Any] | None:
"""Create authentication options for a user.
Args:
username: The user authenticating.
rp_id: Relying Party ID (must match the request domain).
Returns a dict serializable to JSON (for ``navigator.credentials.get()``),
or ``None`` if the user has no registered credentials.
"""
@@ -235,7 +284,7 @@ def create_authentication_options(username: str) -> dict[str, Any] | None:
]
options = generate_authentication_options(
rp_id=get_rp_id(),
rp_id=rp_id,
allow_credentials=allow_credentials,
user_verification=UserVerificationRequirement.PREFERRED,
)
@@ -247,6 +296,8 @@ def verify_authentication(
username: str,
assertion_response: dict[str, Any],
auth_options: dict[str, Any],
origin: str = "",
rp_id: str = "",
) -> bool:
"""Verify an authentication assertion.
@@ -254,6 +305,8 @@ def verify_authentication(
username: The user authenticating.
assertion_response: Browser response from ``credentials.get()``.
auth_options: The options dict from ``create_authentication_options``.
origin: WebAuthn origin for verification.
rp_id: Relying Party ID for verification.
Returns:
True on successful verification.
@@ -261,8 +314,6 @@ def verify_authentication(
Raises:
ValueError: On verification failure.
"""
expected_origin = get_origin()
expected_rp_id = get_rp_id()
challenge = b64u_decode(auth_options["challenge"])
cred_id_str = assertion_response["id"]
@@ -279,8 +330,8 @@ def verify_authentication(
col = verify_authentication_response(
credential=assertion_response,
expected_challenge=challenge,
expected_origin=expected_origin,
expected_rp_id=expected_rp_id,
expected_origin=origin,
expected_rp_id=rp_id,
credential_public_key=public_key,
credential_current_sign_count=old_sign_count,
require_user_verification=False,