Add an OPDS catalog
A reader app can browse the stored books by title, author and last change, search them, and download them. The download and cover routes share their code with the story page. An admin turns the catalog on in the settings. The login follows AUTH_METHOD: none needs no login, local and cwa take the account login, and proxy takes the user name from the proxy with an OPDS password that each user sets. The API key does not open the catalog.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
"""The login of the OPDS catalog: the account login in the local and CWA modes, a password of its own in proxy mode."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from test_api import H, login
|
||||
from test_api import client as client
|
||||
from test_api import cwa as cwa
|
||||
from test_build import URL, metahtml
|
||||
from test_opds import PATHS, add_book, enabled_catalog, entries, feed, links, no_site_requests
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from bindery import auth, config, opds, orchestrator, web
|
||||
|
||||
__all__ = ["enabled_catalog", "no_site_requests"] # fixtures that every test here uses
|
||||
|
||||
PASSWORD = "reader-password"
|
||||
CHALLENGE = 'Basic realm="Bindery", charset="UTF-8"'
|
||||
ACQUIRE = "http://opds-spec.org/acquisition"
|
||||
GATED = [*PATHS, "/opds/search?q=a", "/opds/search.xml", "/opds/books/1.epub", "/opds/books/1/cover"]
|
||||
OTHER_LOGINS = [
|
||||
{"Authorization": "Bearer api-secret"},
|
||||
{"X-Api-Key": "api-secret"},
|
||||
{"X-Remote-User": "admin"},
|
||||
]
|
||||
|
||||
|
||||
def basic(password, user="reader"):
|
||||
return {"Authorization": "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()}
|
||||
|
||||
|
||||
def set_password(password=PASSWORD, user="reader"):
|
||||
hashed = generate_password_hash(password, method="pbkdf2:sha256:1000")
|
||||
orchestrator.store.set_setting(None, orchestrator._opds_password_name(user), hashed)
|
||||
|
||||
|
||||
def fresh_lockout(monkeypatch):
|
||||
monkeypatch.setattr(auth, "limiter", auth.LoginLimiter())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proxy(monkeypatch):
|
||||
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
|
||||
monkeypatch.setattr(config, "PROXY_AUTH_TRUSTED_IPS", ["127.0.0.1"])
|
||||
monkeypatch.setattr(config, "API_KEY", "api-secret")
|
||||
set_password()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local(monkeypatch):
|
||||
monkeypatch.setattr(config, "AUTH_METHOD", "local")
|
||||
monkeypatch.setattr(config, "LOCAL_USERNAME", "reader")
|
||||
monkeypatch.setattr(config, "LOCAL_PASSWORD_HASH", generate_password_hash("web-pw", method="pbkdf2:sha256:1000"))
|
||||
monkeypatch.setattr(config, "API_KEY", "api-secret")
|
||||
set_password() # an OPDS password does not apply here
|
||||
|
||||
|
||||
def test_proxy_mode_takes_the_proxy_user_name_with_the_password_of_that_user(client, proxy, monkeypatch):
|
||||
add_book()
|
||||
set_password("alice-password", "alice")
|
||||
for path in GATED:
|
||||
response = client.get(path)
|
||||
assert response.status_code == 401
|
||||
assert response.headers["WWW-Authenticate"] == CHALLENGE
|
||||
for path in PATHS:
|
||||
response = client.get(path, headers=basic(PASSWORD, "reader"))
|
||||
feed(response)
|
||||
assert "Set-Cookie" not in response.headers
|
||||
assert client.get("/opds/search.xml", headers=basic("alice-password", "alice")).status_code == 200
|
||||
# The user name matches without regard to case and spaces. Each user has their own password.
|
||||
assert client.get("/opds", headers=basic(PASSWORD, " READER ")).status_code == 200
|
||||
for headers in [
|
||||
basic(PASSWORD, "alice"),
|
||||
basic("alice-password", "reader"),
|
||||
basic(PASSWORD, "nobody"),
|
||||
basic(PASSWORD, ""),
|
||||
*OTHER_LOGINS,
|
||||
basic("api-secret"),
|
||||
{**OTHER_LOGINS[2], **basic("wrong")},
|
||||
{"X-Remote-User": "alice", **basic(PASSWORD, "nobody")},
|
||||
]:
|
||||
fresh_lockout(monkeypatch)
|
||||
for path in PATHS[:3]:
|
||||
assert client.get(path, headers=headers).status_code == 401
|
||||
|
||||
|
||||
def test_the_catalog_password_does_not_open_the_api(client, proxy):
|
||||
for headers in (basic(PASSWORD), {"Authorization": f"Bearer {PASSWORD}"}, {"X-Api-Key": PASSWORD}):
|
||||
response = client.get("/api/stories", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" not in response.headers
|
||||
assert client.get("/api/stories", headers={"X-Api-Key": "api-secret"}).status_code == 200
|
||||
assert client.post("/api/stories", json={"urls": []}, headers={**H, **basic(PASSWORD)}).status_code == 401
|
||||
|
||||
|
||||
def test_a_user_without_a_password_meets_the_same_work_as_one_with_a_password(client, proxy, monkeypatch):
|
||||
calls = []
|
||||
real = auth.check_password_hash
|
||||
monkeypatch.setattr(auth, "check_password_hash", lambda *args: calls.append(args[0]) or real(*args))
|
||||
for user in ("nobody", "reader"):
|
||||
calls.clear()
|
||||
response = client.get("/opds", headers=basic("wrong-password", user))
|
||||
assert response.status_code == 401
|
||||
assert response.json == {"error": "Unauthorized"}
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_local_mode_takes_the_account_login(client, local, monkeypatch):
|
||||
for path in GATED:
|
||||
response = client.get(path)
|
||||
assert response.status_code == 401
|
||||
assert response.headers["WWW-Authenticate"] == CHALLENGE
|
||||
assert response.json["error"] == "Unauthorized"
|
||||
for path in PATHS:
|
||||
response = client.get(path, headers=basic("web-pw"))
|
||||
feed(response)
|
||||
assert "Set-Cookie" not in response.headers
|
||||
for headers in [basic(PASSWORD), basic("web-pw", "other"), basic("wrong"), *OTHER_LOGINS]:
|
||||
fresh_lockout(monkeypatch)
|
||||
assert client.get("/opds", headers=headers).status_code == 401
|
||||
assert client.get("/opds", headers=basic("web-pw", " reader ")).status_code == 200
|
||||
|
||||
|
||||
def test_local_mode_takes_the_web_session_but_not_the_api_key_with_it(client, local):
|
||||
assert client.get("/opds").status_code == 401
|
||||
assert login(client, password="web-pw").status_code == 200
|
||||
for path in PATHS:
|
||||
feed(client.get(path))
|
||||
assert client.get("/opds", headers={"X-Api-Key": "api-secret"}).status_code == 200 # the session counts
|
||||
client.post("/api/auth/logout", headers=H)
|
||||
assert client.get("/opds").status_code == 401
|
||||
assert client.get("/opds", headers={"X-Api-Key": "api-secret"}).status_code == 401
|
||||
|
||||
|
||||
def test_cwa_mode_takes_the_cwa_login(client, cwa, monkeypatch):
|
||||
monkeypatch.setattr(config, "API_KEY", "api-secret")
|
||||
set_password()
|
||||
assert client.get("/opds").status_code == 401
|
||||
assert client.get("/opds", headers=basic("pw")).status_code == 200
|
||||
for headers in [basic("wrong"), basic("pw", "other"), basic(PASSWORD), *OTHER_LOGINS]:
|
||||
fresh_lockout(monkeypatch)
|
||||
assert client.get("/opds", headers=headers).status_code == 401
|
||||
assert login(client).status_code == 200
|
||||
feed(client.get("/opds/title"))
|
||||
client.post("/api/auth/logout", headers=H)
|
||||
monkeypatch.setattr(config, "CWA_ALLOWED_USERS", ["someone"])
|
||||
assert client.get("/opds", headers=basic("pw")).status_code == 401
|
||||
|
||||
|
||||
def test_none_mode_needs_no_login(client):
|
||||
assert orchestrator.store.get_setting(None, "opds_password_hash") is None
|
||||
for path in PATHS:
|
||||
feed(client.get(path))
|
||||
set_password()
|
||||
feed(client.get("/opds"))
|
||||
|
||||
|
||||
def test_a_good_account_login_is_kept_for_five_minutes(client, local, monkeypatch):
|
||||
clock = [1000.0]
|
||||
monkeypatch.setattr(auth.time, "monotonic", lambda: clock[0])
|
||||
calls = []
|
||||
real = auth.login_local
|
||||
monkeypatch.setattr(auth, "login_local", lambda *args: calls.append(args) or real(*args))
|
||||
for seconds in (0, auth.KEPT_SECONDS - 1, 1):
|
||||
clock[0] += seconds
|
||||
assert client.get("/opds", headers=basic("web-pw")).status_code == 200
|
||||
assert len(calls) == 2
|
||||
# A wrong password is never kept. A new web password ends a kept login.
|
||||
assert client.get("/opds", headers=basic("wrong")).status_code == 401
|
||||
assert client.get("/opds", headers=basic("wrong")).status_code == 401
|
||||
assert len(calls) == 4
|
||||
monkeypatch.setattr(config, "LOCAL_PASSWORD_HASH", generate_password_hash("new-pw", method="pbkdf2:sha256:1000"))
|
||||
assert client.get("/opds", headers=basic("web-pw")).status_code == 401
|
||||
assert client.get("/opds", headers=basic("new-pw")).status_code == 200
|
||||
|
||||
|
||||
def test_a_good_catalog_password_is_kept_and_a_new_password_ends_it(client, proxy, monkeypatch):
|
||||
calls = []
|
||||
real = auth.check_password_hash
|
||||
monkeypatch.setattr(auth, "check_password_hash", lambda *args: calls.append(args) or real(*args))
|
||||
for _ in range(3):
|
||||
assert client.get("/opds", headers=basic(PASSWORD)).status_code == 200
|
||||
assert len(calls) == 1
|
||||
set_password("a new password")
|
||||
assert client.get("/opds", headers=basic(PASSWORD)).status_code == 401
|
||||
assert client.get("/opds", headers=basic("a new password")).status_code == 200
|
||||
|
||||
|
||||
def test_a_disabled_catalog_answers_404_before_any_login(client, proxy):
|
||||
orchestrator.store.set_setting(None, "opds_enabled", False)
|
||||
for headers in ({}, basic(PASSWORD), basic("wrong")):
|
||||
assert client.get("/opds", headers=headers).status_code == 404
|
||||
|
||||
|
||||
def test_wrong_catalog_passwords_lock_out_a_user_at_an_address(client, proxy):
|
||||
set_password("alice-password", "alice")
|
||||
for _ in range(auth.MAX_FAILURES):
|
||||
assert client.get("/opds", headers=basic("wrong")).status_code == 401
|
||||
assert client.get("/opds", headers=basic(PASSWORD)).status_code == 401
|
||||
# Another user at that address, and the same user at another address, can log in.
|
||||
assert client.get("/opds", headers=basic("alice-password", "alice")).status_code == 200
|
||||
other = {"environ_base": {"REMOTE_ADDR": "198.51.100.7"}}
|
||||
assert client.get("/opds", headers=basic(PASSWORD), **other).status_code == 200
|
||||
|
||||
|
||||
def test_wrong_account_passwords_lock_out_an_address(client, local):
|
||||
for _ in range(auth.MAX_FAILURES):
|
||||
assert client.get("/opds", headers=basic("wrong")).status_code == 401
|
||||
assert client.get("/opds", headers=basic("web-pw")).status_code == 401
|
||||
assert client.get("/opds", headers=basic("web-pw"), environ_base={"REMOTE_ADDR": "198.51.100.7"}).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["Basic", "Basic !!!", "Digest cmVhZGVyOnB3", basic("")["Authorization"], basic("x" * 257)["Authorization"]]
|
||||
+ ["Basic " + base64.b64encode(text).decode() for text in (b"reader", b"\xff:pw")],
|
||||
)
|
||||
def test_bad_basic_headers(client, proxy, monkeypatch, value):
|
||||
def fail(*args):
|
||||
pytest.fail("A bad header must not reach the hash")
|
||||
|
||||
monkeypatch.setattr(auth, "check_password_hash", fail)
|
||||
assert client.get("/opds", headers={"Authorization": value}).status_code == 401
|
||||
|
||||
|
||||
def test_bad_config_fails_closed(client, monkeypatch):
|
||||
set_password()
|
||||
monkeypatch.setattr(config, "AUTH_METHOD", "invalid")
|
||||
for path in PATHS:
|
||||
assert client.get(path, headers=basic(PASSWORD)).status_code == 503
|
||||
|
||||
|
||||
def test_host_protection(client):
|
||||
assert client.get("/opds", base_url="http://attacker.example").status_code == 400
|
||||
|
||||
|
||||
def test_url_base_and_download_auth(client, proxy, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(web.app, "wsgi_app", web.PrefixMiddleware(web.app.wsgi_app, "/bindery"))
|
||||
monkeypatch.setattr(opds, "PAGE_SIZE", 1)
|
||||
sid = add_book()
|
||||
add_book("Second")
|
||||
headers = basic(PASSWORD)
|
||||
root = feed(client.get("/bindery/opds", headers=headers))
|
||||
for entry in entries(root):
|
||||
books = feed(client.get(links(entry)["subsection"], headers=headers))
|
||||
assert all(href.startswith("/bindery/") for href in links(books).values())
|
||||
if entry.find("a:link", {"a": opds.ATOM}).attrib["type"] == opds.ACQUISITION:
|
||||
assert client.get(links(books)["next"], headers=headers).status_code == 200
|
||||
books = feed(client.get("/bindery/opds/title", headers=headers))
|
||||
download = links(entries(books)[0])[ACQUIRE]
|
||||
assert download == f"/bindery/opds/books/{sid}.epub"
|
||||
assert client.get(download).status_code == 401
|
||||
assert client.get(download, headers=basic("wrong")).status_code == 401
|
||||
file = tmp_path / "book.epub"
|
||||
file.write_bytes(b"epub bytes")
|
||||
monkeypatch.setattr(orchestrator, "build_for_download", lambda story_id: (file.open("rb"), "book.epub"))
|
||||
with client.get(download, headers=headers) as response:
|
||||
assert response.data == b"epub bytes"
|
||||
assert response.mimetype == "application/epub+zip"
|
||||
assert client.get("/opds", headers=headers).status_code == 404
|
||||
|
||||
|
||||
def test_download_builds_from_store_without_changing_chapters(client, local, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(config, "TMP_DIR", tmp_path)
|
||||
sid = add_book(url=URL)
|
||||
orchestrator.store.update_story(sid, metahtml=metahtml())
|
||||
before = orchestrator.store.chapters_for_build(sid)
|
||||
root = feed(client.get("/opds/title", headers=basic("web-pw")))
|
||||
with client.get(links(entries(root)[0])[ACQUIRE], headers=basic("web-pw")) as response:
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "application/epub+zip"
|
||||
with zipfile.ZipFile(io.BytesIO(response.data)) as epub:
|
||||
assert epub.read("mimetype") == b"application/epub+zip"
|
||||
assert orchestrator.store.chapters_for_build(sid) == before
|
||||
|
||||
|
||||
def test_the_catalog_takes_no_changes(client, proxy):
|
||||
assert client.post("/opds", headers=basic(PASSWORD)).status_code == 405
|
||||
|
||||
|
||||
def test_an_opds_download_is_named_by_the_download_template(client, local, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(config, "TMP_DIR", tmp_path)
|
||||
sid = add_book(url=URL, meta={"title": "The Book", "authors": ["Ann Writer"]})
|
||||
orchestrator.store.update_story(sid, metahtml=metahtml())
|
||||
root = feed(client.get("/opds/title", headers=basic("web-pw")))
|
||||
href = links(entries(root)[0])[ACQUIRE]
|
||||
|
||||
def disposition():
|
||||
with client.get(href, headers=basic("web-pw")) as response:
|
||||
assert response.status_code == 200
|
||||
return response.headers["Content-Disposition"]
|
||||
|
||||
assert 'filename="Book, The - Ann Writer.epub"' in disposition()
|
||||
orchestrator.store.set_setting(None, "download_template", "{title} - {authors}")
|
||||
assert 'filename="The Book - Ann Writer.epub"' in disposition()
|
||||
|
||||
|
||||
def test_forget_clears_the_wrong_passwords_of_one_user_from_every_address():
|
||||
limiter = auth.LoginLimiter()
|
||||
for address in ("10.0.0.1", "10.0.0.2"):
|
||||
for _ in range(auth.MAX_FAILURES):
|
||||
limiter.failed("Alice", address)
|
||||
limiter.failed("bob", address)
|
||||
limiter.forget("ALICE")
|
||||
assert not limiter.locked("alice", "10.0.0.1")
|
||||
assert not limiter.locked("alice", "10.0.0.2")
|
||||
assert limiter.locked("bob", "10.0.0.1")
|
||||
|
||||
|
||||
def test_setting_a_password_ends_the_lockout_of_that_user(client, proxy):
|
||||
set_password("bob-password", "bob")
|
||||
far = {"environ_base": {"REMOTE_ADDR": "198.51.100.7"}}
|
||||
# A user without a password tries from two addresses, and meets the lockout.
|
||||
for kwargs in ({}, far):
|
||||
for _ in range(auth.MAX_FAILURES):
|
||||
assert client.get("/opds", headers=basic("guess", "alice"), **kwargs).status_code == 401
|
||||
for _ in range(auth.MAX_FAILURES):
|
||||
assert client.get("/opds", headers=basic("guess", "bob")).status_code == 401
|
||||
assert client.get("/opds", headers=basic("alice-password", "alice")).status_code == 401
|
||||
saved = client.put(
|
||||
"/api/settings/opds-password", json={"password": "alice-password"}, headers={**H, "X-Remote-User": "Alice"}
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
for kwargs in ({}, far):
|
||||
assert client.get("/opds", headers=basic("alice-password", "alice"), **kwargs).status_code == 200
|
||||
# The lockout of another user stays.
|
||||
assert client.get("/opds", headers=basic("bob-password", "bob")).status_code == 401
|
||||
Reference in New Issue
Block a user