mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-04 04:23:04 +02:00
Add basic auth
This commit is contained in:
@@ -109,7 +109,7 @@ Radio startup/setup is one place where that frontend bubbling is intentional: if
|
||||
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
|
||||
|
||||
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
|
||||
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
|
||||
2. **Minimal optional access control only**: The app has no user accounts, sessions, authorization model, or per-feature permissions. Operators may optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but this is only a coarse gate and still requires HTTPS plus a trusted network posture.
|
||||
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
|
||||
|
||||
## Intentional Packet Handling Decision
|
||||
@@ -443,6 +443,8 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on message audit task from hourly checks to aggressive 10-second `get_msg()` fallback polling |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | *(none)* | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
|
||||
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
|
||||
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
|
||||
|
||||

|
||||
|
||||
@@ -225,9 +225,13 @@ npm run build # build the frontend
|
||||
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
|
||||
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly audit task |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. If that audit ever finds radio data that was not surfaced through event subscription, the backend logs an error and the UI shows a toast telling the operator to check the logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second `get_msg()` safety net.
|
||||
|
||||
## Additional Setup
|
||||
|
||||
@@ -44,6 +44,7 @@ app/
|
||||
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
|
||||
├── events.py # Typed WS event payload serialization
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
|
||||
@@ -19,6 +19,8 @@ class Settings(BaseSettings):
|
||||
database_path: str = "data/meshcore.db"
|
||||
disable_bots: bool = False
|
||||
enable_message_poll_fallback: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
@@ -36,6 +38,11 @@ class Settings(BaseSettings):
|
||||
)
|
||||
if self.ble_address and not self.ble_pin:
|
||||
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
|
||||
if self.basic_auth_partially_configured:
|
||||
raise ValueError(
|
||||
"MESHCORE_BASIC_AUTH_USERNAME and MESHCORE_BASIC_AUTH_PASSWORD "
|
||||
"must be set together."
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -46,6 +53,15 @@ class Settings(BaseSettings):
|
||||
return "ble"
|
||||
return "serial"
|
||||
|
||||
@property
|
||||
def basic_auth_enabled(self) -> bool:
|
||||
return bool(self.basic_auth_username and self.basic_auth_password)
|
||||
|
||||
@property
|
||||
def basic_auth_partially_configured(self) -> bool:
|
||||
any_credentials_set = bool(self.basic_auth_username or self.basic_auth_password)
|
||||
return any_credentials_set and not self.basic_auth_enabled
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
@@ -7,6 +7,32 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_CACHE_CONTROL = "no-store"
|
||||
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
||||
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
|
||||
|
||||
|
||||
class CacheControlStaticFiles(StaticFiles):
|
||||
"""StaticFiles variant that adds a fixed Cache-Control header."""
|
||||
|
||||
def __init__(self, *args, cache_control: str, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.cache_control = cache_control
|
||||
|
||||
def file_response(self, *args, **kwargs):
|
||||
response = super().file_response(*args, **kwargs)
|
||||
response.headers["Cache-Control"] = self.cache_control
|
||||
return response
|
||||
|
||||
|
||||
def _file_response(path: Path, *, cache_control: str) -> FileResponse:
|
||||
return FileResponse(path, headers={"Cache-Control": cache_control})
|
||||
|
||||
|
||||
def _is_index_file(path: Path, index_file: Path) -> bool:
|
||||
"""Return True when the requested file is the SPA shell index.html."""
|
||||
return path == index_file
|
||||
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
@@ -57,7 +83,11 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
return False
|
||||
|
||||
if assets_dir.exists() and assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
app.mount(
|
||||
"/assets",
|
||||
CacheControlStaticFiles(directory=assets_dir, cache_control=ASSET_CACHE_CONTROL),
|
||||
name="assets",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Frontend assets directory missing at %s; /assets files will not be served",
|
||||
@@ -67,7 +97,7 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
"""Serve the frontend index.html."""
|
||||
return FileResponse(index_file)
|
||||
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
|
||||
|
||||
@app.get("/site.webmanifest")
|
||||
async def serve_webmanifest(request: Request):
|
||||
@@ -114,9 +144,14 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
raise HTTPException(status_code=404, detail="Not found") from None
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
cache_control = (
|
||||
INDEX_CACHE_CONTROL
|
||||
if _is_index_file(file_path, index_file)
|
||||
else STATIC_FILE_CACHE_CONTROL
|
||||
)
|
||||
return _file_response(file_path, cache_control=cache_control)
|
||||
|
||||
return FileResponse(index_file)
|
||||
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
|
||||
|
||||
logger.info("Serving frontend from %s", frontend_dir)
|
||||
return True
|
||||
|
||||
@@ -5,8 +5,10 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
@@ -30,6 +32,7 @@ from app.routers import (
|
||||
statistics,
|
||||
ws,
|
||||
)
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
setup_logging()
|
||||
@@ -114,6 +117,8 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
add_optional_basic_auth_middleware(app, server_settings)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
|
||||
121
app/security.py
Normal file
121
app/security.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""ASGI middleware for optional app-wide HTTP Basic authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AUTH_REALM = "RemoteTerm"
|
||||
_UNAUTHORIZED_BODY = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
|
||||
|
||||
|
||||
class BasicAuthMiddleware:
|
||||
"""Protect all HTTP and WebSocket entrypoints with HTTP Basic auth."""
|
||||
|
||||
def __init__(self, app, *, username: str, password: str, realm: str = _AUTH_REALM) -> None:
|
||||
self.app = app
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.realm = realm
|
||||
self._challenge_value = f'Basic realm="{realm}", charset="UTF-8"'.encode("latin-1")
|
||||
|
||||
def _is_authorized(self, scope: dict[str, Any]) -> bool:
|
||||
headers = Headers(scope=scope)
|
||||
authorization = headers.get("authorization")
|
||||
if not authorization:
|
||||
return False
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if not token or scheme.lower() != "basic":
|
||||
return False
|
||||
|
||||
token = token.strip()
|
||||
try:
|
||||
decoded = base64.b64decode(token, validate=True).decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
logger.debug("Rejecting malformed basic auth header")
|
||||
return False
|
||||
|
||||
username, sep, password = decoded.partition(":")
|
||||
if not sep:
|
||||
return False
|
||||
|
||||
return secrets.compare_digest(username, self.username) and secrets.compare_digest(
|
||||
password, self.password
|
||||
)
|
||||
|
||||
async def _send_http_unauthorized(self, send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
|
||||
(b"www-authenticate", self._challenge_value),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": _UNAUTHORIZED_BODY,
|
||||
}
|
||||
)
|
||||
|
||||
async def _send_websocket_unauthorized(self, send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "websocket.http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
|
||||
(b"www-authenticate", self._challenge_value),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "websocket.http.response.body",
|
||||
"body": _UNAUTHORIZED_BODY,
|
||||
}
|
||||
)
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
scope_type = scope["type"]
|
||||
if scope_type not in {"http", "websocket"}:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if self._is_authorized(scope):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if scope_type == "http":
|
||||
await self._send_http_unauthorized(send)
|
||||
return
|
||||
|
||||
await self._send_websocket_unauthorized(send)
|
||||
|
||||
|
||||
def add_optional_basic_auth_middleware(app, settings) -> None:
|
||||
"""Enable app-wide basic auth when configured via environment variables."""
|
||||
if not settings.basic_auth_enabled:
|
||||
return
|
||||
|
||||
app.add_middleware(
|
||||
BasicAuthMiddleware,
|
||||
username=settings.basic_auth_username,
|
||||
password=settings.basic_auth_password,
|
||||
)
|
||||
@@ -80,3 +80,41 @@ class TestBLEPinRequirement:
|
||||
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
|
||||
assert s.ble_address == "AA:BB:CC:DD:EE:FF"
|
||||
assert s.ble_pin == "123456"
|
||||
|
||||
|
||||
class TestBasicAuthConfiguration:
|
||||
"""Ensure basic auth credentials are configured as a pair."""
|
||||
|
||||
def test_basic_auth_disabled_by_default(self):
|
||||
s = Settings(serial_port="", tcp_host="", ble_address="")
|
||||
assert s.basic_auth_enabled is False
|
||||
|
||||
def test_basic_auth_enabled_when_both_credentials_are_set(self):
|
||||
s = Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="mesh",
|
||||
basic_auth_password="secret",
|
||||
)
|
||||
assert s.basic_auth_enabled is True
|
||||
|
||||
def test_basic_auth_requires_password_with_username(self):
|
||||
with pytest.raises(ValidationError, match="MESHCORE_BASIC_AUTH_USERNAME"):
|
||||
Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="mesh",
|
||||
basic_auth_password="",
|
||||
)
|
||||
|
||||
def test_basic_auth_requires_username_with_password(self):
|
||||
with pytest.raises(ValidationError, match="MESHCORE_BASIC_AUTH_USERNAME"):
|
||||
Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="",
|
||||
basic_auth_password="secret",
|
||||
)
|
||||
|
||||
@@ -3,7 +3,13 @@ import logging
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.frontend_static import (
|
||||
ASSET_CACHE_CONTROL,
|
||||
INDEX_CACHE_CONTROL,
|
||||
STATIC_FILE_CACHE_CONTROL,
|
||||
register_frontend_missing_fallback,
|
||||
register_frontend_static_routes,
|
||||
)
|
||||
|
||||
|
||||
def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog):
|
||||
@@ -57,10 +63,12 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
|
||||
root_response = client.get("/")
|
||||
assert root_response.status_code == 200
|
||||
assert "index page" in root_response.text
|
||||
assert root_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
manifest_response = client.get("/site.webmanifest")
|
||||
assert manifest_response.status_code == 200
|
||||
assert manifest_response.headers["content-type"].startswith("application/manifest+json")
|
||||
assert manifest_response.headers["cache-control"] == "no-store"
|
||||
manifest = manifest_response.json()
|
||||
assert manifest["start_url"] == "http://testserver/"
|
||||
assert manifest["scope"] == "http://testserver/"
|
||||
@@ -71,14 +79,22 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
|
||||
file_response = client.get("/robots.txt")
|
||||
assert file_response.status_code == 200
|
||||
assert file_response.text == "User-agent: *"
|
||||
assert file_response.headers["cache-control"] == STATIC_FILE_CACHE_CONTROL
|
||||
|
||||
explicit_index_response = client.get("/index.html")
|
||||
assert explicit_index_response.status_code == 200
|
||||
assert "index page" in explicit_index_response.text
|
||||
assert explicit_index_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
missing_response = client.get("/channel/some-route")
|
||||
assert missing_response.status_code == 200
|
||||
assert "index page" in missing_response.text
|
||||
assert missing_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
asset_response = client.get("/assets/app.js")
|
||||
assert asset_response.status_code == 200
|
||||
assert "console.log('ok');" in asset_response.text
|
||||
assert asset_response.headers["cache-control"] == ASSET_CACHE_CONTROL
|
||||
|
||||
|
||||
def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
|
||||
|
||||
13
tests/test_http_quality.py
Normal file
13
tests/test_http_quality.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Tests for direct-serve HTTP quality features such as gzip compression."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_openapi_json_is_gzipped_when_client_accepts_gzip():
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/openapi.json", headers={"Accept-Encoding": "gzip"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
98
tests/test_security.py
Normal file
98
tests/test_security.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for optional app-wide HTTP Basic authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.testclient import WebSocketDenialResponse
|
||||
|
||||
from app.config import Settings
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
|
||||
|
||||
def _auth_header(username: str, password: str) -> dict[str, str]:
|
||||
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
|
||||
|
||||
def _build_app(*, username: str = "", password: str = "") -> FastAPI:
|
||||
settings = Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username=username,
|
||||
basic_auth_password=password,
|
||||
)
|
||||
app = FastAPI()
|
||||
add_optional_basic_auth_middleware(app, settings)
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected():
|
||||
return {"ok": True}
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"ok": True})
|
||||
await websocket.close()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_http_request_is_denied_without_basic_auth_credentials():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Unauthorized"}
|
||||
assert response.headers["www-authenticate"] == 'Basic realm="RemoteTerm", charset="UTF-8"'
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_http_request_is_allowed_with_valid_basic_auth_credentials():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected", headers=_auth_header("mesh", "secret"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_http_request_accepts_case_insensitive_basic_auth_scheme():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
header = _auth_header("mesh", "secret")
|
||||
header["Authorization"] = header["Authorization"].replace("Basic", "basic")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected", headers=header)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_websocket_handshake_is_denied_without_basic_auth_credentials():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
|
||||
with TestClient(app) as client:
|
||||
with pytest.raises(WebSocketDenialResponse) as exc_info:
|
||||
with client.websocket_connect("/ws"):
|
||||
pass
|
||||
|
||||
response = exc_info.value
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Unauthorized"}
|
||||
assert response.headers["www-authenticate"] == 'Basic realm="RemoteTerm", charset="UTF-8"'
|
||||
|
||||
|
||||
def test_websocket_handshake_is_allowed_with_valid_basic_auth_credentials():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws", headers=_auth_header("mesh", "secret")) as websocket:
|
||||
assert websocket.receive_json() == {"ok": True}
|
||||
Reference in New Issue
Block a user