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
+45
View File
@@ -15,6 +15,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,
@@ -219,6 +220,39 @@ def delete_user(username: str):
# ---------------------------------------------------------------------------
def _resolve_webauthn_origin() -> tuple[str, str]:
"""Extract WebAuthn origin and rp_id from the current request.
Returns (origin, rp_id) derived from the actual request, falling back
to config values when the request metadata is unavailable.
"""
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
host = request.headers.get("X-Forwarded-Host", request.host.split(":")[0])
origin = f"{scheme}://{host}"
# rp_id is the registered domain (strip port numbers)
rp_id = host.split(":")[0]
return origin, rp_id
@bp.route("/webauthn/capable", methods=["GET"])
def webauthn_capable():
"""Check if WebAuthn is available on the current request domain.
Endpoint:
GET /api/auth/webauthn/capable
Returns:
{ "enabled": true/false, "rp_id": "...", "rp_name": "...", "origin": "..." }
or { "enabled": false, "reason": "..." }
"""
try:
origin, rp_id = _resolve_webauthn_origin()
body = {"webauthn_origin": origin, "webauthn_rp_id": rp_id}
return _ok(get(GET_AUTH_WEBAUTHN_CAPABLE, body))
except Exception as exc:
logger.error("WebAuthn capable check failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/webauthn/register-begin", methods=["POST"])
def webauthn_register_begin():
"""Begin WebAuthn registration.
@@ -232,6 +266,9 @@ def webauthn_register_begin():
"""
try:
body = request.get_json(silent=True) or {}
origin, rp_id = _resolve_webauthn_origin()
body["webauthn_origin"] = origin
body["webauthn_rp_id"] = rp_id
return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_BEGIN, body))
except Exception as exc:
logger.error("WebAuthn register begin failed: %s", exc)
@@ -251,6 +288,9 @@ def webauthn_register_finish():
"""
try:
body = request.get_json(silent=True) or {}
origin, rp_id = _resolve_webauthn_origin()
body["webauthn_origin"] = origin
body["webauthn_rp_id"] = rp_id
return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_FINISH, body))
except Exception as exc:
logger.error("WebAuthn register finish failed: %s", exc)
@@ -271,6 +311,8 @@ def webauthn_authenticate_begin():
"""
try:
body = request.get_json(silent=True) or {}
_, rp_id = _resolve_webauthn_origin()
body["webauthn_rp_id"] = rp_id
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN, body))
except Exception as exc:
logger.error("WebAuthn authenticate begin failed: %s", exc)
@@ -291,6 +333,9 @@ def webauthn_authenticate_finish():
try:
body = request.get_json(silent=True) or {}
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
origin, rp_id = _resolve_webauthn_origin()
body["webauthn_origin"] = origin
body["webauthn_rp_id"] = rp_id
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, body))
except Exception as exc:
logger.error("WebAuthn authenticate finish failed: %s", exc)