Files
teslayoutube/auth.py
Erhan Keseli 74f49c7712 Initial commit: ASCILINE YouTube Streamer
ASCII-art YouTube streaming for the Tesla in-car browser.

- FastAPI server on a Mac mini, no Docker.
- yt-dlp resolver: ID/URL/search.
- ffmpeg with -re -fps_mode cfr for source-paced video; trivial drain
  consumer.  Separate ffmpeg for AAC/ADTS audio.
- Vendored ASCILINE renderer (MIT) for the binary wire protocol; pure
  fillText color path, on-demand selection flush.
- HMAC PIN-gated cookie; Secure flag scheme-aware so /audio works on
  plain http during local dev.
- LOW preset (120x50 24fps) verified clean on M4: FPS 24/24, JIT ~42ms.
2026-06-13 18:05:19 +02:00

163 lines
4.6 KiB
Python

"""PIN gate.
- Signing secret loaded from / generated into .secret (survives restarts).
- HMAC-signed session cookie via itsdangerous TimestampSigner.
- Per-IP brute-force counter: 5 failures → 10-minute lockout.
- Middleware enforces the cookie on every route; WS rejects with code 4003.
"""
from __future__ import annotations
import logging
import secrets
import time
from pathlib import Path
from typing import Optional
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
log = logging.getLogger("auth")
COOKIE_NAME = "asciline_auth"
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
_TOKEN_PAYLOAD = "ok" # fixed payload; signature is the proof
_MAX_ATTEMPTS = 5
_LOCKOUT_SECONDS = 600 # 10 minutes
SECRET_FILE = Path(__file__).parent / ".secret"
def _load_or_create_secret() -> str:
if SECRET_FILE.exists():
secret = SECRET_FILE.read_text().strip()
if secret:
return secret
secret = secrets.token_hex(32)
SECRET_FILE.write_text(secret)
SECRET_FILE.chmod(0o600)
log.info("Generated new signing secret → %s", SECRET_FILE)
return secret
_SECRET: Optional[str] = None
_SIGNER: Optional[TimestampSigner] = None
def _signer() -> TimestampSigner:
if _SIGNER is None:
raise RuntimeError("auth not initialised — call auth.init() first")
return _SIGNER
def init() -> None:
"""Load (or generate) the signing secret. Must be called before serving."""
global _SECRET, _SIGNER
_SECRET = _load_or_create_secret()
_SIGNER = TimestampSigner(_SECRET)
# Brute-force tracker keyed by IP. In-memory is enough for a personal service.
# {ip: {"count": int, "locked_until": float}}
_attempts: dict[str, dict] = {}
def _ip_from_scope(scope) -> str:
"""CF-Connecting-IP first (the real client behind Cloudflare), then peer."""
headers = dict(scope.get("headers") or [])
cf_ip = headers.get(b"cf-connecting-ip", b"").decode("latin-1").strip()
if cf_ip:
return cf_ip
client = scope.get("client")
if client:
return client[0]
return "unknown"
def is_locked_out(ip: str) -> bool:
entry = _attempts.get(ip)
if entry is None:
return False
if entry["locked_until"] and time.monotonic() < entry["locked_until"]:
return True
if entry["locked_until"] and time.monotonic() >= entry["locked_until"]:
_attempts.pop(ip, None)
return False
def record_failure(ip: str) -> bool:
"""Record a failed attempt; return True if this IP is now locked out."""
entry = _attempts.setdefault(ip, {"count": 0, "locked_until": 0.0})
entry["count"] += 1
if entry["count"] >= _MAX_ATTEMPTS:
entry["locked_until"] = time.monotonic() + _LOCKOUT_SECONDS
log.warning("IP %s locked out for %ds after %d failures",
ip, _LOCKOUT_SECONDS, entry["count"])
return True
return False
def record_success(ip: str) -> None:
_attempts.pop(ip, None)
def make_cookie_value() -> str:
return _signer().sign(_TOKEN_PAYLOAD).decode()
def verify_cookie(value: str) -> bool:
try:
_signer().unsign(value, max_age=COOKIE_MAX_AGE)
return True
except (BadSignature, SignatureExpired):
return False
def cookie_header(value: str, *, secure: bool = True) -> str:
"""Build the Set-Cookie header string.
Pass ``secure=False`` only when the request that triggered this
Set-Cookie was itself served over plain http — browsers refuse to send
Secure cookies over plain http, which would silently 403 the
/audio GET on http://localhost:* while the WebSocket still works
(different upgrade path).
"""
parts = [
f"{COOKIE_NAME}={value}",
f"Max-Age={COOKIE_MAX_AGE}",
"Path=/",
"HttpOnly",
"SameSite=Lax",
]
if secure:
parts.append("Secure")
return "; ".join(parts)
_PIN: Optional[str] = None
def set_pin(pin: str) -> None:
global _PIN
_PIN = pin
def check_pin(provided: str) -> bool:
return _PIN is not None and provided == _PIN
def get_cookie(scope) -> Optional[str]:
"""Extract the auth cookie value from a Starlette scope's headers."""
headers = scope.get("headers") or []
for name, value in headers:
if name.lower() == b"cookie":
for part in value.decode("latin-1").split(";"):
part = part.strip()
if part.startswith(COOKIE_NAME + "="):
return part[len(COOKIE_NAME) + 1:]
return None
def is_authenticated(scope) -> bool:
value = get_cookie(scope)
return value is not None and verify_cookie(value)