From 1a10fe53f3acd3e824446f0d653408997c1dde4e Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sat, 26 Sep 2026 16:20:32 +0200 Subject: [PATCH] Add single-user local authentication --- README.md | 50 +++++++- bindery/auth.py | 62 +++++++++- bindery/config.py | 4 +- bindery/static/js/main.js | 4 +- bindery/web.py | 17 +-- docker-compose.yml | 5 +- tests/conftest.py | 2 + tests/js/local-auth.test.mjs | 58 +++++++++ tests/test_api.py | 2 +- tests/test_config.py | 8 ++ tests/test_local_auth.py | 233 +++++++++++++++++++++++++++++++++++ 11 files changed, 425 insertions(+), 20 deletions(-) create mode 100644 tests/js/local-auth.test.mjs create mode 100644 tests/test_local_auth.py diff --git a/README.md b/README.md index 39c81da..4760b36 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ Environment variables set the configuration. **Restart the container after a cha | `CALIBRE_DB_PATH` | The calibre `metadata.db`, for book ids. See "How books reach the library". | None | | `UPDATE_INTERVAL_HOURS` | Hours between checks for new chapters. `0` turns them off. | `24` | | `DEFAULT_LOCKED_FIELDS` | Fields that new stories lock: `title`, `authors`, `comments`, `tags`, `series`, `pubdate`, `languages` | `title` | -| `AUTH_METHOD` | `none`, `cwa` or `proxy`. See "Logins". | `none` | +| `AUTH_METHOD` | `none`, `local`, `cwa` or `proxy`. See "Logins". | `none` | +| `BINDERY_USERNAME` | The user name for `AUTH_METHOD=local`. Case-sensitive. | None | +| `BINDERY_PASSWORD_HASH` | The password hash for `AUTH_METHOD=local`. See "Local login". | None | | `CWA_ALLOWED_USERS` | The CWA users that can log in. Empty allows every CWA user. | Empty | | `PROXY_AUTH_USER_HEADER` | The header in which a reverse proxy sends the user name | `X-Remote-User` | | `PROXY_AUTH_LOGOUT_URL` | Where the Log out button goes in proxy mode | None | @@ -79,6 +81,7 @@ Separate the values in a list with commas. - `none`: Bindery asks for no login. Use this only on a private network, or behind a proxy that does the login. +- `local`: One user logs in with a user name and password that you set. See "Local login". - `cwa`: Users log in with their CWA user name and password. See "CWA logins". - `proxy`: A reverse proxy, such as [Rauthy](https://github.com/sebadob/rauthy), [Authelia](https://github.com/authelia/authelia) or @@ -90,6 +93,47 @@ Separate the values in a list with commas. **If the login settings are incomplete or wrong, Bindery refuses every request**, also with the API key, and shows the reason. +#### Local login + +`AUTH_METHOD=local` gives one Bindery account without CWA. You must set `BINDERY_USERNAME` and +`BINDERY_PASSWORD_HASH`. The hash is a one-way form of the password. + +1. In the folder with your `docker-compose.yaml` file, run this command to make the hash. The command hides the + password as you type it. Use a password that is not empty. + + ```bash + docker compose run --rm --no-deps bindery python -c 'from getpass import getpass; from werkzeug.security import generate_password_hash; print(generate_password_hash(getpass("Password: ")))' + ``` + + In a source checkout, you can use: + + ```bash + uv run python -c 'from getpass import getpass; from werkzeug.security import generate_password_hash; print(generate_password_hash(getpass("Password: ")))' + ``` + +2. Make a `.env` file beside the `docker-compose.yaml` file, with your user name and the hash: + + ```dotenv + BINDERY_USERNAME=reader + BINDERY_PASSWORD_HASH='paste-the-generated-hash-here' + ``` + + **Keep the single quotes around the hash.** They stop Docker from reading its `$` characters as + variables. Keep `.env` private, and do not commit it. + + If you put the hash in `docker-compose.yml` instead, **write each `$` as `$$`**, for example + `'scrypt:32768:8:1$$salt$$digest'`. In the Compose file, quotes do not stop Compose from reading + `$` as the start of a variable. Bindery then gets a shortened hash, and it reports that the hash + is not valid. + +3. In the Compose file, set `AUTH_METHOD: local`, and uncomment `BINDERY_USERNAME` and + `BINDERY_PASSWORD_HASH`. Restart the container. + +4. Log in with the user name and the password, not the hash. + +To change the account, change the user name or make a new hash, and restart Bindery. This ends the +open sessions. + #### CWA logins `AUTH_METHOD=cwa` needs `CWA_URL`, `CWA_DB_PATH` or both. Without `CWA_DB_PATH`, Bindery sends the @@ -113,7 +157,7 @@ authentication do not apply. If they must apply, use the `proxy` mode. #### Lockouts and sessions -The `cwa` mode locks out password guesses: +The `local` and `cwa` modes lock out password guesses: - After 5 wrong passwords for one user name from one address in 15 minutes, Bindery locks that user name for 15 minutes, for that address only. The real user can still log in from another address. @@ -137,7 +181,7 @@ restart Bindery. Scripts can use the JSON API of the web interface. -- In the `cwa` and `proxy` login modes, a script needs the API key. Make a long random key, +- In the `local`, `cwa` and `proxy` login modes, a script needs the API key. Make a long random key, for example with `openssl rand -hex 32`, and set it in `BINDERY_API_KEY`. Send it with each request as `Authorization: Bearer ` or `X-Api-Key: `. - In the `none` login mode, a script needs no key, but it must send `X-Requested-With: bindery` with diff --git a/bindery/auth.py b/bindery/auth.py index 48a468f..277f586 100644 --- a/bindery/auth.py +++ b/bindery/auth.py @@ -1,23 +1,27 @@ -"""Logins: none, CWA accounts, or a reverse proxy header. An API key works in every mode. +"""Logins: none, one local account, CWA accounts, or a proxy. API keys work in every mode. CWA accounts use an optional read-only app.db, with OPDS as the fallback when the database cannot answer. A rejected database login never falls back. """ +import hashlib import hmac import logging import threading import time from collections.abc import Mapping from dataclasses import dataclass +from functools import lru_cache from typing import Any +from werkzeug.security import check_password_hash + from bindery import calibre_db, config from bindery.cwa import CwaClient, CwaError logger = logging.getLogger(__name__) -AUTH_NONE, AUTH_CWA, AUTH_PROXY = "none", "cwa", "proxy" +AUTH_NONE, AUTH_LOCAL, AUTH_CWA, AUTH_PROXY = "none", "local", "cwa", "proxy" MAX_FAILURES = 5 # for one user name from one address MAX_ADDRESS_FAILURES = 20 # for all user names from one address LOCKOUT_SECONDS = 15 * 60 @@ -33,14 +37,40 @@ class Identity: via: str # "none", "session", "proxy" or "api_key" +@lru_cache(maxsize=8) +def _valid_local_hash(value: str) -> bool: + """Validate once per configured hash, not with an expensive hash on each request.""" + try: + method, salt, digest = value.split("$") + if not salt or not method.startswith(("scrypt:", "pbkdf2:")): + return False + size = 64 if method.startswith("scrypt:") else hashlib.new(method.split(":")[1]).digest_size + if len(digest) != size * 2 or bytes.fromhex(digest).hex() != digest: + return False + # This also rejects unsupported parameters and hashes of an empty password. + return not check_password_hash(value, "") + except (ValueError, OverflowError): + return False + + +def local_password_version() -> str: + """Invalidate local sessions when the configured password hash changes.""" + return hashlib.sha256(config.LOCAL_PASSWORD_HASH.encode()).hexdigest() + + def config_error() -> str | None: """Why the login settings cannot work, or None. Bindery then refuses every request, so a mistake in the settings never turns the logins off. """ method = config.AUTH_METHOD - if method not in (AUTH_NONE, AUTH_CWA, AUTH_PROXY): - return f"AUTH_METHOD must be none, cwa or proxy, not {method!r}" + if method not in (AUTH_NONE, AUTH_LOCAL, AUTH_CWA, AUTH_PROXY): + return f"AUTH_METHOD must be none, local, cwa or proxy, not {method!r}" + if method == AUTH_LOCAL: + if not config.LOCAL_USERNAME or not config.LOCAL_PASSWORD_HASH: + return "AUTH_METHOD=local needs BINDERY_USERNAME and BINDERY_PASSWORD_HASH" + if not _valid_local_hash(config.LOCAL_PASSWORD_HASH): + return "BINDERY_PASSWORD_HASH must be a Werkzeug scrypt or PBKDF2 hash of a nonempty password" if method == AUTH_CWA and not (config.CWA_URL or config.CWA_DB_PATH): return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH" if method == AUTH_PROXY and not config.PROXY_AUTH_USER_HEADER: @@ -83,7 +113,12 @@ def current_identity(headers: Mapping[str, str], session: Mapping[str, Any]) -> name = (headers.get(config.PROXY_AUTH_USER_HEADER) or "").strip() return Identity(name, "proxy") if name else None name = session.get("user") - if name and session.get("mode") == AUTH_CWA and _allowed(name): + if not name or session.get("mode") != mode: + return None + if mode == AUTH_LOCAL: + if name == config.LOCAL_USERNAME and session.get("local_password_version") == local_password_version(): + return Identity(name, "session") + elif mode == AUTH_CWA and _allowed(name): return Identity(name, "session") return None @@ -142,6 +177,23 @@ def _login_ok(username: str, password: str, client: CwaClient) -> bool: return client.login_ok(username, password) +def login_local(username: str, password: str, address: str) -> Identity: + username = username.strip() + if active_mode() != AUTH_LOCAL: + raise AuthError("Bindery does not use local logins") + if error := config_error(): + raise AuthError(error) + if limiter.locked(username, address): + raise AuthError("Too many wrong passwords. Try again in 15 minutes.") + # Check the password even for a wrong user name, so both failures take the same work. + password_ok = check_password_hash(config.LOCAL_PASSWORD_HASH, password) + if not (password_ok and password and hmac.compare_digest(username.encode(), config.LOCAL_USERNAME.encode())): + limiter.failed(username, address) + raise AuthError("Wrong user name or password") + limiter.clear(username, address) + return Identity(config.LOCAL_USERNAME, "session") + + def login_cwa(username: str, password: str, client: CwaClient, address: str) -> Identity: """Check a CWA login. Raises AuthError with a message for the user.""" username = username.strip() diff --git a/bindery/config.py b/bindery/config.py index d58c8ba..d1134d3 100644 --- a/bindery/config.py +++ b/bindery/config.py @@ -52,8 +52,10 @@ CALIBRE_DB_PATH = _optional_path("CALIBRE_DB_PATH") # calibre metadata.db, for UPDATE_INTERVAL_HOURS = float(os.getenv("UPDATE_INTERVAL_HOURS", "24")) DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title") -# Logins: none, cwa or proxy. An API key works in every mode. +# Logins: none, local, cwa or proxy. An API key works in every mode. AUTH_METHOD = os.getenv("AUTH_METHOD", "none").strip().lower() +LOCAL_USERNAME = os.getenv("BINDERY_USERNAME", "").strip() +LOCAL_PASSWORD_HASH = os.getenv("BINDERY_PASSWORD_HASH", "").strip() CWA_ALLOWED_USERS = _list("CWA_ALLOWED_USERS") PROXY_AUTH_USER_HEADER = os.getenv("PROXY_AUTH_USER_HEADER", "X-Remote-User").strip() PROXY_AUTH_LOGOUT_URL = os.getenv("PROXY_AUTH_LOGOUT_URL", "").strip() diff --git a/bindery/static/js/main.js b/bindery/static/js/main.js index 2e7fb64..7a7a148 100644 --- a/bindery/static/js/main.js +++ b/bindery/static/js/main.js @@ -78,7 +78,7 @@ function shell() { auth.mode !== 'none' && auth.user && [ h('span', {}, auth.user), - (auth.mode === 'cwa' || auth.logout_url) && + (auth.mode === 'cwa' || auth.mode === 'local' || auth.logout_url) && h('button', { type: 'button', onclick: () => logout().catch(showError) }, 'Log out'), ]; nav = h('nav', { 'aria-label': 'Main navigation' }, h('a', { href: '#/' }, 'Stories'), h('a', { href: '#/settings' }, 'Settings')); @@ -134,7 +134,7 @@ function loginPage() { }, }, h('h1', { class: 'brand' }, h('img', { src: 'static/favicon.svg', alt: '', width: 28, height: 28 }), 'Bindery'), - h('p', { class: 'hint' }, 'Log in with your CWA account.'), + h('p', { class: 'hint' }, auth.mode === 'local' ? 'Log in with your Bindery account.' : 'Log in with your CWA account.'), h('label', {}, 'User name', username), h('label', {}, 'Password', password), h('label', {}, remember, ' Keep me logged in'), diff --git a/bindery/web.py b/bindery/web.py index b7782f4..f60d52e 100644 --- a/bindery/web.py +++ b/bindery/web.py @@ -179,17 +179,20 @@ def auth_check() -> Any: def auth_login() -> Any: data = _json() try: - identity = auth.login_cwa( - str(data.get("username", "")), - str(data.get("password", "")), - orchestrator.cwa(), - auth.client_address(request.headers, request.remote_addr), - ) + username = str(data.get("username", "")) + password = str(data.get("password", "")) + address = auth.client_address(request.headers, request.remote_addr) + if auth.active_mode() == auth.AUTH_LOCAL: + identity = auth.login_local(username, password, address) + else: + identity = auth.login_cwa(username, password, orchestrator.cwa(), address) except auth.AuthError as e: return {"error": str(e)}, 401 session.clear() session["user"] = identity.name - session["mode"] = auth.AUTH_CWA + session["mode"] = auth.active_mode() + if auth.active_mode() == auth.AUTH_LOCAL: + session["local_password_version"] = auth.local_password_version() session.permanent = bool(data.get("remember")) logger.info(f"{identity.name} logged in") return {"authenticated": True, "user": identity.name} diff --git a/docker-compose.yml b/docker-compose.yml index ab08cf1..6e34f39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,11 @@ services: CWA_URL: http://calibre-web-automated:8083 CWA_USERNAME: bindery CWA_PASSWORD: change-me - # none, cwa (log in with a CWA user) or proxy (a reverse proxy sets a user header) + # none, local (one Bindery account), cwa (CWA accounts), or proxy (a trusted user header) AUTH_METHOD: cwa + # For local logins, put these values in .env. See "Local login" in the README. + # BINDERY_USERNAME: ${BINDERY_USERNAME:-} + # BINDERY_PASSWORD_HASH: ${BINDERY_PASSWORD_HASH:-} # Optional and independent. app.db is for logins, metadata.db is for book IDs. # CWA_DB_PATH: /cwa-config/app.db # CALIBRE_DB_PATH: /calibre-library/metadata.db diff --git a/tests/conftest.py b/tests/conftest.py index fd3400d..3a2cabc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,8 @@ for name in ( "CWA_DB_PATH", "CALIBRE_DB_PATH", "AUTH_METHOD", + "BINDERY_USERNAME", + "BINDERY_PASSWORD_HASH", "BINDERY_API_KEY", "URL_BASE", "CWA_ALLOWED_USERS", diff --git a/tests/js/local-auth.test.mjs b/tests/js/local-auth.test.mjs new file mode 100644 index 0000000..f041f82 --- /dev/null +++ b/tests/js/local-auth.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { emit, field, find, install, nodes, server, settle } from './flow-helpers.mjs'; + +const ui = install(); +const { api, setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js'); +const { stopPolling } = await import('../../bindery/static/js/jobs.js'); + +test('local login, retry, session expiry and logout use the existing form', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const backend = server(t); + let auth = { authenticated: false, mode: 'local', user: null }; + backend.on('GET', 'auth/check', () => auth); + backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false }); + backend.on('GET', 'stories', []); + backend.on('GET', 'jobs', []); + backend.on('GET', 'expired', { error: 'Session ended' }, 401); + backend.on('POST', 'auth/logout', () => { auth = { ...auth, authenticated: false, user: null }; return {}; }); + t.after(() => { stopPolling(); setUnauthorizedHandler(() => {}); }); + await import('../../bindery/static/js/main.js'); + await settle(); + assert.match(ui.app.textContent, /Log in with your Bindery account/); + assert.doesNotMatch(ui.app.textContent, /CWA account/); + assert.equal(document.activeElement, field(ui.app, 'username')); + assert.equal(backend.sent('GET', 'config').length, 0); + const form = () => nodes(ui.app).find((el) => el.tagName === 'form'); + field(ui.app, 'username').value = 'reader'; + field(ui.app, 'password').value = 'wrong'; + backend.on('POST', 'auth/login', { error: 'Wrong user name or password' }, 401); + await emit(form(), 'submit'); + assert.match(ui.app.textContent, /Wrong user name or password/); + assert.equal(find(ui.app, 'button', 'Log in').disabled, false); + assert.deepEqual(backend.sent('POST', 'auth/login')[0].body, { username: 'reader', password: 'wrong', remember: true }); + field(ui.app, 'password').value = 'correct'; + nodes(form()).find((el) => el.attributes.type === 'checkbox').checked = false; + backend.on('POST', 'auth/login', () => { auth = { authenticated: true, mode: 'local', user: 'reader' }; return {}; }); + await emit(form(), 'submit'); + await settle(); + assert.ok(find(ui.app, 'button', 'Log out')); + assert.match(ui.app.textContent, /reader/); + const request = backend.sent('POST', 'auth/login')[1]; + assert.deepEqual(request.body, { username: 'reader', password: 'correct', remember: false }); + assert.equal(request.headers['X-Requested-With'], 'bindery'); + auth = { ...auth, authenticated: false }; + await assert.rejects(api('expired'), /Session ended/); + await settle(); + assert.ok(find(ui.app, 'button', 'Log in')); + const polls = backend.sent('GET', 'jobs').length; + t.mock.timers.tick(60000); + await settle(); + assert.equal(backend.sent('GET', 'jobs').length, polls); + await emit(form(), 'submit'); + await settle(); + await emit(find(ui.app, 'button', 'Log out'), 'click'); + await settle(); + assert.equal(backend.sent('POST', 'auth/logout').length, 1); + assert.ok(find(ui.app, 'button', 'Log in')); +}); diff --git a/tests/test_api.py b/tests/test_api.py index 3ec45ed..b3c733d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -163,7 +163,7 @@ def test_proxy_header(client, monkeypatch): ("setting", "value", "error"), [ ("AUTH_METHOD", "cwa", "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"), - ("AUTH_METHOD", "cwa-login", "AUTH_METHOD must be none, cwa or proxy, not 'cwa-login'"), + ("AUTH_METHOD", "cwa-login", "AUTH_METHOD must be none, local, cwa or proxy, not 'cwa-login'"), ("PROXY_AUTH_USER_HEADER", "", "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER"), ], ) diff --git a/tests/test_config.py b/tests/test_config.py index 23c1b6c..6f1d76c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -73,6 +73,8 @@ def test_values_from_the_environment(monkeypatch, reload_config): monkeypatch.setenv("TRUST_PROXY", "yes") monkeypatch.setenv("CWA_DB_PATH", " /cwa/app.db ") monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db") + monkeypatch.setenv("BINDERY_USERNAME", " reader ") + monkeypatch.setenv("BINDERY_PASSWORD_HASH", " hash ") reload_config() assert config.URL_BASE == "/bindery" assert config.AUTH_METHOD == "cwa" @@ -83,6 +85,8 @@ def test_values_from_the_environment(monkeypatch, reload_config): assert config.TRUST_PROXY is True assert str(config.CWA_DB_PATH) == "/cwa/app.db" assert str(config.CALIBRE_DB_PATH) == "/library/metadata.db" + assert config.LOCAL_USERNAME == "reader" + assert config.LOCAL_PASSWORD_HASH == "hash" def test_defaults(monkeypatch, reload_config): @@ -97,6 +101,8 @@ def test_defaults(monkeypatch, reload_config): "TRUST_PROXY", "CWA_DB_PATH", "CALIBRE_DB_PATH", + "BINDERY_USERNAME", + "BINDERY_PASSWORD_HASH", ): monkeypatch.delenv(name, raising=False) reload_config() @@ -110,3 +116,5 @@ def test_defaults(monkeypatch, reload_config): assert config.TRUST_PROXY is False assert config.CWA_DB_PATH is None assert config.CALIBRE_DB_PATH is None + assert config.LOCAL_USERNAME == "" + assert config.LOCAL_PASSWORD_HASH == "" diff --git a/tests/test_local_auth.py b/tests/test_local_auth.py new file mode 100644 index 0000000..8a84105 --- /dev/null +++ b/tests/test_local_auth.py @@ -0,0 +1,233 @@ +"""One local account, without CWA, with the same login and session protections.""" + +import time +from unittest.mock import Mock + +import pytest +from itsdangerous import TimestampSigner +from test_api import H, login +from test_api import client as client +from werkzeug.security import generate_password_hash + +from bindery import auth, config, orchestrator, web + + +def password_hash(password="pw", method="pbkdf2:sha256:1000"): + return generate_password_hash(password, method=method) + + +@pytest.fixture +def local(client, monkeypatch, tmp_path): + monkeypatch.setattr(config, "AUTH_METHOD", "local") + monkeypatch.setattr(config, "LOCAL_USERNAME", "reader") + monkeypatch.setattr(config, "LOCAL_PASSWORD_HASH", password_hash()) + monkeypatch.setattr(config, "CWA_ALLOWED_USERS", ["someone-else"]) + monkeypatch.setattr(config, "CWA_DB_PATH", tmp_path / "missing-app.db") + monkeypatch.setattr(config, "CALIBRE_DB_PATH", tmp_path / "missing-metadata.db") + remote = Mock(side_effect=AssertionError("Local logins must not contact CWA")) + monkeypatch.setattr(orchestrator, "cwa", remote) + yield remote + remote.assert_not_called() + + +@pytest.mark.parametrize("method", ["scrypt:1024:8:1", "pbkdf2:sha256:1000", "pbkdf2:sha512:1000", "scrypt"]) +def test_local_login_logout_and_password_hashes(client, local, monkeypatch, method): + monkeypatch.setattr(config, "LOCAL_PASSWORD_HASH", password_hash(method=method)) + assert client.get("/api/auth/check").json == { + "authenticated": False, + "mode": "local", + "user": None, + "logout_url": None, + } + assert client.get("/api/stories").status_code == 401 + with client.session_transaction() as session: + session["old_session_data"] = "discard" + assert login(client).json == {"authenticated": True, "user": "reader"} + assert client.get("/api/auth/check").json["authenticated"] + assert client.get("/api/stories").status_code == 200 + with client.session_transaction() as session: + assert session["mode"] == "local" + assert session["user"] == "reader" + assert "old_session_data" not in session + assert "pw" not in session.values() + assert config.LOCAL_PASSWORD_HASH not in session.values() + assert client.post("/api/auth/logout", headers=H).status_code == 200 + assert client.get("/api/stories").status_code == 401 + + +@pytest.mark.parametrize( + "username,password", [("reader", "wrong"), ("missing", "pw"), ("READER", "pw"), ("", "pw"), ("reader", "")] +) +def test_wrong_credentials_do_not_fall_back(client, local, username, password): + response = login(client, username=username, password=password) + assert response.status_code == 401 + assert response.json == {"error": "Wrong user name or password"} + assert not client.get("/api/auth/check").json["authenticated"] + + +def test_unicode_user_name_and_password_whitespace(client, local, monkeypatch): + monkeypatch.setattr(config, "LOCAL_USERNAME", "读者") + monkeypatch.setattr(config, "LOCAL_PASSWORD_HASH", password_hash(" pässword ")) + assert login(client, username=" 读者 ", password="pässword").status_code == 401 + assert login(client, username=" 读者 ", password=" pässword ").json["user"] == "读者" + + +def test_wrong_user_name_still_checks_the_password(client, local, monkeypatch): + assert auth.config_error() is None # Populate the hash validation cache. + check = Mock(wraps=auth.check_password_hash) + monkeypatch.setattr(auth, "check_password_hash", check) + assert login(client, username="missing").status_code == 401 + check.assert_called_once_with(config.LOCAL_PASSWORD_HASH, "pw") + + +def test_local_user_lockout_expires_and_is_address_specific(client, local, monkeypatch): + clock = [1000.0] + monkeypatch.setattr(auth.time, "monotonic", lambda: clock[0]) + for _ in range(auth.MAX_FAILURES): + assert login(client, password="wrong").status_code == 401 + assert "Too many wrong passwords" in login(client).json["error"] + assert login(client, address="10.0.0.2").status_code == 200 + clock[0] += auth.LOCKOUT_SECONDS + 1 + assert login(client).status_code == 200 + + +def test_rotating_names_cannot_bypass_the_address_limit(client, local): + for i in range(auth.MAX_ADDRESS_FAILURES): + assert login(client, username=f"user-{i}").status_code == 401 + assert "Too many wrong passwords" in login(client).json["error"] + + +def test_success_clears_failures_and_proxy_addresses_need_trust(client, local, monkeypatch): + for _ in range(auth.MAX_FAILURES - 1): + login(client, password="wrong") + assert login(client).status_code == 200 + assert not auth.limiter._failures["127.0.0.1"] + for _ in range(auth.MAX_FAILURES): + login(client, password="wrong") + assert "Too many wrong passwords" in login(client, forwarded="10.0.0.2").json["error"] + monkeypatch.setattr(config, "TRUST_PROXY", True) + assert login(client, forwarded="10.0.0.2").status_code == 200 + + +@pytest.mark.parametrize( + "setting,value", + [ + ("LOCAL_USERNAME", ""), + ("LOCAL_PASSWORD_HASH", ""), + ("LOCAL_PASSWORD_HASH", "plaintext"), + ("LOCAL_PASSWORD_HASH", "plain$salt$secret"), + ("LOCAL_PASSWORD_HASH", "scrypt:1024:8:1$$" + "a" * 128), + ("LOCAL_PASSWORD_HASH", "scrypt:3:8:1$salt$" + "a" * 128), + ("LOCAL_PASSWORD_HASH", "pbkdf2:no-such-hash:1000$salt$" + "a" * 64), + ("LOCAL_PASSWORD_HASH", "pbkdf2:sha256:1000$salt$" + "z" * 64), + ("LOCAL_PASSWORD_HASH", "pbkdf2:sha256:1000$salt$abc"), + ("LOCAL_PASSWORD_HASH", "pbkdf2:sha256:999999999999999999999$salt$" + "a" * 64), + ], +) +def test_bad_local_configuration_fails_closed(client, local, monkeypatch, setting, value): + monkeypatch.setattr(config, setting, value) + monkeypatch.setattr(config, "API_KEY", "test-key") + for response in [ + client.get("/api/auth/check"), + client.get("/api/stories", headers={"X-Api-Key": "test-key"}), + login(client), + ]: + assert response.status_code == 503 + assert "Fix the setting and restart Bindery" in response.json["error"] + assert "test-key" not in response.json["error"] + assert client.get("/api/health").status_code == 200 + + +def test_empty_password_hash_is_not_usable(client, local, monkeypatch): + monkeypatch.setattr(config, "LOCAL_PASSWORD_HASH", password_hash("")) + assert login(client, password="").status_code == 503 + with pytest.raises(auth.AuthError, match="nonempty password"): + auth.login_local("reader", "", "127.0.0.1") + + +def test_local_login_cannot_be_used_in_another_mode(local, monkeypatch): + monkeypatch.setattr(config, "AUTH_METHOD", "none") + with pytest.raises(auth.AuthError, match="does not use local logins"): + auth.login_local("reader", "pw", "127.0.0.1") + + +def test_local_logins_and_sessions_keep_csrf_protection(client, local): + assert client.post("/api/auth/login", json={"username": "reader", "password": "pw"}).status_code == 403 + assert login(client).status_code == 200 + assert client.post("/api/auth/logout").status_code == 403 + assert client.post("/api/stories", json={"urls": []}).status_code == 403 + assert client.post("/api/stories", json={"urls": []}, headers=H).status_code == 200 + + +@pytest.mark.parametrize("remember", [False, True]) +def test_remember_me_and_session_expiry(client, local, monkeypatch, remember): + response = client.post( + "/api/auth/login", headers=H, json={"username": "reader", "password": "pw", "remember": remember} + ) + assert response.status_code == 200 + cookie = response.headers["Set-Cookie"] + assert ("Expires=" in cookie) == remember + assert "HttpOnly" in cookie and "SameSite=Lax" in cookie + with client.session_transaction() as session: + assert session.permanent == remember + monkeypatch.setattr(TimestampSigner, "get_timestamp", lambda self: int(time.time()) + 86401) + assert client.get("/api/stories").status_code == 401 + + +@pytest.mark.parametrize("setting,value", [("LOCAL_USERNAME", "renamed"), ("LOCAL_PASSWORD_HASH", None)]) +def test_credential_changes_end_existing_sessions(client, local, monkeypatch, setting, value): + assert login(client).status_code == 200 + monkeypatch.setattr(config, setting, value or password_hash("new")) + assert client.get("/api/stories").status_code == 401 + assert login(client, username=config.LOCAL_USERNAME, password="new" if value is None else "pw").status_code == 200 + + +def test_sessions_do_not_cross_login_modes(client, local, monkeypatch): + assert login(client).status_code == 200 + monkeypatch.setattr(config, "AUTH_METHOD", "cwa") + monkeypatch.setattr(config, "CWA_ALLOWED_USERS", []) + assert client.get("/api/stories").status_code == 401 + monkeypatch.setattr(config, "AUTH_METHOD", "local") + with client.session_transaction() as session: + session["mode"] = "cwa" + assert client.get("/api/stories").status_code == 401 + + +def test_api_keys_still_work_in_local_mode(client, local, monkeypatch): + monkeypatch.setattr(config, "API_KEY", "test-key") + assert client.get("/api/stories", headers={"X-Api-Key": "test-key"}).status_code == 200 + assert ( + client.post("/api/stories", headers={"Authorization": "Bearer test-key"}, json={"urls": []}).status_code == 200 + ) + assert client.get("/api/stories", headers={"X-Api-Key": "wrong"}).status_code == 401 + + +def test_local_login_under_url_base_uses_a_secure_scoped_cookie(client, local, monkeypatch): + monkeypatch.setattr(web.app, "wsgi_app", web.PrefixMiddleware(web.app.wsgi_app, "/bindery")) + monkeypatch.setitem(web.app.config, "SESSION_COOKIE_PATH", "/bindery") + monkeypatch.setitem(web.app.config, "SESSION_COOKIE_SECURE", True) + response = client.post( + "/bindery/api/auth/login", + base_url="https://localhost", + headers=H, + json={"username": "reader", "password": "pw"}, + ) + assert response.status_code == 200 + assert "Secure" in response.headers["Set-Cookie"] + assert "Path=/bindery" in response.headers["Set-Cookie"] + assert client.get("/bindery/api/stories", base_url="https://localhost").status_code == 200 + assert client.get("/api/stories", base_url="https://localhost").status_code == 404 + + +def test_local_session_needs_its_password_version(client, local): + assert login(client).status_code == 200 + with client.session_transaction() as session: + del session["local_password_version"] + assert client.get("/api/stories").status_code == 401 + + +def test_tampered_cookie_does_not_grant_access(client, local): + assert login(client).status_code == 200 + cookie = client.get_cookie(web.app.config["SESSION_COOKIE_NAME"]) + client.set_cookie(cookie.key, cookie.value + "changed") + assert client.get("/api/stories").status_code == 401