"""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