Merge branch 'dev' into feat/ftp-report-pipeline
This commit is contained in:
+64
-47
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
|
||||
|
||||
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed
|
||||
session cookie, then sees their locations (overview) and per-location read-only
|
||||
live data sourced from SLMM's cache. Every data route re-checks ownership.
|
||||
A client opens a per-project secure link (/portal/p/{link_token}), enters the
|
||||
shared password, and gets a signed session cookie scoped to that project; they
|
||||
then see that project's locations (overview) and per-location read-only live
|
||||
data sourced from SLMM's cache. Every data route re-checks ownership.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -14,7 +15,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
|
||||
@@ -23,10 +24,12 @@ 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,
|
||||
COOKIE_NAME, COOKIE_MAX_AGE,
|
||||
get_current_client, client_from_cookie, make_session_cookie,
|
||||
COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE,
|
||||
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"])
|
||||
@@ -91,46 +94,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:
|
||||
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():
|
||||
@@ -147,6 +110,60 @@ 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 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)
|
||||
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 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)
|
||||
|
||||
# Shared per-project password → lock per token. (Keying on IP too only enabled a
|
||||
# bypass via source-IP rotation, and behind the reverse proxy every client shares
|
||||
# one IP anyway.)
|
||||
lock_key = link_token
|
||||
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 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", secure=COOKIE_SECURE)
|
||||
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)):
|
||||
|
||||
Reference in New Issue
Block a user