c720f4ca67
- drop unused BAR import, _VALID_MODES, _is_optional(), OPTIONAL_IMPORTS and is_loopback() - replace hand-written event name strings with EVENT_INSERTED, EVENT_REMOVED and EVENT_INVALID, so a typo cannot silently disable an event path - drop the now-moot pytest/coverage entries from .gitignore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
544 lines
21 KiB
Python
544 lines
21 KiB
Python
"""HTTP bridge: expose card taps to a web page, without ever becoming the
|
|
authority on who the card belongs to.
|
|
|
|
The reader is a *sensor*. This module publishes what the sensor sees and stops
|
|
there - resolving a card id to a user is the server's job, so nothing here
|
|
writes to a database, mints session tokens or decides whether a login succeeds.
|
|
|
|
Served by default:
|
|
|
|
============================ ============================================
|
|
``GET /api/health`` reader state, port, uptime, last tap age
|
|
``GET /api/card`` the card on the reader right now (latched)
|
|
``POST /api/card/claim`` claim that tap, single use, for one login
|
|
``GET /api/events`` Server-Sent Events stream of card events
|
|
``GET /api/last`` most recent card event (or 204)
|
|
``GET /api/taps`` recent tap history (ring buffer)
|
|
``POST /api/mode`` switch reader mode: login / monitor
|
|
============================ ============================================
|
|
|
|
The usual integration, for a web page that may load at any moment::
|
|
|
|
const r = await fetch("http://127.0.0.1:8765/api/card");
|
|
const state = await r.json();
|
|
if (state.claimable) {
|
|
const c = await fetch("http://127.0.0.1:8765/api/card/claim", {method: "POST"});
|
|
if (c.ok) {
|
|
const { card } = await c.json();
|
|
// hand card.id to your server; it decides who that is
|
|
}
|
|
}
|
|
|
|
``claimable`` stays true for ``--tap-ttl`` seconds after the card is read -
|
|
including after the card is lifted - so a quick tap is not lost.
|
|
|
|
Security posture (important, and deliberate):
|
|
|
|
* Binds to ``127.0.0.1`` unless the operator explicitly opts in to more, because
|
|
an open card-event endpoint on a LAN lets anyone read who is tapping.
|
|
* Cross-origin requests are refused unless the origin is on an allow-list.
|
|
A page on another site must not be able to observe your taps.
|
|
* Optionally requires a pre-shared token (``--token`` / ``RDM_BRIDGE_TOKEN``)
|
|
for non-local callers.
|
|
|
|
An EM4100 card id is a *username*, not a password: it is broadcast in the clear
|
|
and can be cloned. Never let holding a card id alone create a session.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import secrets
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Any, Callable, Deque, Dict, List, Optional
|
|
|
|
from .card import EVENT_INVALID, EVENT_INSERTED, EVENT_REMOVED, CardEvent
|
|
|
|
#: Reader modes. ``login`` publishes inserts only (a tap = an authentication
|
|
#: attempt). ``monitor`` publishes inserts and removals both.
|
|
MODE_LOGIN = "login"
|
|
MODE_MONITOR = "monitor"
|
|
VALID_MODES = (MODE_LOGIN, MODE_MONITOR)
|
|
|
|
DEFAULT_HOST = "127.0.0.1"
|
|
DEFAULT_PORT = 8765
|
|
DEFAULT_HISTORY = 50
|
|
|
|
#: How long a tap stays claimable. This deliberately outlives the card
|
|
#: leaving the reader: a quick tap that is lifted straight away must still be
|
|
#: claimable, otherwise a page that polls a moment later sees nothing and the
|
|
#: login silently fails. Set to 0 to keep a tap claimable until it is used or
|
|
#: replaced.
|
|
DEFAULT_TAP_TTL = 10.0
|
|
|
|
#: How long a *claimed* tap stays readable afterwards. The page that claimed it
|
|
#: needs a moment to confirm which card logged in, but an id that stays
|
|
#: readable forever is indistinguishable from a tap still waiting to be spent -
|
|
#: so it is cleared once this window passes. Set to 0 to keep it indefinitely.
|
|
DEFAULT_CLAIMED_TTL = 30.0
|
|
|
|
|
|
class EventHub:
|
|
"""Tracks reader state and fans card events out to waiting subscribers.
|
|
|
|
This is the only place the raw card id is held. Subscribers are HTTP
|
|
connections owned by the same process, never third-party code.
|
|
|
|
The card currently read is *latched*: it stays readable through
|
|
:meth:`current_card` after the card has left the reader, for ``tap_ttl``
|
|
seconds from the tap. That is what makes the data "always available" to a
|
|
web page that may load or poll at any moment.
|
|
|
|
Claiming it through :meth:`consume_card` marks it used, so a page reload or
|
|
a second tab cannot replay the same physical tap into a second login.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
history: int = DEFAULT_HISTORY,
|
|
mode: str = MODE_LOGIN,
|
|
tap_ttl: float = DEFAULT_TAP_TTL,
|
|
claimed_ttl: float = DEFAULT_CLAIMED_TTL,
|
|
):
|
|
self._lock = threading.Lock()
|
|
self._subscribers: List["deque"] = []
|
|
self._history: Deque[CardEvent] = deque(maxlen=history)
|
|
self._last: Optional[CardEvent] = None
|
|
self._last_at: Optional[float] = None
|
|
self._mode = mode if mode in VALID_MODES else MODE_LOGIN
|
|
self._started_at = time.time()
|
|
self._counts = {EVENT_INSERTED: 0, EVENT_REMOVED: 0, EVENT_INVALID: 0}
|
|
self._port: Optional[str] = None
|
|
self._tap_ttl = float(tap_ttl if tap_ttl else 0.0)
|
|
self._claimed_ttl = float(claimed_ttl if claimed_ttl else 0.0)
|
|
|
|
# The latched tap. It deliberately outlives the card leaving the
|
|
# reader, so a page that loads after the tap can still claim it.
|
|
self._tap: Optional[CardEvent] = None
|
|
self._tap_at: Optional[float] = None
|
|
self._on_reader = False
|
|
self._consumed_at: Optional[float] = None
|
|
|
|
# -- state ------------------------------------------------------------
|
|
|
|
def set_port(self, port: str) -> None:
|
|
with self._lock:
|
|
self._port = port
|
|
|
|
@property
|
|
def mode(self) -> str:
|
|
with self._lock:
|
|
return self._mode
|
|
|
|
def set_mode(self, mode: str) -> bool:
|
|
"""Switch mode. Returns False for an unknown mode."""
|
|
if mode not in VALID_MODES:
|
|
return False
|
|
with self._lock:
|
|
self._mode = mode
|
|
return True
|
|
|
|
def publish(self, event: CardEvent) -> None:
|
|
"""Record an event and wake every subscriber. Safe from any thread."""
|
|
with self._lock:
|
|
self._counts[event.event] = self._counts.get(event.event, 0) + 1
|
|
|
|
# Presence tracking always happens, in every mode: the latched tap
|
|
# is state, not a published event.
|
|
if event.event == EVENT_INSERTED:
|
|
self._last = event
|
|
self._last_at = time.time()
|
|
# A new tap replaces any previous unclaimed one.
|
|
self._tap = event
|
|
self._tap_at = self._last_at
|
|
self._on_reader = True
|
|
self._consumed_at = None
|
|
elif event.event == EVENT_REMOVED:
|
|
# Lifting the card does NOT discard the tap: it must stay
|
|
# claimable for tap_ttl, otherwise a quick tap is lost before
|
|
# the web page gets a chance to ask for it.
|
|
self._on_reader = False
|
|
|
|
# In login mode a tap is an authentication attempt; card removal is
|
|
# not published, so a web app cannot mistake it for a second tap.
|
|
if self._mode == MODE_LOGIN and event.event == EVENT_REMOVED:
|
|
return
|
|
|
|
self._history.append(event)
|
|
queues = list(self._subscribers)
|
|
payload = event.to_dict()
|
|
|
|
for queue in queues:
|
|
try:
|
|
queue.append(payload)
|
|
except Exception:
|
|
pass
|
|
|
|
# -- latched card -----------------------------------------------------
|
|
|
|
def current_card(self) -> Dict[str, Any]:
|
|
"""The latched tap, so a page can ask at any moment.
|
|
|
|
``present`` is whether the card is physically on the reader right now;
|
|
``claimable`` is whether this tap can still be spent on a login.
|
|
|
|
A claimed tap is reported for ``claimed_ttl`` seconds after it was
|
|
spent, so the page that claimed it can confirm which card logged in. An
|
|
expired unclaimed tap is cleared too: keeping it would leave an id that
|
|
can never be claimed again, which is indistinguishable from a tap that
|
|
is still waiting to be used.
|
|
"""
|
|
with self._lock:
|
|
self._drop_dead_tap()
|
|
|
|
if self._tap is None:
|
|
return {
|
|
"present": False,
|
|
"claimable": False,
|
|
"consumed": False,
|
|
"expired": False,
|
|
"age_seconds": None,
|
|
"card": None,
|
|
}
|
|
age = time.time() - (self._tap_at or 0.0)
|
|
expired = bool(self._tap_ttl) and age > self._tap_ttl
|
|
consumed = self._consumed_at is not None
|
|
return {
|
|
"present": self._on_reader,
|
|
"claimable": (not consumed) and (not expired),
|
|
"consumed": consumed,
|
|
"expired": expired,
|
|
"age_seconds": round(age, 1),
|
|
"card": self._tap.to_dict(),
|
|
}
|
|
|
|
def _drop_dead_tap(self) -> None:
|
|
"""Clear the latched tap once it can no longer be acted on.
|
|
|
|
Called with the lock held. Two independent deadlines: an unclaimed tap
|
|
is dead after ``tap_ttl``, a claimed one after ``claimed_ttl``. A tap of
|
|
zero TTL means "keep until replaced", which the claimed case uses to
|
|
keep the id around indefinitely.
|
|
|
|
Crucially this drops only the *tap*, never the presence flag: whether a
|
|
card is on the reader is a physical fact learnt from insert/remove
|
|
events, and a tap ageing out says nothing about it. Clearing presence
|
|
here reported "no card" while a card was sitting on the antenna.
|
|
"""
|
|
if self._tap is None:
|
|
return
|
|
now = time.time()
|
|
if self._consumed_at is not None:
|
|
if self._claimed_ttl and now - self._consumed_at > self._claimed_ttl:
|
|
self._tap = None
|
|
self._tap_at = None
|
|
self._consumed_at = None
|
|
return
|
|
if self._tap_ttl and now - (self._tap_at or 0.0) > self._tap_ttl:
|
|
self._tap = None
|
|
self._tap_at = None
|
|
|
|
def consume_card(self):
|
|
"""Atomically claim the latched tap for one authentication.
|
|
|
|
Returns ``(payload, error, status)``. The tap is marked used only when
|
|
this call succeeds, so two concurrent requests cannot both win.
|
|
"""
|
|
with self._lock:
|
|
self._drop_dead_tap()
|
|
if self._tap is None:
|
|
return None, "no card read yet", 409
|
|
|
|
age = time.time() - (self._tap_at or 0.0)
|
|
if self._tap_ttl and age > self._tap_ttl:
|
|
return None, "card tap expired", 410
|
|
if self._consumed_at is not None:
|
|
return None, "card already used", 409
|
|
|
|
self._consumed_at = time.time()
|
|
return (
|
|
{
|
|
"card": self._tap.to_dict(),
|
|
"age_seconds": round(age, 1),
|
|
"consumed": True,
|
|
},
|
|
None,
|
|
200,
|
|
)
|
|
|
|
def subscribe(self) -> "deque":
|
|
"""Register a queue that receives every subsequent event."""
|
|
queue: Deque[Dict[str, Any]] = deque(maxlen=100)
|
|
with self._lock:
|
|
self._subscribers.append(queue)
|
|
return queue
|
|
|
|
def unsubscribe(self, queue: "deque") -> None:
|
|
with self._lock:
|
|
try:
|
|
self._subscribers.remove(queue)
|
|
except ValueError:
|
|
pass
|
|
|
|
# -- snapshots --------------------------------------------------------
|
|
|
|
def health(self) -> Dict[str, Any]:
|
|
with self._lock:
|
|
self._drop_dead_tap()
|
|
last_age = (
|
|
round(time.time() - self._last_at, 1) if self._last_at else None
|
|
)
|
|
return {
|
|
"status": "ok",
|
|
"port": self._port,
|
|
"mode": self._mode,
|
|
"uptime_seconds": round(time.time() - self._started_at, 1),
|
|
"subscribers": len(self._subscribers),
|
|
"counts": dict(self._counts),
|
|
"last_tap": self._last.to_dict() if self._last else None,
|
|
"last_tap_age_seconds": last_age,
|
|
"card_present": self._on_reader,
|
|
"card_consumed": self._consumed_at is not None,
|
|
"tap_ttl_seconds": self._tap_ttl or None,
|
|
"claimed_ttl_seconds": self._claimed_ttl or None,
|
|
}
|
|
|
|
def last(self) -> Optional[Dict[str, Any]]:
|
|
with self._lock:
|
|
return self._last.to_dict() if self._last else None
|
|
|
|
def taps(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
with self._lock:
|
|
items = list(self._history)[-max(1, limit):]
|
|
return [event.to_dict() for event in items]
|
|
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
"""One HTTP connection. ``server.hub`` holds the shared state."""
|
|
|
|
server_version = "rdm-card-reader"
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
# -- helpers ----------------------------------------------------------
|
|
|
|
def _cors_ok(self) -> bool:
|
|
"""Refuse cross-origin reads unless the origin was allow-listed."""
|
|
origin = self.headers.get("Origin")
|
|
if not origin:
|
|
return True # same-origin fetch or a non-browser client
|
|
allowed: List[str] = getattr(self.server, "allowed_origins", [])
|
|
if not allowed:
|
|
return False # browsers only; opt out entirely
|
|
return origin in allowed
|
|
|
|
def _token_ok(self) -> bool:
|
|
"""Check the pre-shared token for non-loopback callers."""
|
|
required: Optional[str] = getattr(self.server, "token", None)
|
|
if not required:
|
|
return True
|
|
client = self.client_address[0]
|
|
if client in ("127.0.0.1", "::1", "localhost"):
|
|
return True
|
|
supplied = self.headers.get("X-Auth-Token") or ""
|
|
if not supplied:
|
|
query = self.path.split("?", 1)[1] if "?" in self.path else ""
|
|
for pair in query.split("&"):
|
|
if pair.startswith("token="):
|
|
supplied = pair.split("=", 1)[1]
|
|
return secrets.compare_digest(supplied, required)
|
|
|
|
def _send_json(self, payload: Any, status: int = 200) -> None:
|
|
body = json.dumps(payload).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
origin = self.headers.get("Origin")
|
|
if origin and origin in getattr(self.server, "allowed_origins", []):
|
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
self.send_header("Vary", "Origin")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_no_content(self) -> None:
|
|
self.send_response(204)
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", "0")
|
|
origin = self.headers.get("Origin")
|
|
if origin and origin in getattr(self.server, "allowed_origins", []):
|
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
self.send_header("Vary", "Origin")
|
|
self.end_headers()
|
|
|
|
def log_message(self, fmt: str, *args: Any) -> None:
|
|
logger: Optional[Callable[[str], None]] = getattr(self.server, "logger", None)
|
|
if logger:
|
|
logger("{} - {}".format(self.client_address[0], fmt % args))
|
|
|
|
@property
|
|
def _hub(self) -> EventHub:
|
|
return self.server.hub # type: ignore[attr-defined]
|
|
|
|
# -- routing ----------------------------------------------------------
|
|
|
|
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
|
|
|
if path == "/api/health":
|
|
self._send_json(self._hub.health())
|
|
return
|
|
|
|
if not self._cors_ok():
|
|
self._send_json({"error": "origin not allowed"}, status=403)
|
|
return
|
|
if not self._token_ok():
|
|
self._send_json({"error": "missing or invalid token"}, status=401)
|
|
return
|
|
|
|
if path == "/api/last":
|
|
payload = self._hub.last()
|
|
if payload is None:
|
|
self._send_no_content()
|
|
else:
|
|
self._send_json(payload)
|
|
return
|
|
|
|
if path == "/api/card":
|
|
self._send_json(self._hub.current_card())
|
|
return
|
|
|
|
if path == "/api/taps":
|
|
limit = 20
|
|
if "?" in self.path:
|
|
for pair in self.path.split("?", 1)[1].split("&"):
|
|
if pair.startswith("limit="):
|
|
try:
|
|
limit = int(pair.split("=", 1)[1])
|
|
except ValueError:
|
|
pass
|
|
self._send_json({"taps": self._hub.taps(limit)})
|
|
return
|
|
|
|
if path == "/api/events":
|
|
self._stream_events()
|
|
return
|
|
|
|
self._send_json({"error": "not found", "path": path}, status=404)
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
|
|
|
if path == "/api/card/claim":
|
|
if not self._cors_ok():
|
|
self._send_json({"error": "origin not allowed"}, status=403)
|
|
return
|
|
if not self._token_ok():
|
|
self._send_json({"error": "missing or invalid token"}, status=401)
|
|
return
|
|
payload, error, status = self._hub.consume_card()
|
|
if error:
|
|
self._send_json(
|
|
{"error": error, "state": self._hub.current_card()},
|
|
status=status,
|
|
)
|
|
return
|
|
self._send_json(payload, status=status)
|
|
return
|
|
|
|
if path != "/api/mode":
|
|
self._send_json({"error": "not found", "path": path}, status=404)
|
|
return
|
|
if not self._cors_ok():
|
|
self._send_json({"error": "origin not allowed"}, status=403)
|
|
return
|
|
if not self._token_ok():
|
|
self._send_json({"error": "missing or invalid token"}, status=401)
|
|
return
|
|
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
raw = self.rfile.read(length) if length else b"{}"
|
|
try:
|
|
payload = json.loads(raw.decode("utf-8") or "{}")
|
|
except ValueError:
|
|
self._send_json({"error": "invalid JSON body"}, status=400)
|
|
return
|
|
|
|
mode = str(payload.get("mode", ""))
|
|
if not self._hub.set_mode(mode):
|
|
self._send_json(
|
|
{"error": "invalid mode", "valid": list(VALID_MODES)}, status=400
|
|
)
|
|
return
|
|
self._send_json({"mode": mode})
|
|
|
|
def do_OPTIONS(self) -> None: # noqa: N802
|
|
origin = self.headers.get("Origin")
|
|
self.send_response(204)
|
|
if origin and origin in getattr(self.server, "allowed_origins", []):
|
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token")
|
|
self.send_header("Vary", "Origin")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
# -- Server-Sent Events ----------------------------------------------
|
|
|
|
def _stream_events(self) -> None:
|
|
"""Hold the connection open and push each event as it happens."""
|
|
queue = self._hub.subscribe()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Connection", "keep-alive")
|
|
origin = self.headers.get("Origin")
|
|
if origin and origin in getattr(self.server, "allowed_origins", []):
|
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
self.send_header("Vary", "Origin")
|
|
self.end_headers()
|
|
|
|
try:
|
|
self.wfile.write(b": connected\n\n")
|
|
self.wfile.flush()
|
|
while True:
|
|
try:
|
|
payload = queue.popleft()
|
|
except IndexError:
|
|
payload = None
|
|
|
|
if payload is not None:
|
|
message = "data: {}\n\n".format(json.dumps(payload))
|
|
self.wfile.write(message.encode("utf-8"))
|
|
self.wfile.flush()
|
|
else:
|
|
# Comment frame keeps proxies and the browser from timing out.
|
|
self.wfile.write(b": keepalive\n\n")
|
|
self.wfile.flush()
|
|
time.sleep(0.2)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
finally:
|
|
self._hub.unsubscribe(queue)
|
|
|
|
|
|
def create_server(
|
|
hub: EventHub,
|
|
host: str = DEFAULT_HOST,
|
|
port: int = DEFAULT_PORT,
|
|
allowed_origins: Optional[List[str]] = None,
|
|
token: Optional[str] = None,
|
|
logger: Optional[Callable[[str], None]] = None,
|
|
) -> ThreadingHTTPServer:
|
|
"""Build (but do not start) the HTTP server around *hub*."""
|
|
server = ThreadingHTTPServer((host, port), _Handler)
|
|
server.daemon_threads = True
|
|
server.hub = hub # type: ignore[attr-defined]
|
|
server.allowed_origins = list(allowed_origins or []) # type: ignore[attr-defined]
|
|
server.token = token or os.environ.get("RDM_BRIDGE_TOKEN") or None # type: ignore[attr-defined]
|
|
server.logger = logger # type: ignore[attr-defined]
|
|
return server
|