Add single-user local authentication
This commit is contained in:
@@ -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 <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
|
||||
|
||||
+57
-5
@@ -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()
|
||||
|
||||
+3
-1
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
+10
-7
@@ -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}
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user