feat: add session authentication

This commit is contained in:
Lucas Winther committed 2026-09-26 11:03:27 +02:00
1 parent 863f671129
commit 57e5215a36
10 files changed
+450 -12

No files matched your search

+5 -4
View File
@@ -5,8 +5,8 @@ How `recoll-modern` is built today. Update in the same commit as any structural
## Overview
The Python service scaffold, SQLite schema, collection and membership repositories, document
discovery/identity modules, durable job worker, and engine adapter are built. The HTTP API remains
intended structure. Recoll behaviour below was verified against a real container.
discovery/identity modules, durable job worker, engine adapter, and authentication service are built.
The HTTP API remains intended structure. Recoll behaviour below was verified against a real container.
The goal: a self-hosted service that wraps Recoll's indexing and full-text search behind (a) a modern
web UI and (b) an MCP server, running in Docker, with several independent collections such as
@@ -29,6 +29,7 @@ web UI and (b) an MCP server, running in Docker, with several independent collec
| `src/recoll_modern/indexing/map_export.py` | Writes a durable atomic map from canonical paths to opaque collection IDs |
| `helper/` | C reader run by Recoll's `metadatacmds` for each indexed file |
| `src/recoll_modern/engine/` | Shared Protocol, deterministic fake, and isolated Recoll Python/indexer adapter |
| `src/recoll_modern/domain/auth.py`, `storage/auth.py` | Argon2 password checks, seeded administrator, hashed server-side sessions and CSRF verification |
| `tests/` | Automated service tests |
Discovery produces content hashes and root-relative paths without writing to the document volume.
@@ -89,8 +90,8 @@ is direct membership union non-excluded rule membership, so direct additions rem
`jobs` and `job_file_errors` make work and partial failures durable. At startup, the worker requeues
well-formed interrupted jobs, clears errors from the prior attempt, and fails unreadable requests.
`users` and hashed-token `sessions`
provide authentication. `schema_migrations` is forward-only. The exact columns and constraints are the
`users` and hashed-token `sessions` provide authentication; the session lifetime is configurable and
defaults to 12 hours. `schema_migrations` is forward-only. The exact columns and constraints are the
contract in `docs/specs/001-search-engine-api.md`.
### HTTP boundary
+5 -4
View File
@@ -15,11 +15,12 @@ Where the workflow stands (`AGENTS.md` -> Workflow: brainstorm -> spec -> plan -
selectable search fields, subpath hosting and the packaging seam for deferred desktop spec 005.
- Plan `docs/plans/001-search-engine-api.md`: **approved 2026-09-18.** Its listed Python runtime and
development dependencies are approved. Execution follows the plan task by task.
- Plan Tasks 1–3 are complete and independently reviewed. Tasks 4–8 add discovery, content-hash
identity, additive membership, durable jobs, the atomic membership map, and the engine boundary.
Their checkboxes are current in the plan. The next task is authentication.
- Plan Tasks 1–3 are complete and independently reviewed. Tasks 4–9 add discovery, content-hash
identity, additive membership, durable jobs, the atomic membership map, the engine boundary, and
session authentication. Their checkboxes are current in the plan. The next task is the HTTP shell.
The next action is Task 9, "Implement users, sessions, seeding, and CSRF." Do not repeat Tasks 1–8.
The next action is Task 10, "Build the FastAPI shell, middleware, and core resources." Do not repeat
Tasks 1–9.
Things a resuming agent will otherwise get wrong:
+4 -4
View File
@@ -665,7 +665,7 @@ git commit -m "feat: add Recoll engine boundary"
- Produces: `AuthService.login/authenticate/logout/seed_initial_admin`
- Produces: `SessionCredentials(cookie_token, csrf_token, expires_at)`
- [ ] **Step 1: Write failing auth tests**
- [x] **Step 1: Write failing auth tests**
```python
def test_passwords_are_argon2_hashes(auth: AuthService) -> None:
@@ -687,15 +687,15 @@ def test_first_boot_without_credentials_refuses_startup(empty_auth: AuthService)
Also test disabled/expired sessions, generic invalid credentials, case-folded usernames, CSRF mismatch, and cleanup.
- [ ] **Step 2: Run tests and observe failure**
- [x] **Step 2: Run tests and observe failure**
Run: `uv run pytest tests/domain/test_auth.py tests/storage/test_auth_repository.py -v`
- [ ] **Step 3: Implement auth services**
- [x] **Step 3: Implement auth services**
Use `argon2.PasswordHasher` defaults, `secrets.token_urlsafe(32)` for independent cookie/CSRF tokens, and SHA-256 digests for stored lookup values. Session lifetime is configurable, defaults to 12 hours, and sliding refresh updates `last_seen_at` without extending beyond the configured policy. Use `hmac.compare_digest` for CSRF hashes. Seed `RECOLL_MODERN_ADMIN_USERNAME` and `RECOLL_MODERN_ADMIN_PASSWORD` only when no user exists; erase local password references after hashing and never log them.
- [ ] **Step 4: Verify and commit**
- [x] **Step 4: Verify and commit**
Run: `uv run pytest tests/domain/test_auth.py tests/storage/test_auth_repository.py -v && uv run ruff check . && uv run mypy src tests`
+6
View File
@@ -20,6 +20,7 @@ class Settings:
trusted_proxies: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]
inline_text_limit: int
inline_text_limit_max: int
session_lifetime_hours: int
@property
def database_path(self) -> Path:
@@ -43,6 +44,10 @@ class Settings:
if not 0 < text_default <= text_max:
raise ValueError("inline text limit must be positive and no greater than its maximum")
session_hours = int(environ.get("RECOLL_MODERN_SESSION_HOURS", "12"))
if session_hours <= 0:
raise ValueError("session lifetime must be positive")
proxy_text = environ.get("RECOLL_MODERN_TRUSTED_PROXIES", "")
proxies = tuple(
ipaddress.ip_network(value.strip(), strict=False)
@@ -64,4 +69,5 @@ class Settings:
trusted_proxies=proxies,
inline_text_limit=text_default,
inline_text_limit_max=text_max,
session_lifetime_hours=session_hours,
)
+147
View File
@@ -0,0 +1,147 @@
"""Password, session, and CSRF behavior without HTTP dependencies."""
import hashlib
import hmac
import secrets
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Protocol
from unicodedata import normalize
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from recoll_modern.domain.errors import AppError
@dataclass(frozen=True, slots=True)
class AuthUser:
id: str
username: str
username_key: str
password_hash: str
disabled_at: datetime | None
@dataclass(frozen=True, slots=True)
class SessionRecord:
token_hash: str
csrf_hash: str
user_id: str
expires_at: datetime
@dataclass(frozen=True, slots=True)
class SessionCredentials:
cookie_token: str
csrf_token: str
expires_at: datetime
@dataclass(frozen=True, slots=True)
class AuthenticatedUser:
id: str
username: str
class AuthRepository(Protocol):
def user_count(self) -> int: ...
def create_user(self, username: str, password_hash: str) -> AuthUser: ...
def get_user_by_name(self, username: str) -> AuthUser | None: ...
def get_user_by_id(self, user_id: str) -> AuthUser | None: ...
def create_session(
self, cookie_token: str, csrf_token: str, user_id: str,
expires_at: datetime, now: datetime,
) -> None: ...
def get_session(self, cookie_token: str) -> SessionRecord | None: ...
def delete_session(self, cookie_token: str) -> None: ...
def touch_session(self, cookie_token: str, now: datetime) -> None: ...
def normalize_username(username: str) -> tuple[str, str]:
display = normalize("NFC", username.strip())
if not display:
raise ValueError("username cannot be empty")
return display, display.casefold()
class AuthService:
def __init__(
self,
repository: AuthRepository,
*,
lifetime: timedelta = timedelta(hours=12),
now: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
if lifetime <= timedelta(0):
raise ValueError("session lifetime must be positive")
self._repository = repository
self._lifetime = lifetime
self._now = now
self._hasher = PasswordHasher()
def seed_initial_admin(self, username: str | None, password: str | None) -> None:
if self._repository.user_count() > 0:
return
if not username or not password:
raise AppError(
"validation_failed", "initial administrator credentials are required", 422
)
digest = self._hasher.hash(password)
password = None
self._repository.create_user(username, digest)
def login(self, username: str, password: str) -> SessionCredentials:
user = self._repository.get_user_by_name(username)
if user is None or user.disabled_at is not None:
raise _invalid_credentials()
try:
valid = self._hasher.verify(user.password_hash, password)
except (VerifyMismatchError, InvalidHashError):
raise _invalid_credentials() from None
if not valid:
raise _invalid_credentials()
now = self._now()
cookie_token = secrets.token_urlsafe(32)
csrf_token = secrets.token_urlsafe(32)
expires_at = now + self._lifetime
self._repository.create_session(cookie_token, csrf_token, user.id, expires_at, now)
return SessionCredentials(cookie_token, csrf_token, expires_at)
def authenticate(
self,
cookie_token: str | None,
*,
csrf_token: str | None = None,
unsafe: bool = False,
) -> AuthenticatedUser:
session = self._repository.get_session(cookie_token) if cookie_token else None
now = self._now()
if session is None or session.expires_at <= now:
raise AppError("unauthenticated", "Authentication is required", 401)
user = self._repository.get_user_by_id(session.user_id)
if user is None or user.disabled_at is not None:
raise AppError("unauthenticated", "Authentication is required", 401)
if unsafe:
supplied_hash = hashlib.sha256((csrf_token or "").encode()).hexdigest()
if not hmac.compare_digest(supplied_hash, session.csrf_hash):
raise AppError("csrf_failed", "CSRF token is invalid", 403)
assert cookie_token is not None
self._repository.touch_session(cookie_token, now)
return AuthenticatedUser(user.id, user.username)
def logout(self, cookie_token: str | None) -> None:
if cookie_token:
self._repository.delete_session(cookie_token)
def _invalid_credentials() -> AppError:
return AppError("invalid_credentials", "Invalid username or password", 401)
+3
View File
@@ -12,3 +12,6 @@ class AppError(Exception):
message: str
status: int
details: Mapping[str, object] = field(default_factory=dict)
def __str__(self) -> str:
return self.message
+136
View File
@@ -0,0 +1,136 @@
"""SQLite users and hashed session-token persistence."""
import hashlib
import secrets
import sqlite3
from datetime import UTC, datetime
from recoll_modern.domain.auth import AuthUser, SessionRecord, normalize_username
from recoll_modern.storage.database import Database
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
class SQLiteAuthRepository:
def __init__(self, database: Database) -> None:
self._database = database
def user_count(self) -> int:
with self._database.connect() as connection:
return int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0])
def create_user(self, username: str, password_hash: str) -> AuthUser:
display, key = normalize_username(username)
now = datetime.now(UTC).isoformat()
while True:
user_id = f"u{secrets.token_hex(16)}"
try:
with self._database.transaction() as connection:
connection.execute(
"INSERT INTO users "
"(id, username, username_key, password_hash, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(user_id, display, key, password_hash, now),
)
except sqlite3.IntegrityError as error:
if "users.id" in str(error):
continue
raise
return AuthUser(user_id, display, key, password_hash, None)
def get_user_by_name(self, username: str) -> AuthUser | None:
try:
_, key = normalize_username(username)
except ValueError:
return None
with self._database.connect() as connection:
row = connection.execute(
"SELECT id, username, username_key, password_hash, disabled_at "
"FROM users WHERE username_key = ?",
(key,),
).fetchone()
return _user(row) if row is not None else None
def get_user_by_id(self, user_id: str) -> AuthUser | None:
with self._database.connect() as connection:
row = connection.execute(
"SELECT id, username, username_key, password_hash, disabled_at "
"FROM users WHERE id = ?",
(user_id,),
).fetchone()
return _user(row) if row is not None else None
def disable_user(self, user_id: str) -> None:
with self._database.transaction() as connection:
connection.execute(
"UPDATE users SET disabled_at = ? WHERE id = ?",
(datetime.now(UTC).isoformat(), user_id),
)
def create_session(
self,
cookie_token: str,
csrf_token: str,
user_id: str,
expires_at: datetime,
now: datetime,
) -> None:
with self._database.transaction() as connection:
connection.execute(
"INSERT INTO sessions "
"(token_hash, csrf_hash, user_id, expires_at, created_at, last_seen_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
_hash_token(cookie_token), _hash_token(csrf_token), user_id,
expires_at.isoformat(), now.isoformat(), now.isoformat(),
),
)
def get_session(self, cookie_token: str) -> SessionRecord | None:
with self._database.connect() as connection:
row = connection.execute(
"SELECT token_hash, csrf_hash, user_id, expires_at "
"FROM sessions WHERE token_hash = ?",
(_hash_token(cookie_token),),
).fetchone()
if row is None:
return None
return SessionRecord(
str(row["token_hash"]), str(row["csrf_hash"]), str(row["user_id"]),
datetime.fromisoformat(str(row["expires_at"])),
)
def delete_session(self, cookie_token: str) -> None:
with self._database.transaction() as connection:
connection.execute(
"DELETE FROM sessions WHERE token_hash = ?", (_hash_token(cookie_token),)
)
def touch_session(self, cookie_token: str, now: datetime) -> None:
with self._database.transaction() as connection:
connection.execute(
"UPDATE sessions SET last_seen_at = ? WHERE token_hash = ?",
(now.isoformat(), _hash_token(cookie_token)),
)
def cleanup_expired(self, now: datetime) -> int:
with self._database.transaction() as connection:
cursor = connection.execute(
"DELETE FROM sessions WHERE expires_at <= ?", (now.isoformat(),)
)
return cursor.rowcount
def _user(row: sqlite3.Row) -> AuthUser:
return AuthUser(
id=str(row["id"]),
username=str(row["username"]),
username_key=str(row["username_key"]),
password_hash=str(row["password_hash"]),
disabled_at=(
datetime.fromisoformat(str(row["disabled_at"]))
if row["disabled_at"] is not None else None
),
)
+84
View File
@@ -0,0 +1,84 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from recoll_modern.domain.auth import AuthService
from recoll_modern.domain.errors import AppError
from recoll_modern.storage.auth import SQLiteAuthRepository
from recoll_modern.storage.database import Database
PASSWORD = "correct horse battery staple"
def _auth(tmp_path: Path, now: datetime | None = None) -> tuple[AuthService, SQLiteAuthRepository]:
database = Database(tmp_path / "state.sqlite3")
database.migrate()
repository = SQLiteAuthRepository(database)
def clock() -> datetime:
return now or datetime.now(UTC)
return AuthService(repository, now=clock), repository
def test_passwords_are_argon2_hashes(tmp_path: Path) -> None:
auth, repository = _auth(tmp_path)
auth.seed_initial_admin("admin", PASSWORD)
stored = repository.get_user_by_name("admin")
assert stored is not None
assert stored.password_hash.startswith("$argon2id$")
assert "correct horse" not in stored.password_hash
def test_first_boot_without_credentials_refuses_startup(tmp_path: Path) -> None:
auth, _ = _auth(tmp_path)
with pytest.raises(AppError, match="initial administrator"):
auth.seed_initial_admin(None, None)
def test_seed_is_idempotent_when_user_exists(tmp_path: Path) -> None:
auth, repository = _auth(tmp_path)
auth.seed_initial_admin("admin", PASSWORD)
auth.seed_initial_admin(None, None)
assert repository.user_count() == 1
def test_login_is_case_folded_and_invalid_credentials_are_generic(tmp_path: Path) -> None:
auth, _ = _auth(tmp_path)
auth.seed_initial_admin("Straße", PASSWORD)
credentials = auth.login("STRASSE", PASSWORD)
assert auth.authenticate(credentials.cookie_token).username == "Straße"
with pytest.raises(AppError) as caught:
auth.login("unknown", "wrong")
assert caught.value.code == "invalid_credentials"
def test_csrf_mismatch_and_logout(tmp_path: Path) -> None:
auth, _ = _auth(tmp_path)
auth.seed_initial_admin("admin", PASSWORD)
credentials = auth.login("admin", PASSWORD)
with pytest.raises(AppError) as caught:
auth.authenticate(credentials.cookie_token, csrf_token="wrong", unsafe=True)
assert caught.value.code == "csrf_failed"
assert auth.authenticate(
credentials.cookie_token, csrf_token=credentials.csrf_token, unsafe=True
).username == "admin"
auth.logout(credentials.cookie_token)
with pytest.raises(AppError) as caught:
auth.authenticate(credentials.cookie_token)
assert caught.value.code == "unauthenticated"
def test_expired_session_is_refused(tmp_path: Path) -> None:
now = datetime(2026, 1, 1, tzinfo=UTC)
database = Database(tmp_path / "state.sqlite3")
database.migrate()
repository = SQLiteAuthRepository(database)
clock = [now]
auth = AuthService(repository, now=lambda: clock[0], lifetime=timedelta(hours=1))
auth.seed_initial_admin("admin", PASSWORD)
credentials = auth.login("admin", PASSWORD)
clock[0] = now + timedelta(hours=2)
with pytest.raises(AppError) as caught:
auth.authenticate(credentials.cookie_token)
assert caught.value.code == "unauthenticated"
+53
View File
@@ -0,0 +1,53 @@
import hashlib
from pathlib import Path
import pytest
from recoll_modern.domain.auth import AuthService
from recoll_modern.domain.errors import AppError
from recoll_modern.storage.auth import SQLiteAuthRepository
from recoll_modern.storage.database import Database
def test_database_stores_only_token_hashes(tmp_path: Path) -> None:
database = Database(tmp_path / "state.sqlite3")
database.migrate()
repository = SQLiteAuthRepository(database)
auth = AuthService(repository)
auth.seed_initial_admin("admin", "correct horse battery staple")
credentials = auth.login("admin", "correct horse battery staple")
assert repository.get_session(credentials.cookie_token) is not None
with database.connect() as connection:
row = connection.execute("SELECT token_hash, csrf_hash FROM sessions").fetchone()
assert row is not None
assert row["token_hash"] == hashlib.sha256(credentials.cookie_token.encode()).hexdigest()
assert row["csrf_hash"] == hashlib.sha256(credentials.csrf_token.encode()).hexdigest()
database_bytes = database.path.read_bytes()
assert credentials.cookie_token.encode() not in database_bytes
assert credentials.csrf_token.encode() not in database_bytes
def test_disabled_user_session_is_rejected(tmp_path: Path) -> None:
database = Database(tmp_path / "state.sqlite3")
database.migrate()
repository = SQLiteAuthRepository(database)
auth = AuthService(repository)
auth.seed_initial_admin("admin", "correct horse battery staple")
credentials = auth.login("admin", "correct horse battery staple")
user = repository.get_user_by_name("admin")
assert user is not None
repository.disable_user(user.id)
with pytest.raises(AppError) as caught:
auth.authenticate(credentials.cookie_token)
assert caught.value.code == "unauthenticated"
def test_cleanup_removes_expired_sessions(tmp_path: Path) -> None:
database = Database(tmp_path / "state.sqlite3")
database.migrate()
repository = SQLiteAuthRepository(database)
auth = AuthService(repository)
auth.seed_initial_admin("admin", "correct horse battery staple")
credentials = auth.login("admin", "correct horse battery staple")
assert repository.cleanup_expired(credentials.expires_at) == 1
assert repository.get_session(credentials.cookie_token) is None
+7
View File
@@ -24,3 +24,10 @@ def test_settings_are_rooted_in_configured_state(tmp_path: Path) -> None:
def test_root_path_rejects_trailing_slash() -> None:
with pytest.raises(ValueError, match="mount prefix"):
Settings.from_env({"RECOLL_MODERN_ROOT_PATH": "/recoll/"})
def test_session_lifetime_is_configurable() -> None:
settings = Settings.from_env({"RECOLL_MODERN_SESSION_HOURS": "6"})
assert settings.session_lifetime_hours == 6
with pytest.raises(ValueError, match="session lifetime"):
Settings.from_env({"RECOLL_MODERN_SESSION_HOURS": "0"})