Files
felhom.eu/documentation/audits/login-gate-2026-09-29/B/gate.py
T
2026-09-29 08:20:42 +02:00

142 lines
6.6 KiB
Python

# SPIKE ONLY (2026-09-29, decision 46) — a throwaway prototype of the setup gate, run on scratch guest 9202.
# It is NOT product code. In a build, these handlers would live in the controller itself.
#
# GET /auth traefik forwardAuth target for a GATED app host.
# GET /__gate/start on the DASHBOARD host (felhom.<domain>): checks the household's dashboard session
# (host-only cookie, never widened) by asking the controller, then hands the app host a
# 60-second one-use token.
# <app>/__felhom_gate/cb?t=… answered by /auth: swaps the token for a host-only gate cookie on the app host.
#
# The dashboard cookie never reaches an app host. The gate cookie is host-only, HMAC-bound to ONE app host.
import hashlib, hmac, html, http.client, json, os, secrets, sys, time, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DOMAIN = os.environ["GATE_DOMAIN"]
GATED = set(h.strip() for h in os.environ["GATE_HOSTS"].split(",") if h.strip()) # full host names
KEY = secrets.token_bytes(32)
USED = {}
COOKIE = "felhom_gate"
DASH = "felhom." + DOMAIN
COUNT = {"auth": 0}
def log(*a):
print(time.strftime("%H:%M:%S"), *a, flush=True)
def sign(*parts):
return hmac.new(KEY, "|".join(parts).encode(), hashlib.sha256).hexdigest()
def dashboard_session_ok(cookie_header):
"""Ask the controller whether this dashboard session is valid (401 without one, 200 with one)."""
sess = ""
for c in (cookie_header or "").split(";"):
k, _, v = c.strip().partition("=")
if k == "felhom_session":
sess = v
if not sess:
return False
conn = http.client.HTTPConnection("felhom-controller", 8080, timeout=5)
conn.request("GET", "/api/stacks", headers={"Host": DASH, "Cookie": "felhom_session=" + sess})
ok = conn.getresponse().status == 200
conn.close()
return ok
PAGE = """<!doctype html><html lang="hu"><meta charset="utf-8"><title>Felhom</title>
<body style="font-family:sans-serif;max-width:32rem;margin:4rem auto">
<p>Ez az alkalmazás még beállításra vár. Jelentkezz be a Felhom vezérlőpultba.</p>
<p lang="en">This app is waiting for its first setup. Sign in to the Felhom dashboard.</p>
<p><a href="{login}">Bejelentkezés / Sign in</a></p></body></html>"""
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def send(self, code, body=b"", headers=()):
self.send_response(code)
for k, v in headers:
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
u = urllib.parse.urlsplit(self.path)
if u.path == "/auth":
return self.auth()
if u.path == "/__gate/start":
return self.start(urllib.parse.parse_qs(u.query))
self.send(404)
# forwardAuth may call with the original method; the body is never needed.
def _any(self):
n = int(self.headers.get("Content-Length") or 0)
if n:
self.rfile.read(n)
self.do_GET()
do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = _any
# forwardAuth: traefik forwards every method as a GET here, with X-Forwarded-* headers.
def auth(self):
COUNT["auth"] += 1
host = self.headers.get("X-Forwarded-Host", "")
uri = self.headers.get("X-Forwarded-Uri", "/")
method = self.headers.get("X-Forwarded-Method", "GET")
if host not in GATED:
log("auth", host, "not gated -> 403 (fail closed)")
return self.send(403)
u = urllib.parse.urlsplit(uri)
if u.path == "/__felhom_gate/cb":
t = urllib.parse.parse_qs(u.query).get("t", [""])[0]
try:
h, exp, nonce, rd, mac = json.loads(urllib.parse.unquote(t))
except Exception:
return self.send(400, b"bad token")
if (h != host or int(exp) < time.time() or nonce in USED
or not hmac.compare_digest(mac, sign("tok", h, str(exp), nonce, rd))):
log("cb", host, "token refused")
return self.send(403, b"token refused")
USED[nonce] = 1
cexp = str(int(time.time()) + 7 * 86400)
val = cexp + "." + sign("cookie", host, cexp)
log("cb", host, "token ok -> gate cookie, back to", rd)
return self.send(302, headers=[("Location", rd), ("Set-Cookie",
f"{COOKIE}={val}; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax")])
for c in (self.headers.get("Cookie") or "").split(";"):
k, _, v = c.strip().partition("=")
if k == COOKIE and "." in v:
cexp, mac = v.split(".", 1)
if cexp.isdigit() and int(cexp) > time.time() and hmac.compare_digest(mac, sign("cookie", host, cexp)):
return self.send(200)
wants_html = "text/html" in (self.headers.get("Accept") or "") and method == "GET"
log("auth", host, method, uri[:60], "no gate cookie ->", "302 to dashboard" if wants_html else "401")
if wants_html:
rd = f"https://{host}{uri}"
return self.send(302, headers=[("Location", f"https://{DASH}/__gate/start?" +
urllib.parse.urlencode({"rd": rd}))])
return self.send(401, b'{"error":"this app is waiting for its first setup"}',
[("Content-Type", "application/json")])
def start(self, q):
rd = (q.get("rd") or [""])[0]
host = urllib.parse.urlsplit(rd).hostname or ""
if host not in GATED or not rd.startswith("https://" + host + "/"):
return self.send(400, b"not a gated app")
if not dashboard_session_ok(self.headers.get("Cookie")):
login = "/login?" + urllib.parse.urlencode({"next": "/__gate/start?" + urllib.parse.urlencode({"rd": rd})})
log("start", host, "no dashboard session -> gate page")
return self.send(200, PAGE.format(login=html.escape(login)).encode(),
[("Content-Type", "text/html; charset=utf-8")])
exp, nonce = str(int(time.time()) + 60), secrets.token_hex(8)
t = urllib.parse.quote(json.dumps([host, exp, nonce, rd, sign("tok", host, exp, nonce, rd)]))
log("start", host, "dashboard session ok -> token")
return self.send(302, headers=[("Location", f"https://{host}/__felhom_gate/cb?t={t}")])
if __name__ == "__main__":
log("gate spike up; domain", DOMAIN, "gated", sorted(GATED))
ThreadingHTTPServer(("0.0.0.0", 8000), H).serve_forever()