From c04830a0ada7a0c55de20745fd3a9de9e9e1b422 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 15 Jun 2026 23:35:48 +0000 Subject: [PATCH] feat: per-project portal session mint + link-token resolve + lockout Co-Authored-By: Claude Sonnet 4.6 --- backend/portal_auth.py | 73 +++++++++++++++++++++++++++++++ tests/test_portal_auth_helpers.py | 45 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/test_portal_auth_helpers.py diff --git a/backend/portal_auth.py b/backend/portal_auth.py index d233e2f..dff985c 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -181,3 +181,76 @@ def provision_preview_session(project, db) -> str: db.add(tok) db.commit() return tok.id + + +# --- Phase-1 per-project password gate ------------------------------------------- +# A portal-enabled project gets its OWN dedicated client (slug "portal-") +# owning exactly that project, so the existing client-scoped routes are automatically +# per-project. Project.client_id is left untouched (deferred per-client rollup). + +from backend.models import Project # local import; Project not needed above + + +def portal_client_for_project(project, db) -> Client: + """Get-or-create the dedicated 1:1 portal client for a project.""" + slug = f"portal-{project.id}" + client = db.query(Client).filter_by(slug=slug).first() + if client is None: + client = Client(id=str(uuid.uuid4()), + name=(project.client_name or project.name or "Client"), + slug=slug, active=True) + db.add(client) + db.flush() + return client + + +def mint_portal_session(project, db) -> str: + """Ensure the project's portal client + an access token exist; return the token + id to seal into a session cookie. Reuses an existing token to avoid clutter.""" + client = portal_client_for_project(project, db) + tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first() + if tok is None: + tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, + token_hash=hash_token(secrets.token_urlsafe(32)), + label="portal") + db.add(tok) + db.commit() + return tok.id + + +def resolve_project_by_link_token(link_token: str, db): + """Return the portal-enabled Project for a link token, or None.""" + if not link_token: + return None + return db.query(Project).filter_by( + portal_link_token=link_token, portal_enabled=True).first() + + +# In-memory brute-force lockout (per link_token+IP). Resets on restart; adequate for +# a read-only surface behind the UniFi edge. Single-worker dev; note multi-worker +# would need a shared store. +MAX_ATTEMPTS = 5 +LOCK_SECONDS = 15 * 60 +_failures: dict = {} # key -> (count, first_failure_epoch) + + +def is_locked(key: str) -> bool: + rec = _failures.get(key) + if not rec: + return False + count, first = rec + if count < MAX_ATTEMPTS: + return False + if (time.time() - first) > LOCK_SECONDS: + _failures.pop(key, None) # window expired + return False + return True + + +def register_failure(key: str) -> None: + count, first = _failures.get(key, (0, time.time())) + _failures[key] = (count + 1, first) + + +def clear_failures(key: str) -> None: + _failures.pop(key, None) diff --git a/tests/test_portal_auth_helpers.py b/tests/test_portal_auth_helpers.py new file mode 100644 index 0000000..29b3ede --- /dev/null +++ b/tests/test_portal_auth_helpers.py @@ -0,0 +1,45 @@ +import time +from tests.conftest import make_project +from backend import portal_auth as pa +from backend.models import Client, ClientAccessToken +from backend.auth_passwords import hash_password + + +def test_portal_client_for_project_is_1to1_and_idempotent(db_session): + p = make_project(db_session) + c1 = pa.portal_client_for_project(p, db_session) + c2 = pa.portal_client_for_project(p, db_session) + assert isinstance(c1, Client) and c1.id == c2.id + assert c1.slug == f"portal-{p.id}" + assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1 + + +def test_mint_portal_session_returns_usable_token_id(db_session): + p = make_project(db_session) + tid = pa.mint_portal_session(p, db_session) + tok = db_session.query(ClientAccessToken).filter_by(id=tid, revoked_at=None).first() + assert tok is not None + cookie = pa.make_session_cookie(tid) + client = pa.client_from_cookie(cookie, db_session) + assert client is not None and client.slug == f"portal-{p.id}" + + +def test_resolve_project_by_link_token(db_session): + p = make_project(db_session, portal_enabled=True, portal_link_token="tok-abc") + assert pa.resolve_project_by_link_token("tok-abc", db_session).id == p.id + assert pa.resolve_project_by_link_token("nope", db_session) is None + + +def test_resolve_project_ignores_disabled_portal(db_session): + make_project(db_session, portal_enabled=False, portal_link_token="tok-off") + assert pa.resolve_project_by_link_token("tok-off", db_session) is None + + +def test_lockout_after_max_attempts(): + pa.clear_failures("k1") + assert pa.is_locked("k1") is False + for _ in range(pa.MAX_ATTEMPTS): + pa.register_failure("k1") + assert pa.is_locked("k1") is True + pa.clear_failures("k1") + assert pa.is_locked("k1") is False