From f0a13ea2ffb16ce8b95e177e64324c2bdd495ca9 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 16 Jun 2026 00:06:02 +0000 Subject: [PATCH] refactor: retire interim magic-link/open-link in favor of password gate Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 67 +++--------------------------------- backend/portal_auth.py | 9 ----- backend/routers/portal.py | 46 +------------------------ tests/test_retired_routes.py | 19 ++++++++++ 4 files changed, 24 insertions(+), 117 deletions(-) create mode 100644 tests/test_retired_routes.py diff --git a/backend/main.py b/backend/main.py index cbd9809..d48dad7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -69,7 +69,7 @@ from backend.templates_config import templates # Client-portal auth: an unauthenticated portal request renders the access page # (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every # portal route can simply Depends(get_current_client). -from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS +from backend.portal_auth import PortalAuthError @app.exception_handler(PortalAuthError) async def portal_auth_handler(request: Request, exc: PortalAuthError): @@ -410,83 +410,24 @@ async def project_detail_page(request: Request, project_id: str): return templates.TemplateResponse("projects/detail.html", { "request": request, "project_id": project_id, - "portal_open_links": PORTAL_OPEN_LINKS, }) @app.get("/projects/{project_id}/portal-preview") async def project_portal_preview(project_id: str, db: Session = Depends(get_db)): - """Operator testing shortcut: log into the client portal scoped to this project - (auto-provisioning a client/link if needed), no CLI. Lives under /projects (not - /portal), so a public proxy that exposes only /portal/* won't expose this.""" + """Operator testing shortcut: open this project's client portal (no CLI).""" from backend.models import Project - from backend.portal_auth import ( - provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, - ) + from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE project = db.query(Project).filter_by(id=project_id).first() if not project: return JSONResponse(status_code=404, content={"detail": "Project not found"}) - token_id = provision_preview_session(project, db) + token_id = mint_portal_session(project, db) resp = RedirectResponse(url="/portal", status_code=303) resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") return resp -@app.post("/projects/{project_id}/portal-link") -async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)): - """Mint a fresh shareable client link for this project's client. Returns the - full /portal/enter/ URL (shown once). Operator-only (internal app).""" - from backend.models import Project - from backend.portal_auth import ensure_project_client, mint_link_token - project = db.query(Project).filter_by(id=project_id).first() - if not project: - return JSONResponse(status_code=404, content={"detail": "Project not found"}) - client = ensure_project_client(project, db) - raw = mint_link_token(client, db, label="shared link") - url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}" - return {"url": url, "client_name": client.name} - - -@app.get("/projects/{project_id}/portal-links") -async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)): - """List active (non-revoked) shareable links for this project's client.""" - from backend.models import Project, ClientAccessToken, Client - project = db.query(Project).filter_by(id=project_id).first() - if not project or not project.client_id: - return {"client_name": None, "links": []} - client = db.query(Client).filter_by(id=project.client_id).first() - toks = (db.query(ClientAccessToken) - .filter_by(client_id=project.client_id, revoked_at=None) - .order_by(ClientAccessToken.created_at.desc()).all()) - return { - "client_name": client.name if client else None, - "links": [{ - "id": t.id, "label": t.label, - "created_at": t.created_at.isoformat() if t.created_at else None, - "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None, - } for t in toks], - } - - -@app.post("/projects/{project_id}/portal-link/{token_id}/revoke") -async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)): - """Revoke one shareable link (scoped to this project's client). Kills the link - and any live session minted from it on the next request.""" - from datetime import datetime as _dt - from backend.models import Project, ClientAccessToken - project = db.query(Project).filter_by(id=project_id).first() - if not project or not project.client_id: - return JSONResponse(status_code=404, content={"detail": "Not found"}) - tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first() - if not tok: - return JSONResponse(status_code=404, content={"detail": "Link not found"}) - if not tok.revoked_at: - tok.revoked_at = _dt.utcnow() - db.commit() - return {"ok": True} - - @app.get("/projects/{project_id}/portal-access") async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)): """Current portal-access state for the operator panel.""" diff --git a/backend/portal_auth.py b/backend/portal_auth.py index f87d21c..2caf15a 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -40,15 +40,6 @@ if SECRET_KEY == "dev-insecure-change-me": COOKIE_NAME = "portal_session" COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days -# Plain, no-token portal links (/portal/open/{project_id}). These are an -# UNAUTHENTICATED, proxy-reachable session-minting path (and a linked project's -# open link grants the *whole* client's scope), so they default OFF and must be -# explicitly enabled — set PORTAL_OPEN_LINKS=true only in a dev/prototype env. -PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "false").lower() in ("1", "true", "yes") -if PORTAL_OPEN_LINKS: - logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. " - "Keep this OFF in any internet-facing / production deployment.") - class PortalAuthError(Exception): """Raised by get_current_client when there's no valid portal session. diff --git a/backend/routers/portal.py b/backend/routers/portal.py index 980ca81..7d941b1 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -23,8 +23,7 @@ from backend.database import get_db, SessionLocal from backend.models import Client, MonitoringLocation, Project, UnitAssignment from backend.templates_config import templates from backend.portal_auth import ( - get_current_client, client_from_cookie, make_session_cookie, resolve_token, - provision_preview_session, PORTAL_OPEN_LINKS, + get_current_client, client_from_cookie, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, resolve_project_by_link_token, mint_portal_session, is_locked, register_failure, clear_failures, @@ -94,49 +93,6 @@ def _client_locations(client: Client, db: Session) -> list: } for loc in locs] -@router.get("/enter/{token}") -def portal_enter(token: str, request: Request, db: Session = Depends(get_db)): - """Magic-URL entry: validate the token, mint a session cookie, land on /portal.""" - tok, client = resolve_token(token, db) - if not client: - return templates.TemplateResponse( - "portal/access_required.html", - {"request": request, "reason": "invalid"}, - status_code=403, - ) - resp = RedirectResponse(url="/portal", status_code=303) - resp.set_cookie( - COOKIE_NAME, make_session_cookie(tok.id), - max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", - ) - logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}") - return resp - - -@router.get("/open/{project_id}") -def portal_open(project_id: str, request: Request, db: Session = Depends(get_db)): - """Dev-only plain shareable link: open a project's client portal with no token - (gated by PORTAL_OPEN_LINKS). Lets anyone with the URL view it for feedback — - sets the session cookie and lands on /portal. Lives under /portal so it works - through a reverse proxy that exposes only /portal/*.""" - if not PORTAL_OPEN_LINKS: - return templates.TemplateResponse( - "portal/access_required.html", {"request": request, "reason": "required"}, - status_code=404) - project = db.query(Project).filter_by(id=project_id).first() - if not project or not project.portal_password_hash: - # unknown token, disabled portal, or enabled-but-no-password-set — all look - # identical to a client (no existence/config leak, no self-lockout on a - # passwordless project). - return templates.TemplateResponse( - "portal/access_required.html", {"request": request, "reason": "invalid"}, - status_code=404) - token_id = provision_preview_session(project, db) - resp = RedirectResponse(url="/portal", status_code=303) - resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), - max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") - return resp - @router.get("/logout") def portal_logout(): diff --git a/tests/test_retired_routes.py b/tests/test_retired_routes.py new file mode 100644 index 0000000..079caf5 --- /dev/null +++ b/tests/test_retired_routes.py @@ -0,0 +1,19 @@ +from tests.conftest import make_project + + +def test_enter_and_open_are_gone(client, db_session): + assert client.get("/portal/enter/anything", follow_redirects=False).status_code == 404 + assert client.get("/portal/open/anything", follow_redirects=False).status_code == 404 + + +def test_portal_link_endpoints_are_gone(client, db_session): + p = make_project(db_session) + assert client.post(f"/projects/{p.id}/portal-link").status_code == 404 + assert client.get(f"/projects/{p.id}/portal-links").status_code == 404 + + +def test_preview_still_mints_a_session(client, db_session): + p = make_project(db_session) + r = client.get(f"/projects/{p.id}/portal-preview", follow_redirects=False) + assert r.status_code == 303 + assert "portal_session=" in r.headers.get("set-cookie", "")