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:
+87
-15
@@ -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()
|
||||
|
||||
@@ -199,6 +199,7 @@ GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS: Endpoint = _ep(
|
||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL: Endpoint = _ep(
|
||||
"DELETE", "/auth/webauthn/creds/<credential_id>"
|
||||
)
|
||||
GET_AUTH_WEBAUTHN_CAPABLE: Endpoint = _ep("GET", "/auth/webauthn/capable")
|
||||
|
||||
# ---- Server infra (not going through client) ----
|
||||
GET_HEALTH: Endpoint = _ep("GET", "/health")
|
||||
|
||||
+11
-1
@@ -397,7 +397,9 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
session_header = request.headers.get("X-Session-Id")
|
||||
payload = validate_token(token_param, token_type="access", session_id=session_header)
|
||||
payload = validate_token(
|
||||
token_param, token_type="access", session_id=session_header
|
||||
)
|
||||
if payload is None:
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||
|
||||
@@ -556,6 +558,14 @@ def main() -> None:
|
||||
|
||||
setup_logging()
|
||||
|
||||
# Startup checks
|
||||
try:
|
||||
from lib import webauthn as lib_webauthn
|
||||
|
||||
lib_webauthn.check_webauthn_config()
|
||||
except Exception:
|
||||
pass # ignore if webauthn module import failed
|
||||
|
||||
_register_routes()
|
||||
app = create_app()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user