Add single-user local authentication

This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 75cc0464a2
commit 1a10fe53f3
11 changed files with 425 additions and 20 deletions
+47 -3
View File
@@ -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 | | `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` | | `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` | | `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 | | `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_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 | | `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 - `none`: Bindery asks for no login. Use this only on a private network, or behind a proxy that does
the login. 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". - `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), - `proxy`: A reverse proxy, such as [Rauthy](https://github.com/sebadob/rauthy),
[Authelia](https://github.com/authelia/authelia) or [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 **If the login settings are incomplete or wrong, Bindery refuses every request**, also with the API
key, and shows the reason. 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 #### CWA logins
`AUTH_METHOD=cwa` needs `CWA_URL`, `CWA_DB_PATH` or both. Without `CWA_DB_PATH`, Bindery sends the `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 #### 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 - 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. 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. 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 for example with `openssl rand -hex 32`, and set it in `BINDERY_API_KEY`. Send it with each
request as `Authorization: Bearer <key>` or `X-Api-Key: <key>`. request as `Authorization: Bearer <key>` or `X-Api-Key: <key>`.
- In the `none` login mode, a script needs no key, but it must send `X-Requested-With: bindery` with - In the `none` login mode, a script needs no key, but it must send `X-Requested-With: bindery` with
+57 -5
View File
@@ -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 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. the database cannot answer. A rejected database login never falls back.
""" """
import hashlib
import hmac import hmac
import logging import logging
import threading import threading
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from typing import Any from typing import Any
from werkzeug.security import check_password_hash
from bindery import calibre_db, config from bindery import calibre_db, config
from bindery.cwa import CwaClient, CwaError from bindery.cwa import CwaClient, CwaError
logger = logging.getLogger(__name__) 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_FAILURES = 5 # for one user name from one address
MAX_ADDRESS_FAILURES = 20 # for all user names from one address MAX_ADDRESS_FAILURES = 20 # for all user names from one address
LOCKOUT_SECONDS = 15 * 60 LOCKOUT_SECONDS = 15 * 60
@@ -33,14 +37,40 @@ class Identity:
via: str # "none", "session", "proxy" or "api_key" 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: def config_error() -> str | None:
"""Why the login settings cannot work, or 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. Bindery then refuses every request, so a mistake in the settings never turns the logins off.
""" """
method = config.AUTH_METHOD method = config.AUTH_METHOD
if method not in (AUTH_NONE, AUTH_CWA, AUTH_PROXY): if method not in (AUTH_NONE, AUTH_LOCAL, AUTH_CWA, AUTH_PROXY):
return f"AUTH_METHOD must be none, cwa or proxy, not {method!r}" 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): 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" return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"
if method == AUTH_PROXY and not config.PROXY_AUTH_USER_HEADER: 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() name = (headers.get(config.PROXY_AUTH_USER_HEADER) or "").strip()
return Identity(name, "proxy") if name else None return Identity(name, "proxy") if name else None
name = session.get("user") 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 Identity(name, "session")
return None return None
@@ -142,6 +177,23 @@ def _login_ok(username: str, password: str, client: CwaClient) -> bool:
return client.login_ok(username, password) 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: def login_cwa(username: str, password: str, client: CwaClient, address: str) -> Identity:
"""Check a CWA login. Raises AuthError with a message for the user.""" """Check a CWA login. Raises AuthError with a message for the user."""
username = username.strip() username = username.strip()
+3 -1
View File
@@ -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")) UPDATE_INTERVAL_HOURS = float(os.getenv("UPDATE_INTERVAL_HOURS", "24"))
DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title") 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() 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") CWA_ALLOWED_USERS = _list("CWA_ALLOWED_USERS")
PROXY_AUTH_USER_HEADER = os.getenv("PROXY_AUTH_USER_HEADER", "X-Remote-User").strip() 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() PROXY_AUTH_LOGOUT_URL = os.getenv("PROXY_AUTH_LOGOUT_URL", "").strip()
+2 -2
View File
@@ -78,7 +78,7 @@ function shell() {
auth.mode !== 'none' && auth.mode !== 'none' &&
auth.user && [ auth.user && [
h('span', {}, 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'), 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')); 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('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', {}, 'User name', username),
h('label', {}, 'Password', password), h('label', {}, 'Password', password),
h('label', {}, remember, ' Keep me logged in'), h('label', {}, remember, ' Keep me logged in'),
+10 -7
View File
@@ -179,17 +179,20 @@ def auth_check() -> Any:
def auth_login() -> Any: def auth_login() -> Any:
data = _json() data = _json()
try: try:
identity = auth.login_cwa( username = str(data.get("username", ""))
str(data.get("username", "")), password = str(data.get("password", ""))
str(data.get("password", "")), address = auth.client_address(request.headers, request.remote_addr)
orchestrator.cwa(), if auth.active_mode() == auth.AUTH_LOCAL:
auth.client_address(request.headers, request.remote_addr), identity = auth.login_local(username, password, address)
) else:
identity = auth.login_cwa(username, password, orchestrator.cwa(), address)
except auth.AuthError as e: except auth.AuthError as e:
return {"error": str(e)}, 401 return {"error": str(e)}, 401
session.clear() session.clear()
session["user"] = identity.name 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")) session.permanent = bool(data.get("remember"))
logger.info(f"{identity.name} logged in") logger.info(f"{identity.name} logged in")
return {"authenticated": True, "user": identity.name} return {"authenticated": True, "user": identity.name}
+4 -1
View File
@@ -9,8 +9,11 @@ services:
CWA_URL: http://calibre-web-automated:8083 CWA_URL: http://calibre-web-automated:8083
CWA_USERNAME: bindery CWA_USERNAME: bindery
CWA_PASSWORD: change-me 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 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. # Optional and independent. app.db is for logins, metadata.db is for book IDs.
# CWA_DB_PATH: /cwa-config/app.db # CWA_DB_PATH: /cwa-config/app.db
# CALIBRE_DB_PATH: /calibre-library/metadata.db # CALIBRE_DB_PATH: /calibre-library/metadata.db
+2
View File
@@ -16,6 +16,8 @@ for name in (
"CWA_DB_PATH", "CWA_DB_PATH",
"CALIBRE_DB_PATH", "CALIBRE_DB_PATH",
"AUTH_METHOD", "AUTH_METHOD",
"BINDERY_USERNAME",
"BINDERY_PASSWORD_HASH",
"BINDERY_API_KEY", "BINDERY_API_KEY",
"URL_BASE", "URL_BASE",
"CWA_ALLOWED_USERS", "CWA_ALLOWED_USERS",
+58
View File
@@ -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'));
});
+1 -1
View File
@@ -163,7 +163,7 @@ def test_proxy_header(client, monkeypatch):
("setting", "value", "error"), ("setting", "value", "error"),
[ [
("AUTH_METHOD", "cwa", "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"), ("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"), ("PROXY_AUTH_USER_HEADER", "", "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER"),
], ],
) )
+8
View File
@@ -73,6 +73,8 @@ def test_values_from_the_environment(monkeypatch, reload_config):
monkeypatch.setenv("TRUST_PROXY", "yes") monkeypatch.setenv("TRUST_PROXY", "yes")
monkeypatch.setenv("CWA_DB_PATH", " /cwa/app.db ") monkeypatch.setenv("CWA_DB_PATH", " /cwa/app.db ")
monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db") monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db")
monkeypatch.setenv("BINDERY_USERNAME", " reader ")
monkeypatch.setenv("BINDERY_PASSWORD_HASH", " hash ")
reload_config() reload_config()
assert config.URL_BASE == "/bindery" assert config.URL_BASE == "/bindery"
assert config.AUTH_METHOD == "cwa" 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 config.TRUST_PROXY is True
assert str(config.CWA_DB_PATH) == "/cwa/app.db" assert str(config.CWA_DB_PATH) == "/cwa/app.db"
assert str(config.CALIBRE_DB_PATH) == "/library/metadata.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): def test_defaults(monkeypatch, reload_config):
@@ -97,6 +101,8 @@ def test_defaults(monkeypatch, reload_config):
"TRUST_PROXY", "TRUST_PROXY",
"CWA_DB_PATH", "CWA_DB_PATH",
"CALIBRE_DB_PATH", "CALIBRE_DB_PATH",
"BINDERY_USERNAME",
"BINDERY_PASSWORD_HASH",
): ):
monkeypatch.delenv(name, raising=False) monkeypatch.delenv(name, raising=False)
reload_config() reload_config()
@@ -110,3 +116,5 @@ def test_defaults(monkeypatch, reload_config):
assert config.TRUST_PROXY is False assert config.TRUST_PROXY is False
assert config.CWA_DB_PATH is None assert config.CWA_DB_PATH is None
assert config.CALIBRE_DB_PATH is None assert config.CALIBRE_DB_PATH is None
assert config.LOCAL_USERNAME == ""
assert config.LOCAL_PASSWORD_HASH == ""
+233
View File
@@ -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