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
+87 -15
View File
@@ -14,6 +14,7 @@ from daemon.iface import (
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
GET_AUTH_SESSION,
GET_AUTH_USERS,
GET_AUTH_WEBAUTHN_CAPABLE,
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS,
GET_AUTH_WEBAUTHN_CREDENTIALS,
POST_AUTH_LOGIN,
@@ -36,6 +37,8 @@ from lib.auth import (
clear_active_refresh_token,
generate_tokens,
get_access_ttl,
record_login_failure,
record_login_success,
validate_token,
)
from lib.auth_users import (
@@ -51,12 +54,16 @@ from lib.auth_users import (
from lib.webauthn import (
create_authentication_options,
create_registration_options,
get_all_credential_counts,
get_management_domains,
get_rp_name,
list_credentials,
remove_credential,
verify_authentication,
verify_registration,
)
from lib.webauthn import (
is_enabled as webauthn_is_enabled,
)
logger = logging.getLogger(__name__)
@@ -96,8 +103,11 @@ def auth_login(_request: Any, body: Any) -> dict[str, Any]:
user = verify_user_password(username, password)
if user is None:
record_login_failure(username, client_ip)
raise ValueError("Invalid credentials")
record_login_success(username, client_ip)
permissions = user["permissions"]
tokens = generate_tokens(username, permissions)
@@ -125,7 +135,7 @@ def auth_logout(request: Any, body: Any) -> dict[str, Any]:
Success response.
"""
if not body:
return {"ok": True}
return {}
jti = body.get("jti")
if jti:
@@ -135,7 +145,7 @@ def auth_logout(request: Any, body: Any) -> dict[str, Any]:
if username:
blacklist_active_refresh_token(username)
return {"ok": True}
return {}
@registry.register(POST_AUTH_REFRESH)
@@ -379,25 +389,73 @@ def auth_delete_user(_request: Any, body: Any) -> dict[str, Any]:
# ---------------------------------------------------------------------------
def _check_webauthn_domain(body: Any) -> tuple[str, str]:
"""Validate that the request domain is eligible for WebAuthn.
Returns (origin, rp_id) if valid. Raises ValueError otherwise.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
if not webauthn_is_enabled():
raise ValueError("WebAuthn is disabled")
rp_id = body.get("webauthn_rp_id")
origin = body.get("webauthn_origin")
if not rp_id or not origin:
raise ValueError("Missing WebAuthn domain configuration")
if rp_id not in get_management_domains():
raise ValueError("WebAuthn is not available on this domain")
return origin, rp_id
@registry.register(GET_AUTH_WEBAUTHN_CAPABLE)
def webauthn_capable(_request: Any, body: Any) -> dict[str, Any]:
"""Check if WebAuthn is available for the current request domain."""
rp_id = body.get("webauthn_rp_id") if body else None
origin = body.get("webauthn_origin") if body else None
if not webauthn_is_enabled():
return {"enabled": False, "reason": "WebAuthn is disabled in config"}
if not rp_id or not origin:
return {"enabled": False, "reason": "Domain information unavailable"}
mgmt_domains = get_management_domains()
if rp_id not in mgmt_domains:
return {"enabled": False, "reason": "Not a management domain"}
return {
"enabled": True,
"rp_id": rp_id,
"rp_name": get_rp_name(),
"origin": origin,
}
@registry.register(POST_AUTH_WEBAUTHN_REGISTER_BEGIN)
def webauthn_register_begin(_request: Any, body: Any) -> dict[str, Any]:
"""Begin WebAuthn registration — return options for ``credentials.create()``."""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
origin, rp_id = _check_webauthn_domain(body)
username = body.get("username")
if not username:
raise ValueError("username is required")
options = create_registration_options(username)
options = create_registration_options(
username,
origin=origin,
rp_id=rp_id,
)
return options
@registry.register(POST_AUTH_WEBAUTHN_REGISTER_FINISH)
def webauthn_register_finish(_request: Any, body: Any) -> dict[str, Any]:
"""Finish WebAuthn registration — verify credential and persist."""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
origin, rp_id = _check_webauthn_domain(body)
username = body.get("username")
credential_response = body.get("credential_response")
@@ -410,7 +468,12 @@ def webauthn_register_finish(_request: Any, body: Any) -> dict[str, Any]:
)
cred = verify_registration(
username, credential_response, registration_options, credential_name
username,
credential_response,
registration_options,
credential_name,
origin=origin,
rp_id=rp_id,
)
return {"ok": True, "credential": cred}
@@ -422,14 +485,16 @@ def webauthn_authenticate_begin(_request: Any, body: Any) -> dict[str, Any]:
Public endpoint — no JWT required. Returns ``{"noWebAuthn": true}`` if the
user has no registered credentials (so the frontend can fall back to password).
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
_, rp_id = _check_webauthn_domain(body)
username = body.get("username")
if not username:
raise ValueError("username is required")
options = create_authentication_options(username)
options = create_authentication_options(
username,
rp_id=rp_id,
)
if options is None:
return {"no_webauthn": True}
return options
@@ -441,8 +506,7 @@ def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]:
Public endpoint — no JWT required.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
origin, rp_id = _check_webauthn_domain(body)
username = body.get("username")
assertion_response = body.get("assertion_response")
@@ -455,7 +519,13 @@ def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]:
if not check_webauthn_rate(username, client_ip):
raise ValueError("Too many WebAuthn attempts. Please try again later.")
verify_authentication(username, assertion_response, auth_options)
verify_authentication(
username,
assertion_response,
auth_options,
origin=origin,
rp_id=rp_id,
)
user = get_user(username)
if user is None:
@@ -512,4 +582,6 @@ def webauthn_remove_credential(request: Any, body: Any) -> dict[str, Any]:
@registry.register(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS)
def webauthn_credential_counts(_request: Any, body: Any) -> dict[str, int]:
"""Return credential counts for all users (admin endpoint)."""
from lib.webauthn import get_all_credential_counts
return get_all_credential_counts()