Compare commits
21 Commits
dev
...
feat/portal-auth
| Author | SHA1 | Date | |
|---|---|---|---|
| 766f64f35f | |||
| da128f6173 | |||
| 20f62a5c0a | |||
| 01180d5725 | |||
| f0a13ea2ff | |||
| 0394f4b0c8 | |||
| eb91441904 | |||
| 25a4a28433 | |||
| b8e4718318 | |||
| c3eb900b7e | |||
| c74dada8b3 | |||
| d75f405857 | |||
| 446d8704f9 | |||
| c04830a0ad | |||
| b11e1a554f | |||
| ad6de946b5 | |||
| d44625374d | |||
| 33069a070d | |||
| ec5d986ac5 | |||
| 0888da32b4 | |||
| 485e3f165b |
@@ -105,6 +105,14 @@ SLMM feed; every route resolves the client through one swappable
|
||||
|
||||
---
|
||||
|
||||
### Portal authentication (Phase 1)
|
||||
- Each project's client portal is now gated by a **secure per-project link + shared password** (argon2-hashed). Operators manage it from the project page's **Portal access** panel (enable, generate password, copy link).
|
||||
- Per-project session isolation (a session for one project can't read another's data); brute-force lockout (5 tries / 15 min) on the password gate.
|
||||
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links and the `portal_admin.py mint-link` command.
|
||||
- **Upgrade:** run `python3 backend/migrate_add_project_portal_auth.py` per DB. Set `COOKIE_SECURE=true` once served over HTTPS.
|
||||
|
||||
---
|
||||
|
||||
## [0.13.3] - 2026-06-05
|
||||
|
||||
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
|
||||
|
||||
@@ -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)
|
||||
+55
-57
@@ -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,81 +410,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-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()
|
||||
@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()
|
||||
return {"ok": True}
|
||||
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-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):
|
||||
|
||||
+64
-54
@@ -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()
|
||||
if client is None:
|
||||
slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects
|
||||
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:
|
||||
client = Client(id=str(uuid.uuid4()),
|
||||
name=(project.client_name or project.name or "Preview"),
|
||||
name=(project.client_name or project.name or "Client"),
|
||||
slug=slug, active=True)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
if not project.client_id:
|
||||
project.client_id = client.id
|
||||
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)):
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
|
||||
|
||||
> **Update (Phase-1 auth landed):** the interim magic-link gate described below is
|
||||
> **retired** — client access is now a per-project secure link + shared password
|
||||
> (argon2). See the design at `docs/superpowers/specs/2026-06-15-portal-auth-design.md`
|
||||
> and the build plan at `docs/superpowers/plans/2026-06-15-portal-auth.md`. The
|
||||
> operator manages access from each project's **Portal access** panel.
|
||||
|
||||
A client-facing, **read-only**, **scoped** view into a client's own monitoring
|
||||
data. The first internet-facing-with-real-clients surface in the system. Built
|
||||
*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
# Portal Authentication — Design & Build Plan
|
||||
|
||||
**Status:** in development (`feat/portal-auth`) · **Targets:** 0.14.x · **Date:** 2026-06-15
|
||||
|
||||
Supersedes the interim shareable magic-link described in
|
||||
[CLIENT_PORTAL.md](../../CLIENT_PORTAL.md) with a real password gate.
|
||||
|
||||
## Goal
|
||||
|
||||
Give a client a **secure link + password** that opens a **read-only dashboard** —
|
||||
live data plus access to historical data — for the machines commissioned on
|
||||
**their project**. Nothing else: no device control, no editing, no internal pages.
|
||||
|
||||
This is the first real, internet-facing, client-credentialed surface in the
|
||||
system.
|
||||
|
||||
## Scope
|
||||
|
||||
**Phase 1 (this spec — build now):** per-project, password-gated, read-only portal.
|
||||
|
||||
**Deferred (designed, not built — captured below so nothing is lost):**
|
||||
- **Operator auth** — logins + roles for the *internal* app (you / parents).
|
||||
Full design in [Deferred A](#deferred-a--operator-auth-designed-not-built).
|
||||
- **Full multi-tenancy** — per-client rollups, per-project separation within a
|
||||
client, individual client user accounts, and extending the portal to all
|
||||
client-relevant data. [Deferred B](#deferred-b--full-multi-tenancy).
|
||||
|
||||
## Principles (the portal's standing charter)
|
||||
|
||||
1. **Read-only.** A client can look, never touch.
|
||||
2. **Strictly scoped, server-side.** Never trust a project / location / unit id
|
||||
from the request — always re-resolve ownership.
|
||||
3. **Cache-first.** Portal live data comes from SLMM's cache (the same cached
|
||||
reads the internal dashboard uses). A client can never make us hit the device.
|
||||
4. **The gate is a swappable seam.** Everything routes through the scoping layer
|
||||
the portal already has; auth is the thin thing in front of it.
|
||||
|
||||
## The model
|
||||
|
||||
- **Tenant unit = the project.** Each project is its own portal: one link, one
|
||||
password, showing that project's commissioned machines.
|
||||
- **Shared credential — "company / project-manager wide."** No individual client
|
||||
accounts. Because access is read-only, one shared password per project is an
|
||||
acceptable trade. (Per-person accounts are a Deferred-B item.)
|
||||
- **The link identifies the project; the password authorizes.** A password alone
|
||||
can't say *which* project — so the link carries an unguessable, revocable
|
||||
per-project token, and the password is the shared secret gating it.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two layers, two subdomains (hosting target: office Synology NAS behind a UniFi
|
||||
UXG Max; own domain `terra-mechanics.com`).
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
UniFi UXG Max ── Layer 1 (IT pro): firewall, IPS/IDS, GeoIP allow-list,
|
||||
│ kill-switch rule, 443 only
|
||||
Synology NAS ── DSM reverse proxy + Let's Encrypt wildcard TLS
|
||||
│
|
||||
├─ terra-view.terra-mechanics.com → internal app (operator auth = Deferred A)
|
||||
└─ portal.terra-mechanics.com → LOCKED to /portal/* only, password gate
|
||||
```
|
||||
|
||||
The portal subdomain is **restricted to `/portal/*` at the reverse proxy** — a
|
||||
client on `portal.` physically cannot reach `/roster`, `/admin/*`, etc., even by
|
||||
guessing URLs. This path-lock is a load-bearing control for as long as the
|
||||
internal app remains unauthenticated (until Deferred A lands).
|
||||
|
||||
## Data model
|
||||
|
||||
Add three columns to **`Project`**:
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `portal_enabled` | bool, default `false` | Is the portal open for this project. |
|
||||
| `portal_password_hash` | text, nullable | argon2id hash of the shared password. Never plaintext. |
|
||||
| `portal_link_token` | text, unique, nullable | Unguessable token in the secure link; identifies the project without exposing its raw id, and is revocable (regenerate → old link dies). |
|
||||
|
||||
**Reused unchanged:** the `Client → Project → MonitoringLocation →
|
||||
UnitAssignment → unit` scoping chain and the existing read-only scoped data
|
||||
routes (`resolve_client_location` + live / history / events).
|
||||
|
||||
**Migration:** `migrate_add_project_portal_auth.py` — an `ALTER TABLE` adding the
|
||||
three columns to the existing (non-empty) `projects` table. Same pattern as
|
||||
`migrate_add_client_portal.py`; `create_all` won't add columns to an existing
|
||||
table.
|
||||
|
||||
## Auth flow
|
||||
|
||||
1. **Operator enables + shares.** On the project page, the operator turns the
|
||||
portal on; the system generates a strong password + a `portal_link_token`; the
|
||||
operator copies **link + password** to send the client.
|
||||
2. **Client opens the link** `portal.terra-mechanics.com/portal/p/{link_token}` →
|
||||
the project is resolved from the token → a **password prompt** renders.
|
||||
3. **Client submits the password** → argon2-verified against
|
||||
`portal_password_hash`. On success, a **signed session cookie scoped to that
|
||||
project** is set (HMAC via the existing `SECRET_KEY` cookie machinery), and
|
||||
they are redirected to the project dashboard.
|
||||
4. **Subsequent requests** re-validate the cookie (signature + project still
|
||||
`portal_enabled` + within cookie max-age) and serve the existing read-only
|
||||
scoped data.
|
||||
5. **Logout** clears the cookie. **Revoke** = disable the portal or regenerate the
|
||||
token / password, which kills outstanding links and any session minted from
|
||||
them on the next request.
|
||||
|
||||
**Lockout:** track failed attempts (per token + IP); after 5 failures refuse for
|
||||
a 15-minute cooldown. Combined with the UniFi GeoIP/IPS edge, that's solid for a
|
||||
read-only surface.
|
||||
|
||||
**Shared cookie machinery:** lift the portal's cookie sign/verify out of
|
||||
`portal_auth.py` into a small shared `backend/auth_cookies.py` — one signer, so
|
||||
the future operator auth (Deferred A) reuses it instead of copy-pasting crypto.
|
||||
|
||||
### Relationship to the existing portal code
|
||||
|
||||
The portal today is *client-scoped* (a `ClientAccessToken` magic-link → a cookie
|
||||
covering all of a client's projects, with a `/portal` overview). Phase 1 makes the
|
||||
entry point *project-scoped*:
|
||||
|
||||
- The **`/portal/p/{link_token}` + password** flow becomes the way in; the
|
||||
interim client magic-link (`/portal/enter/{token}`, `/portal/open/*`,
|
||||
`PORTAL_OPEN_LINKS`) is **retired** in its favor.
|
||||
- The existing read-only views (`/portal/location/{id}`, live / history / events)
|
||||
and the scoping helper are **reused as-is**, just resolved against the project in
|
||||
the session cookie instead of the client.
|
||||
- `Client` / `ClientAccessToken` rows are **left in place** (no destructive
|
||||
migration) — they become the substrate for the Deferred-B per-client rollup.
|
||||
|
||||
## Operator "Portal access" panel
|
||||
|
||||
On the project detail page (internal app), a panel that:
|
||||
- Toggles `portal_enabled`.
|
||||
- **Regenerate password** → shows a freshly generated strong password **once** for
|
||||
the operator to copy.
|
||||
- **Copy link** → the `/portal/p/{token}` URL.
|
||||
- **Revoke** → regenerate the token (old link dies) and/or disable the portal.
|
||||
|
||||
This is an operator action. Until operator auth lands (Deferred A), it sits behind
|
||||
the same posture as the rest of the internal app — see Security notes.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Bad password** → generic "incorrect password" + increment fail count.
|
||||
- **Unknown / disabled / revoked token** → generic "this portal link is no longer
|
||||
active" page (no project-existence leak).
|
||||
- **Locked out** → "too many attempts, try again in 15 minutes."
|
||||
- **Expired / invalid cookie** → back to the password prompt.
|
||||
- **Portal disabled after a session started** → next request bounced to the prompt.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Implement on `feat/portal-auth` → review → merge to `dev`.
|
||||
2. **Migration** `migrate_add_project_portal_auth.py` on each DB (dev + prod), same
|
||||
drill as the client-portal migration.
|
||||
3. **`SECRET_KEY`** must be a real value in prod (already required for the existing
|
||||
portal cookie; the password gate reuses it).
|
||||
4. **Hosting:** DSM reverse proxy routes `portal.` → app, locked to `/portal/*`;
|
||||
Let's Encrypt wildcard TLS; cookies `Secure` once on TLS. UXG Max GeoIP + IPS +
|
||||
kill-switch handled by the IT pro.
|
||||
5. Enable a real project's portal, set a password, and test the full
|
||||
link → password → dashboard flow over HTTPS before sending a client.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit:** argon2 hash/verify; token resolution (valid / unknown / disabled);
|
||||
lockout counter; cookie sign/verify + scope check; "disabled mid-session" bounce.
|
||||
- **Scoping:** a session for project A cannot read project B's locations / history
|
||||
/ events (404, no existence leak).
|
||||
- **Manual smoke:** enable → copy link + password → open in a fresh browser →
|
||||
wrong password (lockout) → right password → see live + history → logout.
|
||||
|
||||
---
|
||||
|
||||
## Deferred A — Operator auth (designed, not built)
|
||||
|
||||
Logins + roles for the **internal** app (`terra-view.` subdomain). Closes the
|
||||
"internal app is wide open" hole. Full design, ready to lift into its own spec:
|
||||
|
||||
- **Two layers:** UniFi UXG Max edge (IT-pro owned — firewall, IPS, GeoIP,
|
||||
kill-switch, 443-only) + in-app auth (built by us). Internet-exposed with login
|
||||
(no VPN — deliberately, to spare non-technical family members).
|
||||
- **`OperatorUser` model:** `id, email (unique, lowercased), display_name,
|
||||
password_hash (argon2id), role, active, created_at, last_login_at,
|
||||
sessions_valid_from, failed_login_count, locked_until` (+ later `totp_secret`,
|
||||
`totp_enabled`).
|
||||
- **Role ladder:** `superadmin > admin > operator`.
|
||||
- `superadmin` = you — everything + account management (create/disable users,
|
||||
reset passwords, assign roles).
|
||||
- `admin` = your parents (company owners) + you — full run of the app, no
|
||||
operational restrictions.
|
||||
- `operator` = **future** restricted tier for hires; the ladder accepts it with
|
||||
no route changes.
|
||||
- The only thing gated above plain `admin` in v1 is account management
|
||||
(`superadmin`).
|
||||
- **Sessions:** stateless signed cookie reusing `auth_cookies.py` + `SECRET_KEY`
|
||||
(distinct cookie name from the portal). `sessions_valid_from` gives "log out
|
||||
everywhere" / revoke-on-password-change with no session table.
|
||||
- **Authorization:** one **deny-by-default middleware** gates the whole internal
|
||||
app (exempt: `/login`, `/logout`, `/health`, `/static/*`, `/portal/*`);
|
||||
`require_role("admin"|"superadmin")` guards specific routes. New routes are
|
||||
protected automatically.
|
||||
- **Lockout:** 5 fails → 15-min cooldown (doubling).
|
||||
- **2FA:** deferred; TOTP later, admin/superadmin account first.
|
||||
- **Safe rollout (no self-lockout):** ship behind a feature flag
|
||||
`OPERATOR_AUTH_ENABLED` (default **off** = app behaves as today) → seed the first
|
||||
`superadmin` via a small CLI (`backend/operator_admin.py`, modeled on
|
||||
`portal_admin.py`) → log in while still open → flip the flag on → create
|
||||
parents' accounts. Flag back off = instant escape hatch; break-glass =
|
||||
re-run seed / `reset-password` CLI in the container.
|
||||
- **`OperatorUser` is a brand-new table** → `create_all` builds it on startup; only
|
||||
the seed step is required.
|
||||
|
||||
## Deferred B — Full multi-tenancy
|
||||
|
||||
- Per-client **rollup**: one login spanning all of a client's projects.
|
||||
- Per-project **separation within a client** (true tenant isolation).
|
||||
- **Individual client user accounts** (per-person, optional roles) replacing the
|
||||
shared per-project password.
|
||||
- Extend the portal to **all client-relevant data types** (beyond sound:
|
||||
vibration, reports, etc.) — the long-term goal of "everything we can show a
|
||||
client."
|
||||
- All additive on the existing scoping seam — no teardown.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Auth-gated from day one (even the shared password) — never wide-open like the
|
||||
internal app currently is.
|
||||
- Scoping enforced server-side; client-supplied ids always re-checked.
|
||||
- Passwords argon2-hashed; link tokens unguessable + revocable; raw password shown
|
||||
once.
|
||||
- `SECRET_KEY` a real secret in prod; cookies `HttpOnly` + `SameSite=Lax` +
|
||||
`Secure` (once on TLS).
|
||||
- **Known risk:** the operator "Portal access" panel — and the whole internal app —
|
||||
is unauthenticated until Deferred A. Mitigated for now by the `/portal/*`
|
||||
path-lock on the public subdomain plus keeping the internal app off the public
|
||||
internet. Tracked in the hardening backlog (CLIENT_PORTAL.md).
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.3
|
||||
@@ -10,3 +10,4 @@ httpx==0.25.2
|
||||
openpyxl==3.1.2
|
||||
rapidfuzz==3.10.1
|
||||
schedule==1.2.2
|
||||
argon2-cffi==23.1.0
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "portal/base.html" %}
|
||||
{% block title %}{{ project_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto mt-20 text-center reveal">
|
||||
<div class="panel inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6">
|
||||
<svg class="w-7 h-7 text-[var(--text-dim)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold tracking-tight mb-1">{{ project_name }}</h1>
|
||||
<p class="text-[var(--text-dim)] text-sm mb-6">Enter the password to view this monitoring portal.</p>
|
||||
{% if error %}
|
||||
<p class="text-[var(--lvl-bad)] text-sm mb-4">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/portal/p/{{ link_token }}" class="panel p-5 text-left">
|
||||
<label class="block text-xs text-[var(--text-dim)] mb-1" for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autofocus required
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--panel-b)] text-[var(--text)] mb-4">
|
||||
<button type="submit"
|
||||
class="w-full px-4 py-2 rounded-lg bg-seismo-orange text-white font-medium hover:opacity-90">
|
||||
View portal
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -18,16 +18,16 @@
|
||||
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
|
||||
</nav>
|
||||
|
||||
<!-- Client portal actions for this project -->
|
||||
<!-- Client portal access for this project -->
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button type="button" onclick="openShareModal()"
|
||||
<button type="button" onclick="openPortalAccess()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
|
||||
title="Get a shareable link to this project's client portal">
|
||||
title="Manage this project's client portal access">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 11-5.656-5.656l1.5-1.5"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 115.656 5.656l-1.5 1.5"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
Copy client link
|
||||
Portal access
|
||||
</button>
|
||||
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
|
||||
@@ -36,7 +36,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
View client portal
|
||||
Preview
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2098,123 +2098,95 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Share client portal link modal -->
|
||||
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick="if(event.target===this)closeShareModal()">
|
||||
<!-- Portal access modal -->
|
||||
<div id="portal-access-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick="if(event.target===this)closePortalAccess()">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal link</h3>
|
||||
<button onclick="closeShareModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal access</h3>
|
||||
<button onclick="closePortalAccess()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Anyone with a link can view this project's client portal (read-only). Links are revocable.
|
||||
Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
|
||||
</p>
|
||||
|
||||
{% if portal_open_links %}
|
||||
<!-- Dev quick link: plain, no-token URL anyone can open (PORTAL_OPEN_LINKS on) -->
|
||||
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<label class="block text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">Quick share link (dev — anyone can open, no login)</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="open-url" readonly
|
||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
|
||||
<button onclick="copyOpenUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Portal enabled</span>
|
||||
<button id="pa-toggle" onclick="togglePortalEnabled()"
|
||||
class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600">…</button>
|
||||
</div>
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">For feedback during development. Disable <code>PORTAL_OPEN_LINKS</code> before real clients.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="share-new" class="hidden mb-4">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link — copy it now</label>
|
||||
<div id="pa-details" class="hidden space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Portal link</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="share-new-url" readonly
|
||||
<input id="pa-link" readonly class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
|
||||
<button onclick="copyField('pa-link', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Password</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="pa-pass" readonly placeholder="•••••••• (set one below)"
|
||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
|
||||
<button onclick="copyShareUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||
<button onclick="copyField('pa-pass', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
|
||||
</div>
|
||||
<button onclick="regeneratePassword()" class="mt-2 text-sm text-seismo-orange hover:text-seismo-navy font-medium">↻ Generate new password</button>
|
||||
<p class="text-xs text-gray-400 mt-1">Shown once — copy it now. Regenerating invalidates the old one.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Active links</span>
|
||||
<button onclick="generateShareLink()" class="text-sm text-seismo-orange hover:text-seismo-navy font-medium">+ Generate new link</button>
|
||||
</div>
|
||||
<div id="share-list" class="space-y-2 max-h-56 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SHARE_PROJECT_ID = "{{ project_id }}";
|
||||
function openShareModal() {
|
||||
document.getElementById('share-modal').classList.remove('hidden');
|
||||
document.getElementById('share-new').classList.add('hidden');
|
||||
const ou = document.getElementById('open-url'); // only present when PORTAL_OPEN_LINKS on
|
||||
if (ou) ou.value = `${location.origin}/portal/open/${SHARE_PROJECT_ID}`;
|
||||
loadShareLinks();
|
||||
}
|
||||
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
|
||||
const PA_PROJECT_ID = "{{ project_id }}";
|
||||
let paEnabled = false;
|
||||
function paToast(msg) { if (window.showToast) showToast(msg, 'error'); else alert(msg); }
|
||||
function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
|
||||
function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }
|
||||
|
||||
function copyOpenUrl(btn) {
|
||||
const inp = document.getElementById('open-url');
|
||||
inp.select();
|
||||
function copyField(id, btn) {
|
||||
const inp = document.getElementById(id); inp.select();
|
||||
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
|
||||
else { document.execCommand('copy'); done(); }
|
||||
}
|
||||
|
||||
async function loadShareLinks() {
|
||||
const list = document.getElementById('share-list');
|
||||
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>';
|
||||
async function loadPortalAccess() {
|
||||
try {
|
||||
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-links`)).json();
|
||||
if (!j.links || !j.links.length) {
|
||||
list.innerHTML = '<div class="text-sm text-gray-400">No links yet — generate one above.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
for (const l of j.links) {
|
||||
const last = l.last_used_at ? ('last used ' + new Date(l.last_used_at + 'Z').toLocaleString()) : 'never used';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
|
||||
row.innerHTML = `<div class="text-sm min-w-0">
|
||||
<div class="text-gray-800 dark:text-gray-200 truncate">${l.label || 'Link'}</div>
|
||||
<div class="text-xs text-gray-400">${last}</div></div>`;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'shrink-0 text-xs text-red-600 hover:text-red-700';
|
||||
btn.textContent = 'Revoke';
|
||||
btn.onclick = () => revokeShareLink(l.id);
|
||||
row.appendChild(btn);
|
||||
list.appendChild(row);
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="text-sm text-red-500">Failed to load links.</div>';
|
||||
}
|
||||
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access`);
|
||||
if (!r.ok) throw new Error('load failed');
|
||||
renderPortalAccess(await r.json());
|
||||
} catch (e) { paToast('Could not load portal access.'); }
|
||||
}
|
||||
|
||||
async function generateShareLink() {
|
||||
function renderPortalAccess(j) {
|
||||
paEnabled = !!j.enabled;
|
||||
const toggle = document.getElementById('pa-toggle');
|
||||
const details = document.getElementById('pa-details');
|
||||
toggle.textContent = paEnabled ? 'On — click to disable' : 'Off — click to enable';
|
||||
toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
|
||||
(paEnabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
|
||||
details.classList.toggle('hidden', !paEnabled);
|
||||
document.getElementById('pa-link').value = (paEnabled && j.link_url) ? j.link_url : '';
|
||||
}
|
||||
async function togglePortalEnabled() {
|
||||
const action = paEnabled ? 'disable' : 'enable';
|
||||
try {
|
||||
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link`, { method: 'POST' })).json();
|
||||
if (j.url) {
|
||||
document.getElementById('share-new').classList.remove('hidden');
|
||||
document.getElementById('share-new-url').value = j.url;
|
||||
loadShareLinks();
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) showToast('Failed to generate link', 'error');
|
||||
}
|
||||
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${action}`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error('toggle failed');
|
||||
const j = await r.json();
|
||||
renderPortalAccess(action === 'disable' ? { enabled: false, link_url: null } : j);
|
||||
} catch (e) { paToast(`Could not ${action} the portal.`); }
|
||||
}
|
||||
|
||||
function copyShareUrl(btn) {
|
||||
const inp = document.getElementById('share-new-url');
|
||||
inp.select();
|
||||
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
|
||||
else { document.execCommand('copy'); done(); }
|
||||
}
|
||||
|
||||
async function revokeShareLink(id) {
|
||||
if (!confirm('Revoke this link? Anyone using it will be signed out on their next action.')) return;
|
||||
try { await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link/${id}/revoke`, { method: 'POST' }); loadShareLinks(); }
|
||||
catch (e) { if (window.showToast) showToast('Failed to revoke', 'error'); }
|
||||
async function regeneratePassword() {
|
||||
try {
|
||||
const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error('password failed');
|
||||
const j = await r.json();
|
||||
if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
|
||||
} catch (e) { paToast('Could not generate a password.'); }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Test harness: a throwaway SQLite DB per test, get_db overridden, a TestClient
|
||||
that does NOT run lifespan startup (so schedulers/SLMM polling stay off)."""
|
||||
import uuid
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from backend.database import Base, get_db
|
||||
import backend.models as models # noqa: F401 (ensure all tables are registered on Base)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session(tmp_path):
|
||||
db_file = tmp_path / "test.db"
|
||||
engine = create_engine(f"sqlite:///{db_file}", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
sess = TestingSession()
|
||||
try:
|
||||
yield sess
|
||||
finally:
|
||||
sess.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(db_session):
|
||||
from backend.main import app # imported lazily so module side effects are contained
|
||||
def _override():
|
||||
yield db_session
|
||||
app.dependency_overrides[get_db] = _override
|
||||
# No `with` → lifespan/startup events do not run (no scheduler/SLMM threads).
|
||||
c = TestClient(app)
|
||||
yield c
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_portal_lockout():
|
||||
"""Portal lockout state is a module-global dict; clear it between tests so
|
||||
one test's failed attempts can't lock out another."""
|
||||
try:
|
||||
import backend.portal_auth as _pa
|
||||
if hasattr(_pa, "_failures"):
|
||||
_pa._failures.clear()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
|
||||
|
||||
def make_project(db_session, name=None, **kwargs):
|
||||
"""Insert and return a Project with a unique name."""
|
||||
p = models.Project(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name or f"Proj {uuid.uuid4().hex[:8]}",
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
**kwargs,
|
||||
)
|
||||
db_session.add(p)
|
||||
db_session.commit()
|
||||
return p
|
||||
@@ -0,0 +1,23 @@
|
||||
from backend.auth_passwords import hash_password, verify_password, generate_password
|
||||
|
||||
|
||||
def test_hash_is_not_plaintext_and_verifies():
|
||||
h = hash_password("hunter2")
|
||||
assert h != "hunter2"
|
||||
assert h.startswith("$argon2")
|
||||
assert verify_password("hunter2", h) is True
|
||||
|
||||
|
||||
def test_verify_rejects_wrong_password():
|
||||
h = hash_password("hunter2")
|
||||
assert verify_password("nope", h) is False
|
||||
|
||||
|
||||
def test_verify_is_safe_on_garbage_hash():
|
||||
assert verify_password("anything", "not-a-real-hash") is False
|
||||
|
||||
|
||||
def test_generated_password_is_strong_and_unique():
|
||||
a, b = generate_password(), generate_password()
|
||||
assert a != b
|
||||
assert len(a) >= 12
|
||||
@@ -0,0 +1,16 @@
|
||||
import importlib
|
||||
from tests.conftest import make_project
|
||||
from backend.auth_passwords import hash_password
|
||||
|
||||
|
||||
def test_cookie_secure_flag_is_applied(monkeypatch, client, db_session):
|
||||
import backend.portal_auth as pa
|
||||
monkeypatch.setattr(pa, "COOKIE_SECURE", True, raising=False)
|
||||
# also patch the name imported into the router module
|
||||
import backend.routers.portal as pr
|
||||
monkeypatch.setattr(pr, "COOKIE_SECURE", True, raising=False)
|
||||
|
||||
make_project(db_session, portal_enabled=True, portal_link_token="ts",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
r = client.post("/portal/p/ts", data={"password": "pw"}, follow_redirects=False)
|
||||
assert "secure" in r.headers.get("set-cookie", "").lower()
|
||||
@@ -0,0 +1,40 @@
|
||||
from tests.conftest import make_project
|
||||
from backend.models import Project
|
||||
|
||||
|
||||
def test_enable_creates_link_token_and_reports_state(client, db_session):
|
||||
p = make_project(db_session)
|
||||
r = client.post(f"/projects/{p.id}/portal-access/enable")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["enabled"] is True
|
||||
assert body["link_url"].endswith(f"/portal/p/{db_session.get(Project, p.id).portal_link_token}")
|
||||
|
||||
|
||||
def test_set_password_returns_raw_once_and_stores_hash(client, db_session):
|
||||
p = make_project(db_session)
|
||||
client.post(f"/projects/{p.id}/portal-access/enable")
|
||||
r = client.post(f"/projects/{p.id}/portal-access/password")
|
||||
assert r.status_code == 200
|
||||
raw = r.json()["password"]
|
||||
assert len(raw) >= 12
|
||||
fresh = db_session.get(Project, p.id)
|
||||
assert fresh.portal_password_hash and fresh.portal_password_hash != raw
|
||||
|
||||
|
||||
def test_disable_turns_off_and_rotates_token(client, db_session):
|
||||
p = make_project(db_session)
|
||||
client.post(f"/projects/{p.id}/portal-access/enable")
|
||||
old = db_session.get(Project, p.id).portal_link_token
|
||||
r = client.post(f"/projects/{p.id}/portal-access/disable")
|
||||
assert r.status_code == 200
|
||||
fresh = db_session.get(Project, p.id)
|
||||
assert fresh.portal_enabled is False
|
||||
assert fresh.portal_link_token != old
|
||||
|
||||
|
||||
def test_get_state(client, db_session):
|
||||
p = make_project(db_session)
|
||||
r = client.get(f"/projects/{p.id}/portal-access")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"enabled": False, "has_password": False, "link_url": None}
|
||||
@@ -0,0 +1,46 @@
|
||||
import time
|
||||
from tests.conftest import make_project
|
||||
from backend import portal_auth as pa
|
||||
from backend.models import Client, ClientAccessToken
|
||||
|
||||
|
||||
def test_portal_client_for_project_is_1to1_and_idempotent(db_session):
|
||||
p = make_project(db_session)
|
||||
c1 = pa.portal_client_for_project(p, db_session)
|
||||
c2 = pa.portal_client_for_project(p, db_session)
|
||||
assert isinstance(c1, Client) and c1.id == c2.id
|
||||
assert c1.slug == f"portal-{p.id}"
|
||||
assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1
|
||||
# the project must be linked to its portal client, or client-scoped routes find nothing
|
||||
assert p.client_id == c1.id
|
||||
|
||||
|
||||
def test_mint_portal_session_returns_usable_token_id(db_session):
|
||||
p = make_project(db_session)
|
||||
tid = pa.mint_portal_session(p, db_session)
|
||||
tok = db_session.query(ClientAccessToken).filter_by(id=tid, revoked_at=None).first()
|
||||
assert tok is not None
|
||||
cookie = pa.make_session_cookie(tid)
|
||||
client = pa.client_from_cookie(cookie, db_session)
|
||||
assert client is not None and client.slug == f"portal-{p.id}"
|
||||
|
||||
|
||||
def test_resolve_project_by_link_token(db_session):
|
||||
p = make_project(db_session, portal_enabled=True, portal_link_token="tok-abc")
|
||||
assert pa.resolve_project_by_link_token("tok-abc", db_session).id == p.id
|
||||
assert pa.resolve_project_by_link_token("nope", db_session) is None
|
||||
|
||||
|
||||
def test_resolve_project_ignores_disabled_portal(db_session):
|
||||
make_project(db_session, portal_enabled=False, portal_link_token="tok-off")
|
||||
assert pa.resolve_project_by_link_token("tok-off", db_session) is None
|
||||
|
||||
|
||||
def test_lockout_after_max_attempts():
|
||||
pa.clear_failures("k1")
|
||||
assert pa.is_locked("k1") is False
|
||||
for _ in range(pa.MAX_ATTEMPTS):
|
||||
pa.register_failure("k1")
|
||||
assert pa.is_locked("k1") is True
|
||||
pa.clear_failures("k1")
|
||||
assert pa.is_locked("k1") is False
|
||||
@@ -0,0 +1,60 @@
|
||||
from tests.conftest import make_project
|
||||
from backend import portal_auth as pa
|
||||
from backend.auth_passwords import hash_password
|
||||
|
||||
|
||||
def _enabled_project(db_session, token="tok-1", password="secretpw"):
|
||||
return make_project(db_session, portal_enabled=True, portal_link_token=token,
|
||||
portal_password_hash=hash_password(password))
|
||||
|
||||
|
||||
def test_get_prompt_renders_for_valid_token(client, db_session):
|
||||
_enabled_project(db_session)
|
||||
r = client.get("/portal/p/tok-1")
|
||||
assert r.status_code == 200
|
||||
assert "password" in r.text.lower()
|
||||
|
||||
|
||||
def test_get_unknown_token_shows_generic_page(client, db_session):
|
||||
r = client.get("/portal/p/does-not-exist")
|
||||
assert r.status_code in (403, 404)
|
||||
assert "password" not in r.text.lower() or "isn't valid" in r.text.lower()
|
||||
|
||||
|
||||
def test_wrong_password_is_rejected(client, db_session):
|
||||
_enabled_project(db_session, password="rightpw")
|
||||
r = client.post("/portal/p/tok-1", data={"password": "wrongpw"}, follow_redirects=False)
|
||||
assert r.status_code == 200 # re-renders the form, no cookie
|
||||
assert "portal_session" not in r.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
def test_correct_password_sets_cookie_and_redirects(client, db_session):
|
||||
_enabled_project(db_session, password="rightpw")
|
||||
r = client.post("/portal/p/tok-1", data={"password": "rightpw"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/portal"
|
||||
assert "portal_session=" in r.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
def test_lockout_after_five_wrong(client, db_session):
|
||||
_enabled_project(db_session, token="tok-lock", password="rightpw")
|
||||
for _ in range(5):
|
||||
client.post("/portal/p/tok-lock", data={"password": "x"}, follow_redirects=False)
|
||||
# 6th attempt — even the CORRECT password is refused while locked
|
||||
r = client.post("/portal/p/tok-lock", data={"password": "rightpw"}, follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert "portal_session=" not in r.headers.get("set-cookie", "")
|
||||
assert "too many" in r.text.lower()
|
||||
|
||||
|
||||
def test_enabled_without_password_is_not_accessible(client, db_session):
|
||||
# enabled portal but no password set yet (operator enabled before generating one)
|
||||
# must NOT show a usable form — looks like an invalid link, no self-lockout.
|
||||
make_project(db_session, portal_enabled=True, portal_link_token="tok-nopw")
|
||||
r = client.get("/portal/p/tok-nopw")
|
||||
assert r.status_code == 404
|
||||
assert "isn't valid" in r.text.lower()
|
||||
# and a POST can't succeed or set a cookie either
|
||||
r2 = client.post("/portal/p/tok-nopw", data={"password": "anything"}, follow_redirects=False)
|
||||
assert r2.status_code == 404
|
||||
assert "portal_session=" not in r2.headers.get("set-cookie", "")
|
||||
@@ -0,0 +1,29 @@
|
||||
import sqlite3
|
||||
import importlib
|
||||
|
||||
|
||||
def _columns(db_file):
|
||||
conn = sqlite3.connect(db_file)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
conn.close()
|
||||
return cols
|
||||
|
||||
|
||||
def test_migration_adds_columns_and_is_idempotent(tmp_path, monkeypatch):
|
||||
db_file = tmp_path / "seismo_fleet.db"
|
||||
conn = sqlite3.connect(db_file)
|
||||
conn.execute("CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
monkeypatch.chdir(tmp_path) # migration resolves data/ relative to cwd
|
||||
(tmp_path / "data").mkdir()
|
||||
(tmp_path / "data" / "seismo_fleet.db").write_bytes(db_file.read_bytes())
|
||||
|
||||
mod = importlib.import_module("backend.migrate_add_project_portal_auth")
|
||||
mod.migrate()
|
||||
cols = _columns(tmp_path / "data" / "seismo_fleet.db")
|
||||
assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= cols
|
||||
|
||||
mod.migrate() # second run must not raise
|
||||
assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= _columns(tmp_path / "data" / "seismo_fleet.db")
|
||||
@@ -0,0 +1,81 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.testclient import WebSocketDisconnect
|
||||
from tests.conftest import make_project
|
||||
from backend import portal_auth as pa
|
||||
from backend.auth_passwords import hash_password
|
||||
from backend.models import MonitoringLocation
|
||||
|
||||
|
||||
def _sound_location(db_session, project):
|
||||
loc = MonitoringLocation(
|
||||
id=str(uuid.uuid4()), project_id=project.id, name="Site",
|
||||
location_type="sound", created_at=datetime.utcnow(),
|
||||
sort_order=0)
|
||||
db_session.add(loc)
|
||||
db_session.commit()
|
||||
return loc
|
||||
|
||||
|
||||
def test_session_for_A_cannot_open_B_location(client, db_session):
|
||||
a = make_project(db_session, portal_enabled=True, portal_link_token="ta",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
b = make_project(db_session)
|
||||
b_loc = _sound_location(db_session, b)
|
||||
|
||||
# Establish an A session
|
||||
r = client.post("/portal/p/ta", data={"password": "pw"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
# Try to open B's location page → 404 (not 403), no leak
|
||||
r2 = client.get(f"/portal/location/{b_loc.id}")
|
||||
assert r2.status_code == 404
|
||||
|
||||
|
||||
def test_session_can_open_its_own_location(client, db_session):
|
||||
# Positive case: proves the negative test's 404 is real scoping, not a blanket
|
||||
# "client owns nothing" failure — an A session CAN open A's own location.
|
||||
a = make_project(db_session, portal_enabled=True, portal_link_token="ta2",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
a_loc = _sound_location(db_session, a)
|
||||
r = client.post("/portal/p/ta2", data={"password": "pw"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
r2 = client.get(f"/portal/location/{a_loc.id}")
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
def test_ws_stream_rejects_unauthenticated(client, db_session):
|
||||
# The live-feed WebSocket must refuse a connection with no session cookie (1008).
|
||||
a = make_project(db_session, portal_enabled=True, portal_link_token="tw1",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
a_loc = _sound_location(db_session, a)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws:
|
||||
ws.receive_text()
|
||||
assert exc.value.code == 1008
|
||||
|
||||
|
||||
def test_ws_stream_rejects_cross_project(client, db_session, monkeypatch):
|
||||
# The WebSocket enforces the SAME per-project ownership as the HTTP routes: a
|
||||
# B-session opening A's stream is closed 1008 (ownership) before any device feed.
|
||||
# The handler uses SessionLocal() directly (not the get_db override), so point it
|
||||
# at the test DB engine so this genuinely exercises the ownership check (not a
|
||||
# vacuous "client not found").
|
||||
import backend.routers.portal as portal_router
|
||||
monkeypatch.setattr(portal_router, "SessionLocal",
|
||||
sessionmaker(bind=db_session.get_bind()))
|
||||
|
||||
a = make_project(db_session, portal_enabled=True, portal_link_token="tw2",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
a_loc = _sound_location(db_session, a)
|
||||
make_project(db_session, portal_enabled=True, portal_link_token="tw3",
|
||||
portal_password_hash=hash_password("pw"))
|
||||
# Log in as project B, then aim the stream at project A's location.
|
||||
assert client.post("/portal/p/tw3", data={"password": "pw"},
|
||||
follow_redirects=False).status_code == 303
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws:
|
||||
ws.receive_text()
|
||||
assert exc.value.code == 1008
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
assert client.post(f"/projects/{p.id}/portal-link/sometoken/revoke").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