0888da32b4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1260 lines
52 KiB
Markdown
1260 lines
52 KiB
Markdown
# Portal Authentication (Phase 1) Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Gate each project's read-only client portal behind a secure per-project link + shared password.
|
|
|
|
**Architecture:** Add `portal_enabled` / `portal_password_hash` / `portal_link_token` to `Project`. A new `/portal/p/{link_token}` page resolves the project from the (unguessable) token and prompts for the password; on success it mints the *existing* signed session cookie — pinned to a **dedicated 1:1 portal client** for that project — so every existing client-scoped read-only route (overview, location, live/history/events) works unchanged and is automatically scoped to that one project. The interim magic-link / open-link entry points are retired.
|
|
|
|
**Why reuse the client cookie instead of re-keying routes to "project":** the existing scoped-data layer (`get_current_client` → `_client_project_ids` → `resolve_client_location`) is battle-tested in prod. Giving each portal-enabled project its own dedicated `Client` (slug `portal-<project.id>`, owning exactly that one project) yields per-project isolation with **zero route surgery**. `Project.client_id` is left untouched as substrate for the deferred per-client rollup.
|
|
|
|
**Tech Stack:** FastAPI, SQLAlchemy 2 + SQLite (raw-`sqlite3` migrations, no Alembic), Jinja2, argon2-cffi (new), pytest + Starlette TestClient (new — no test harness exists yet). Docker: deps are baked into the image, so new deps require a rebuild.
|
|
|
|
**Key existing references (verbatim from codebase):**
|
|
- `backend/database.py`: `Base`, `SessionLocal`, `get_db` (generator dependency). SQLite at `data/seismo_fleet.db`.
|
|
- `backend/models.py`: `Project` (table `projects`, `id` is caller-supplied `str(uuid.uuid4())`, no FK constraints/relationships), `Client` (slug unique), `ClientAccessToken`.
|
|
- `backend/portal_auth.py`: `SECRET_KEY` (env, default `"dev-insecure-change-me"`), `COOKIE_NAME="portal_session"`, `COOKIE_MAX_AGE=60*60*24*30`, `make_session_cookie(token_id)` → `{"tid","iat"}` signed cookie, `client_from_cookie`, `get_current_client`, `ensure_project_client`, `provision_preview_session`, `mint_link_token`, `PortalAuthError`, `PORTAL_OPEN_LINKS`.
|
|
- `backend/routers/portal.py`: `APIRouter(prefix="/portal")`. Routes `/portal/enter/{token}`, `/portal/open/{project_id}`, `/portal/logout`, `/portal/access`, `/portal`, `/portal/location/{id}`, `/portal/api/location/{id}/{live,history,events,thresholds}`, WS `/stream`. Cookie set with `max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax"`. Scope helpers `_client_project_ids`, `resolve_client_location`, `active_unit_for_location`, `_client_locations`.
|
|
- `backend/main.py`: `app.include_router(portal.router)`; `PortalAuthError` handler; operator routes `/projects/{id}/portal-preview`, `/projects/{id}/portal-link` (create/list/revoke); `project_detail_page` passes `portal_open_links` to `projects/detail.html`.
|
|
- `templates/portal/base.html`: blocks `title`/`head`/`content`/`scripts`; shows client chip + Sign-out when `client` context var is truthy; globals `esc()`, `cssVar()`. `templates/portal/access_required.html`: lock-card splash branching on `reason`.
|
|
- `templates/projects/detail.html`: breadcrumb buttons (lines 21-41) + share modal & JS (lines 2101-2219) — the operator portal-sharing UI to replace.
|
|
- `backend/migrate_add_client_portal.py`: the raw-`sqlite3`, idempotent `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` migration pattern.
|
|
|
|
---
|
|
|
|
## Task 1: Stand up the test harness + add dependencies
|
|
|
|
**Files:**
|
|
- Modify: `requirements.txt`
|
|
- Create: `requirements-dev.txt`
|
|
- Create: `tests/__init__.py` (empty)
|
|
- Create: `tests/conftest.py`
|
|
- Create: `tests/test_harness.py` (sanity test, deleted at end of task)
|
|
|
|
- [ ] **Step 1: Add the runtime dependency (argon2)**
|
|
|
|
Append to `requirements.txt`:
|
|
```
|
|
argon2-cffi==23.1.0
|
|
```
|
|
|
|
- [ ] **Step 2: Create the dev/test dependency file**
|
|
|
|
Create `requirements-dev.txt`:
|
|
```
|
|
-r requirements.txt
|
|
pytest==8.3.3
|
|
```
|
|
|
|
- [ ] **Step 3: Create the test package + fixtures**
|
|
|
|
Create `tests/__init__.py` (empty file).
|
|
|
|
Create `tests/conftest.py`:
|
|
```python
|
|
"""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 os
|
|
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():
|
|
try:
|
|
yield db_session
|
|
finally:
|
|
pass
|
|
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.clear()
|
|
|
|
|
|
@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
|
|
```
|
|
|
|
- [ ] **Step 4: Add a sanity test that proves the harness boots**
|
|
|
|
Create `tests/test_harness.py`:
|
|
```python
|
|
def test_app_boots_and_serves(client):
|
|
r = client.get("/portal/access")
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_make_project_persists(db_session):
|
|
from tests.conftest import make_project
|
|
p = make_project(db_session, name="Harness Co")
|
|
assert p.id and p.name == "Harness Co"
|
|
```
|
|
|
|
- [ ] **Step 5: Bind-mount the source into the dev container (one-time, so code is live)**
|
|
|
|
The dev container bakes source at build time (only `data-dev` is bind-mounted), so it won't pick up code edits — which would force a rebuild per TDD change. Fix it once by mounting the source. In `docker-compose.override.yml`, under the `terra-view` service `volumes:`, add a source mount **above** the existing data mount so the data mount still wins for `/app/data`:
|
|
|
|
```yaml
|
|
volumes:
|
|
- .:/app
|
|
- ./data-dev:/app/data
|
|
```
|
|
(Local dev-env convenience — does not need to be committed with the feature.)
|
|
|
|
- [ ] **Step 6: Rebuild (bakes argon2), recreate with the mount, install pytest, run the sanity test**
|
|
|
|
```bash
|
|
cd /home/serversdown/terra-view
|
|
docker compose build terra-view && docker compose up -d terra-view
|
|
docker exec terra-view-terra-view-1 pip install -r requirements-dev.txt
|
|
docker exec terra-view-terra-view-1 python -m pytest tests/test_harness.py -v
|
|
```
|
|
Expected: both tests PASS. With the source mounted, later tasks need **no rebuild** — `docker exec … pytest` sees your edits live. (`pip install` of pytest is ephemeral; re-run it only if you recreate the container.)
|
|
|
|
- [ ] **Step 7: Remove the throwaway sanity test and commit**
|
|
|
|
```bash
|
|
rm tests/test_harness.py
|
|
git add requirements.txt requirements-dev.txt tests/__init__.py tests/conftest.py
|
|
git commit -m "test: stand up pytest harness + add argon2-cffi"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Password hashing helpers
|
|
|
|
**Files:**
|
|
- Create: `backend/auth_passwords.py`
|
|
- Test: `tests/test_auth_passwords.py`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `tests/test_auth_passwords.py`:
|
|
```python
|
|
import pytest
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify failure**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_auth_passwords.py -v`
|
|
Expected: FAIL (`ModuleNotFoundError: backend.auth_passwords`).
|
|
|
|
- [ ] **Step 3: Implement the helpers**
|
|
|
|
Create `backend/auth_passwords.py`:
|
|
```python
|
|
"""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
|
|
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
|
|
|
_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 (VerifyMismatchError, VerificationError, InvalidHashError, Exception):
|
|
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)
|
|
```
|
|
|
|
- [ ] **Step 4: Run to verify pass**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_auth_passwords.py -v`
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/auth_passwords.py tests/test_auth_passwords.py
|
|
git commit -m "feat: argon2 password hashing helpers for the portal"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Project portal columns + migration
|
|
|
|
**Files:**
|
|
- Modify: `backend/models.py` (the `Project` class, after `client_id` ~`models.py:195`)
|
|
- Create: `backend/migrate_add_project_portal_auth.py`
|
|
- Test: `tests/test_portal_migration.py`
|
|
|
|
- [ ] **Step 1: Add the three columns to the `Project` model**
|
|
|
|
In `backend/models.py`, inside `class Project`, immediately after the `client_id` line, add:
|
|
```python
|
|
# --- 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
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing migration test**
|
|
|
|
Create `tests/test_portal_migration.py`:
|
|
```python
|
|
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")
|
|
```
|
|
|
|
- [ ] **Step 3: Run to verify failure**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_migration.py -v`
|
|
Expected: FAIL (`ModuleNotFoundError: backend.migrate_add_project_portal_auth`).
|
|
|
|
- [ ] **Step 4: Write the migration (copy the `migrate_add_client_portal.py` pattern)**
|
|
|
|
Create `backend/migrate_add_project_portal_auth.py`:
|
|
```python
|
|
#!/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()
|
|
```
|
|
|
|
- [ ] **Step 5: Run to verify pass**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_migration.py -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/models.py backend/migrate_add_project_portal_auth.py tests/test_portal_migration.py
|
|
git commit -m "feat: add per-project portal gate columns + migration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Portal session-mint + link-token + lockout helpers
|
|
|
|
**Files:**
|
|
- Modify: `backend/portal_auth.py` (append new helpers + a lockout store)
|
|
- Test: `tests/test_portal_auth_helpers.py`
|
|
|
|
These build on the existing `ensure_project_client`-style pattern but pin a **dedicated 1:1 portal client** to the project, and add link-token resolution + an in-memory lockout.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `tests/test_portal_auth_helpers.py`:
|
|
```python
|
|
import time
|
|
from tests.conftest import make_project
|
|
from backend import portal_auth as pa
|
|
from backend.models import Client, ClientAccessToken
|
|
from backend.auth_passwords import hash_password
|
|
|
|
|
|
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}"
|
|
# exactly one client row for this project
|
|
assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1
|
|
|
|
|
|
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
|
|
# the cookie built from it resolves back to this project's portal client
|
|
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
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify failure**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_auth_helpers.py -v`
|
|
Expected: FAIL (`AttributeError: module 'backend.portal_auth' has no attribute 'portal_client_for_project'`).
|
|
|
|
- [ ] **Step 3: Append the helpers to `backend/portal_auth.py`**
|
|
|
|
Add at the end of `backend/portal_auth.py`:
|
|
```python
|
|
# --- Phase-1 per-project password gate -------------------------------------------
|
|
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
|
|
# owning exactly that project, so the existing client-scoped routes are automatically
|
|
# per-project. Project.client_id is left untouched (deferred per-client rollup).
|
|
|
|
from backend.models import Project # local import; Project not needed above
|
|
|
|
|
|
def portal_client_for_project(project, db) -> Client:
|
|
"""Get-or-create the dedicated 1:1 portal client for a 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 "Client"),
|
|
slug=slug, active=True)
|
|
db.add(client)
|
|
db.flush()
|
|
return client
|
|
|
|
|
|
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="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 (per link_token+IP). Resets on restart; adequate for
|
|
# a read-only surface behind the UniFi edge. Single-worker dev; note 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)
|
|
```
|
|
|
|
Note: `hash_token`, `secrets`, `uuid`, `time`, `Client`, `ClientAccessToken` are already imported at the top of `portal_auth.py`.
|
|
|
|
- [ ] **Step 4: Run to verify pass**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_auth_helpers.py -v`
|
|
Expected: PASS (5 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/portal_auth.py tests/test_portal_auth_helpers.py
|
|
git commit -m "feat: per-project portal session mint + link-token resolve + lockout"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: The password-gate page + the password prompt template
|
|
|
|
**Files:**
|
|
- Create: `templates/portal/password.html`
|
|
- Modify: `backend/routers/portal.py` (add GET + POST `/portal/p/{link_token}`; import `Form`, the new helpers, `verify_password`)
|
|
- Test: `tests/test_portal_gate.py`
|
|
|
|
- [ ] **Step 1: Create the password prompt template**
|
|
|
|
Create `templates/portal/password.html`:
|
|
```jinja
|
|
{% 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 %}
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing route tests**
|
|
|
|
Create `tests/test_portal_gate.py`:
|
|
```python
|
|
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()
|
|
```
|
|
|
|
- [ ] **Step 3: Run to verify failure**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_gate.py -v`
|
|
Expected: FAIL (404 — routes not defined).
|
|
|
|
- [ ] **Step 4: Add the routes to `backend/routers/portal.py`**
|
|
|
|
Update the imports block in `backend/routers/portal.py`:
|
|
```python
|
|
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form
|
|
```
|
|
and extend the `backend.portal_auth` import to add the new helpers:
|
|
```python
|
|
from backend.portal_auth import (
|
|
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
|
|
provision_preview_session, PORTAL_OPEN_LINKS,
|
|
COOKIE_NAME, COOKIE_MAX_AGE,
|
|
resolve_project_by_link_token, mint_portal_session,
|
|
is_locked, register_failure, clear_failures,
|
|
)
|
|
from backend.auth_passwords import verify_password
|
|
```
|
|
|
|
Add these two routes (place them just after the existing `/portal/access` handler):
|
|
```python
|
|
@router.get("/p/{link_token}")
|
|
def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)):
|
|
"""Secure per-project link: resolve the project from the token, prompt for the
|
|
shared password. Generic page if the token is unknown/disabled (no leak)."""
|
|
project = resolve_project_by_link_token(link_token, db)
|
|
if not project:
|
|
return templates.TemplateResponse(
|
|
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
|
status_code=404)
|
|
return templates.TemplateResponse("portal/password.html", {
|
|
"request": request, "link_token": link_token,
|
|
"project_name": project.name, "error": None})
|
|
|
|
|
|
@router.post("/p/{link_token}")
|
|
def portal_password_submit(link_token: str, request: Request,
|
|
password: str = Form(...), db: Session = Depends(get_db)):
|
|
"""Verify the shared password; on success mint a project-scoped session cookie."""
|
|
project = resolve_project_by_link_token(link_token, db)
|
|
if not project:
|
|
return templates.TemplateResponse(
|
|
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
|
status_code=404)
|
|
|
|
lock_key = f"{link_token}:{request.client.host if request.client else '?'}"
|
|
if is_locked(lock_key):
|
|
return templates.TemplateResponse("portal/password.html", {
|
|
"request": request, "link_token": link_token, "project_name": project.name,
|
|
"error": "Too many attempts. Try again in 15 minutes."}, status_code=200)
|
|
|
|
if not project.portal_password_hash or not verify_password(password, project.portal_password_hash):
|
|
register_failure(lock_key)
|
|
return templates.TemplateResponse("portal/password.html", {
|
|
"request": request, "link_token": link_token, "project_name": project.name,
|
|
"error": "Incorrect password."}, status_code=200)
|
|
|
|
clear_failures(lock_key)
|
|
token_id = mint_portal_session(project, db)
|
|
resp = RedirectResponse(url="/portal", status_code=303)
|
|
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
|
logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened")
|
|
return resp
|
|
```
|
|
|
|
- [ ] **Step 5: Run to verify pass**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_gate.py -v`
|
|
Expected: PASS (5 tests). (If a prior test left lock state, restart the container or run the file in isolation — lockout state is module-global by design.)
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add templates/portal/password.html backend/routers/portal.py tests/test_portal_gate.py
|
|
git commit -m "feat: per-project portal password gate (/portal/p/{token}) + lockout"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Per-project scope isolation test
|
|
|
|
**Files:**
|
|
- Test: `tests/test_portal_scope.py`
|
|
|
|
Proves a session minted for project A cannot read project B's location.
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
Create `tests/test_portal_scope.py`:
|
|
```python
|
|
import uuid
|
|
from datetime import datetime
|
|
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())
|
|
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
|
|
```
|
|
|
|
Note: confirm `MonitoringLocation`'s required columns by checking `backend/models.py`; add any `nullable=False` fields the constructor needs (e.g. `sort_order` defaults). If the model requires extra non-null fields, set them in `_sound_location`.
|
|
|
|
- [ ] **Step 2: Run to verify pass (behavior already implemented by `resolve_client_location`)**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_scope.py -v`
|
|
Expected: PASS. (If it fails on a missing non-null column, fix `_sound_location` per the note, not the app.)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/test_portal_scope.py
|
|
git commit -m "test: portal session is isolated to its own project (404 on others)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Operator "Portal access" endpoints
|
|
|
|
**Files:**
|
|
- Modify: `backend/main.py` (add 4 routes near the existing `/projects/{id}/portal-*` routes ~`main.py:417-487`)
|
|
- Test: `tests/test_portal_access_admin.py`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `tests/test_portal_access_admin.py`:
|
|
```python
|
|
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}
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify failure**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_access_admin.py -v`
|
|
Expected: FAIL (404).
|
|
|
|
- [ ] **Step 3: Implement the four routes in `backend/main.py`**
|
|
|
|
Add after the existing `/projects/{project_id}/portal-link/{token_id}/revoke` route (~`main.py:487`):
|
|
```python
|
|
@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
|
|
p = db.query(Project).filter_by(id=project_id).first()
|
|
if not p:
|
|
return JSONResponse(status_code=404, content={"detail": "Project not found"})
|
|
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.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-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}
|
|
```
|
|
|
|
- [ ] **Step 4: Run to verify pass**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_access_admin.py -v`
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/main.py tests/test_portal_access_admin.py
|
|
git commit -m "feat: operator portal-access endpoints (enable/password/disable/state)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Operator "Portal access" panel (replace the share modal)
|
|
|
|
**Files:**
|
|
- Modify: `templates/projects/detail.html` (replace breadcrumb buttons lines 21-41 and the share modal + JS lines 2101-2219)
|
|
|
|
This is a UI task — verify manually in the browser.
|
|
|
|
- [ ] **Step 1: Replace the breadcrumb buttons (lines 21-41)**
|
|
|
|
Replace the whole `<!-- Client portal actions for this project -->` block (lines 21-41) with:
|
|
```html
|
|
<!-- Client portal access for this project -->
|
|
<div class="shrink-0 flex items-center gap-2">
|
|
<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="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="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>
|
|
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"
|
|
title="Preview this project's client portal in a new tab">
|
|
<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="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>
|
|
Preview
|
|
</a>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 2: Replace the share modal + JS (lines 2101-2219)**
|
|
|
|
Replace the entire `<!-- Share client portal link modal -->` block and its `<script>` (lines 2101-2219, ending just before `{% endblock %}`) with:
|
|
```html
|
|
<!-- 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 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">
|
|
Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
|
|
</p>
|
|
|
|
<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>
|
|
|
|
<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="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="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>
|
|
</div>
|
|
|
|
<script>
|
|
const PA_PROJECT_ID = "{{ project_id }}";
|
|
function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
|
|
function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }
|
|
|
|
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 loadPortalAccess() {
|
|
const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access`)).json();
|
|
renderPortalAccess(j);
|
|
}
|
|
function renderPortalAccess(j) {
|
|
const toggle = document.getElementById('pa-toggle');
|
|
const details = document.getElementById('pa-details');
|
|
toggle.textContent = j.enabled ? 'On — click to disable' : 'Off — click to enable';
|
|
toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
|
|
(j.enabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
|
|
details.classList.toggle('hidden', !j.enabled);
|
|
if (j.enabled && j.link_url) document.getElementById('pa-link').value = j.link_url;
|
|
}
|
|
async function togglePortalEnabled() {
|
|
const on = document.getElementById('pa-toggle').textContent.startsWith('On');
|
|
const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${on ? 'disable' : 'enable'}`, { method: 'POST' })).json();
|
|
if (on) renderPortalAccess({ enabled: false, link_url: null });
|
|
else renderPortalAccess(j);
|
|
}
|
|
async function regeneratePassword() {
|
|
const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' })).json();
|
|
if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
|
|
}
|
|
</script>
|
|
{% endblock %}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify manually in the browser**
|
|
|
|
```bash
|
|
docker compose restart terra-view
|
|
```
|
|
Then open `http://localhost:1001/projects/<a-real-project-id>`, click **Portal access**:
|
|
- Toggle On → a link appears.
|
|
- Generate new password → a password appears.
|
|
- Copy buttons work.
|
|
- Open the link in a private window → password prompt → enter the generated password → lands on the project's read-only portal.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add templates/projects/detail.html
|
|
git commit -m "feat: operator Portal access panel (enable + password + link)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Retire the interim magic-link / open-link entry points
|
|
|
|
**Files:**
|
|
- Modify: `backend/routers/portal.py` (remove `/portal/enter/{token}`, `/portal/open/{project_id}`)
|
|
- Modify: `backend/main.py` (remove `/projects/{id}/portal-link` create/list/revoke; repoint `/projects/{id}/portal-preview` to `mint_portal_session`; drop the `portal_open_links` template arg)
|
|
- Modify: `backend/portal_auth.py` (remove `PORTAL_OPEN_LINKS` + its warning)
|
|
- Test: `tests/test_retired_routes.py`
|
|
|
|
- [ ] **Step 1: Write the failing test (routes should be gone / preview still works)**
|
|
|
|
Create `tests/test_retired_routes.py`:
|
|
```python
|
|
from tests.conftest import make_project
|
|
|
|
|
|
def test_enter_and_open_are_gone(client, db_session):
|
|
assert client.get("/portal/enter/anything", follow_redirects=False).status_code == 404
|
|
assert client.get("/portal/open/anything", follow_redirects=False).status_code == 404
|
|
|
|
|
|
def test_portal_link_endpoints_are_gone(client, db_session):
|
|
p = make_project(db_session)
|
|
assert client.post(f"/projects/{p.id}/portal-link").status_code == 404
|
|
assert client.get(f"/projects/{p.id}/portal-links").status_code == 404
|
|
|
|
|
|
def test_preview_still_mints_a_session(client, db_session):
|
|
p = make_project(db_session)
|
|
r = client.get(f"/projects/{p.id}/portal-preview", follow_redirects=False)
|
|
assert r.status_code == 303
|
|
assert "portal_session=" in r.headers.get("set-cookie", "")
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify failure**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_retired_routes.py -v`
|
|
Expected: FAIL (the old routes still answer 200/303).
|
|
|
|
- [ ] **Step 3: Remove `/portal/enter/{token}` and `/portal/open/{project_id}` from `backend/routers/portal.py`**
|
|
|
|
Delete both route functions (`portal_enter` at `portal.py:94-110` and `portal_open` at `portal.py:113-132`). From the `backend.portal_auth` import line in `portal.py`, remove the now-unused `resolve_token`, `provision_preview_session`, `PORTAL_OPEN_LINKS` names (keep `make_session_cookie`, `mint_portal_session`, etc.).
|
|
|
|
- [ ] **Step 4: Update `backend/main.py`**
|
|
|
|
- Delete the three routes `project_portal_link_create`, `project_portal_links_list`, `project_portal_link_revoke` (`main.py:436-487`).
|
|
- In `project_portal_preview` (`main.py:417-433`), change the body to use the new mint helper:
|
|
```python
|
|
@app.get("/projects/{project_id}/portal-preview")
|
|
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
|
|
"""Operator testing shortcut: open this project's client portal (no CLI)."""
|
|
from backend.models import Project
|
|
from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE
|
|
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 = 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")
|
|
return resp
|
|
```
|
|
- In `project_detail_page` (`main.py:407-414`), remove the `"portal_open_links": PORTAL_OPEN_LINKS,` context line.
|
|
- In the imports (`main.py:72`), change `from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS` to `from backend.portal_auth import PortalAuthError`.
|
|
|
|
- [ ] **Step 5: Remove `PORTAL_OPEN_LINKS` from `backend/portal_auth.py`**
|
|
|
|
Delete the `PORTAL_OPEN_LINKS = ...` assignment and its `if PORTAL_OPEN_LINKS: logger.warning(...)` block (`portal_auth.py:43-50`).
|
|
|
|
- [ ] **Step 6: Run to verify pass + full suite green**
|
|
|
|
```bash
|
|
docker exec terra-view-terra-view-1 python -m pytest tests/ -v
|
|
```
|
|
Expected: all tests PASS (including `test_retired_routes.py`). Fix any import errors surfaced by the removals.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add backend/routers/portal.py backend/main.py backend/portal_auth.py tests/test_retired_routes.py
|
|
git commit -m "refactor: retire interim magic-link/open-link in favor of password gate"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Harden the session cookie (Secure flag, env-driven)
|
|
|
|
**Files:**
|
|
- Modify: `backend/portal_auth.py` (add `COOKIE_SECURE`)
|
|
- Modify: `backend/routers/portal.py` + `backend/main.py` (pass `secure=COOKIE_SECURE` at the cookie set sites)
|
|
- Test: `tests/test_cookie_secure.py`
|
|
|
|
- [ ] **Step 1: Add the flag to `backend/portal_auth.py`**
|
|
|
|
Near `COOKIE_NAME`/`COOKIE_MAX_AGE`, add:
|
|
```python
|
|
# 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")
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing test**
|
|
|
|
Create `tests/test_cookie_secure.py`:
|
|
```python
|
|
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()
|
|
```
|
|
|
|
- [ ] **Step 3: Run to verify failure**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_cookie_secure.py -v`
|
|
Expected: FAIL (cookie has no `Secure`).
|
|
|
|
- [ ] **Step 4: Apply `secure=COOKIE_SECURE` at every `set_cookie` site**
|
|
|
|
In `backend/routers/portal.py`: add `COOKIE_SECURE` to the `backend.portal_auth` import, and in the `portal_password_submit` `set_cookie` call add `secure=COOKIE_SECURE`:
|
|
```python
|
|
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
|
|
secure=COOKIE_SECURE)
|
|
```
|
|
In `backend/main.py` `project_portal_preview`, import `COOKIE_SECURE` alongside the others and add `secure=COOKIE_SECURE` to its `set_cookie` call the same way.
|
|
|
|
- [ ] **Step 5: Run to verify pass**
|
|
|
|
Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_cookie_secure.py -v`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/portal_auth.py backend/routers/portal.py backend/main.py tests/test_cookie_secure.py
|
|
git commit -m "feat: env-driven Secure flag on portal session cookie"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Docs, changelog, and rollout notes
|
|
|
|
**Files:**
|
|
- Modify: `CHANGELOG.md` (`[Unreleased]`)
|
|
- Modify: `docs/CLIENT_PORTAL.md` (note Phase-1 password gate supersedes the magic-link)
|
|
|
|
- [ ] **Step 1: Add a changelog entry**
|
|
|
|
Under `[Unreleased]` in `CHANGELOG.md`, add:
|
|
```markdown
|
|
### 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).
|
|
- Brute-force lockout (5 tries / 15 min) on the password gate.
|
|
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links.
|
|
- **Upgrade:** run `python3 backend/migrate_add_project_portal_auth.py` per DB. Set `COOKIE_SECURE=true` once served over HTTPS.
|
|
```
|
|
|
|
- [ ] **Step 2: Note the supersession in `docs/CLIENT_PORTAL.md`**
|
|
|
|
Add a short line at the top of `docs/CLIENT_PORTAL.md` pointing to the new spec/plan and noting the magic-link is retired in favor of the per-project password gate (see `docs/superpowers/specs/2026-06-15-portal-auth-design.md`).
|
|
|
|
- [ ] **Step 3: Run the full suite one last time**
|
|
|
|
```bash
|
|
docker exec terra-view-terra-view-1 python -m pytest tests/ -v
|
|
```
|
|
Expected: all green.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add CHANGELOG.md docs/CLIENT_PORTAL.md
|
|
git commit -m "docs: changelog + portal-auth Phase 1 notes"
|
|
```
|
|
|
|
---
|
|
|
|
## Rollout (after merge to dev)
|
|
|
|
1. Run the migration on each DB: `docker exec <container> python3 backend/migrate_add_project_portal_auth.py` (dev: `terra-view-terra-view-1`; prod: `terra-view-web-app-1`). Rebuild images first so `argon2-cffi` is present.
|
|
2. Set a real `SECRET_KEY` in prod (already required); set `COOKIE_SECURE=true` once behind TLS.
|
|
3. Enable a project's portal, set a password, and walk the link→password→dashboard flow over HTTPS before sending a client.
|
|
|
|
## Deferred (separate specs later)
|
|
|
|
- **Operator auth** (roles, UniFi edge, staged flip) — see `docs/superpowers/specs/2026-06-15-portal-auth-design.md` §Deferred A.
|
|
- **Full multi-tenancy** (per-client rollup, individual client accounts) — §Deferred B.
|