feat: per-project portal session mint + link-token resolve + lockout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:35:48 +00:00
parent b11e1a554f
commit c04830a0ad
2 changed files with 118 additions and 0 deletions
+73
View File
@@ -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-<project.id>")
# 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)