8000 fix(iiif): refuse password sign-in on the access page when passwords are off by adeelahmad · Pull Request #158 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/iiif.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ can open them, and harvesters can follow them.
recordings.
- **Authorization Flow 2.0:** protected audio and transcripts carry a probe service.
- Other viewers open `/iiif/auth/access` to sign in and get a token from `/iiif/auth/token`, which posts it only to
the viewer's origin.
the viewer's origin. The page signs in with a password, so where passwords are off (`auth.passwords`) it offers
no form and a submitted one answers 403; such viewers play only what is open to everyone.
- A successful probe returns a short-lived signed link, so playback doesn't depend on third-party cookies.
- `/iiif/auth/logout` revokes the tokens.
- **Import:** `POST /api/v1/import/iiif` (admins) takes a Presentation 3 Manifest or Collection from another server.
Expand Down
9 changes: 7 additions & 2 deletions fastapi_backend/app/api/iiif.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,14 @@ def _secure(request: Request, cfg: Config) -> bool:
return request.url.scheme == "https" or bool(cfg["server"].get("secure_cookies"))


def _access_form(request: Request, cfg: Config, origin: str, error: str = "", account: dict[str, Any] | None = None) -> HTMLResponse:
def _access_form(
request: Request, cfg: Config, origin: str, error: str = "", account: dict[str, Any] | None = None, status: int = 200
) -> HTMLResponse:
"""The sign-in (or continue) form with a fresh double-submit CSRF token."""
nonce, csrf = secrets.token_urlsafe(12), secrets.token_urlsafe(24)
site = iiif.site_label(cfg, base_url(request, cfg))
resp = _auth_page(iiif_auth.access_page(site, nonce, account, csrf, origin, error), nonce)
resp = _auth_page(iiif_auth.access_page(site, nonce, account, csrf, origin, error, passwords=auth.passwords_on(cfg)), nonce)
resp.status_code = status
resp.set_cookie(CSRF_COOKIE, csrf, max_age=3600, httponly=True, path="/iiif/auth", secure=_secure(request, cfg), samesite="strict")
return resp

Expand Down Expand Up @@ -322,6 +325,8 @@ async def iiif_access_submit(request: Request) -> HTMLResponse:
acct, _ = await run_in_threadpool(iiif_auth.cookie_account, db, request.cookies.get(iiif_auth.COOKIE))
if not acct:
return _access_form(request, cfg, origin, "Your session expired; sign in again.")
elif not auth.passwords_on(cfg): # auth.passwords off: password sign-in answers 403 here too
return _access_form(request, cfg, origin, "Password sign-in is turned off here.", status=403)
else:
key = f"{form.get('email', '').strip().lower()}|{client_ip(request)}"
if auth.throttled(key):
Expand Down
9 changes: 8 additions & 1 deletion fastapi_backend/app/domain/iiif_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def token_page(message, origin, nonce):
)


def access_page(site, nonce, account=None, csrf="", origin="", error="", done=False):
def access_page(site, nonce, account=None, csrf="", origin="", error="", done=False, passwords=True):
style = (
"body{font:15px/1.5 system-ui,sans-serif;max-width:24rem;margin:3rem auto;padding:0 1rem;color:#1d2733}"
"label{display:block;margin:.6rem 0}input{width:100%;padding:.45rem;border:1px solid #bbb;border-radius:6px}"
Expand All @@ -110,6 +110,13 @@ def access_page(site, nonce, account=None, csrf="", origin="", error="", done=Fa
f'<form method="post"><input type="hidden" name="continue" value="1"><input type="hidden" name="csrf" value="{esc(csrf)}">'
f'<input type="hidden" name="origin" value="{esc(origin)}"><button type="submit">Continue</button></form>'
)
elif not passwords: # auth.passwords off: this page has no passkey sign-in yet, so there is nothing to submit
body = (
f"<h1>Sign in to {esc(site)}</h1><p>The viewer at {esc(origin or 'another site')} wants to play recordings that need an account.</p>"
+ (f'<p class="err">{esc(error)}</p>' if error else "")
+ f"<p>{esc(site)} signs people in with a passkey only, and this page can't use passkeys yet, so this viewer "
"can only play what is open to everyone.</p>"
)
else:
body = (
f"<h1>Sign in to {esc(site)}</h1><p>The viewer at {esc(origin or 'another site')} wants to play recordings that need an account.</p>"
Expand Down
14 changes: 13 additions & 1 deletion fastapi_backend/tests/api/test_iiif.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import pytest
from fastapi.testclient import TestClient

from app.domain import analyze, iiif, iiif_auth, ingest, metadata
from app.domain import analyze, auth, iiif, iiif_auth, ingest, metadata
from tests.helpers import drain, login, make_user, manifests, quiet, seed, write_wav

BASE = "https://127.0.0.1"
Expand Down Expand Up @@ -296,3 +296,15 @@ def test_import_from_iiif(env):
assert c.get(f"/api/v1/recordings/{rid2}", headers=h).json()["collection"] == shelf
finally:
srv.shutdown()


def test_access_form_refuses_passwords_when_they_are_off(env):
"""With auth.passwords off, the IIIF sign-in page offers no password form and a submitted one answers 403."""
anon = env.client()
csrf = csrf_of(anon.get("/iiif/auth/access", params={"origin": VIEWER})) # a form opened while passwords were on
with mock.patch.object(auth, "passwords_on", return_value=False): # as on an install that turned them off
r = anon.post("/iiif/auth/access", data={"email": "root@x.io", "password": "root password 1", "origin": VIEWER, "csrf": csrf})
page = anon.get("/iiif/auth/access", params={"origin": VIEWER})
assert r.status_code == 403 and "window.close()" not in r.text
assert not [x for x in r.headers.get_list("set-cookie") if x.startswith(f"{iiif_auth.COOKIE}=")]
assert 'name="password"' not in page.text and "passkey" in page.text
Loading
0