diff --git a/backend/routers/portal.py b/backend/routers/portal.py index 8c58614..49a4732 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -14,7 +14,7 @@ from datetime import datetime import httpx import websockets -from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket +from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form from fastapi.responses import RedirectResponse from sqlalchemy import or_ from sqlalchemy.orm import Session @@ -26,7 +26,10 @@ from backend.portal_auth import ( get_current_client, client_from_cookie, make_session_cookie, resolve_token, provision_preview_session, PORTAL_OPEN_LINKS, COOKIE_NAME, COOKIE_MAX_AGE, + resolve_project_by_link_token, mint_portal_session, + is_locked, register_failure, clear_failures, ) +from backend.auth_passwords import verify_password logger = logging.getLogger(__name__) router = APIRouter(prefix="/portal", tags=["portal"]) @@ -147,6 +150,51 @@ def portal_access(request: Request): ) +@router.get("/p/{link_token}") +def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)): + """Secure per-project link: resolve the project from the token, prompt for the + shared password. Generic page if the token is unknown/disabled (no leak).""" + project = resolve_project_by_link_token(link_token, db) + if not project: + return templates.TemplateResponse( + "portal/access_required.html", {"request": request, "reason": "invalid"}, + status_code=404) + return templates.TemplateResponse("portal/password.html", { + "request": request, "link_token": link_token, + "project_name": project.name, "error": None}) + + +@router.post("/p/{link_token}") +def portal_password_submit(link_token: str, request: Request, + password: str = Form(...), db: Session = Depends(get_db)): + """Verify the shared password; on success mint a project-scoped session cookie.""" + project = resolve_project_by_link_token(link_token, db) + if not project: + return templates.TemplateResponse( + "portal/access_required.html", {"request": request, "reason": "invalid"}, + status_code=404) + + lock_key = f"{link_token}:{request.client.host if request.client else '?'}" + if is_locked(lock_key): + return templates.TemplateResponse("portal/password.html", { + "request": request, "link_token": link_token, "project_name": project.name, + "error": "Too many attempts. Try again in 15 minutes."}, status_code=200) + + if not project.portal_password_hash or not verify_password(password, project.portal_password_hash): + register_failure(lock_key) + return templates.TemplateResponse("portal/password.html", { + "request": request, "link_token": link_token, "project_name": project.name, + "error": "Incorrect password."}, status_code=200) + + clear_failures(lock_key) + 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") + logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened") + return resp + + @router.get("") def portal_home(request: Request, client: Client = Depends(get_current_client), db: Session = Depends(get_db)): diff --git a/templates/portal/password.html b/templates/portal/password.html new file mode 100644 index 0000000..93b12a1 --- /dev/null +++ b/templates/portal/password.html @@ -0,0 +1,26 @@ +{% extends "portal/base.html" %} +{% block title %}{{ project_name }}{% endblock %} +{% block content %} +
Enter the password to view this monitoring portal.
+ {% if error %} +{{ error }}
+ {% endif %} + +