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:
+69
-18
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user