mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 09:10:23 +01:00
feat(auth): static API_KEY (env) accepted as Bearer or X-Api-Key, cookie or key (#1366)
Supersedes #1353, per the discussion in #1352: one `API_KEY` environment variable; when set, a request carrying it is authenticated as the first admin, and cookie sessions keep working exactly as before (cookie **or** key). Nothing else changes. No table, no UI, no settings-tab switch, no per-user keys. ## What - `API_KEY` (env). Unset → the feature is off and none of the new code runs. - `Authorization: Bearer <key>` or `X-Api-Key: <key>` on any existing `/api/*` route authenticates that request as the first admin in `users.db` (`ORDER BY id`), or as a bare admin identity (`user_id="api"`, `is_admin=True`, no local user row) if the install has no admin yet. Per request only; nothing is persisted; the admin's role is read live, so deleting or demoting that user takes effect on the next request. - Both headers are checked and either may match. That is what makes the key usable behind a reverse proxy that injects its own `Authorization` header (oauth2-proxy, Authelia, forwardAuth): send the key in `X-Api-Key`. - A credential that is **not** the key is ignored and the request continues on the normal session path, so proxy-forwarded tokens are unaffected. Without a valid session such a request gets the usual `401 {"error": "Unauthorized"}`, identical to a request with no credential, so there is nothing to probe. ## How - `shelfmark/config/env.py`: `API_KEY = os.getenv("API_KEY", "").strip()`. - `shelfmark/core/api_key.py`: `extract_api_key_candidates()` (Bearer token if the scheme is Bearer, then `X-Api-Key`) and `matches_api_key()` using `hmac.compare_digest` on bytes. - `shelfmark/core/user_db.py`: `UserDB.get_first_admin()`. - `shelfmark/main.py`: `api_key_auth_middleware` (`before_request`, registered before `proxy_auth_middleware`, which early-returns for keyed requests). Only `/api/` paths; `/api/health` and `/api/auth/*` exempt; no-op when `API_KEY` is unset or the auth mode is `none`. On a match it mirrors the proxy-auth pattern: `session.clear()` then populate `user_id` / `is_admin` / `db_user_id` for this request, `permanent = False`, `modified = False`, `g.api_key_auth = True`. An `after_request` hook guarantees no `Set-Cookie` is written for a keyed request even if a handler dirties the session. - `docs/api-access.md` (new), the `API_KEY` entry in `docs/environment-variables.md`, and a README link. ## Security - Constant-time compare; the key is never logged or echoed. - Keyed requests never mint or refresh a session cookie and ignore any cookie sent with them (a non-admin cookie plus the key yields admin for that request; the browser's own session is left untouched and usable). - The mismatch path touches neither the session nor `g`, so a stray bearer on a browser request can neither log the user out nor change how their cookie is refreshed. - Store errors during the admin lookup fail closed (`500 {"error": "Authentication error"}`), never to anonymous. - Verified against Flask's `save_session` / `should_set_cookie` ordering, and under auth modes `none`, `builtin`, `proxy`. ## Tests `tests/core/test_api_key_env.py` (36): extraction and matching; first-admin lookup; middleware behaviour on a guarded route and an admin route, with and without a user_db, `X-Api-Key`, both-headers combinations, no `Set-Cookie` when a handler dirties the session, incoming non-admin cookie ignored, browser cookie still usable after a keyed request, security headers, store error → 500, mismatch → guard's 401 / cookie path / permanent cookie untouched, unset → off, exempt paths and path probes, `none` and `proxy` modes, deleted and demoted first admin, a keyed write passing the guard. Existing auth suites unchanged. All CI gates green on the fork: https://github.com/gavinmcfall/shelfmark/pull/2 (CI-only draft). Also exercised against a running instance: 47 scripted checks including 150 concurrent requests, proxy-mode switching through the key, an unset-key restart, and a log scan for the key. ## Naming `API_KEY` as discussed. If you'd rather namespace it (`SHELFMARK_API_KEY`) to avoid clashing with other tools' env vars in shared compose files, it is a one-line change; say the word. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
1a5b37d9d3
commit
3b280009ae
@@ -0,0 +1,72 @@
|
||||
# API access with an API key
|
||||
|
||||
Shelfmark's web interface is driven entirely by a JSON API under `/api/`. Set
|
||||
the `API_KEY` environment variable and scripts, dashboards and assistants can
|
||||
call the same API without a browser session. Browser logins keep working
|
||||
exactly as before: it is cookie **or** key.
|
||||
|
||||
## Set the key
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
API_KEY: "a-long-random-secret"
|
||||
```
|
||||
|
||||
Generate something long and random (for example `openssl rand -base64 32`).
|
||||
A request carrying the key acts as an **admin**: the first admin user in
|
||||
Shelfmark's user database. Create an admin before relying on the key in any
|
||||
install that has none yet (for example an OIDC-only install). Without an
|
||||
admin user, the key still authenticates as an admin identity with no user
|
||||
row, and routes that need one (requests, activity) answer 403. To rotate,
|
||||
change the variable and restart. Unset it and the feature is off. When the
|
||||
instance runs with no authentication configured (`AUTH_METHOD=none`), the
|
||||
key is simply unnecessary.
|
||||
|
||||
## Send the key
|
||||
|
||||
Either header works, and both are checked, so the key can be sent in
|
||||
`X-Api-Key` behind a reverse proxy that sets its own `Authorization` header.
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $API_KEY" https://shelfmark.example.com/api/downloads/active
|
||||
curl -s -H "X-Api-Key: $API_KEY" https://shelfmark.example.com/api/downloads/active
|
||||
```
|
||||
|
||||
A request that carries the key is authenticated by the key alone. Session
|
||||
cookies are ignored and none are set. A bearer value that is not the configured
|
||||
key is ignored and the request continues with normal session authentication,
|
||||
so reverse proxies that forward their own tokens are unaffected; without a valid
|
||||
session such a request gets the usual `401 {"error": "Unauthorized"}`. A
|
||||
database error while resolving the admin returns
|
||||
`500 {"error": "Authentication error"}` — never anonymous access.
|
||||
`/api/auth/check` reflects the browser session only and ignores the key, so
|
||||
use `/api/status` to verify a key.
|
||||
|
||||
## Examples
|
||||
|
||||
Search, then look up releases, then queue one (the same calls the web UI makes):
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"https://shelfmark.example.com/api/metadata/search?query=dune%20frank%20herbert"
|
||||
# -> {"books":[{"provider":"hardcover","provider_id":"427363", ...}]}
|
||||
|
||||
curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
"https://shelfmark.example.com/api/releases?provider=hardcover&book_id=427363&content_type=ebook"
|
||||
# -> {"releases":[{"source":"direct_download","source_id":"...", ...}], ...}
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
|
||||
-d @release.json https://shelfmark.example.com/api/releases/download
|
||||
# release.json = one object from "releases" (source and source_id are required)
|
||||
|
||||
curl -s -H "Authorization: Bearer $API_KEY" https://shelfmark.example.com/api/status
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- The key is compared in constant time and is never logged.
|
||||
- Keyed requests never set cookies and ignore any cookie sent with them.
|
||||
- WebSocket (live activity) connections do not accept the key; poll `/api/status` instead.
|
||||
- The key is a root-equivalent credential: an admin can configure a custom
|
||||
post-download script that the server executes, so treat it like a root
|
||||
password and send it only over HTTPS.
|
||||
@@ -47,6 +47,7 @@ These environment variables are used at startup before the settings system loads
|
||||
| `CWA_DB_PATH` | Path to the Calibre-Web database for authentication integration. | string (path) | `/auth/app.db` |
|
||||
| `HIDE_LOCAL_AUTH` | Hide the username/password login form when OIDC is active. | boolean | `false` |
|
||||
| `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed. | boolean | `false` |
|
||||
| `API_KEY` | Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off. | string | `unset` |
|
||||
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` |
|
||||
| `DOCKERMODE` | Indicates the application is running inside a Docker container. | boolean | `false` |
|
||||
| `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` |
|
||||
@@ -124,6 +125,13 @@ Disable username/password login and remove the local-admin prerequisite for OIDC
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `API_KEY`
|
||||
|
||||
Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `unset`
|
||||
|
||||
#### `OIDC_AUTO_REDIRECT`
|
||||
|
||||
Automatically redirect to the OIDC provider instead of showing the login page.
|
||||
|
||||
@@ -15,6 +15,7 @@ Use the guides below to set up the app, connect your library tools, and understa
|
||||
- [Users & Requests](users-and-requests.md)
|
||||
- [Reverse Proxy](reverse-proxy.md)
|
||||
- [OIDC](oidc.md)
|
||||
- [API Access](api-access.md)
|
||||
- [URL Search Parameters](url-search-parameters.md)
|
||||
- [Custom Scripts](custom-scripts.md)
|
||||
|
||||
|
||||
@@ -248,6 +248,8 @@ volumes:
|
||||
|
||||
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
|
||||
|
||||
See [API Access](docs/api-access.md) to call the API with a static key from scripts and integrations.
|
||||
|
||||
## Project Scope
|
||||
|
||||
Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope.
|
||||
|
||||
@@ -184,6 +184,12 @@ def _generate_bootstrap_env_docs() -> list[str]:
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "API_KEY",
|
||||
"description": "Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.",
|
||||
"type": "string",
|
||||
"default": "unset",
|
||||
},
|
||||
{
|
||||
"name": "OIDC_AUTO_REDIRECT",
|
||||
"description": "Automatically redirect to the OIDC provider instead of showing the login page.",
|
||||
|
||||
@@ -166,6 +166,9 @@ SESSION_COOKIE_NAME = "shelfmark_session"
|
||||
CWA_DB_PATH = _resolve_cwa_db_path()
|
||||
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
|
||||
DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false"))
|
||||
# Optional static API key. When set, requests carrying it as a Bearer token
|
||||
# (or X-Api-Key) are authenticated as an admin for that request only.
|
||||
API_KEY = os.getenv("API_KEY", "").strip()
|
||||
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Static API-key authentication backed by the API_KEY environment variable.
|
||||
|
||||
When ``API_KEY`` is set, a request carrying that value as a Bearer token or in
|
||||
``X-Api-Key`` is treated as an admin for that request only. Both headers are
|
||||
checked, since a reverse proxy in front of Shelfmark may set its own
|
||||
``Authorization`` header, which would otherwise shadow an operator-supplied
|
||||
``X-Api-Key``. A candidate that matches neither is ignored so that bearer
|
||||
tokens forwarded by reverse proxies keep working. The key is never logged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from shelfmark.config.env import API_KEY
|
||||
|
||||
|
||||
def extract_api_key_candidates(
|
||||
authorization_header: str | None, api_key_header: str | None
|
||||
) -> list[str]:
|
||||
"""Return the non-empty credentials a client presented, Bearer token first."""
|
||||
candidates: list[str] = []
|
||||
if authorization_header:
|
||||
scheme, _, token = authorization_header.strip().partition(" ")
|
||||
token = token.strip()
|
||||
if scheme.lower() == "bearer" and token:
|
||||
candidates.append(token)
|
||||
if api_key_header:
|
||||
token = api_key_header.strip()
|
||||
if token:
|
||||
candidates.append(token)
|
||||
return candidates
|
||||
|
||||
|
||||
def matches_api_key(candidate: str) -> bool:
|
||||
"""Constant-time comparison against the configured key. False when unset."""
|
||||
if not API_KEY or not candidate:
|
||||
return False
|
||||
return hmac.compare_digest(candidate.encode("utf-8"), API_KEY.encode("utf-8"))
|
||||
@@ -433,6 +433,17 @@ class UserDB:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_first_admin(self) -> dict[str, Any] | None:
|
||||
"""Return the lowest-id admin user, or None. Used as the identity for API_KEY requests."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def has_admin(self) -> bool:
|
||||
"""Return True when at least one admin user exists."""
|
||||
conn = self._connect()
|
||||
|
||||
+73
-2
@@ -14,7 +14,7 @@ from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
|
||||
|
||||
from flask import Flask, jsonify, request, send_file, send_from_directory, session
|
||||
from flask import Flask, g, jsonify, request, send_file, send_from_directory, session
|
||||
from flask_cors import CORS
|
||||
from flask_socketio import SocketIO, emit
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
@@ -42,6 +42,7 @@ from shelfmark.config.settings import (
|
||||
_SUPPORTED_BOOK_LANGUAGE,
|
||||
migrate_audiobook_format_settings,
|
||||
)
|
||||
from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch API_KEY
|
||||
from shelfmark.core import search_deadline
|
||||
from shelfmark.core.activity_view_state_service import ActivityViewStateService
|
||||
from shelfmark.core.auth_modes import (
|
||||
@@ -550,7 +551,7 @@ if _is_debug_enabled():
|
||||
r"/*": {
|
||||
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
"supports_credentials": True,
|
||||
"allow_headers": ["Content-Type", "Authorization"],
|
||||
"allow_headers": ["Content-Type", "Authorization", "X-Api-Key"],
|
||||
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
}
|
||||
},
|
||||
@@ -670,6 +671,61 @@ def _proxy_default_is_admin(db: UserDB) -> bool:
|
||||
return role == "admin"
|
||||
|
||||
|
||||
_API_KEY_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_API_KEY_EXEMPT_PATHS = frozenset({"/api/health"})
|
||||
|
||||
|
||||
@app.before_request
|
||||
def api_key_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
"""Authenticate requests that present the configured API_KEY.
|
||||
|
||||
Both Authorization: Bearer and X-Api-Key are checked, and either
|
||||
matching authenticates the request as an admin for this request only:
|
||||
any session cookie is ignored and none is written back. Checking both
|
||||
means a reverse proxy's own Authorization header never shadows an
|
||||
operator-supplied X-Api-Key. No matching candidate is ignored so bearer
|
||||
tokens forwarded by reverse proxies keep working; the request then
|
||||
continues on the normal session path.
|
||||
"""
|
||||
if not request.path.startswith("/api/"):
|
||||
return None
|
||||
if request.path in _API_KEY_EXEMPT_PATHS or request.path.startswith(_API_KEY_EXEMPT_PREFIXES):
|
||||
return None
|
||||
if not api_key_module.API_KEY:
|
||||
return None
|
||||
|
||||
candidates = api_key_module.extract_api_key_candidates(
|
||||
request.headers.get("Authorization"), request.headers.get("X-Api-Key")
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
if not any(api_key_module.matches_api_key(candidate) for candidate in candidates):
|
||||
return None
|
||||
if get_auth_mode() == "none":
|
||||
return None
|
||||
|
||||
# Mark the request as keyed before the lookup so the after-request cookie
|
||||
# reset also covers the error path below.
|
||||
g.api_key_auth = True
|
||||
|
||||
try:
|
||||
admin = user_db.get_first_admin() if user_db is not None else None
|
||||
except _OPERATIONAL_ERRORS:
|
||||
logger.exception("API key auth middleware error")
|
||||
return jsonify({"error": "Authentication error"}), 500
|
||||
|
||||
session.clear()
|
||||
session["user_id"] = admin["username"] if admin else "api"
|
||||
session["is_admin"] = True
|
||||
if admin:
|
||||
session["db_user_id"] = admin["id"]
|
||||
session.permanent = False
|
||||
# Identity is per request; never persist it as a cookie.
|
||||
session.modified = False
|
||||
return None
|
||||
|
||||
|
||||
@app.before_request
|
||||
def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
"""Middleware to handle proxy authentication.
|
||||
@@ -683,6 +739,10 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
if auth_mode != "proxy":
|
||||
return None
|
||||
|
||||
# A request already authenticated by API_KEY needs no proxy headers.
|
||||
if g.get("api_key_auth"):
|
||||
return None
|
||||
|
||||
# Skip for public endpoints that don't need auth
|
||||
if request.path == "/api/health":
|
||||
return None
|
||||
@@ -815,6 +875,17 @@ def set_security_headers(response: Response) -> Response:
|
||||
return response
|
||||
|
||||
|
||||
@app.after_request
|
||||
def strip_cookie_for_api_key_requests(response: Response) -> Response:
|
||||
"""Keyed requests never mint or refresh a session cookie, even if a handler dirties the session."""
|
||||
if g.get("api_key_auth"):
|
||||
# Setting `permanent` mutates the session dict (re-marking it
|
||||
# modified), so it must be reset before `modified`, not after.
|
||||
session.permanent = False
|
||||
session.modified = False
|
||||
return response
|
||||
|
||||
|
||||
def login_required(
|
||||
f: Callable[..., Response | tuple[Response, int]],
|
||||
) -> Callable[..., Response | tuple[Response, int]]:
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Tests for the API_KEY environment-variable authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core import api_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_db():
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db = UserDB(os.path.join(tmpdir, "users.db"))
|
||||
db.initialize()
|
||||
yield db
|
||||
|
||||
|
||||
class TestExtractCandidates:
|
||||
def test_bearer_only(self):
|
||||
assert api_key.extract_api_key_candidates("Bearer a", None) == ["a"]
|
||||
|
||||
def test_bearer_case_insensitive_and_trimmed(self):
|
||||
assert api_key.extract_api_key_candidates("bearer a ", None) == ["a"]
|
||||
|
||||
def test_x_api_key_only(self):
|
||||
assert api_key.extract_api_key_candidates(None, "b") == ["b"]
|
||||
|
||||
def test_both_present(self):
|
||||
assert api_key.extract_api_key_candidates("Bearer a", "b") == ["a", "b"]
|
||||
|
||||
def test_non_bearer_scheme_plus_x_api_key(self):
|
||||
assert api_key.extract_api_key_candidates("Basic dXNlcjpwYXNz", "b") == ["b"]
|
||||
|
||||
def test_empty(self):
|
||||
assert api_key.extract_api_key_candidates("Bearer ", "") == []
|
||||
assert api_key.extract_api_key_candidates(None, None) == []
|
||||
|
||||
|
||||
class TestMatches:
|
||||
def test_unset_never_matches(self, monkeypatch):
|
||||
monkeypatch.setattr(api_key, "API_KEY", "")
|
||||
assert api_key.matches_api_key("anything") is False
|
||||
assert api_key.matches_api_key("") is False
|
||||
|
||||
def test_match(self, monkeypatch):
|
||||
monkeypatch.setattr(api_key, "API_KEY", "s3cret")
|
||||
assert api_key.matches_api_key("s3cret") is True
|
||||
|
||||
def test_mismatch_and_prefix(self, monkeypatch):
|
||||
monkeypatch.setattr(api_key, "API_KEY", "s3cret")
|
||||
assert api_key.matches_api_key("s3cre") is False
|
||||
assert api_key.matches_api_key("s3cret ") is False
|
||||
assert api_key.matches_api_key("") is False
|
||||
|
||||
|
||||
class TestFirstAdmin:
|
||||
def test_none_when_no_admin(self, user_db):
|
||||
user_db.create_user(username="alice")
|
||||
assert user_db.get_first_admin() is None
|
||||
|
||||
def test_first_admin_by_id(self, user_db):
|
||||
user_db.create_user(username="alice")
|
||||
root = user_db.create_user(username="root", role="admin")
|
||||
user_db.create_user(username="root2", role="admin")
|
||||
assert user_db.get_first_admin()["id"] == root["id"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
"""Import `shelfmark.main` with background startup disabled."""
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wired(main_module, user_db, monkeypatch):
|
||||
monkeypatch.setattr(main_module, "user_db", user_db)
|
||||
monkeypatch.setattr(api_key, "API_KEY", "s3cret")
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
yield main_module
|
||||
|
||||
|
||||
def _bearer(value):
|
||||
return {"Authorization": f"Bearer {value}"}
|
||||
|
||||
|
||||
def _x_api_key(value):
|
||||
return {"X-Api-Key": value}
|
||||
|
||||
|
||||
def _identity_for_key(wired):
|
||||
"""Run the middleware directly against the isolated `user_db` and return the resulting session.
|
||||
|
||||
Going through a real request/response round trip would need a route
|
||||
that reveals identity, and the only such route (`/api/users/me/edit-context`)
|
||||
is closed over the real per-worker `users.db` at registration time, not
|
||||
the isolated temp DB the `wired` fixture monkeypatches onto
|
||||
`main_module.user_db` -- so it can't see admins created here. Calling
|
||||
the middleware in a request context sidesteps that entirely.
|
||||
"""
|
||||
with wired.app.test_request_context("/api/downloads/active", headers=_bearer("s3cret")):
|
||||
assert wired.api_key_auth_middleware() is None
|
||||
from flask import session
|
||||
|
||||
return dict(session)
|
||||
|
||||
|
||||
def _cookie_client(app, user, *, is_admin=False, permanent=False):
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user["username"]
|
||||
sess["is_admin"] = is_admin
|
||||
sess["db_user_id"] = user["id"]
|
||||
sess.permanent = permanent
|
||||
return client
|
||||
|
||||
|
||||
class TestKeyedRequests:
|
||||
def test_match_reaches_login_required_route(self, wired, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
assert (
|
||||
wired.app.test_client()
|
||||
.get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
.status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_match_is_admin(self, wired, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
assert (
|
||||
wired.app.test_client().get("/api/settings", headers=_bearer("s3cret")).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_match_without_any_admin_user_is_still_admin(self, wired):
|
||||
assert (
|
||||
wired.app.test_client().get("/api/settings", headers=_bearer("s3cret")).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_match_no_user_db(self, main_module, monkeypatch):
|
||||
monkeypatch.setattr(main_module, "user_db", None)
|
||||
monkeypatch.setattr(api_key, "API_KEY", "s3cret")
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
assert (
|
||||
main_module.app.test_client()
|
||||
.get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
.status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_x_api_key(self, wired, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
assert (
|
||||
wired.app.test_client()
|
||||
.get("/api/downloads/active", headers={"X-Api-Key": "s3cret"})
|
||||
.status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_wrong_bearer_plus_correct_x_api_key_authenticates(self, wired, user_db):
|
||||
"""A proxy's own Authorization header must not shadow a correct X-Api-Key."""
|
||||
user_db.create_user(username="root", role="admin")
|
||||
headers = {**_bearer("wrong"), **_x_api_key("s3cret")}
|
||||
assert wired.app.test_client().get("/api/settings", headers=headers).status_code == 200
|
||||
|
||||
def test_correct_bearer_plus_wrong_x_api_key_authenticates(self, wired, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
headers = {**_bearer("s3cret"), **_x_api_key("wrong")}
|
||||
assert wired.app.test_client().get("/api/settings", headers=headers).status_code == 200
|
||||
|
||||
def test_no_set_cookie_even_when_handler_dirties_session(self, wired, user_db, monkeypatch):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
original = wired.app.view_functions["api_active_downloads"]
|
||||
|
||||
def dirty(*args, **kwargs):
|
||||
from flask import session
|
||||
|
||||
session["dirty"] = True
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setitem(wired.app.view_functions, "api_active_downloads", dirty)
|
||||
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
assert response.status_code == 200
|
||||
assert "Set-Cookie" not in response.headers
|
||||
|
||||
def test_incoming_non_admin_cookie_is_ignored(self, wired, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
alice = user_db.create_user(username="alice")
|
||||
client = _cookie_client(wired.app, alice)
|
||||
assert client.get("/api/settings", headers=_bearer("s3cret")).status_code == 200
|
||||
|
||||
def test_matched_key_leaves_browser_cookie_usable(self, wired, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
alice = user_db.create_user(username="alice")
|
||||
client = _cookie_client(wired.app, alice)
|
||||
assert client.get("/api/settings", headers=_bearer("s3cret")).status_code == 200
|
||||
|
||||
assert client.get("/api/downloads/active").status_code == 200
|
||||
assert client.get("/api/settings").status_code == 403
|
||||
|
||||
def test_security_headers_present(self, wired, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
|
||||
def test_store_error_is_500_not_anonymous(self, wired, user_db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
user_db,
|
||||
"get_first_admin",
|
||||
lambda: (_ for _ in ()).throw(sqlite3.OperationalError("boom")),
|
||||
)
|
||||
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
assert response.status_code == 500
|
||||
assert response.get_json() == {"error": "Authentication error"}
|
||||
|
||||
def test_store_error_does_not_refresh_browser_cookie(self, wired, user_db, monkeypatch):
|
||||
alice = user_db.create_user(username="alice")
|
||||
monkeypatch.setattr(
|
||||
user_db,
|
||||
"get_first_admin",
|
||||
lambda: (_ for _ in ()).throw(sqlite3.OperationalError("boom")),
|
||||
)
|
||||
client = _cookie_client(wired.app, alice, permanent=True)
|
||||
response = client.get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
assert response.status_code == 500
|
||||
assert "Set-Cookie" not in response.headers
|
||||
|
||||
# The browser's own session is untouched and still usable afterwards.
|
||||
assert client.get("/api/downloads/active").status_code == 200
|
||||
|
||||
|
||||
class TestMismatchFallsThrough:
|
||||
def test_mismatch_no_cookie_is_plain_unauthorized(self, wired):
|
||||
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("wrong"))
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"error": "Unauthorized"}
|
||||
|
||||
def test_mismatch_is_indistinguishable_from_no_credential(self, wired):
|
||||
client = wired.app.test_client()
|
||||
no_credential = client.get("/api/downloads/active")
|
||||
with_wrong_bearer = client.get("/api/downloads/active", headers=_bearer("wrong"))
|
||||
|
||||
assert no_credential.status_code == with_wrong_bearer.status_code
|
||||
assert no_credential.get_json() == with_wrong_bearer.get_json()
|
||||
|
||||
ignored_headers = {"date", "content-length", "server"}
|
||||
|
||||
def header_names(response):
|
||||
return {name.lower() for name in response.headers.keys()} - ignored_headers
|
||||
|
||||
assert header_names(no_credential) == header_names(with_wrong_bearer)
|
||||
assert "WWW-Authenticate" not in no_credential.headers
|
||||
assert "WWW-Authenticate" not in with_wrong_bearer.headers
|
||||
|
||||
def test_mismatch_writes_no_log(self, wired, caplog):
|
||||
with caplog.at_level(logging.INFO, logger="shelfmark"):
|
||||
wired.app.test_client().get("/api/downloads/active", headers=_bearer("wrong"))
|
||||
|
||||
for record in caplog.records:
|
||||
message = record.getMessage()
|
||||
assert "API key" not in message
|
||||
assert "wrong" not in message
|
||||
|
||||
def test_mismatch_with_cookie_uses_cookie(self, wired, user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
client = _cookie_client(wired.app, alice)
|
||||
assert client.get("/api/downloads/active", headers=_bearer("wrong")).status_code == 200
|
||||
assert client.get("/api/settings", headers=_bearer("wrong")).status_code == 403
|
||||
|
||||
def test_mismatch_leaves_browser_session_untouched(self, wired, user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
client = _cookie_client(wired.app, alice, permanent=True)
|
||||
response = client.get("/api/downloads/active", headers=_bearer("wrong"))
|
||||
assert response.status_code == 200
|
||||
assert client.get("/api/downloads/active").status_code == 200
|
||||
|
||||
def test_unset_key_is_noop(self, main_module, user_db, monkeypatch):
|
||||
monkeypatch.setattr(main_module, "user_db", user_db)
|
||||
monkeypatch.setattr(api_key, "API_KEY", "")
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
response = main_module.app.test_client().get(
|
||||
"/api/downloads/active", headers=_bearer("s3cret")
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"error": "Unauthorized"}
|
||||
|
||||
alice = user_db.create_user(username="alice")
|
||||
client = _cookie_client(main_module.app, alice, permanent=True)
|
||||
cookie_response = client.get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
assert cookie_response.status_code == 200
|
||||
assert "Set-Cookie" in cookie_response.headers
|
||||
|
||||
|
||||
class TestScopeAndModes:
|
||||
def test_health_and_auth_paths_and_root_ignore_key(self, wired):
|
||||
client = wired.app.test_client()
|
||||
assert client.get("/api/health", headers=_bearer("s3cret")).status_code == 200
|
||||
assert (
|
||||
client.get("/api/auth/check", headers=_bearer("s3cret")).get_json()["authenticated"]
|
||||
is False
|
||||
)
|
||||
assert client.get("/", headers=_bearer("s3cret")).status_code != 401
|
||||
|
||||
def test_none_mode_noop(self, main_module, user_db, monkeypatch):
|
||||
monkeypatch.setattr(main_module, "user_db", user_db)
|
||||
monkeypatch.setattr(api_key, "API_KEY", "s3cret")
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
assert (
|
||||
main_module.app.test_client()
|
||||
.get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
.status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_proxy_mode_keyed_request_needs_no_proxy_header(
|
||||
self, main_module, user_db, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(main_module, "user_db", user_db)
|
||||
monkeypatch.setattr(api_key, "API_KEY", "s3cret")
|
||||
user_db.create_user(username="root", role="admin", auth_source="proxy")
|
||||
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
|
||||
assert (
|
||||
main_module.app.test_client()
|
||||
.get("/api/downloads/active", headers=_bearer("s3cret"))
|
||||
.status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
class TestIdentityAndRouting:
|
||||
def test_deleted_first_admin_changes_identity(self, wired, user_db):
|
||||
root = user_db.create_user(username="root", role="admin")
|
||||
root2 = user_db.create_user(username="root2", role="admin")
|
||||
|
||||
identity = _identity_for_key(wired)
|
||||
assert identity["user_id"] == "root"
|
||||
assert identity["db_user_id"] == root["id"]
|
||||
assert identity["is_admin"] is True
|
||||
|
||||
user_db.delete_user(root["id"])
|
||||
|
||||
identity = _identity_for_key(wired)
|
||||
assert identity["user_id"] == "root2"
|
||||
assert identity["db_user_id"] == root2["id"]
|
||||
|
||||
user_db.delete_user(root2["id"])
|
||||
|
||||
# No admin row left at all; the key still authenticates a bare
|
||||
# admin identity for routes that don't need a local user row.
|
||||
identity = _identity_for_key(wired)
|
||||
assert identity["user_id"] == "api"
|
||||
assert identity["is_admin"] is True
|
||||
assert "db_user_id" not in identity
|
||||
|
||||
def test_demoted_first_admin_changes_identity(self, wired, user_db):
|
||||
root = user_db.create_user(username="root", role="admin")
|
||||
|
||||
identity = _identity_for_key(wired)
|
||||
assert identity["user_id"] == "root"
|
||||
|
||||
user_db.update_user(root["id"], role="user")
|
||||
|
||||
# No admin row left to match; the key falls back to a bare identity.
|
||||
identity = _identity_for_key(wired)
|
||||
assert identity["user_id"] == "api"
|
||||
assert identity["is_admin"] is True
|
||||
assert "db_user_id" not in identity
|
||||
|
||||
# Bare admin identity still reaches a route that doesn't need a
|
||||
# local user row.
|
||||
response = wired.app.test_client().get("/api/settings", headers=_bearer("s3cret"))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_path_probes(self, wired, user_db):
|
||||
user_db.create_user(username="root", role="admin")
|
||||
client = wired.app.test_client()
|
||||
|
||||
# Flask routing is case-sensitive: /API/... doesn't match the /api/
|
||||
# prefix the middleware checks, so it falls through to the SPA
|
||||
# catch-all route instead of the JSON handler with elevated state.
|
||||
response = client.get("/API/downloads/active", headers=_bearer("s3cret"))
|
||||
assert response.status_code != 200 or response.content_type != "application/json"
|
||||
|
||||
# A trailing slash must not silently reach the handler either.
|
||||
response = client.get("/api/downloads/active/", headers=_bearer("s3cret"))
|
||||
assert response.status_code != 200 or not response.data
|
||||
|
||||
# A dot-segment path trick must not resolve to a live route.
|
||||
response = client.get("/api/auth/../downloads/active", headers=_bearer("s3cret"))
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_keyed_post_reaches_guarded_route(self, wired, user_db):
|
||||
"""A keyed POST passes the login_required guard and reaches the handler's own validation.
|
||||
|
||||
Uses /api/releases/inspect rather than a settings-save route: that
|
||||
handler does no persistence at all, so this proves the request got
|
||||
past the auth guard into real handler logic without touching the
|
||||
process-wide Config singleton or the on-disk settings files other
|
||||
tests (and other workers, under xdist) share.
|
||||
"""
|
||||
user_db.create_user(username="root", role="admin")
|
||||
|
||||
response = wired.app.test_client().post(
|
||||
"/api/releases/inspect",
|
||||
json={},
|
||||
headers=_bearer("s3cret"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.get_json() == {"error": "source_id is required"}
|
||||
assert "Set-Cookie" not in response.headers
|
||||
Reference in New Issue
Block a user