diff --git a/.env.example b/.env.example index adde01c1..e2850145 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,19 @@ AUTH_COOKIE_NAME=librislog_session AUTH_COOKIE_SECURE=false # set true in production (HTTPS) AUTH_COOKIE_SAMESITE=lax # lax | strict | none AUTH_COOKIE_DOMAIN= # optional, e.g. app.example.com +PASSWORD_LOGIN_ENABLED=true # set false only once OIDC or proxy auth already works for every user + +# Trusted reverse-proxy authentication (optional) +# Reuses an identity already established by a proxy (for example Traefik BasicAuth). +# The header value is mapped to an account email (see TRUSTED_PROXY_AUTH_USER_MAP), +# or used as the email directly. Fails closed when the IP list is empty. +TRUSTED_PROXY_AUTH_ENABLED=false +TRUSTED_PROXY_AUTH_HEADER=X-Forwarded-User +TRUSTED_PROXY_AUTH_IPS= # comma/space-separated IPs or CIDR ranges; empty disables the feature +TRUSTED_PROXY_AUTH_SECRET= # optional shared secret; the proxy sends it in X-Trusted-Proxy-Secret +# Optional mapping of proxy usernames to LibrisLog account emails. +# Format: proxy_user=email pairs separated by commas or spaces. +TRUSTED_PROXY_AUTH_USER_MAP= # Optional OIDC settings (leave OIDC_ENABLED=false to disable) OIDC_ENABLED=false diff --git a/README.md b/README.md index 83dc8c8e..7a537cbd 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ See [docs.librislog.app/guide/telemetry](https://docs.librislog.app/guide/teleme |---|---| | **Backend** | FastAPI, SQLModel, SQLite, Alembic, Pydantic v2 | | **Frontend** | Svelte 5, SvelteKit, Tailwind CSS v4, DaisyUI v5 | -| **Auth** | Session cookies, optional OIDC (Authlib) | +| **Auth** | Session cookies, optional OIDC (Authlib), optional trusted reverse-proxy auth, configurable password login | | **Deployment** | Docker, Docker Compose | | **Package managers** | `uv` (Python), `npm` (Node) | | **Testing** | pytest + pytest-cov (backend), Vitest + Testing Library (frontend), Playwright (E2E) | diff --git a/backend/app/config.py b/backend/app/config.py index ea95b4ed..3299a544 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,6 +19,12 @@ class Settings(BaseSettings): auth_cookie_secure: bool = False auth_cookie_samesite: str = "lax" auth_cookie_domain: str = "" + password_login_enabled: bool = True + trusted_proxy_auth_enabled: bool = False + trusted_proxy_auth_header: str = "X-Forwarded-User" + trusted_proxy_auth_ips: str = "" + trusted_proxy_auth_secret: str = "" + trusted_proxy_auth_user_map: str = "" oidc_enabled: bool = False oidc_provider_id: str = "oidc" oidc_provider_name: str = "Single Sign-On" @@ -62,7 +68,7 @@ class Settings(BaseSettings): mail_starttls: bool = True mail_ssl_tls: bool = False password_reset_token_max_age: int = 3600 - public_app_url: str = "http://localhost:5173" + public_app_url: str = "" forwarded_allow_ips: str = "*" telemetry_disabled: bool = False telemetry_endpoint: str = "https://metrics.librislog.app/api/telemetry" diff --git a/backend/app/database.py b/backend/app/database.py index 3c9bae5d..bdf1aaca 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -45,3 +45,13 @@ def get_session() -> Generator[Session, None, None]: """Yield a SQLModel Session for the lifespan of the request.""" with Session(engine) as session: yield session + + +def create_session() -> Session: + """Create a standalone session for code that runs outside request dependencies. + + Used by the trusted-proxy authentication middleware, which cannot use FastAPI's + dependency injection. The returned session is a context manager; callers must + close it, for example ``with create_session() as session:``. + """ + return Session(engine) diff --git a/backend/app/main.py b/backend/app/main.py index a94522bd..4ecd169c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,15 +2,19 @@ import asyncio import logging +import secrets from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from starlette.concurrency import run_in_threadpool from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response +from app import proxy_auth from app._build_info import __git_sha__, __version__ +from app.auth import start_browser_session from app.config import settings from app.logging_config import configure_logging from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, sessions, share_links, statistics, tags, users @@ -159,26 +163,120 @@ async def lifespan(app: FastAPI): redoc_url=None, ) -app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins, - allow_methods=["*"], - allow_headers=["*"], - allow_credentials=True, -) - - def _clean_env_value(value: str) -> str: """Strip inline comments and whitespace from an env-var string.""" return value.split("#", 1)[0].strip() +def _parse_forwarded_allow_ips(value: str) -> set[str]: + """Parse a comma/space-separated IP allow list.""" + if value.strip() == "*": + return {"*"} + return {ip.strip() for ip in value.replace(",", " ").split() if ip.strip()} + + cookie_domain_raw: str = _clean_env_value(settings.auth_cookie_domain) cookie_domain: str | None = cookie_domain_raw or None cookie_samesite: str = _clean_env_value(settings.auth_cookie_samesite).lower() if cookie_samesite not in {"lax", "strict", "none"}: cookie_samesite = "lax" cookie_name: str = _clean_env_value(settings.auth_cookie_name) or "librislog_session" + +_proxy_auth_ips = proxy_auth.trusted_ips() +_proxy_auth_header = proxy_auth.header_name() + +if settings.trusted_proxy_auth_enabled: + if not _proxy_auth_ips: + logger.warning( + "TRUSTED_PROXY_AUTH_ENABLED is true but TRUSTED_PROXY_AUTH_IPS is empty; " + "trusted proxy authentication is disabled" + ) + elif not _proxy_auth_header: + logger.warning( + "TRUSTED_PROXY_AUTH_ENABLED is true but TRUSTED_PROXY_AUTH_HEADER is empty; " + "trusted proxy authentication is disabled" + ) + elif "*" in _proxy_auth_ips: + logger.warning( + "TRUSTED_PROXY_AUTH_IPS contains '*'; trusted proxy authentication trusts any " + "client and is unsafe outside an isolated network" + ) + + # Parse the optional mapping at startup so invalid entries are logged once. + _ = proxy_auth.user_map() + + if not proxy_auth.secret(): + logger.warning( + "TRUSTED_PROXY_AUTH_SECRET is not set; anyone who can reach the backend directly " + "and spoof the identity header could impersonate a user" + ) + if "*" in _parse_forwarded_allow_ips(settings.forwarded_allow_ips): + logger.warning( + "FORWARDED_ALLOW_IPS contains '*'; uvicorn may rewrite request.client from " + "X-Forwarded-For, so the trusted proxy IP check can be bypassed" + ) + + +# This middleware must be registered before SessionMiddleware so that +# SessionMiddleware wraps it and request.session is available inside. +@app.middleware("http") +async def trusted_proxy_auth_middleware(request: Request, call_next) -> Response: + """Auto-authenticate a user forwarded by a trusted reverse proxy.""" + if not settings.trusted_proxy_auth_enabled: + return await call_next(request) + + allow_ips = proxy_auth.trusted_ips() + configured_header = proxy_auth.header_name() + if not allow_ips or not configured_header: + return await call_next(request) + if request.method == "OPTIONS": + return await call_next(request) + + client = request.client + if client is None or not proxy_auth.ip_is_trusted(client.host, allow_ips): + return await call_next(request) + + required_secret = proxy_auth.secret() + if required_secret: + provided_secret = request.headers.get(proxy_auth.SECRET_HEADER_NAME, "") + if not secrets.compare_digest(provided_secret, required_secret): + return await call_next(request) + + forwarded_user = request.headers.get(configured_header, "").strip() + if not forwarded_user: + return await call_next(request) + + claimed_email = proxy_auth.effective_identity(forwarded_user) + if ( + request.session.get("user_id") is not None + and request.session.get("proxy_auth_email") == claimed_email + ): + return await call_next(request) + + try: + user = await run_in_threadpool(proxy_auth.resolve_user, forwarded_user) + except Exception: # noqa: BLE001 — proxy auth must fail closed, never break the request + logger.warning("Trusted proxy user lookup failed", exc_info=True) + return await call_next(request) + + if user is None: + logger.debug("Trusted proxy header did not match exactly one existing user") + return await call_next(request) + + existing_id = request.session.get("user_id") + if existing_id == user.id: + request.session["proxy_auth_email"] = claimed_email + return await call_next(request) + if existing_id is not None: + # Remove only authentication keys so an in-flight OIDC state survives. + for key in ("user_id", "csrf_token", "credentials_version"): + request.session.pop(key, None) + start_browser_session(request, user.id, user.credentials_version) + request.session["proxy_auth_email"] = claimed_email + logger.debug("Authenticated user %s via trusted proxy header", user.id) + return await call_next(request) + + app.add_middleware( SessionMiddleware, secret_key=settings.api_key_encryption_key, @@ -188,14 +286,8 @@ def _clean_env_value(value: str) -> str: domain=cookie_domain, ) -def _parse_forwarded_allow_ips(value: str) -> set[str]: - """Parse the comma/space-separated ``forwarded_allow_ips`` setting.""" - if value.strip() == "*": - return {"*"} - return {ip.strip() for ip in value.replace(",", " ").split() if ip.strip()} - -_TRUSTED_PROXY_IPS = _parse_forwarded_allow_ips(settings.forwarded_allow_ips) +_FORWARDED_PROTO_TRUSTED_IPS = _parse_forwarded_allow_ips(settings.forwarded_allow_ips) @app.middleware("http") @@ -207,7 +299,9 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: That breaks OIDC flows because Authlib validates the redirect URI against ``request.url``. """ - if "*" in _TRUSTED_PROXY_IPS or (request.client and request.client.host in _TRUSTED_PROXY_IPS): + if "*" in _FORWARDED_PROTO_TRUSTED_IPS or ( + request.client and request.client.host in _FORWARDED_PROTO_TRUSTED_IPS + ): forwarded_proto = request.headers.get("x-forwarded-proto") if forwarded_proto: logger.debug("X-Forwarded-Proto=%s — patching scheme to %s", forwarded_proto, forwarded_proto) @@ -219,6 +313,17 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response: return await call_next(request) +# Register CORS last so it is the outermost middleware and can add headers to +# every response, including requests handled by the proxy-auth middleware. +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, +) + + app.include_router(books.router) app.include_router(sessions.router) app.include_router(import_.router) diff --git a/backend/app/proxy_auth.py b/backend/app/proxy_auth.py new file mode 100644 index 00000000..2657895b --- /dev/null +++ b/backend/app/proxy_auth.py @@ -0,0 +1,228 @@ +"""Trusted reverse-proxy authentication helpers. + +Kept separate from :mod:`app.main` so the public config endpoint can report the +effective (fail-closed) state without importing the application module. +""" + +import ipaddress +import logging +import re +from collections.abc import Callable, Mapping +from functools import lru_cache +from types import MappingProxyType +from typing import NamedTuple + +from sqlalchemy import func +from sqlmodel import Session, select + +from app.config import settings +from app.database import create_session +from app.models import User + +logger = logging.getLogger(__name__) + +#: Header a proxy can set in addition to the identity header. Optional, but +#: recommended whenever the backend might be reachable directly. +SECRET_HEADER_NAME = "X-Trusted-Proxy-Secret" + + +class ProxyUser(NamedTuple): + """The minimal user data proxy authentication needs.""" + + id: int + credentials_version: int + + +def _clean(value: str) -> str: + """Strip inline comments and surrounding whitespace from an env value.""" + return value.split("#", 1)[0].strip() + + +def parse_ip_allow_list(value: str) -> set[str]: + """Parse a comma/space-separated list of IP addresses and CIDR networks. + + ``*`` trusts any client. Invalid entries are skipped with a warning so a + typo cannot silently widen or break the allow list. + """ + raw = _clean(value) + if not raw: + return set() + + entries: set[str] = set() + for item in raw.replace(",", " ").split(): + item = item.strip() + if not item: + continue + if item == "*": + entries.add("*") + continue + try: + if "/" in item: + entries.add(str(ipaddress.ip_network(item, strict=False))) + else: + address = ipaddress.ip_address(item) + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: + address = address.ipv4_mapped + entries.add(str(address)) + except ValueError: + logger.warning("Ignoring invalid trusted proxy IP entry %r", item) + return entries + + +@lru_cache(maxsize=16) +def _cached_ip_allow_list(value: str) -> frozenset[str]: + """Parse the allow list once per distinct configuration value.""" + return frozenset(parse_ip_allow_list(value)) + + +def trusted_ips() -> frozenset[str]: + """Return the configured allow list, parsed once per distinct value.""" + return _cached_ip_allow_list(settings.trusted_proxy_auth_ips) + + +def ip_is_trusted(host: str | None, allow_list: set[str] | frozenset[str]) -> bool: + """Return True when *host* matches the parsed allow list.""" + if not host: + return False + if "*" in allow_list: + return True + + try: + address = ipaddress.ip_address(host) + except ValueError: + return False + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped: + address = address.ipv4_mapped + + for entry in allow_list: + if entry == "*": + return True + try: + if "/" in entry: + if address in ipaddress.ip_network(entry): + return True + elif address == ipaddress.ip_address(entry): + return True + except ValueError: + continue + return False + + +def header_name() -> str: + """Return the configured identity header name.""" + return _clean(settings.trusted_proxy_auth_header) + + +def secret() -> str: + """Return the optional shared secret. + + Do not strip inline ``#`` comments here: a secret may legitimately contain + ``#``, and truncating it would silently lock the proxy out. + """ + return settings.trusted_proxy_auth_secret.strip() + + +def _parse_user_map(value: str) -> dict[str, str]: + """Parse ``proxy_user=email`` pairs separated by commas or whitespace. + + A comment starts at a ``#`` preceded by whitespace (or at the beginning of + the value). A ``#`` inside a token is preserved, so usernames and local + parts may contain it. + """ + raw = value.strip() + if not raw or raw.startswith("#"): + return {} + raw = re.split(r"\s+#", raw, maxsplit=1)[0].strip() + # Tolerate ``alice = alice@example.com`` before splitting on separators. + raw = re.sub(r"\s*=\s*", "=", raw) + + mapping: dict[str, str] = {} + for entry in re.split(r"[,\s]+", raw): + entry = entry.strip() + if not entry: + continue + if "=" not in entry: + logger.warning("Ignoring invalid trusted proxy user mapping entry %r", entry) + continue + proxy_user, email = entry.split("=", 1) + proxy_user = proxy_user.strip().lower() + email = email.strip() + if not proxy_user or not email: + logger.warning("Ignoring invalid trusted proxy user mapping entry %r", entry) + continue + if "=" in email: + logger.warning( + "Ignoring trusted proxy user mapping %r: value contains '='", entry + ) + continue + if "@" not in email: + logger.warning( + "Ignoring trusted proxy user mapping %r: value is not an email", entry + ) + continue + if proxy_user in mapping: + logger.warning( + "Trusted proxy user mapping overrides earlier entry for %r", proxy_user + ) + mapping[proxy_user] = email + return mapping + + +@lru_cache(maxsize=16) +def _cached_user_map(value: str) -> Mapping[str, str]: + return MappingProxyType(_parse_user_map(value)) + + +def user_map() -> Mapping[str, str]: + """Return the optional proxy-user to email mapping. + + Keys are matched case-insensitively. Values are LibrisLog account emails. + """ + return _cached_user_map(settings.trusted_proxy_auth_user_map) + + +def effective_identity(identity: str) -> str: + """Return the account email a proxy identity maps to, before lookup.""" + raw = identity.strip() + if not raw: + return "" + mapped = user_map().get(raw.lower()) + return (mapped or raw).strip().lower() + + +def is_active() -> bool: + """Return True only when the feature is enabled and safely configured.""" + if not settings.trusted_proxy_auth_enabled: + return False + return bool(trusted_ips() and header_name()) + + +_session_factory: Callable[[], Session] = create_session + + +def resolve_user(identity: str) -> ProxyUser | None: + """Find exactly one existing user for a proxy identity. + + The identity is first looked up in the optional proxy-user mapping and then + matched against user emails case-insensitively. Returns ``None`` when no + user matches, when the address is ambiguous, or when the lookup fails. The + caller must treat every non-match as anonymous access rather than as an + error. + """ + key = effective_identity(identity) + if not key: + return None + + with _session_factory() as session: + rows = session.exec( + select(User.id, User.credentials_version).where(func.lower(User.email) == key) + ).all() + + if len(rows) > 1: + logger.warning( + "Trusted proxy email matched %d users; refusing to authenticate", len(rows) + ) + return None + if not rows: + return None + return ProxyUser(rows[0][0], rows[0][1]) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 75071e9f..a5129e3c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -29,6 +29,15 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) +def _ensure_password_login_enabled() -> None: + """Reject password-based flows when the operator disabled them.""" + if not settings.password_login_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Password login is disabled", + ) + + @router.get("/setup-required") def setup_required(session: Session = Depends(get_session)) -> dict: """Return whether initial admin setup is required.""" @@ -79,6 +88,7 @@ def login( session: Session = Depends(get_session), ) -> dict: """Authenticate with email and password, starting a browser session.""" + _ensure_password_login_enabled() user = session.exec(select(User).where(User.email == credentials.email)).first() if not user or not verify_password(credentials.password, user.hashed_password): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password") @@ -120,6 +130,7 @@ async def forgot_password( Always returns 200 to prevent user enumeration. """ + _ensure_password_login_enabled() user = session.exec(select(User).where(User.email == body.email)).first() if user and settings.mail_server: token = generate_password_reset_token(body.email, user.credentials_version) @@ -134,6 +145,7 @@ def reset_password( session: Session = Depends(get_session), ) -> dict: """Reset a user's password using a valid reset token.""" + _ensure_password_login_enabled() payload = verify_password_reset_token(body.token, max_age=settings.password_reset_token_max_age) if not payload: raise HTTPException( diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index ca6aae93..015aacf4 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -2,6 +2,7 @@ from fastapi import APIRouter +from app import proxy_auth from app.config import settings from app.schemas import AppConfigRead @@ -15,4 +16,7 @@ def app_config() -> AppConfigRead: embed_enabled=settings.embed_enabled, dashboard_quote_enabled=settings.dashboard_quote_enabled, thalia_cover_search_enabled=settings.thalia_cover_search_enabled, + password_login_enabled=settings.password_login_enabled, + # Report the effective state: a misconfigured proxy feature is inactive. + trusted_proxy_auth_enabled=proxy_auth.is_active(), ) diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py index 0e4fc0c9..728b4e3f 100644 --- a/backend/app/routers/oidc.py +++ b/backend/app/routers/oidc.py @@ -31,24 +31,39 @@ def _provider_id() -> str: return settings.oidc_provider_id +def _frontend_base_url() -> str: + """Base URL of the frontend app, used for browser redirects. + + Empty in single-origin (production) deployments, where the backend serves + the frontend and relative redirects resolve correctly. Set + ``PUBLIC_APP_URL`` in split-origin (local dev) setups. + """ + return (settings.public_app_url or "").rstrip("/") + + +def _with_frontend_base(path: str) -> str: + base = _frontend_base_url() + return f"{base}{path}" if base else path + + def _frontend_warning_redirect(message: str) -> RedirectResponse: """Redirect to the frontend login page with an OIDC error message.""" - return RedirectResponse(url=f"/login?oidc_error={quote_plus(message)}", status_code=302) + return RedirectResponse(url=_with_frontend_base(f"/login?oidc_error={quote_plus(message)}"), status_code=302) def _frontend_success_redirect() -> RedirectResponse: """Redirect to the frontend callback page on successful login.""" - return RedirectResponse(url="/auth/oidc/callback", status_code=302) + return RedirectResponse(url=_with_frontend_base("/auth/oidc/callback"), status_code=302) def _link_success_redirect() -> RedirectResponse: """Redirect to the frontend link callback with success status.""" - return RedirectResponse(url="/auth/oidc/link-callback?status=success", status_code=302) + return RedirectResponse(url=_with_frontend_base("/auth/oidc/link-callback?status=success"), status_code=302) def _link_error_redirect(message: str) -> RedirectResponse: """Redirect to the frontend link callback with an error message.""" - return RedirectResponse(url=f"/auth/oidc/link-callback?error={quote_plus(message)}", status_code=302) + return RedirectResponse(url=_with_frontend_base(f"/auth/oidc/link-callback?error={quote_plus(message)}"), status_code=302) def _resolve_callback_redirect_uri(request: Request, route_name: str) -> str: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c5ba63b0..064e5bd6 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -665,6 +665,8 @@ class AppConfigRead(SQLModel): embed_enabled: bool dashboard_quote_enabled: bool thalia_cover_search_enabled: bool + password_login_enabled: bool + trusted_proxy_auth_enabled: bool class OidcLinkRead(SQLModel): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2852bdbb..83d76e2e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,9 @@ from sqlmodel.pool import StaticPool os.environ.setdefault("API_KEY_ENCRYPTION_KEY", "test-api-key-encryption-secret") +# OIDC frontend redirects stay relative in tests (single-origin assumption); +# local dev sets PUBLIC_APP_URL in .env for split-origin setups. +os.environ.setdefault("PUBLIC_APP_URL", "") # Telemetry must never fire during the test suite (no network, no dev-DB writes). os.environ["TELEMETRY_DISABLED"] = "true" @@ -67,7 +70,9 @@ def override_get_session() -> Generator[Session, None, None]: yield session app.dependency_overrides[get_session] = override_get_session - with TestClient(app) as client: + # Use a real loopback address so middleware that inspects request.client + # can be configured with ordinary IP allow lists. + with TestClient(app, client=("127.0.0.1", 50000)) as client: client.headers.update({"X-API-Key": key_plain}) yield client app.dependency_overrides.clear() diff --git a/backend/tests/test_auth_methods.py b/backend/tests/test_auth_methods.py new file mode 100644 index 00000000..0a58239f --- /dev/null +++ b/backend/tests/test_auth_methods.py @@ -0,0 +1,537 @@ +"""Tests for configurable password login and trusted reverse-proxy authentication.""" + +from collections.abc import Generator + +import pytest +from fastapi import Request +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import proxy_auth +from app.auth import get_password_hash +from app.database import get_session +from app.main import app +from app.models import User, UserRole + + +@pytest.fixture(name="password_login_disabled") +def password_login_disabled_fixture(monkeypatch) -> None: + """Disable password-based flows for the duration of a test.""" + monkeypatch.setattr("app.config.settings.password_login_enabled", False) + + +def test_login_rejected_when_password_login_disabled( + client: TestClient, password_login_disabled: None +) -> None: + resp = client.post( + "/api/auth/login", + json={"email": "test@example.com", "password": "test-password"}, + ) + assert resp.status_code == 403 + assert resp.json()["detail"] == "Password login is disabled" + + +def test_forgot_password_rejected_when_password_login_disabled( + client: TestClient, password_login_disabled: None +) -> None: + resp = client.post("/api/auth/forgot-password", json={"email": "test@example.com"}) + assert resp.status_code == 403 + assert resp.json()["detail"] == "Password login is disabled" + + +def test_reset_password_rejected_when_password_login_disabled( + client: TestClient, password_login_disabled: None +) -> None: + resp = client.post( + "/api/auth/reset-password", + json={"token": "irrelevant", "password": "Secret123!"}, + ) + assert resp.status_code == 403 + assert resp.json()["detail"] == "Password login is disabled" + + +def test_setup_still_bootstraps_when_password_login_disabled( + session: Session, password_login_disabled: None +) -> None: + def override_get_session() -> Generator[Session, None, None]: + yield session + + app.dependency_overrides[get_session] = override_get_session + try: + with TestClient(app) as raw_client: + assert raw_client.get("/api/auth/setup-required").json() == {"required": True} + setup = raw_client.post( + "/api/auth/setup", + json={ + "firstname": "First", + "lastname": "Admin", + "email": "first-admin@example.com", + "password": "Secret123!", + }, + ) + assert setup.status_code == 200 + assert setup.json()["user"]["role"] == "admin" + + me = raw_client.get("/api/auth/me") + assert me.status_code == 200 + assert me.json()["email"] == "first-admin@example.com" + finally: + app.dependency_overrides.clear() + + +# ── Trusted reverse-proxy authentication ────────────────────────────────────── + + +@pytest.fixture(name="proxy_auth_enabled") +def proxy_auth_enabled_fixture(monkeypatch, session: Session) -> Session: + """Enable trusted proxy auth and point the resolver at the in-memory DB.""" + import app.main as main_module + + engine = session.get_bind() + monkeypatch.setattr(proxy_auth, "_session_factory", lambda: Session(engine)) + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_enabled", True) + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "127.0.0.1") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_header", "X-Forwarded-User") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_secret", "") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_user_map", "") + return session + + +def _add_user( + session: Session, + *, + email: str, + firstname: str = "Proxy", + role: UserRole = UserRole.user, +) -> User: + user = User( + firstname=firstname, + lastname="User", + email=email, + role=role, + hashed_password=get_password_hash("proxy-password"), + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def test_proxy_auth_creates_session_from_trusted_header( + client: TestClient, proxy_auth_enabled: Session +) -> None: + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 200 + assert resp.json()["email"] == "test@example.com" + assert "set-cookie" in {key.lower() for key in resp.headers} + + +def test_proxy_auth_ignores_untrusted_client( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "10.0.0.1") + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 401 + + +def test_proxy_auth_ignores_unknown_email( + client: TestClient, proxy_auth_enabled: Session +) -> None: + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "nobody@example.com"}) + assert resp.status_code == 401 + + +def test_proxy_auth_disabled_when_ip_list_empty( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "") + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 401 + + config = client.get("/api/config").json() + assert config["trusted_proxy_auth_enabled"] is False + + +def test_proxy_auth_accepts_wildcard_ip( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "*") + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 200 + + +def test_proxy_auth_requires_configured_secret( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_secret", "s3cret") + client.headers.pop("X-API-Key", None) + + missing = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert missing.status_code == 401 + + wrong = client.get( + "/api/auth/me", + headers={"X-Forwarded-User": "test@example.com", proxy_auth.SECRET_HEADER_NAME: "nope"}, + ) + assert wrong.status_code == 401 + + correct = client.get( + "/api/auth/me", + headers={"X-Forwarded-User": "test@example.com", proxy_auth.SECRET_HEADER_NAME: "s3cret"}, + ) + assert correct.status_code == 200 + assert correct.json()["email"] == "test@example.com" + + +def test_proxy_auth_fails_closed_when_user_lookup_raises( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + def broken_factory(): + raise RuntimeError("database unavailable") + + monkeypatch.setattr(proxy_auth, "_session_factory", broken_factory) + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 401 + + +def test_proxy_auth_skips_lookup_for_same_session_user( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + calls = {"count": 0} + original = proxy_auth.resolve_user + + def counting_resolver(email: str): + calls["count"] += 1 + return original(email) + + monkeypatch.setattr(proxy_auth, "resolve_user", counting_resolver) + client.headers.pop("X-API-Key", None) + + first = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + second = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert first.status_code == 200 + assert second.status_code == 200 + assert calls["count"] == 1 + + +def test_proxy_auth_reauthenticates_when_user_changes( + client: TestClient, proxy_auth_enabled: Session +) -> None: + second = _add_user(proxy_auth_enabled, email="second@example.com", firstname="Second") + client.headers.pop("X-API-Key", None) + + first = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert first.status_code == 200 + assert first.json()["email"] == "test@example.com" + + second_resp = client.get("/api/auth/me", headers={"X-Forwarded-User": second.email}) + assert second_resp.status_code == 200 + assert second_resp.json()["email"] == "second@example.com" + + +@pytest.mark.anyio +async def test_proxy_auth_middleware_skips_options_and_missing_client(monkeypatch) -> None: + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_enabled", True) + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "10.0.0.1") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_header", "X-Forwarded-User") + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_secret", "") + + called = {"count": 0} + + async def call_next(request: Request): + called["count"] += 1 + return None + + options_scope = { + "type": "http", + "method": "OPTIONS", + "path": "/", + "headers": [(b"x-forwarded-user", b"test@example.com")], + "client": ("10.0.0.1", 1234), + "session": {}, + } + await main_module.trusted_proxy_auth_middleware(Request(options_scope), call_next) + assert called["count"] == 1 + + no_client_scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"x-forwarded-user", b"test@example.com")], + "client": None, + "session": {}, + } + await main_module.trusted_proxy_auth_middleware(Request(no_client_scope), call_next) + assert called["count"] == 2 + + +def test_proxy_auth_is_inactive_by_default(client: TestClient) -> None: + """Default configuration must not auto-authenticate anyone.""" + client.headers.pop("X-API-Key", None) + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 401 + + +def test_proxy_auth_lookup_is_case_insensitive( + client: TestClient, proxy_auth_enabled: Session +) -> None: + from sqlmodel import select + + expected = proxy_auth_enabled.exec( + select(User).where(User.email == "test@example.com") + ).first() + assert expected is not None + + user = proxy_auth.resolve_user("TEST@EXAMPLE.COM") + assert user is not None + assert user.id == expected.id + + +def test_proxy_auth_lookup_rejects_ambiguous_case_variants( + client: TestClient, proxy_auth_enabled: Session +) -> None: + _add_user(proxy_auth_enabled, email="TEST@example.com", firstname="Variant") + + assert proxy_auth.resolve_user("test@example.com") is None + + +def test_proxy_auth_user_mapping_translates_proxy_username( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr( + main_module.settings, + "trusted_proxy_auth_user_map", + "alice=test@example.com", + ) + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "alice"}) + assert resp.status_code == 200 + assert resp.json()["email"] == "test@example.com" + + +def test_proxy_auth_user_mapping_falls_back_to_header_value( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr( + main_module.settings, + "trusted_proxy_auth_user_map", + "alice=other@example.com", + ) + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "test@example.com"}) + assert resp.status_code == 200 + assert resp.json()["email"] == "test@example.com" + + +def test_proxy_auth_user_mapping_is_case_insensitive( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr( + main_module.settings, + "trusted_proxy_auth_user_map", + "ALICE=TEST@EXAMPLE.COM", + ) + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "Alice"}) + assert resp.status_code == 200 + assert resp.json()["email"] == "test@example.com" + + +def test_proxy_auth_user_mapping_unknown_email_is_ignored( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr( + main_module.settings, + "trusted_proxy_auth_user_map", + "alice=nobody@example.com", + ) + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "alice"}) + assert resp.status_code == 401 + + +def test_proxy_auth_user_map_parsing_skips_invalid_entries(monkeypatch) -> None: + monkeypatch.setattr( + "app.config.settings.trusted_proxy_auth_user_map", + "alice=alice@example.com, bob=bob@example.com, broken", + ) + + mapping = proxy_auth.user_map() + assert mapping["alice"] == "alice@example.com" + assert mapping["bob"] == "bob@example.com" + assert len(mapping) == 2 + + +def test_proxy_auth_user_map_handles_spaces_and_comments(monkeypatch) -> None: + monkeypatch.setattr( + "app.config.settings.trusted_proxy_auth_user_map", + "alice = alice@example.com, bob=bob@example.com # household", + ) + + mapping = proxy_auth.user_map() + assert mapping == {"alice": "alice@example.com", "bob": "bob@example.com"} + + +def test_proxy_auth_user_map_preserves_hash_inside_tokens(monkeypatch) -> None: + monkeypatch.setattr( + "app.config.settings.trusted_proxy_auth_user_map", + "us#er=alice@example.com", + ) + + mapping = proxy_auth.user_map() + assert mapping == {"us#er": "alice@example.com"} + + +def test_proxy_auth_user_map_rejects_equals_inside_value(monkeypatch) -> None: + monkeypatch.setattr( + "app.config.settings.trusted_proxy_auth_user_map", + "alice==alice@example.com, bob=a@b.c=d, carol=carol@example.com", + ) + + mapping = proxy_auth.user_map() + assert mapping == {"carol": "carol@example.com"} + + +def test_proxy_auth_user_map_duplicate_keys_last_wins(monkeypatch) -> None: + monkeypatch.setattr( + "app.config.settings.trusted_proxy_auth_user_map", + "alice=one@example.com,ALICE=two@example.com", + ) + + mapping = proxy_auth.user_map() + assert mapping == {"alice": "two@example.com"} + + +def test_proxy_auth_user_map_ignores_non_email_values(monkeypatch) -> None: + monkeypatch.setattr( + "app.config.settings.trusted_proxy_auth_user_map", + "alice=alice@example.com, bob=not-an-email, =missing@example.com, broken", + ) + + mapping = proxy_auth.user_map() + assert mapping == {"alice": "alice@example.com"} + + +def test_proxy_auth_unmapped_non_email_fails_closed( + client: TestClient, proxy_auth_enabled: Session +) -> None: + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "bob"}) + assert resp.status_code == 401 + + +def test_proxy_auth_mapping_does_not_bypass_trusted_ip( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr( + main_module.settings, "trusted_proxy_auth_user_map", "alice=test@example.com" + ) + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_ips", "10.0.0.1") + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "alice"}) + assert resp.status_code == 401 + + +def test_proxy_auth_mapping_does_not_bypass_secret( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + monkeypatch.setattr( + main_module.settings, "trusted_proxy_auth_user_map", "alice=test@example.com" + ) + monkeypatch.setattr(main_module.settings, "trusted_proxy_auth_secret", "s3cret") + client.headers.pop("X-API-Key", None) + + resp = client.get("/api/auth/me", headers={"X-Forwarded-User": "alice"}) + assert resp.status_code == 401 + + +def test_proxy_auth_mapping_change_forces_reauthentication( + client: TestClient, proxy_auth_enabled: Session, monkeypatch +) -> None: + import app.main as main_module + + second = _add_user(proxy_auth_enabled, email="second@example.com", firstname="Second") + monkeypatch.setattr( + main_module.settings, "trusted_proxy_auth_user_map", "alice=test@example.com" + ) + client.headers.pop("X-API-Key", None) + + first = client.get("/api/auth/me", headers={"X-Forwarded-User": "alice"}) + assert first.status_code == 200 + assert first.json()["email"] == "test@example.com" + + monkeypatch.setattr( + main_module.settings, "trusted_proxy_auth_user_map", f"alice={second.email}" + ) + switched = client.get("/api/auth/me", headers={"X-Forwarded-User": "alice"}) + assert switched.status_code == 200 + assert switched.json()["email"] == "second@example.com" + + +def test_proxy_auth_secret_preserves_hash_characters(monkeypatch) -> None: + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_secret", "ab#cd") + assert proxy_auth.secret() == "ab#cd" + + +def test_parse_ip_allow_list_supports_cidr_and_ipv6() -> None: + entries = proxy_auth.parse_ip_allow_list( + "172.18.0.0/16, 2001:db8::1, ::ffff:192.168.1.5, not-an-ip" + ) + + assert "172.18.0.0/16" in entries + assert "2001:db8::1" in entries + assert "192.168.1.5" in entries + assert proxy_auth.ip_is_trusted("172.18.5.4", entries) is True + assert proxy_auth.ip_is_trusted("10.0.0.1", entries) is False + assert proxy_auth.ip_is_trusted(None, entries) is False + + +def test_proxy_auth_middleware_is_inside_session_middleware() -> None: + names = [] + for middleware in app.user_middleware: + cls_name = getattr(middleware.cls, "__name__", str(middleware.cls)) + dispatch = middleware.kwargs.get("dispatch") + names.append(getattr(dispatch, "__name__", cls_name) if dispatch else cls_name) + + # The list is outermost-first, so SessionMiddleware must appear earlier. + assert names.index("SessionMiddleware") < names.index("trusted_proxy_auth_middleware") diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 41b3ac86..7b852c6c 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -1,7 +1,5 @@ """Tests for app configuration validation and config endpoint.""" -from typing import Any - import pytest from fastapi.testclient import TestClient @@ -28,6 +26,10 @@ def test_get_config_returns_feature_flags(client: TestClient, monkeypatch) -> No monkeypatch.setattr("app.config.settings.embed_enabled", True) monkeypatch.setattr("app.config.settings.dashboard_quote_enabled", False) monkeypatch.setattr("app.config.settings.thalia_cover_search_enabled", True) + monkeypatch.setattr("app.config.settings.password_login_enabled", False) + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_enabled", True) + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_ips", "127.0.0.1") + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_header", "X-Forwarded-User") resp = client.get("/api/config") assert resp.status_code == 200 @@ -36,4 +38,24 @@ def test_get_config_returns_feature_flags(client: TestClient, monkeypatch) -> No "embed_enabled": True, "dashboard_quote_enabled": False, "thalia_cover_search_enabled": True, + "password_login_enabled": False, + "trusted_proxy_auth_enabled": True, } + assert "trusted_proxy_auth_header" not in data + + +def test_proxy_auth_flag_is_false_when_misconfigured(client: TestClient, monkeypatch) -> None: + """An enabled but fail-closed feature must not be advertised as usable.""" + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_enabled", True) + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_ips", "") + + assert client.get("/api/config").json()["trusted_proxy_auth_enabled"] is False + + +def test_proxy_auth_flag_is_false_with_empty_header(client: TestClient, monkeypatch) -> None: + """An empty header name is the other fail-closed configuration.""" + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_enabled", True) + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_ips", "127.0.0.1") + monkeypatch.setattr("app.config.settings.trusted_proxy_auth_header", "") + + assert client.get("/api/config").json()["trusted_proxy_auth_enabled"] is False diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 8225eda1..26db833d 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -6,7 +6,6 @@ import pytest from fastapi import FastAPI, Request -from fastapi.testclient import TestClient from starlette.responses import JSONResponse, Response @@ -162,7 +161,7 @@ async def call_next(request: Request) -> Response: return JSONResponse(received) request = Request(scope) - response = await proxy_headers_middleware(request, call_next) + await proxy_headers_middleware(request, call_next) assert received is not None assert received["scheme"] == "https" @@ -353,7 +352,7 @@ async def test_proxy_headers_middleware_skips_untrusted_proxy(monkeypatch) -> No """When request is not from a trusted proxy, X-Forwarded-Proto is ignored.""" from app.main import proxy_headers_middleware - monkeypatch.setattr("app.main._TRUSTED_PROXY_IPS", {"10.0.0.5"}) + monkeypatch.setattr("app.main._FORWARDED_PROTO_TRUSTED_IPS", {"10.0.0.5"}) scope = { "type": "http", diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 6715da14..d889d2b4 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -19,7 +19,104 @@ All configuration is done via environment variables in a `.env` file at the proj | `AUTH_COOKIE_NAME` | Session cookie name | `librislog_session` | | `AUTH_COOKIE_SECURE` | Use secure cookies (HTTPS only) | `false` | | `AUTH_COOKIE_SAMESITE` | SameSite cookie attribute | `lax` | -| `AUTH_COOKIE_DOMAIN` | Cookie domain | — | +| `AUTH_COOKIE_DOMAIN` | Cookie domain | (none) | +| `PASSWORD_LOGIN_ENABLED` | Allow email/password login, "Forgot password?", and password reset | `true` | + +Set `PASSWORD_LOGIN_ENABLED=false` once every user has linked single sign-on. The login +page then hides the password form and the three password endpoints return `403`. The +first-admin setup still works, so a fresh instance can always be bootstrapped. + +::: warning +Disabling password login is a one-way door until you re-enable it. Existing local accounts +that have no OIDC link can no longer sign in while the flag is off. To recover, set +`PASSWORD_LOGIN_ENABLED=true` and restart the backend. +::: + +## Trusted Reverse-Proxy Authentication (Optional) + +When a reverse proxy already authenticates users (for example Traefik BasicAuth or an +SSO gateway), LibrisLog can reuse that identity and create a browser session +automatically. The forwarded value is matched against an existing user's email address; +unknown values are ignored and never create accounts. + +| Variable | Description | Default | +|----------|-------------|---------| +| `TRUSTED_PROXY_AUTH_ENABLED` | Enable proxy-header authentication | `false` | +| `TRUSTED_PROXY_AUTH_HEADER` | HTTP header carrying the authenticated identity | `X-Forwarded-User` | +| `TRUSTED_PROXY_AUTH_IPS` | Comma/space-separated IP addresses or CIDR ranges allowed to send the header. Empty disables the feature even when enabled. | (none) | +| `TRUSTED_PROXY_AUTH_SECRET` | Optional shared secret the proxy must also send in `X-Trusted-Proxy-Secret`. Empty means no secret is required. | (none) | +| `TRUSTED_PROXY_AUTH_USER_MAP` | Optional `proxy_user=email` pairs, separated by commas or spaces. When set, a forwarded value is translated through the map before the email lookup; values not in the map are used as the email directly. | (none) | + +The feature fails closed: if the IP list or the header name is empty, the middleware +logs a warning at startup and stays inactive. `*` in the IP list trusts every client and +is only safe on an isolated network. Invalid IP entries are logged and ignored. CIDR +ranges such as `172.18.0.0/16` are supported, and IPv4-mapped IPv6 addresses are +normalised. + +::: warning +Only enable this when LibrisLog is reachable exclusively through the trusted proxy. +Anyone who can connect directly and spoof the header could impersonate a user. + +The IP check uses the address LibrisLog sees as the connecting peer. If Uvicorn's +proxy-header handling rewrites that address from `X-Forwarded-For` (controlled by +`FORWARDED_ALLOW_IPS` or `--proxy-headers`), a client can make the request look like it +came from the proxy. In that case, set `TRUSTED_PROXY_AUTH_SECRET` and have the proxy +inject it, or restrict `FORWARDED_ALLOW_IPS` to the proxy's address only. + +Other containers on the same Docker network can also reach the backend and present the +header, so use the shared secret in multi-container setups. +::: + +Example Traefik labels for a BasicAuth-protected deployment. The BasicAuth user name +can be any proxy username as long as it appears in `TRUSTED_PROXY_AUTH_USER_MAP`: + +```yaml +labels: + - "traefik.http.middlewares.librislog-auth.basicauth.users=alice:$$apr1$$..." + - "traefik.http.middlewares.librislog-auth.basicauth.headerfield=X-Forwarded-User" + - "traefik.http.middlewares.librislog-auth-secret.headers.customrequestheaders.X-Trusted-Proxy-Secret=change-me" + - "traefik.http.routers.librislog.middlewares=librislog-auth,librislog-auth-secret" + - "traefik.http.services.librislog.loadbalancer.server.port=8000" +``` + +Traefik only sets the authenticated-user header when `basicauth.headerfield` is +configured; its default is empty. The header value is the BasicAuth user name. If you +do not set `TRUSTED_PROXY_AUTH_USER_MAP`, that name must be the LibrisLog account email. +The BasicAuth and Headers rules must use different middleware names, as in the example +above. + +Configure LibrisLog with the proxy's address, the header Traefik sets, and a mapping +from proxy usernames to account emails: + +```dotenv +TRUSTED_PROXY_AUTH_ENABLED=true +TRUSTED_PROXY_AUTH_HEADER=X-Forwarded-User +TRUSTED_PROXY_AUTH_IPS=172.18.0.0/16 +TRUSTED_PROXY_AUTH_SECRET=change-me +TRUSTED_PROXY_AUTH_USER_MAP=alice=alice@example.com,bob=bob@example.com +``` + +Mapping keys are matched case-insensitively. A forwarded value that is not present in +the map is treated as an email address, so mixed deployments can map some proxy users +and pass others through directly. Duplicate keys keep the last value and log a warning; +mapping values without an `@` are ignored so a typo is visible in the logs. + +::: warning +The pass-through fallback is only safe when the proxy alone decides the username and +users cannot choose arbitrary values. If a user can set their proxy username to another +person's email address, they can be signed in as that person. When proxy usernames are +user-controlled, map every allowed username explicitly. +::: + +Email matching is case-insensitive for ASCII addresses. + +The IP must be the address LibrisLog sees as the connecting peer (the proxy container), +not the end user's address. With Docker Compose, use the proxy container's static IP or +the Docker network subnet. + +With proxy authentication enabled, logging out clears the LibrisLog session, but the +next request from the same proxy signs the user in again. This is expected: access is +controlled by the proxy, not by LibrisLog. ## OIDC (Optional) @@ -36,15 +133,15 @@ Password reset is optional. If mail is not configured, the "Forgot password?" li | Variable | Description | Default | |----------|-------------|---------| -| `MAIL_SERVER` | SMTP server hostname (e.g. `smtp.gmail.com`). Leave empty to disable password reset emails. | — | +| `MAIL_SERVER` | SMTP server hostname (e.g. `smtp.gmail.com`). Leave empty to disable password reset emails. | (none) | | `MAIL_PORT` | SMTP server port | `587` | -| `MAIL_USERNAME` | SMTP username (leave empty for unauthenticated relay) | — | -| `MAIL_PASSWORD` | SMTP password | — | -| `MAIL_FROM` | Sender email address | — | +| `MAIL_USERNAME` | SMTP username (leave empty for unauthenticated relay) | (none) | +| `MAIL_PASSWORD` | SMTP password | (none) | +| `MAIL_FROM` | Sender email address | (none) | | `MAIL_FROM_NAME` | Sender display name | `LibrisLog` | | `MAIL_STARTTLS` | Use STARTTLS (`True`/`False`) | `True` | | `MAIL_SSL_TLS` | Use implicit SSL/TLS (`True`/`False`) | `False` | -| `PUBLIC_APP_URL` | Public URL of the frontend (used to build the reset link in emails) | `http://localhost:5173` | +| `PUBLIC_APP_URL` | Public URL of the frontend. Required for password reset emails; needed for OIDC redirects in split-origin (local dev) setups. Leave empty when the backend serves the frontend. | (none) | | `PASSWORD_RESET_TOKEN_MAX_AGE` | Reset token validity in seconds | `3600` (1 hour) | ::: tip Local Development @@ -79,8 +176,8 @@ naming the variables to set. | `DNB_IMPORT_SEARCH_ENABLED` | Query the Deutsche Nationalbibliothek (German National Library), title and ISBN | `true` | `true` | | `HARDCOVER_IMPORT_SEARCH_ENABLED` | Query Hardcover.app (also needs `HARDCOVER_APP_API_TOKEN`) | `true` | `false` unless a token is set | | `GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED` | Allow the Google Books fallback (also needs `GOOGLE_BOOKS_API_KEY`) | `true` | `false` unless a key is set | -| `GOOGLE_BOOKS_API_KEY` | Google Books API key (see [API Keys](/guide/api-keys)) | empty | — | -| `HARDCOVER_APP_API_TOKEN` | Hardcover.app API token (see [API Keys](/guide/api-keys)) | empty | — | +| `GOOGLE_BOOKS_API_KEY` | Google Books API key (see [API Keys](/guide/api-keys)) | empty | (none) | +| `HARDCOVER_APP_API_TOKEN` | Hardcover.app API token (see [API Keys](/guide/api-keys)) | empty | (none) | The built-in default is `true` for every source, so upgrading never silently drops a source that worked before these switches existed. `.env.example` sets the two keyed diff --git a/docs/guide/using-librislog/administration.md b/docs/guide/using-librislog/administration.md index d743de38..eb99c761 100644 --- a/docs/guide/using-librislog/administration.md +++ b/docs/guide/using-librislog/administration.md @@ -19,6 +19,56 @@ Click "Edit" next to a user to change their name, email, password, or role. You Click "Delete" to remove a user account. You cannot delete your own account from this page — use the **Profile → Danger Zone** instead. +## External Authentication + +LibrisLog can run with single sign-on (OIDC), trusted reverse-proxy authentication, or +both. The methods are independent, so a deployment can keep password login as a fallback +or turn it off entirely. + +### Disabling Password Login + +Set `PASSWORD_LOGIN_ENABLED=false` in the backend environment to hide the email/password +form and "Forgot password?" link. `POST /api/auth/login`, `/api/auth/forgot-password`, and +`/api/auth/reset-password` then return `403`. The first-admin setup remains available so a +new instance can be bootstrapped. + +Before disabling password login, make sure every user has linked their OIDC account: +open **Profile → Single Sign-On → Link account**. Admin-created users without an OIDC +link cannot sign in while password login is disabled. If you get locked out, set +`PASSWORD_LOGIN_ENABLED=true` and restart the backend. + +For account management, admins can still create users and set passwords through +**Administration → Users**. These actions do not enable password login while the flag +is off; they only store the password for later use or recovery. The profile password +field is hidden while password login is disabled. + +### Trusted Reverse-Proxy Authentication + +With `TRUSTED_PROXY_AUTH_ENABLED=true`, LibrisLog trusts an HTTP header set by a reverse +proxy such as Traefik BasicAuth and signs the matching user in automatically. See +[Configuration → Trusted Reverse-Proxy Authentication](/guide/configuration#trusted-reverse-proxy-authentication-optional) +for the variables and a Traefik example. + +Things to keep in mind: + +- The forwarded value is matched against an existing user's email address + (case-insensitive). Set `TRUSTED_PROXY_AUTH_USER_MAP` to translate proxy usernames to + account emails, for example `alice=alice@example.com,bob=bob@example.com`. Values not + present in the map are used as the email directly. Only rely on that fallback when the + proxy controls the username and users cannot set it to arbitrary email addresses; + otherwise map every allowed username. Unknown values are ignored; accounts are never + created automatically. +- Only requests from the configured `TRUSTED_PROXY_AUTH_IPS` are honored. Never expose the + backend directly to clients that could spoof the header. +- When Uvicorn's proxy-header handling rewrites the connecting address from + `X-Forwarded-For`, set `TRUSTED_PROXY_AUTH_SECRET` and have the proxy inject it, or + restrict `FORWARDED_ALLOW_IPS` to the proxy only. Other containers on the same Docker + network can present the header, so the secret is recommended there too. +- Because the proxy owns authentication, logging out of LibrisLog does not end the proxy + session. The next request signs the user back in. Use the proxy's logout to switch users. +- On a shared browser, a change of proxy user replaces the LibrisLog session with the new + user on the next request. + ## Backup & Restore ![Backup and Restore tab](/screenshots/admin-backup.png) @@ -39,7 +89,7 @@ Restoring overwrites all current data. Create a fresh backup first if you want t The backup endpoint is accessible via the REST API, making it easy to automate with cron (or any scheduler). -First, [create an API key](/api/setup#3-create-an-api-key) with the admin user. Then use it in a cron script: +First, [create an API key](/api/#creating-an-api-key) with the admin user. Then use it in a cron script: ```bash #!/usr/bin/env bash diff --git a/docs/releases.md b/docs/releases.md index 5c3e97d7..8a6f251d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,8 +8,8 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/ ## Unreleased -::: tip ⭐ v1.12.0 - Mass Tag Editor -LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once. Filter and search the library, select individual books or all books matching the current filters, then apply tag changes in one step. Tags left on no book are cleaned up automatically. The change is non-breaking: no data migration and no change to existing endpoints beyond two new optional filters. +::: tip ⭐ v1.12.0 - Mass Tag Editor & Authentication Methods +LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once, plus two opt-in authentication options: password login can be disabled once single sign-on is in use, and a trusted reverse proxy can sign users in from a forwarded header. The tag editor change is non-breaking, and the authentication features are off by default and need no data migration. ::: ## Latest Release @@ -22,7 +22,7 @@ LibrisLog v1.11.0 lets books be read more than once. Each reading is tracked as | Version | Date | Type | |---|---|---| -| [v1.12.0](#v1-12-0-mass-tag-editor) | Unreleased | Feature release | +| [v1.12.0](#v1-12-0-mass-tag-editor-authentication-methods) | Unreleased | Feature release | | [v1.11.0](#v1-11-0-reading-sessions-re-reads) | 2026-10-24 | Feature release | | [v1.10.0](#v1-10-0-more-book-sources-upgrades) | 2026-10-18 | Feature release | | [v1.9.0](#v1-9-0-smarter-import-search-input-ux) | 2026-09-15 | Feature release | @@ -44,23 +44,28 @@ LibrisLog v1.11.0 lets books be read more than once. Each reading is tracked as --- -## v1.12.0: Mass Tag Editor +## v1.12.0: Mass Tag Editor & Authentication Methods -**Summary:** LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once. Filter and search the library, select individual books or all books matching the current filters, then apply tag changes in one step. Tags left on no book are cleaned up automatically. +**Summary:** LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once, plus two opt-in authentication options. Administrators can disable password login once single sign-on is in use, and deployments behind an authenticating reverse proxy can sign users in automatically from a forwarded header. **Features** - 🏷️ **Mass Tag Editor page.** New page at `/tag-editor`, reachable from Profile → Manage my data, with the same filter bar as the library (search, status, medium, acquisition, tag) plus row selection and "select all matching filters". - 🔁 **Add and remove in one step.** Both fields in a single action, applied in chunks of 500 books with progress reporting. - ⚠️ **Case-variant warning.** Tags that differ only in capitalization from an existing tag are flagged, with a one-click shortcut to reuse the existing spelling. - 🧹 **Orphan tag cleanup.** Tags left on no book are deleted automatically. +- 🔐 **Disable password login.** `PASSWORD_LOGIN_ENABLED=false` hides the email/password form and "Forgot password?" link and makes the login, forgot-password, and reset-password endpoints return `403`. First-admin setup still works, so a new instance can always be bootstrapped. The setup page warns when single sign-on should be linked before logging out. +- 🌐 **Trusted reverse-proxy authentication.** `TRUSTED_PROXY_AUTH_ENABLED=true` signs a user in automatically when a trusted proxy forwards an identity in a configurable header. The header, trusted IPs or CIDR ranges, and an optional shared secret are configurable, and `TRUSTED_PROXY_AUTH_USER_MAP` can translate proxy usernames to account emails when the proxy name is not the email itself. The feature fails closed when misconfigured, rejects ambiguous email matches, and ignores unknown values. +- ⚙️ **Config API.** `GET /api/config` now exposes `password_login_enabled` and the effective `trusted_proxy_auth_enabled` state so the login page can adapt. The profile password field is hidden while password login is disabled. +- 🌍 **Translations.** New login and setup messages in English, German, Spanish, French, and Chinese. **Improvements** - 🔍 **Exact tag filters.** `GET /api/books` and the new `GET /api/books/ids` accept `tag` and `exclude_tag` for exact tag name filtering. - 📋 **Tag listing.** New `GET /api/tags` returns all tags with usage counts. +- 📚 **Authentication documentation.** New configuration reference and administration guidance, including a Traefik BasicAuth example and recovery steps for password lockout. -**Breaking changes:** None. No data migration. Existing endpoints keep their behaviour; only two optional query parameters and new endpoints were added. +**Breaking changes:** None. No data migration. Existing endpoints keep their behaviour; only two optional query parameters and new endpoints were added. Both authentication features are disabled by default. Existing installations keep password login and behave exactly as before. [Compare with v1.11.0](https://github.com/codebude/librislog/compare/v1.11.0...HEAD) diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts index a810b5b7..b7586e68 100644 --- a/frontend/e2e/specs/11-profile.spec.ts +++ b/frontend/e2e/specs/11-profile.spec.ts @@ -93,7 +93,9 @@ test.describe('Profile', () => { body: JSON.stringify({ embed_enabled: false, dashboard_quote_enabled: true, - thalia_cover_search_enabled: false + thalia_cover_search_enabled: false, + password_login_enabled: true, + trusted_proxy_auth_enabled: false }) }); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 8a0d5a0a..22a22212 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "Neues Passwort festlegen", "resetPasswordInstruction": "Gib dein neues Passwort ein.", "resetPasswordSuccess": "Passwort wurde erfolgreich zurückgesetzt.", - "resetPasswordSubmit": "Passwort zurücksetzen" + "resetPasswordSubmit": "Passwort zurücksetzen", + "passwordLoginDisabledHint": "Die Passwort-Anmeldung ist deaktiviert. Verwende Single Sign-on oder wende dich an deinen Administrator, wenn du dich nicht anmelden kannst.", + "proxyManagedHint": "Der Zugriff auf diese Instanz wird von deinem Reverse-Proxy verwaltet.", + "noLoginMethodAvailable": "Es ist derzeit keine Anmeldemethode konfiguriert. Wende dich an deinen Administrator.", + "configLoadFailed": "Die Anmeldekonfiguration konnte nicht geladen werden. Lade die Seite neu oder wende dich an deinen Administrator.", + "setupLinkOidcWarning": "Die Passwort-Anmeldung ist deaktiviert. Verknüpfe dein Single-Sign-on-Konto im Profil, bevor du dich abmeldest, sonst kannst du dich nicht erneut anmelden.", + "setupPasswordDisabledNoOidc": "Die Passwort-Anmeldung ist deaktiviert und kein Single-Sign-on-Anbieter konfiguriert. Aktiviere OIDC oder die Passwort-Anmeldung, bevor du dich abmeldest.", + "setupConfigCheckFailed": "Die Einstellung für die Passwort-Anmeldung konnte nicht geprüft werden. Falls sie deaktiviert ist, verknüpfe dein Single-Sign-on-Konto im Profil, bevor du dich abmeldest." }, "user": { "menu": "Benutzermenü", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index da559412..49adf340 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "Set New Password", "resetPasswordInstruction": "Enter your new password.", "resetPasswordSuccess": "Password has been reset successfully.", - "resetPasswordSubmit": "Reset Password" + "resetPasswordSubmit": "Reset Password", + "passwordLoginDisabledHint": "Password login is disabled. Use single sign-on, or contact your administrator if you cannot sign in.", + "proxyManagedHint": "Access to this instance is managed by your reverse proxy.", + "noLoginMethodAvailable": "No sign-in method is currently configured. Contact your administrator.", + "configLoadFailed": "Could not load the sign-in configuration. Reload the page or contact your administrator.", + "setupLinkOidcWarning": "Password login is disabled. Link your single sign-on account in Profile before you log out, or you will not be able to sign in again.", + "setupPasswordDisabledNoOidc": "Password login is disabled and no single sign-on provider is configured. Enable OIDC or re-enable password login before logging out.", + "setupConfigCheckFailed": "Could not verify the password login setting. If password login is disabled, link single sign-on in Profile before you log out." }, "user": { "menu": "User menu", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index ada389f1..2b5c5c0a 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "Establecer nueva contraseña", "resetPasswordInstruction": "Introduce tu nueva contraseña.", "resetPasswordSuccess": "Contraseña restablecida correctamente.", - "resetPasswordSubmit": "Restablecer contraseña" + "resetPasswordSubmit": "Restablecer contraseña", + "passwordLoginDisabledHint": "El inicio de sesión con contraseña está desactivado. Usa el inicio de sesión único o contacta con tu administrador si no puedes acceder.", + "proxyManagedHint": "El acceso a esta instancia lo gestiona tu proxy inverso.", + "noLoginMethodAvailable": "No hay ningún método de inicio de sesión configurado. Contacta con tu administrador.", + "configLoadFailed": "No se pudo cargar la configuración de inicio de sesión. Recarga la página o contacta con tu administrador.", + "setupLinkOidcWarning": "El inicio de sesión con contraseña está desactivado. Vincula tu cuenta de inicio de sesión único en el perfil antes de cerrar sesión, o no podrás volver a acceder.", + "setupPasswordDisabledNoOidc": "El inicio de sesión con contraseña está desactivado y no hay proveedor de inicio de sesión único configurado. Activa OIDC o vuelve a activar la contraseña antes de cerrar sesión.", + "setupConfigCheckFailed": "No se pudo verificar la configuración del inicio de sesión con contraseña. Si está desactivado, vincula el inicio de sesión único en el perfil antes de cerrar sesión." }, "user": { "menu": "Menú de usuario", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 890e3ac5..3212e78c 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "Définir un nouveau mot de passe", "resetPasswordInstruction": "Saisis ton nouveau mot de passe.", "resetPasswordSuccess": "Mot de passe réinitialisé avec succès.", - "resetPasswordSubmit": "Réinitialiser le mot de passe" + "resetPasswordSubmit": "Réinitialiser le mot de passe", + "passwordLoginDisabledHint": "La connexion par mot de passe est désactivée. Utilisez l'authentification unique ou contactez votre administrateur si vous ne pouvez pas vous connecter.", + "proxyManagedHint": "L'accès à cette instance est géré par votre proxy inverse.", + "noLoginMethodAvailable": "Aucune méthode de connexion n'est configurée. Contactez votre administrateur.", + "configLoadFailed": "Impossible de charger la configuration de connexion. Rechargez la page ou contactez votre administrateur.", + "setupLinkOidcWarning": "La connexion par mot de passe est désactivée. Liez votre compte d'authentification unique dans le profil avant de vous déconnecter, sinon vous ne pourrez plus vous reconnecter.", + "setupPasswordDisabledNoOidc": "La connexion par mot de passe est désactivée et aucun fournisseur d'authentification unique n'est configuré. Activez OIDC ou réactivez le mot de passe avant de vous déconnecter.", + "setupConfigCheckFailed": "Impossible de vérifier le réglage de connexion par mot de passe. S'il est désactivé, liez l'authentification unique dans le profil avant de vous déconnecter." }, "user": { "menu": "Menu utilisateur", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 2691648e..3988d594 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -422,7 +422,14 @@ "resetPasswordTitle": "设置新密码", "resetPasswordInstruction": "输入你的新密码。", "resetPasswordSuccess": "密码已成功重置。", - "resetPasswordSubmit": "重置密码" + "resetPasswordSubmit": "重置密码", + "passwordLoginDisabledHint": "密码登录已禁用。请使用单点登录;如果无法登录,请联系管理员。", + "proxyManagedHint": "对此实例的访问由反向代理管理。", + "noLoginMethodAvailable": "当前未配置任何登录方式。请联系管理员。", + "configLoadFailed": "无法加载登录配置。请刷新页面或联系管理员。", + "setupLinkOidcWarning": "密码登录已禁用。请在退出登录前在个人资料中关联单点登录账户,否则将无法再次登录。", + "setupPasswordDisabledNoOidc": "密码登录已禁用且未配置单点登录提供程序。请在退出登录前启用 OIDC 或重新启用密码登录。", + "setupConfigCheckFailed": "无法验证密码登录设置。如果密码登录已禁用,请在退出登录前在个人资料中关联单点登录。" }, "user": { "menu": "用户菜单", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index acbe9b85..7fb008a5 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -327,6 +327,8 @@ export interface AppConfig { embed_enabled: boolean; dashboard_quote_enabled: boolean; thalia_cover_search_enabled: boolean; + password_login_enabled: boolean; + trusted_proxy_auth_enabled: boolean; } export interface OidcLinkStatus { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 24de68f1..e7b8ee5b 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -19,16 +19,28 @@ let error = $state(''); let oidcEnabled = $state(false); let oidcProviderName = $state('Single Sign-On'); + let passwordLoginEnabled = $state(null); + let trustedProxyAuthEnabled = $state(false); + let configError = $state(false); $effect(() => { void (async () => { - try { - const cfg = await api.oidc.config(); - oidcEnabled = cfg.enabled; - oidcProviderName = cfg.provider_name ?? 'Single Sign-On'; - } catch { + const [oidcResult, appResult] = await Promise.allSettled([ + api.oidc.config(), + api.app.config() + ]); + if (oidcResult.status === 'fulfilled') { + oidcEnabled = oidcResult.value.enabled; + oidcProviderName = oidcResult.value.provider_name ?? 'Single Sign-On'; + } else { oidcEnabled = false; } + if (appResult.status === 'fulfilled') { + passwordLoginEnabled = appResult.value.password_login_enabled; + trustedProxyAuthEnabled = appResult.value.trusted_proxy_auth_enabled; + } else { + configError = true; + } })(); }); @@ -148,48 +160,71 @@ {error} {/if} -
{ e.preventDefault(); submit(); }}> - - -
- +
+ + {#if oidcEnabled} +
{$_('oidc.orContinueWith')}
+ + {/if} +
+ {:else} +
+ {#if oidcEnabled} + + {:else if !trustedProxyAuthEnabled} + + {$_('auth.noLoginMethodAvailable')} + + {/if} +

{$_('auth.passwordLoginDisabledHint')}

- - {#if oidcEnabled} -
{$_('oidc.orContinueWith')}
- - {/if} - + {/if} + {#if trustedProxyAuthEnabled} +

{$_('auth.proxyManagedHint')}

+ {/if} +{#if passwordLoginEnabled} +{/if} diff --git a/frontend/src/routes/login/page.test.ts b/frontend/src/routes/login/page.test.ts new file mode 100644 index 00000000..ba488093 --- /dev/null +++ b/frontend/src/routes/login/page.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; +import LoginPage from './+page.svelte'; + +const mockOidcConfig = vi.fn(); +const mockAppConfig = vi.fn(); + +vi.mock('#lib/api.js', () => ({ + api: { + oidc: { + config: (...args: unknown[]) => mockOidcConfig(...args), + loginUrl: () => '/api/oidc/login' + }, + app: { + config: (...args: unknown[]) => mockAppConfig(...args) + }, + auth: { + login: vi.fn(), + csrf: vi.fn(), + forgotPassword: vi.fn() + }, + profile: { + getSettings: vi.fn(), + updateSettings: vi.fn() + } + } +})); + +function mockConfig({ + passwordLoginEnabled = true, + oidcEnabled = true, + trustedProxyAuthEnabled = false +} = {}) { + mockOidcConfig.mockResolvedValue({ + enabled: oidcEnabled, + provider_id: oidcEnabled ? 'oidc' : null, + provider_name: oidcEnabled ? 'Pocket ID' : null + }); + mockAppConfig.mockResolvedValue({ + embed_enabled: true, + dashboard_quote_enabled: true, + thalia_cover_search_enabled: false, + password_login_enabled: passwordLoginEnabled, + trusted_proxy_auth_enabled: trustedProxyAuthEnabled + }); +} + +describe('Login page authentication methods', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows the password form when password login is enabled', async () => { + mockConfig(); + render(LoginPage); + + await waitFor(() => { + expect(screen.getAllByLabelText('Email').length).toBeGreaterThan(0); + }); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByText('Forgot password?')).toBeInTheDocument(); + expect(screen.getByText('Continue with Pocket ID')).toBeInTheDocument(); + }); + + it('hides the password form and forgot-password link when disabled', async () => { + mockConfig({ passwordLoginEnabled: false }); + render(LoginPage); + + await waitFor(() => { + expect(screen.getByText('Continue with Pocket ID')).toBeInTheDocument(); + }); + expect(screen.queryByLabelText('Email')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + expect(screen.queryByText('Forgot password?')).not.toBeInTheDocument(); + expect(screen.getByText(/Password login is disabled/i)).toBeInTheDocument(); + }); + + it('warns when no login method is available', async () => { + mockConfig({ passwordLoginEnabled: false, oidcEnabled: false }); + render(LoginPage); + + await waitFor(() => { + expect(screen.getByText(/No sign-in method is currently configured/i)).toBeInTheDocument(); + }); + }); + + it('shows an error instead of the form when config cannot be loaded', async () => { + mockOidcConfig.mockResolvedValue({ enabled: false, provider_id: null, provider_name: null }); + mockAppConfig.mockRejectedValue(new Error('config unavailable')); + render(LoginPage); + + await waitFor(() => { + expect(screen.getByText(/Could not load the sign-in configuration/i)).toBeInTheDocument(); + }); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + }); + + it('mentions proxy-managed access when proxy auth is enabled', async () => { + mockConfig({ passwordLoginEnabled: false, oidcEnabled: false, trustedProxyAuthEnabled: true }); + render(LoginPage); + + await waitFor(() => { + expect(screen.getByText(/managed by your reverse proxy/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/No sign-in method is currently configured/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 809bc5c0..a1ef6dd5 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -42,7 +42,7 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; let keys = $state([]); let pendingDeleteKeyId = $state(null); let oidcConfig = $state({ enabled: false, provider_id: null, provider_name: null }); - let appConfig = $state({ embed_enabled: true, dashboard_quote_enabled: true, thalia_cover_search_enabled: false }); + let appConfig = $state({ embed_enabled: true, dashboard_quote_enabled: true, thalia_cover_search_enabled: false, password_login_enabled: true, trusted_proxy_auth_enabled: false }); let oidcLink = $state({ linked: false, provider_name: null, oidc_email: null, oidc_name: null }); let oidcLoading = $state(false); let oidcMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); @@ -698,23 +698,25 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; placeholder={$_('auth.lastname')} autocomplete="family-name" /> - - + {#if appConfig.password_login_enabled} + + - + + {/if} + {:else if noToken}
{$_('auth.resetPasswordMissingToken')}
diff --git a/frontend/src/routes/setup/+page.svelte b/frontend/src/routes/setup/+page.svelte index 400b57f5..819c1d94 100644 --- a/frontend/src/routes/setup/+page.svelte +++ b/frontend/src/routes/setup/+page.svelte @@ -18,6 +18,9 @@ let selectedLanguage = $state('en'); let loading = $state(false); let error = $state(''); + let passwordLoginEnabled = $state(null); + let oidcEnabled = $state(false); + let configError = $state(false); $effect(() => { if (SUPPORTED_LOCALES.includes($locale as AppLocale)) { @@ -25,6 +28,23 @@ } }); + $effect(() => { + void (async () => { + const [appResult, oidcResult] = await Promise.allSettled([ + api.app.config(), + api.oidc.config() + ]); + if (appResult.status === 'fulfilled') { + passwordLoginEnabled = appResult.value.password_login_enabled; + } else { + configError = true; + } + if (oidcResult.status === 'fulfilled') { + oidcEnabled = oidcResult.value.enabled; + } + })(); + }); + function onLanguageChange(event: Event) { const next = (event.currentTarget as HTMLSelectElement).value as AppLocale; if (!SUPPORTED_LOCALES.includes(next)) return; @@ -53,7 +73,13 @@ await api.profile.updateSettings({ language: selectedLanguage, timezone: detected }); setTimezone(detected); setLocale(selectedLanguage); - await goto('/'); + let target = '/'; + if (configError) { + target = '/profile'; + } else if (passwordLoginEnabled === false && oidcEnabled) { + target = '/profile#section-oidc'; + } + await goto(target); } catch (e: unknown) { error = e instanceof Error ? e.message : $_('auth.setupFailed'); } finally { @@ -70,6 +96,19 @@ {$_('app.title')}

{$_('auth.setupTitle')}

+ {#if configError} + + {$_('auth.setupConfigCheckFailed')} + + {:else if passwordLoginEnabled === false} + + {#if oidcEnabled} + {$_('auth.setupLinkOidcWarning')} + {:else} + {$_('auth.setupPasswordDisabledNoOidc')} + {/if} + + {/if}