Merge pull request 'Client portal auth (Phase 1): per-project link + password gate' (#63) from feat/portal-auth into dev
Reviewed-on: #63
This commit was merged in pull request #63.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""Password hashing for the client portal — argon2id via argon2-cffi.
|
||||
|
||||
Kept separate from portal_auth (cookie signing) so the future operator auth can
|
||||
reuse the same hasher. Never store or log raw passwords."""
|
||||
import secrets
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(raw: str) -> str:
|
||||
"""Return an argon2id hash string for a raw password."""
|
||||
return _ph.hash(raw)
|
||||
|
||||
|
||||
def verify_password(raw: str, hashed: str) -> bool:
|
||||
"""True iff raw matches the stored hash. Never raises."""
|
||||
try:
|
||||
return _ph.verify(hashed, raw)
|
||||
except Exception: # argon2 raises on mismatch/garbage; treat all as "no match"
|
||||
return False
|
||||
|
||||
|
||||
def generate_password(n_bytes: int = 12) -> str:
|
||||
"""A strong, URL-safe shareable password (~16 chars for n_bytes=12)."""
|
||||
return secrets.token_urlsafe(n_bytes)
|
||||
+54
-56
@@ -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):
|
||||
@@ -414,81 +414,79 @@ 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, COOKIE_SECURE
|
||||
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")
|
||||
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE)
|
||||
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)."""
|
||||
@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."""
|
||||
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:
|
||||
p = db.query(Project).filter_by(id=project_id).first()
|
||||
if not p:
|
||||
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}
|
||||
link_url = (str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}") \
|
||||
if (p.portal_enabled and p.portal_link_token) else None
|
||||
return {"enabled": bool(p.portal_enabled), "has_password": bool(p.portal_password_hash),
|
||||
"link_url": link_url}
|
||||
|
||||
|
||||
@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-access/enable")
|
||||
async def project_portal_access_enable(project_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Turn the portal on; mint a link token if one doesn't exist yet."""
|
||||
import secrets
|
||||
from backend.models import Project
|
||||
p = db.query(Project).filter_by(id=project_id).first()
|
||||
if not p:
|
||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||
if not p.portal_link_token:
|
||||
p.portal_link_token = secrets.token_urlsafe(24)
|
||||
p.portal_enabled = True
|
||||
db.commit()
|
||||
link_url = str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}"
|
||||
return {"enabled": True, "has_password": bool(p.portal_password_hash), "link_url": link_url}
|
||||
|
||||
|
||||
@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.post("/projects/{project_id}/portal-access/password")
|
||||
async def project_portal_access_password(project_id: str, db: Session = Depends(get_db)):
|
||||
"""Generate a fresh strong password, store its hash, return the raw once."""
|
||||
from backend.models import Project
|
||||
from backend.auth_passwords import hash_password, generate_password
|
||||
p = db.query(Project).filter_by(id=project_id).first()
|
||||
if not p:
|
||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||
raw = generate_password()
|
||||
p.portal_password_hash = hash_password(raw)
|
||||
db.commit()
|
||||
return {"password": raw}
|
||||
|
||||
|
||||
@app.post("/projects/{project_id}/portal-access/disable")
|
||||
async def project_portal_access_disable(project_id: str, db: Session = Depends(get_db)):
|
||||
"""Turn the portal off and rotate the link token (kills the old link)."""
|
||||
import secrets
|
||||
from backend.models import Project
|
||||
p = db.query(Project).filter_by(id=project_id).first()
|
||||
if not p:
|
||||
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
||||
p.portal_enabled = False
|
||||
p.portal_link_token = secrets.token_urlsafe(24) # rotate so the old link 404s
|
||||
db.commit()
|
||||
return {"enabled": False}
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration: Project portal auth (Phase 1).
|
||||
|
||||
Adds the per-project portal gate columns to `projects`:
|
||||
- portal_enabled (BOOLEAN, default 0)
|
||||
- portal_password_hash (TEXT, nullable)
|
||||
- portal_link_token (TEXT, nullable) [+ unique index]
|
||||
|
||||
Idempotent. Run once per existing DB:
|
||||
docker exec terra-view-terra-view-1 python3 backend/migrate_add_project_portal_auth.py
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
_COLUMNS = {
|
||||
"portal_enabled": "BOOLEAN DEFAULT 0",
|
||||
"portal_password_hash": "TEXT",
|
||||
"portal_link_token": "TEXT",
|
||||
}
|
||||
|
||||
|
||||
def migrate():
|
||||
possible_paths = [Path("data/seismo_fleet.db"), Path("data/sfm.db"), Path("data/seismo.db")]
|
||||
db_path = next((p for p in possible_paths if p.exists()), None)
|
||||
if db_path is None:
|
||||
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||
print("A fresh DB created via models.py will include these columns automatically.")
|
||||
return
|
||||
|
||||
print(f"Using database: {db_path}")
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
existing = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
for col, ddl in _COLUMNS.items():
|
||||
if col in existing:
|
||||
print(f"○ Column already exists: projects.{col}")
|
||||
continue
|
||||
try:
|
||||
cursor.execute(f"ALTER TABLE projects ADD COLUMN {col} {ddl}")
|
||||
print(f"✓ Added column: projects.{col} ({ddl})")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"✗ Failed to add projects.{col}: {e}")
|
||||
|
||||
# Unique index on the link token (separate from ADD COLUMN; idempotent via IF NOT EXISTS).
|
||||
try:
|
||||
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_projects_portal_link_token "
|
||||
"ON projects (portal_link_token)")
|
||||
print("✓ Ensured unique index: ix_projects_portal_link_token")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"✗ Failed to create index: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("\n✓ Project portal-auth migration complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -193,6 +193,10 @@ class Project(Base):
|
||||
# Project metadata
|
||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
|
||||
# --- Client portal (Phase 1: per-project link + password gate) ---
|
||||
portal_enabled = Column(Boolean, default=False) # is the portal open for this project
|
||||
portal_password_hash = Column(String, nullable=True) # argon2 hash of the shared password
|
||||
portal_link_token = Column(String, nullable=True, unique=True, index=True) # unguessable token in the secure link
|
||||
site_address = Column(String, nullable=True)
|
||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||
start_date = Column(Date, nullable=True)
|
||||
|
||||
+14
-22
@@ -12,22 +12,22 @@ only its hash is stored.
|
||||
python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
|
||||
python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
|
||||
|
||||
# mint a magic access link (FULL URL PRINTED ONCE — copy it now)
|
||||
python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"]
|
||||
# mint-link is RETIRED — per-client magic URLs (/portal/enter) no longer exist.
|
||||
# Client access is now per-PROJECT + password: open the project's page in
|
||||
# Terra-View → "Portal access" to enable it, generate a password, and copy
|
||||
# the /portal/p/<token> link. (create-client / link-project / list / revoke
|
||||
# still operate on the underlying Client/token rows.)
|
||||
|
||||
# list clients, their projects, and active links
|
||||
python3 backend/portal_admin.py list
|
||||
|
||||
# revoke a link (stops the link AND any live session it minted)
|
||||
python3 backend/portal_admin.py revoke --token-id <TID>
|
||||
|
||||
The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import secrets
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
@@ -37,9 +37,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import Client, ClientAccessToken, Project
|
||||
from backend.portal_auth import hash_token
|
||||
|
||||
PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/")
|
||||
|
||||
|
||||
def _get_client(db, slug):
|
||||
@@ -87,20 +84,15 @@ def link_project(args):
|
||||
|
||||
|
||||
def mint_link(args):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
c = _get_client(db, args.slug)
|
||||
raw = secrets.token_urlsafe(32)
|
||||
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id,
|
||||
token_hash=hash_token(raw), label=args.label)
|
||||
db.add(tok)
|
||||
db.commit()
|
||||
print(f"✓ Minted access link for '{c.name}'"
|
||||
f"{f' ({args.label})' if args.label else ''} — token id {tok.id}")
|
||||
print("\n COPY THIS NOW (shown only once):\n")
|
||||
print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n")
|
||||
finally:
|
||||
db.close()
|
||||
# Retired: the per-client magic URL (/portal/enter/...) was removed when the
|
||||
# portal moved to per-project + password access. Minting a token here would
|
||||
# only produce a dead link.
|
||||
sys.exit(
|
||||
"mint-link is retired: per-client magic URLs (/portal/enter/...) no longer exist.\n"
|
||||
"Client access is now per-project + password. In Terra-View, open the project's page →\n"
|
||||
"'Portal access' to enable the portal, generate a password, and copy the /portal/p/<token>\n"
|
||||
"link to send the client."
|
||||
)
|
||||
|
||||
|
||||
def revoke(args):
|
||||
|
||||
+69
-59
@@ -21,13 +21,12 @@ import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import Client, ClientAccessToken
|
||||
from backend.models import Client, ClientAccessToken, Project
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,15 +38,9 @@ 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.")
|
||||
# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at
|
||||
# the Synology reverse proxy). Default false so plain-HTTP dev still works.
|
||||
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
class PortalAuthError(Exception):
|
||||
@@ -120,64 +113,81 @@ def get_current_client(request: Request, db: Session = Depends(get_db)) -> Clien
|
||||
return client
|
||||
|
||||
|
||||
def resolve_token(raw_token: str, db: Session):
|
||||
"""Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on
|
||||
success, or (None, None). Also stamps last_used_at."""
|
||||
tok = db.query(ClientAccessToken).filter_by(
|
||||
token_hash=hash_token(raw_token), revoked_at=None
|
||||
).first()
|
||||
if not tok:
|
||||
return None, None
|
||||
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
|
||||
if not client:
|
||||
return None, None
|
||||
tok.last_used_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return tok, client
|
||||
# --- Phase-1 per-project password gate -------------------------------------------
|
||||
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
|
||||
# owning exactly that project. The project is linked to it via project.client_id so
|
||||
# the existing client-scoped routes (which resolve projects by Project.client_id ==
|
||||
# client.id) surface exactly this one project for the portal session — per-project
|
||||
# isolation with no route changes. (Phase 1 repurposes project.client_id for this; a
|
||||
# real per-client model is the deferred multi-tenant work.)
|
||||
|
||||
|
||||
def ensure_project_client(project, db) -> Client:
|
||||
"""Find or create the Client for a project. Reuses the project's linked client
|
||||
if it has one; otherwise creates/uses a per-project 'preview-<id>' client and
|
||||
sets project.client_id (only when unset, so it never clobbers a real link)."""
|
||||
client = None
|
||||
if project.client_id:
|
||||
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
|
||||
def portal_client_for_project(project, db) -> Client:
|
||||
"""Get-or-create the dedicated 1:1 portal client for a project, and link the
|
||||
project to it so the client-scoped routes resolve exactly this project."""
|
||||
slug = f"portal-{project.id}"
|
||||
client = db.query(Client).filter_by(slug=slug).first()
|
||||
if client is None:
|
||||
slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects
|
||||
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 "Preview"),
|
||||
slug=slug, active=True)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
if not project.client_id:
|
||||
project.client_id = client.id
|
||||
client = Client(id=str(uuid.uuid4()),
|
||||
name=(project.client_name or project.name or "Client"),
|
||||
slug=slug, active=True)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
if project.client_id != client.id:
|
||||
project.client_id = client.id # without this, the client owns no projects
|
||||
db.flush()
|
||||
return client
|
||||
|
||||
|
||||
def mint_link_token(client, db, label=None) -> str:
|
||||
"""Mint a fresh access token for a client and return the RAW secret (caller
|
||||
builds the /portal/enter/<raw> URL and shows it once). Only the hash is stored."""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
|
||||
token_hash=hash_token(raw), label=label))
|
||||
db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
def provision_preview_session(project, db) -> str:
|
||||
"""Operator preview shortcut: ensure a Client + access token exist for a project
|
||||
and return a token id to seal into a session cookie (no shared link). Reuses an
|
||||
existing token so repeat previews don't accumulate clutter; the raw secret is
|
||||
discarded (preview rides the cookie)."""
|
||||
client = ensure_project_client(project, db)
|
||||
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="preview")
|
||||
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, keyed per link_token (the password is shared per
|
||||
# project, so per-IP granularity buys nothing and an IP term only lets an attacker
|
||||
# reset the budget by rotating source IPs). Resets on restart; adequate for a
|
||||
# read-only surface behind the UniFi edge. Single-worker dev; 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)
|
||||
|
||||
+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