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:
@@ -181,3 +181,76 @@ def provision_preview_session(project, db) -> str:
|
|||||||
db.add(tok)
|
db.add(tok)
|
||||||
db.commit()
|
db.commit()
|
||||||
return tok.id
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user