8000 fix(iiif): give access tokens only to viewers the person confirmed by adeelahmad · Pull Request #160 · 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/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ use, or apps will be sent to the wrong place to sign in.

Other IIIF viewers sign in through the IIIF Authorization Flow 2.0: the access service at `/iiif/auth/access` is a
small sign-in page served by the API, which sets a `SameSite=None; Secure` cookie scoped to IIIF; the token service
posts a token only to the viewer's origin; the probe service answers with a short-lived signed link. See
posts a token only to the viewer's origin, and only to viewers the person signed in for or confirmed with Continue on
the access page; the probe service answers with a short-lived signed link. See
[IIIF](iiif.md).

## Audit
Expand Down
4 changes: 3 additions & 1 deletion docs/iiif.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ namespace, and only owners change it:
Set `iiif.base_url` to the stable public HTTPS address, since identifiers are built from it. Put the server behind
HTTPS before publishing: the authorization flow requires it, and its cookie is `SameSite=None; Secure`. Other IIIF
viewers also need the public host in `server.allowed_hosts`. `iiif.viewers` holds "Open in" links, using `{manifest}` and
`{content_state}` placeholders; `iiif.allowed_origins` limits which viewer sites can get tokens.
`{content_state}` placeholders; `iiif.allowed_origins` limits which viewer sites can get tokens. Even where every site
is allowed (`*`, the default), a site gets a token only for people who signed in for it or confirmed it with Continue
on the access page, so a page someone visits can't quietly use their sign-in.

**Metadata.** Viewers show label, summary (in several languages), label/value pairs, rights (a Creative Commons or
RightsStatements.org URI), attribution, provider, date, languages, creators, contributors, subjects (optionally linked
Expand Down
8 changes: 5 additions & 3 deletions fastapi_backend/app/api/iiif.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,10 @@ async def iiif_access_submit(request: Request) -> HTMLResponse:
expected = request.cookies.get(CSRF_COOKIE, "")
if not expected or not secrets.compare_digest(form.get("csrf", ""), expected):
return _access_form(request, cfg, origin, "Your sign-in form expired; please try again.")
held = None
if form.get("continue"):
acct, _ = await run_in_threadpool(iiif_auth.cookie_account, db, request.cookies.get(iiif_auth.COOKIE))
held = request.cookies.get(iiif_auth.COOKIE)
acct, _ = await run_in_threadpool(iiif_auth.cookie_account, db, held)
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
Expand All @@ -334,7 +336,7 @@ async def iiif_access_submit(request: Request) -> HTMLResponse:
acct = await run_in_threadpool(auth.login, db, form.get("email"), form.get("password"), key)
if not acct:
return _access_form(request, cfg, origin, "Wrong email or password.")
raw = await run_in_threadpool(iiif_auth.grant_cookie, db, cfg, acct["id"])
raw = await run_in_threadpool(iiif_auth.grant_cookie, db, cfg, acct["id"], origin, held)
nonce = secrets.token_urlsafe(12)
resp = _auth_page(iiif_auth.access_page(iiif.site_label(cfg, base_url(request, cfg)), nonce, done=True), nonce)
secure = _secure(request, cfg)
Expand Down Expand Up @@ -367,7 +369,7 @@ def iiif_token_service(request: Request, db: Db, cfg: Cfg, messageId: str = "",
"heading": iiif.lm("This viewer isn't allowed", "en"),
}
else:
acct, problem = iiif_auth.cookie_account(db, request.cookies.get(iiif_auth.COOKIE))
acct, problem = iiif_auth.cookie_account(db, request.cookies.get(iiif_auth.COOKIE), origin)
if acct:
raw, secs = iiif_auth.issue_token(db, cfg, acct["id"], origin)
msg = {**base_msg, "type": "AuthAccessToken2", "accessToken": raw, "expiresIn": secs}
Expand Down
24 changes: 20 additions & 4 deletions fastapi_backend/app/domain/iiif_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,40 @@ def _later(minutes):
return (dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=minutes)).isoformat(timespec="seconds")


def grant_cookie(db, cfg, account):
def grant_cookie(db, cfg, account, origin="", raw=None):
"""A new access cookie for the viewer at `origin`, or, given a valid cookie (`raw`), that viewer added to it. The
token service answers only the viewers a person signed in or pressed Continue for (cookie_account's `origin`)."""
origins = [origin] if ORIGIN_RX.match(origin or "") else []
if raw:
r = R("iiif_cookie", auth.sha(raw))
had = (db.one("SELECT origins FROM $r", r=r) or {}).get("origins") or []
db.q("UPDATE $r SET origins = $o", r=r, o=list(dict.fromkeys([*had, *origins])))
return raw
raw = secrets.token_urlsafe(32)
db.q(
"CREATE $r CONTENT $d",
r=R("iiif_cookie", auth.sha(raw)),
d={"account": account, "expires_at": _later(cfg["server"]["session_hours"] * 60), "created_at": store.now()},
d={
"account": account,
"origins": origins,
"expires_at": _later(cfg["server"]["session_hours"] * 60),
"created_at": store.now(),
},
)
return raw


def cookie_account(db, raw):
def cookie_account(db, raw, origin=None):
"""The cookie's account, or None and the IIIF error profile. With `origin`, only for a viewer the person confirmed."""
if not raw:
return None, "missingAspect"
row = db.one("SELECT account, expires_at FROM $r", r=R("iiif_cookie", auth.sha(raw)))
row = db.one("SELECT account, expires_at, origins FROM $r", r=R("iiif_cookie", auth.sha(raw)))
if not row:
return None, "invalidAspect"
if row["expires_at"] < store.now():
return None, "expiredAspect"
if origin is not None and origin not in (row.get("origins") or []):
return None, "missingAspect" # the viewer opens the access page, which asks the person to confirm this site
u = auth.get_account(db, row["account"])
return (auth.public(u), None) if u and not u.get("disabled") else (None, "invalidAspect")

Expand Down
23 changes: 22 additions & 1 deletion fastapi_backend/tests/api/test_iiif.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def test_authorization_flow(env):
assert "postMessage" not in env.client().get("/iiif/auth/token", params={"messageId": "m2", "origin": "javascript:alert(1)"}).text

vi = env.client()
sign_in(vi, "vi@x.io", "viewer password 1")
sign_in(vi, "vi@x.io", "viewer password 1", VIEWER)
vtok = vi.get("/iiif/auth/token", params={"messageId": "v", "origin": VIEWER}).text
vmsg = json.loads(re.search(r"postMessage\((\{.*?\}), ", vtok).group(1))
vres = fresh.get(f"/iiif/auth/probe/{env.call}/transcript", headers={"Authorization": f"Bearer {vmsg['accessToken']}"}).json()
Expand Down Expand Up @@ -308,3 +308,24 @@ def test_access_form_refuses_passwords_when_they_are_off(env):
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


def test_tokens_go_only_to_viewers_the_person_signed_in_for(env):
"""With iiif.allowed_origins at its default (*), signing in for one viewer doesn't hand tokens to every other site:
another origin gets "sign in" until the person confirms that viewer on the access page."""
anon = env.client()
sign_in(anon, "root@x.io", "root password 1", VIEWER)

def token(origin):
page = anon.get("/iiif/auth/token", params={"messageId": "m", "origin": origin}).text
return json.loads(re.search(r"postMessage\((\{.*?\}), ", page).group(1))

assert token(VIEWER)["type"] == "AuthAccessToken2"
evil = "https://evil.example"
assert (token(evil)["type"], token(evil)["profile"]) == ("AuthAccessTokenError2", "missingAspect")
page = anon.get("/iiif/auth/access", params={"origin": evil})
assert 'name="continue"' in page.text and "evil.example" in page.text # the person sees which site is asking
done = anon.post("/iiif/auth/access", data={"continue": "1", "origin": evil, "csrf": csrf_of(page)})
assert "window.close()" in done.text
assert token(evil)["type"] == "AuthAccessToken2"
assert token(VIEWER)["type"] == "AuthAccessToken2" # confirming a second viewer keeps the first
Loading
0