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:
+36
-17
@@ -346,19 +346,19 @@ def blacklist_expired() -> None:
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple sliding-window rate limiter for login attempts.
|
||||
"""Sliding-window rate limiter that tracks successes and failures separately.
|
||||
|
||||
Maintains per-key attempt timestamps and rejects requests that exceed
|
||||
the allowed count within the window.
|
||||
Failures are counted against the limit. A successful operation resets
|
||||
the failure counter for that key.
|
||||
"""
|
||||
|
||||
def __init__(self, max_attempts: int = 5, window_seconds: int = 300) -> None:
|
||||
self.max_attempts = max_attempts
|
||||
self.window = window_seconds
|
||||
self.attempts: dict[str, list[float]] = {}
|
||||
self.failures: dict[str, list[float]] = {}
|
||||
|
||||
def is_allowed(self, key: str) -> bool:
|
||||
"""Check if a request from *key* is allowed.
|
||||
"""Check if a request from *key* is allowed (does NOT record the attempt).
|
||||
|
||||
Args:
|
||||
key: Identifier for the rate limit bucket (e.g., username or IP).
|
||||
@@ -368,25 +368,29 @@ class RateLimiter:
|
||||
"""
|
||||
now = time.time()
|
||||
cutoff = now - self.window
|
||||
timestamps = self.attempts.get(key, [])
|
||||
timestamps = self.failures.get(key, [])
|
||||
|
||||
# Clean old entries
|
||||
self.attempts[key] = [t for t in timestamps if t > cutoff]
|
||||
self.failures[key] = [t for t in timestamps if t > cutoff]
|
||||
|
||||
if len(self.attempts[key]) >= self.max_attempts:
|
||||
return False
|
||||
return len(self.failures[key]) < self.max_attempts
|
||||
|
||||
self.attempts[key].append(now)
|
||||
return True
|
||||
def record_failure(self, key: str) -> None:
|
||||
"""Record a failed attempt for *key*."""
|
||||
self.failures.setdefault(key, []).append(time.time())
|
||||
|
||||
def record_success(self, key: str) -> None:
|
||||
"""Reset the failure counter for *key* on a successful operation."""
|
||||
self.failures.pop(key, None)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Remove expired entries from all buckets."""
|
||||
now = time.time()
|
||||
cutoff = now - self.window
|
||||
for key in list(self.attempts):
|
||||
self.attempts[key] = [t for t in self.attempts[key] if t > cutoff]
|
||||
if not self.attempts[key]:
|
||||
del self.attempts[key]
|
||||
for key in list(self.failures):
|
||||
self.failures[key] = [t for t in self.failures[key] if t > cutoff]
|
||||
if not self.failures[key]:
|
||||
del self.failures[key]
|
||||
|
||||
|
||||
# Global rate limiters
|
||||
@@ -397,8 +401,9 @@ _webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600)
|
||||
def check_login_rate(username: str, client_ip: str | None = None) -> bool:
|
||||
"""Check if login is rate-limited for the given username.
|
||||
|
||||
Uses dual-key tracking: always records by IP (catches enumeration attacks),
|
||||
additionally records by username (catches legitimate users who forget password).
|
||||
Checks failure counts for both IP and username buckets without
|
||||
recording anything. Callers must invoke record_login_failure() or
|
||||
record_login_success() after the password verification step.
|
||||
|
||||
Args:
|
||||
username: The login attempt username.
|
||||
@@ -412,6 +417,20 @@ def check_login_rate(username: str, client_ip: str | None = None) -> bool:
|
||||
return _login_limiter.is_allowed(username)
|
||||
|
||||
|
||||
def record_login_failure(username: str, client_ip: str | None = None) -> None:
|
||||
"""Record a failed login attempt."""
|
||||
if client_ip:
|
||||
_login_limiter.record_failure(client_ip)
|
||||
_login_limiter.record_failure(username)
|
||||
|
||||
|
||||
def record_login_success(username: str, client_ip: str | None = None) -> None:
|
||||
"""Record a successful login (resets failure counter)."""
|
||||
if client_ip:
|
||||
_login_limiter.record_success(client_ip)
|
||||
_login_limiter.record_success(username)
|
||||
|
||||
|
||||
def check_webauthn_rate(username: str, client_ip: str | None = None) -> bool:
|
||||
"""Check if WebAuthn authentication is rate-limited for the given username.
|
||||
|
||||
|
||||
+8
-3
@@ -31,6 +31,10 @@ from lib.password import hash_password, needs_rehash, verify_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pre-computed dummy hash for constant-time verification on nonexistent users.
|
||||
# Generated once at module load to avoid timing leaks from per-call hash generation.
|
||||
_DUMMY_HASH = hash_password(secrets.token_hex(32))
|
||||
|
||||
# Builtin admin — hardcoded, full access, cannot be modified/deleted
|
||||
BUILTIN_ADMIN_USERNAME = "admin"
|
||||
|
||||
@@ -113,9 +117,10 @@ 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)))
|
||||
# Run a dummy Argon2id verification against a pre-computed hash to
|
||||
# prevent timing-based user enumeration. Uses module-level hash
|
||||
# so both paths take ~1 verify call (~200ms) instead of ~400ms.
|
||||
verify_password(password, _DUMMY_HASH)
|
||||
return None
|
||||
if not verify_password(password, user["password_hash"]):
|
||||
return None
|
||||
|
||||
@@ -292,8 +292,10 @@ def _seed_builtin_admin(db: Database) -> None:
|
||||
if rows:
|
||||
return
|
||||
|
||||
# Create with a placeholder password that should be changed
|
||||
placeholder_hash = hash_password("CHANGEME")
|
||||
# Generate a random password — this fallback should only fire if
|
||||
# bootstrap_auth.py was skipped. Log the password prominently.
|
||||
random_password = secrets.token_urlsafe(24)
|
||||
placeholder_hash = hash_password(random_password)
|
||||
jwt_secret = secrets.token_urlsafe(32)
|
||||
|
||||
with db.in_transaction() as tx:
|
||||
@@ -303,7 +305,11 @@ def _seed_builtin_admin(db: Database) -> None:
|
||||
for subsystem in ALL_SUBSYSTEMS:
|
||||
tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw"))
|
||||
|
||||
logger.info("Builtin admin user created with full access")
|
||||
logger.warning(
|
||||
"Builtin admin user created. THIS IS A FALLBACK — bootstrap_auth.py "
|
||||
"should have run during install. Admin password: %s",
|
||||
random_password,
|
||||
)
|
||||
|
||||
|
||||
def reset_db_for_test() -> None:
|
||||
|
||||
@@ -225,6 +225,33 @@ def get_domains() -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
def get_management_domains() -> list[str]:
|
||||
"""Return domain names that serve the management UI.
|
||||
|
||||
Checks both backend-referenced paths (for migrated configs) and
|
||||
inline paths (for legacy configs pending migration).
|
||||
|
||||
Returns:
|
||||
List of domain name strings.
|
||||
"""
|
||||
cfg = get_config()
|
||||
backends = cfg.get("backends", {})
|
||||
domains: list[str] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
# Check inline paths (pre-migration format)
|
||||
inline_paths = dom.get("paths", {})
|
||||
if any(p.get("is_management") for p in inline_paths.values()):
|
||||
domains.append(name)
|
||||
continue
|
||||
# Check backend-referenced paths
|
||||
backend_name = dom.get("backend", "")
|
||||
if backend_name and backend_name in backends:
|
||||
paths = backends[backend_name].get("paths", {})
|
||||
if any(p.get("is_management") for p in paths.values()):
|
||||
domains.append(name)
|
||||
return domains
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Domain CRUD
|
||||
# ------------------------------------------------------------------
|
||||
@@ -577,6 +604,7 @@ __all__ = [
|
||||
"generate_server_conf",
|
||||
"get_config",
|
||||
"get_domains",
|
||||
"get_management_domains",
|
||||
"remove_domain",
|
||||
"save_config",
|
||||
"test_config",
|
||||
|
||||
+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