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 httpx
|
||||||
import websockets
|
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 fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
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,
|
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
|
||||||
provision_preview_session, PORTAL_OPEN_LINKS,
|
provision_preview_session, PORTAL_OPEN_LINKS,
|
||||||
COOKIE_NAME, COOKIE_MAX_AGE,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/portal", tags=["portal"])
|
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("")
|
@router.get("")
|
||||||
def portal_home(request: Request, client: Client = Depends(get_current_client),
|
def portal_home(request: Request, client: Client = Depends(get_current_client),
|
||||||
db: Session = Depends(get_db)):
|
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