feat: add auth subsystem with WebAuthn passkeys support
New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password, lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps, install script, server.py, app.js, and websocket/api clients
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
"""Authentication daemon handlers.
|
||||
|
||||
Handles login, logout, token refresh, session management, password change,
|
||||
user CRUD, and WebAuthn operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_AUTH_USER,
|
||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
||||
GET_AUTH_SESSION,
|
||||
GET_AUTH_USERS,
|
||||
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS,
|
||||
GET_AUTH_WEBAUTHN_CREDENTIALS,
|
||||
POST_AUTH_LOGIN,
|
||||
POST_AUTH_LOGOUT,
|
||||
POST_AUTH_PASSWORD,
|
||||
POST_AUTH_REFRESH,
|
||||
POST_AUTH_USER_CREATE,
|
||||
POST_AUTH_USER_UPDATE,
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN,
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH,
|
||||
POST_AUTH_WEBAUTHN_REGISTER_BEGIN,
|
||||
POST_AUTH_WEBAUTHN_REGISTER_FINISH,
|
||||
)
|
||||
from daemon.server import ConflictError, NotFoundError, registry
|
||||
from lib.auth import (
|
||||
blacklist_active_refresh_token,
|
||||
blacklist_token,
|
||||
check_login_rate,
|
||||
check_webauthn_rate,
|
||||
clear_active_refresh_token,
|
||||
decode_token,
|
||||
generate_tokens,
|
||||
get_access_ttl,
|
||||
validate_token,
|
||||
)
|
||||
from lib.auth_users import (
|
||||
ALL_SUBSYSTEMS,
|
||||
create_user,
|
||||
delete_user,
|
||||
get_user,
|
||||
list_users,
|
||||
update_password,
|
||||
update_permissions,
|
||||
verify_user_password,
|
||||
)
|
||||
from lib.webauthn import (
|
||||
create_authentication_options,
|
||||
create_registration_options,
|
||||
get_all_credential_counts,
|
||||
list_credentials,
|
||||
remove_credential,
|
||||
verify_authentication,
|
||||
verify_registration,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ALL_RW = {sub: "rw" for sub in ALL_SUBSYSTEMS}
|
||||
|
||||
|
||||
def _clear_refresh_token_after_rotation(username: str) -> None:
|
||||
"""Remove the user's entry from refresh_tokens after a successful refresh rotation."""
|
||||
clear_active_refresh_token(username)
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_LOGIN)
|
||||
def auth_login(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Handle user login.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with ``username`` and ``password``.
|
||||
|
||||
Returns:
|
||||
Dict with ``tokens``, ``user``, and ``permissions``.
|
||||
|
||||
Raises:
|
||||
ValueError: If credentials are invalid.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
password = body.get("password")
|
||||
if not username or not password:
|
||||
raise ValueError("username and password are required")
|
||||
|
||||
if not check_login_rate(username):
|
||||
raise ValueError("Too many login attempts. Please try again later.")
|
||||
|
||||
user = verify_user_password(username, password)
|
||||
if user is None:
|
||||
raise ValueError("Invalid credentials")
|
||||
|
||||
permissions = user["permissions"]
|
||||
tokens = generate_tokens(username, permissions)
|
||||
|
||||
return {
|
||||
"tokens": tokens,
|
||||
"access_ttl": get_access_ttl(),
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
},
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_LOGOUT)
|
||||
def auth_logout(request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Handle user logout by blacklisting the access and refresh tokens.
|
||||
|
||||
Args:
|
||||
request: The aiohttp request.
|
||||
body: Dict with ``jti``, ``username`` from Flask user context, and
|
||||
``refresh_token`` from the client.
|
||||
|
||||
Returns:
|
||||
Success response.
|
||||
"""
|
||||
if not body:
|
||||
return {"ok": True}
|
||||
|
||||
jti = body.get("jti")
|
||||
if jti:
|
||||
blacklist_token(jti)
|
||||
|
||||
refresh_token = body.get("refresh_token")
|
||||
if refresh_token:
|
||||
payload = decode_token(refresh_token)
|
||||
if payload:
|
||||
refresh_jti = payload.get("jti")
|
||||
if refresh_jti:
|
||||
blacklist_token(refresh_jti, token_type="refresh")
|
||||
|
||||
username = body.get("username")
|
||||
if username:
|
||||
blacklist_active_refresh_token(username)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_REFRESH)
|
||||
def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Handle token refresh.
|
||||
|
||||
Validates the refresh token, blacklists it, and issues a new access token.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with ``refresh_token``.
|
||||
|
||||
Returns:
|
||||
Dict with new access token, refresh token, user, and permissions.
|
||||
|
||||
Raises:
|
||||
ValueError: If refresh token is invalid.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
refresh_token = body.get("refresh_token")
|
||||
if not refresh_token:
|
||||
raise ValueError("refresh_token is required")
|
||||
|
||||
payload = validate_token(refresh_token, token_type="refresh")
|
||||
if payload is None:
|
||||
raise ValueError("Invalid or expired refresh token")
|
||||
|
||||
username = payload["sub"]
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti:
|
||||
blacklist_token(jti, token_type="refresh")
|
||||
if username:
|
||||
_clear_refresh_token_after_rotation(username)
|
||||
permissions = user["permissions"]
|
||||
tokens = generate_tokens(username, permissions)
|
||||
|
||||
return {
|
||||
"tokens": tokens,
|
||||
"access_ttl": get_access_ttl(),
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
},
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(GET_AUTH_SESSION)
|
||||
def auth_session(request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Return current user session info.
|
||||
|
||||
Args:
|
||||
request: The aiohttp request.
|
||||
body: Dict with ``username`` from Flask user context.
|
||||
|
||||
Returns:
|
||||
Dict with user info and permissions.
|
||||
"""
|
||||
username = body.get("username") if body else None
|
||||
if username is None:
|
||||
raise ValueError("No active session")
|
||||
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
},
|
||||
"permissions": user["permissions"],
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_PASSWORD)
|
||||
def auth_change_password(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Change user password.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with ``username``, ``oldPassword``, ``newPassword``.
|
||||
|
||||
Returns:
|
||||
Success response.
|
||||
|
||||
Raises:
|
||||
ValueError: If password change fails.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
old_password = body.get("oldPassword")
|
||||
new_password = body.get("newPassword")
|
||||
|
||||
if not username or not old_password or not new_password:
|
||||
raise ValueError("username, oldPassword, and newPassword are required")
|
||||
|
||||
if len(new_password) < 8:
|
||||
raise ValueError("New password must be at least 8 characters")
|
||||
|
||||
update_password(username, old_password, new_password)
|
||||
logger.info("Password changed for user %r", username)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@registry.register(GET_AUTH_USERS)
|
||||
def auth_list_users(_request: Any, body: Any) -> list[dict[str, Any]]:
|
||||
"""List all users.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Unused.
|
||||
|
||||
Returns:
|
||||
List of user summary dicts.
|
||||
"""
|
||||
return list_users()
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_USER_CREATE)
|
||||
def auth_create_user(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Create a new user.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with ``username``, ``password``, ``permissions``.
|
||||
|
||||
Returns:
|
||||
Created user dict.
|
||||
|
||||
Raises:
|
||||
ConflictError: If user already exists.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
password = body.get("password")
|
||||
permissions = body.get("permissions", {})
|
||||
|
||||
if not username or not password:
|
||||
raise ValueError("username and password are required")
|
||||
|
||||
if len(password) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
|
||||
try:
|
||||
user = create_user(username, password, permissions)
|
||||
return {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"permissions": user["permissions"],
|
||||
}
|
||||
except ValueError as e:
|
||||
raise ConflictError(str(e)) from e
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_USER_UPDATE)
|
||||
def auth_update_user(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Update a user's permissions.
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Dict with optional ``permissions``, ``password`` keys.
|
||||
Path param ``username`` is merged into body by the daemon.
|
||||
|
||||
Returns:
|
||||
Updated user dict.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If user not found.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("username is required")
|
||||
|
||||
existing = get_user(username)
|
||||
if existing is None:
|
||||
raise NotFoundError(f"User {username!r} not found")
|
||||
|
||||
if "permissions" in body:
|
||||
update_permissions(username, body["permissions"])
|
||||
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise NotFoundError(f"User {username!r} not found")
|
||||
|
||||
return {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"permissions": user["permissions"],
|
||||
}
|
||||
|
||||
|
||||
@registry.register(DELETE_AUTH_USER)
|
||||
def auth_delete_user(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Delete a user.
|
||||
|
||||
Self-deletion is blocked by Flask middleware (auth blueprint).
|
||||
|
||||
Args:
|
||||
_request: Unused.
|
||||
body: Path param ``username`` merged by the daemon.
|
||||
|
||||
Returns:
|
||||
Success response.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If user not found.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("username is required")
|
||||
|
||||
try:
|
||||
delete_user(username)
|
||||
logger.info("User %r deleted", username)
|
||||
return {"ok": True}
|
||||
except ValueError as e:
|
||||
if "not found" in str(e):
|
||||
raise NotFoundError(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebAuthn handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("username is required")
|
||||
|
||||
options = create_registration_options(username)
|
||||
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")
|
||||
|
||||
username = body.get("username")
|
||||
credential_response = body.get("credential_response")
|
||||
registration_options = body.get("registration_options")
|
||||
credential_name = body.get("name", "")
|
||||
|
||||
if not username or not credential_response or not registration_options:
|
||||
raise ValueError(
|
||||
"username, credential_response, and registration_options are required"
|
||||
)
|
||||
|
||||
try:
|
||||
cred = verify_registration(
|
||||
username, credential_response, registration_options, credential_name
|
||||
)
|
||||
return {"ok": True, "credential": cred}
|
||||
except ValueError:
|
||||
raise
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN)
|
||||
def webauthn_authenticate_begin(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Begin WebAuthn authentication — return options for ``credentials.get()``.
|
||||
|
||||
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")
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("username is required")
|
||||
|
||||
options = create_authentication_options(username)
|
||||
if options is None:
|
||||
return {"no_webauthn": True}
|
||||
return options
|
||||
|
||||
|
||||
@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH)
|
||||
def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Finish WebAuthn authentication — verify assertion, issue tokens.
|
||||
|
||||
Public endpoint — no JWT required.
|
||||
"""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
assertion_response = body.get("assertion_response")
|
||||
auth_options = body.get("auth_options")
|
||||
|
||||
if not username or not assertion_response or not auth_options:
|
||||
raise ValueError("username, assertion_response, and auth_options are required")
|
||||
|
||||
if not check_webauthn_rate(username):
|
||||
raise ValueError("Too many WebAuthn attempts. Please try again later.")
|
||||
|
||||
try:
|
||||
verify_authentication(username, assertion_response, auth_options)
|
||||
except ValueError:
|
||||
raise
|
||||
|
||||
user = get_user(username)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
|
||||
permissions = user["permissions"]
|
||||
tokens = generate_tokens(username, permissions)
|
||||
|
||||
return {
|
||||
"tokens": tokens,
|
||||
"access_ttl": get_access_ttl(),
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
},
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(GET_AUTH_WEBAUTHN_CREDENTIALS)
|
||||
def webauthn_credentials(request: Any, body: Any) -> list[dict[str, Any]]:
|
||||
"""List WebAuthn credentials for the authenticated user."""
|
||||
username = body.get("username") if body else None
|
||||
if not username:
|
||||
raise ValueError("No active session")
|
||||
|
||||
return list_credentials(username)
|
||||
|
||||
|
||||
@registry.register(DELETE_AUTH_WEBAUTHN_CREDENTIAL)
|
||||
def webauthn_remove_credential(request: Any, body: Any) -> dict[str, Any]:
|
||||
"""Remove a WebAuthn credential."""
|
||||
if not body or not isinstance(body, dict):
|
||||
raise ValueError("Request body is required")
|
||||
|
||||
username = body.get("username")
|
||||
if not username:
|
||||
raise ValueError("No active session")
|
||||
|
||||
credential_id = body.get("credential_id")
|
||||
if not credential_id:
|
||||
raise ValueError("credential_id is required")
|
||||
|
||||
try:
|
||||
remove_credential(username, credential_id)
|
||||
logger.info("WebAuthn credential removed for %s", username)
|
||||
return {"ok": True}
|
||||
except ValueError as e:
|
||||
if "not found" in str(e):
|
||||
raise NotFoundError(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
@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)."""
|
||||
return get_all_credential_counts()
|
||||
@@ -302,7 +302,9 @@ def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
|
||||
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "class_up", "class_key": class_key})
|
||||
SyncEvent(
|
||||
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"up": True, "interface": ifname}
|
||||
@@ -332,7 +334,11 @@ def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("wireguard", "config_saved", {"action": "class_down", "class_key": class_key})
|
||||
SyncEvent(
|
||||
"wireguard",
|
||||
"config_saved",
|
||||
{"action": "class_down", "class_key": class_key},
|
||||
)
|
||||
)
|
||||
refresh_state(["wireguard", *sync_result.affected_subsystems])
|
||||
return {"down": True, "interface": ifname}
|
||||
|
||||
@@ -167,6 +167,39 @@ GET_LOGS_NGINX_ERROR: Endpoint = _ep("GET", "/logs/nginx/error")
|
||||
GET_LOGS_DNSMASQ: Endpoint = _ep("GET", "/logs/dnsmasq")
|
||||
GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app")
|
||||
|
||||
# ---- Authentication ----
|
||||
POST_AUTH_LOGIN: Endpoint = _ep("POST", "/auth/login")
|
||||
POST_AUTH_LOGOUT: Endpoint = _ep("POST", "/auth/logout")
|
||||
POST_AUTH_REFRESH: Endpoint = _ep("POST", "/auth/refresh")
|
||||
GET_AUTH_SESSION: Endpoint = _ep("GET", "/auth/session")
|
||||
POST_AUTH_PASSWORD: Endpoint = _ep("POST", "/auth/password")
|
||||
# User admin
|
||||
GET_AUTH_USERS: Endpoint = _ep("GET", "/auth/users")
|
||||
POST_AUTH_USER_CREATE: Endpoint = _ep("POST", "/auth/users")
|
||||
POST_AUTH_USER_UPDATE: Endpoint = _ep("POST", "/auth/users/<username>")
|
||||
DELETE_AUTH_USER: Endpoint = _ep("DELETE", "/auth/users/<username>")
|
||||
|
||||
# WebAuthn
|
||||
POST_AUTH_WEBAUTHN_REGISTER_BEGIN: Endpoint = _ep(
|
||||
"POST", "/auth/webauthn/register-begin"
|
||||
)
|
||||
POST_AUTH_WEBAUTHN_REGISTER_FINISH: Endpoint = _ep(
|
||||
"POST", "/auth/webauthn/register-finish"
|
||||
)
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN: Endpoint = _ep(
|
||||
"POST", "/auth/webauthn/authenticate-begin"
|
||||
)
|
||||
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH: Endpoint = _ep(
|
||||
"POST", "/auth/webauthn/authenticate-finish"
|
||||
)
|
||||
GET_AUTH_WEBAUTHN_CREDENTIALS: Endpoint = _ep("GET", "/auth/webauthn/credentials")
|
||||
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS: Endpoint = _ep(
|
||||
"GET", "/auth/webauthn/credential-counts"
|
||||
)
|
||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL: Endpoint = _ep(
|
||||
"DELETE", "/auth/webauthn/creds/<credential_id>"
|
||||
)
|
||||
|
||||
# ---- Server infra (not going through client) ----
|
||||
GET_HEALTH: Endpoint = _ep("GET", "/health")
|
||||
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
|
||||
|
||||
+34
-1
@@ -368,8 +368,40 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
||||
|
||||
On connect: sends current versions. On state change: broadcasts
|
||||
updated subsystem versions. Clients disconnect to unsubscribe.
|
||||
|
||||
Authentication: JWT access token passed via:
|
||||
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>")
|
||||
2. X-Auth-Token header (nginx-injected)
|
||||
3. token query parameter (dev fallback)
|
||||
"""
|
||||
ws = web.WebSocketResponse()
|
||||
from lib.auth import validate_token
|
||||
|
||||
token_param = None
|
||||
|
||||
# Prefer subprotocol header (client JS sends "Bearer <token>")
|
||||
subprotocols = request.get_subprotocols()
|
||||
for proto in subprotocols or []:
|
||||
if proto and proto.startswith("Bearer "):
|
||||
token_param = proto[7:]
|
||||
break
|
||||
|
||||
if token_param is None:
|
||||
token_param = request.headers.get("X-Auth-Token")
|
||||
|
||||
if token_param is None:
|
||||
token_param = request.query.get("token")
|
||||
|
||||
if token_param is None:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "authentication required"}, status=401
|
||||
)
|
||||
|
||||
payload = validate_token(token_param, token_type="access")
|
||||
if payload is None:
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||
|
||||
# Negotiate the subprotocol the client sent
|
||||
ws = web.WebSocketResponse(protocols=request.get_subprotocols())
|
||||
await ws.prepare(request)
|
||||
_ws_subscribers.add(ws)
|
||||
|
||||
@@ -501,6 +533,7 @@ def _register_routes() -> None:
|
||||
"""
|
||||
from daemon.handlers import (
|
||||
acme, # noqa: F401
|
||||
auth, # noqa: F401
|
||||
dnsmasq, # noqa: F401
|
||||
firewall, # noqa: F401
|
||||
logs, # noqa: F401
|
||||
|
||||
Reference in New Issue
Block a user