refactor: retire interim magic-link/open-link in favor of password gate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-63
@@ -69,7 +69,7 @@ from backend.templates_config import templates
|
|||||||
# Client-portal auth: an unauthenticated portal request renders the access page
|
# Client-portal auth: an unauthenticated portal request renders the access page
|
||||||
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
|
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
|
||||||
# portal route can simply Depends(get_current_client).
|
# 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)
|
@app.exception_handler(PortalAuthError)
|
||||||
async def portal_auth_handler(request: Request, exc: 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", {
|
return templates.TemplateResponse("projects/detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"portal_open_links": PORTAL_OPEN_LINKS,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/portal-preview")
|
@app.get("/projects/{project_id}/portal-preview")
|
||||||
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
|
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
|
"""Operator testing shortcut: open this project's client portal (no CLI)."""
|
||||||
(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."""
|
|
||||||
from backend.models import Project
|
from backend.models import Project
|
||||||
from backend.portal_auth import (
|
from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE
|
||||||
provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE,
|
|
||||||
)
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
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 = RedirectResponse(url="/portal", status_code=303)
|
||||||
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
||||||
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||||||
return resp
|
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/<token> 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")
|
@app.get("/projects/{project_id}/portal-access")
|
||||||
async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)):
|
async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
"""Current portal-access state for the operator panel."""
|
"""Current portal-access state for the operator panel."""
|
||||||
|
|||||||
@@ -40,15 +40,6 @@ if SECRET_KEY == "dev-insecure-change-me":
|
|||||||
COOKIE_NAME = "portal_session"
|
COOKIE_NAME = "portal_session"
|
||||||
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
|
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):
|
class PortalAuthError(Exception):
|
||||||
"""Raised by get_current_client when there's no valid portal session.
|
"""Raised by get_current_client when there's no valid portal session.
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ from backend.database import get_db, SessionLocal
|
|||||||
from backend.models import Client, MonitoringLocation, Project, UnitAssignment
|
from backend.models import Client, MonitoringLocation, Project, UnitAssignment
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
from backend.portal_auth import (
|
from backend.portal_auth import (
|
||||||
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
|
get_current_client, client_from_cookie, make_session_cookie,
|
||||||
provision_preview_session, PORTAL_OPEN_LINKS,
|
|
||||||
COOKIE_NAME, COOKIE_MAX_AGE,
|
COOKIE_NAME, COOKIE_MAX_AGE,
|
||||||
resolve_project_by_link_token, mint_portal_session,
|
resolve_project_by_link_token, mint_portal_session,
|
||||||
is_locked, register_failure, clear_failures,
|
is_locked, register_failure, clear_failures,
|
||||||
@@ -94,49 +93,6 @@ def _client_locations(client: Client, db: Session) -> list:
|
|||||||
} for loc in locs]
|
} 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")
|
@router.get("/logout")
|
||||||
def portal_logout():
|
def portal_logout():
|
||||||
|
|||||||
@@ -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", "")
|
||||||
Reference in New Issue
Block a user