feat: per-project portal password gate (/portal/p/{token}) + lockout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ from datetime import datetime
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -26,7 +26,10 @@ from backend.portal_auth import (
|
||||
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
|
||||
provision_preview_session, PORTAL_OPEN_LINKS,
|
||||
COOKIE_NAME, COOKIE_MAX_AGE,
|
||||
resolve_project_by_link_token, mint_portal_session,
|
||||
is_locked, register_failure, clear_failures,
|
||||
)
|
||||
from backend.auth_passwords import verify_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/portal", tags=["portal"])
|
||||
@@ -147,6 +150,51 @@ def portal_access(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/p/{link_token}")
|
||||
def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""Secure per-project link: resolve the project from the token, prompt for the
|
||||
shared password. Generic page if the token is unknown/disabled (no leak)."""
|
||||
project = resolve_project_by_link_token(link_token, db)
|
||||
if not project:
|
||||
return templates.TemplateResponse(
|
||||
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
||||
status_code=404)
|
||||
return templates.TemplateResponse("portal/password.html", {
|
||||
"request": request, "link_token": link_token,
|
||||
"project_name": project.name, "error": None})
|
||||
|
||||
|
||||
@router.post("/p/{link_token}")
|
||||
def portal_password_submit(link_token: str, request: Request,
|
||||
password: str = Form(...), db: Session = Depends(get_db)):
|
||||
"""Verify the shared password; on success mint a project-scoped session cookie."""
|
||||
project = resolve_project_by_link_token(link_token, db)
|
||||
if not project:
|
||||
return templates.TemplateResponse(
|
||||
"portal/access_required.html", {"request": request, "reason": "invalid"},
|
||||
status_code=404)
|
||||
|
||||
lock_key = f"{link_token}:{request.client.host if request.client else '?'}"
|
||||
if is_locked(lock_key):
|
||||
return templates.TemplateResponse("portal/password.html", {
|
||||
"request": request, "link_token": link_token, "project_name": project.name,
|
||||
"error": "Too many attempts. Try again in 15 minutes."}, status_code=200)
|
||||
|
||||
if not project.portal_password_hash or not verify_password(password, project.portal_password_hash):
|
||||
register_failure(lock_key)
|
||||
return templates.TemplateResponse("portal/password.html", {
|
||||
"request": request, "link_token": link_token, "project_name": project.name,
|
||||
"error": "Incorrect password."}, status_code=200)
|
||||
|
||||
clear_failures(lock_key)
|
||||
token_id = mint_portal_session(project, db)
|
||||
resp = RedirectResponse(url="/portal", status_code=303)
|
||||
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
|
||||
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||||
logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("")
|
||||
def portal_home(request: Request, client: Client = Depends(get_current_client),
|
||||
db: Session = Depends(get_db)):
|
||||
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,47 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user