diff --git a/.env.example b/.env.example index 10b32a3..84ad82c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ # Never commit a real token. Export these variables in your shell. +RESUME_AGENT_USERNAME=resume-user +RESUME_AGENT_PASSWORD=replace-with-a-long-random-password RESUME_AGENT_API_KEY=replace-with-your-token RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1 RESUME_AGENT_MODEL=replace-with-a-model-id-from-resume-agent-models @@ -8,4 +10,4 @@ RESUME_AGENT_LOG_LEVEL=INFO RESUME_AGENT_LOG_MODEL_PAYLOADS=true # Optional host port used by Docker Compose. -RESUME_AGENT_PORT=8000 +RESUME_AGENT_PORT=8900 diff --git a/Dockerfile b/Dockerfile index f5a9650..4b4c522 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,6 @@ USER resume-agent EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=5 \ - CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/status', timeout=5).read()"] + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5).read()"] CMD ["uvicorn", "resume_agent.webapp:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 6f845e3..f61c508 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,29 @@ cp .env.example .env The app loads `.env` automatically. The default OpenAI model is `gpt-5.6-terra`; override it with `RESUME_AGENT_MODEL` or `--model`. +Configure the single-user login before starting the web app: + +```dotenv +RESUME_AGENT_USERNAME=your-username +RESUME_AGENT_PASSWORD=replace-with-a-long-random-password +``` + +You can generate a strong password with: + +```bash +uv run python -c "import secrets; print(secrets.token_urlsafe(32))" +``` + +Authentication covers the Signal interface, APIs, downloads, static files, and the +bundled Oh My CV editor. The server fails closed with HTTP 503 when either credential is +missing or still uses the placeholder value. Your browser will display its standard +username/password prompt. Oh My CV offline caching is disabled so its app shell cannot +bypass the server authentication gate. + +HTTP Basic authentication protects credentials only when the connection uses HTTPS. +Binding to localhost is suitable for personal use; if the app is reachable from another +machine, place it behind an HTTPS reverse proxy and do not expose the plain HTTP port. + For an OpenAI-compatible provider, configure the endpoint without putting credentials in the repository: @@ -87,7 +110,7 @@ After configuring `.env`, build and start the complete app with: docker compose up --build ``` -Open `http://127.0.0.1:8000`. The image builds Oh My CV and serves it from `/cv/` through +Open `http://127.0.0.1:8900`. The image builds Oh My CV and serves it from `/cv/` through the same FastAPI process. Career-profile state and generated resumes persist in the host's `.resume-agent/` and `output/` directories. diff --git a/compose.yaml b/compose.yaml index 5cbefd5..f909394 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,7 +8,7 @@ services: env_file: - .env ports: - - "${RESUME_AGENT_PORT:-8000}:8000" + - "${RESUME_AGENT_PORT:-8900}:8000" volumes: - ./.resume-agent:/app/.resume-agent - ./output:/app/output diff --git a/src/resume_agent/webapp.py b/src/resume_agent/webapp.py index 4d2697b..bd1c4a9 100644 --- a/src/resume_agent/webapp.py +++ b/src/resume_agent/webapp.py @@ -1,15 +1,18 @@ from __future__ import annotations import asyncio +import base64 +import binascii import logging import os +import secrets from collections.abc import Awaitable, Callable from pathlib import Path from typing import Annotated, Any, Literal from urllib.parse import urlparse from uuid import uuid4 -from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles @@ -174,6 +177,32 @@ def _save_app_settings(settings_path: Path, settings: AppSettings) -> None: settings_path.write_text(settings.model_dump_json(indent=2), encoding="utf-8") +def _valid_basic_credentials( + authorization: str | None, + expected_username: str, + expected_password: str, +) -> bool: + if not authorization: + return False + scheme, separator, token = authorization.partition(" ") + if not separator or scheme.lower() != "basic": + return False + try: + decoded = base64.b64decode(token, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return False + username, separator, password = decoded.partition(":") + if not separator: + return False + username_matches = secrets.compare_digest( + username.encode("utf-8"), expected_username.encode("utf-8") + ) + password_matches = secrets.compare_digest( + password.encode("utf-8"), expected_password.encode("utf-8") + ) + return username_matches and password_matches + + def create_app( *, profile_path: Path = DEFAULT_PROFILE, @@ -181,8 +210,53 @@ def create_app( llm_factory: Callable[[], OpenAILLM] = OpenAILLM, cv_dist: Path = DEFAULT_CV_DIST, settings_path: Path | None = None, + auth_username: str | None = None, + auth_password: str | None = None, ) -> FastAPI: web_app = FastAPI(title="Resume Agent", version="0.1.0") + resolved_auth_username = ( + auth_username if auth_username is not None else os.getenv("RESUME_AGENT_USERNAME", "") + ) + resolved_auth_password = ( + auth_password if auth_password is not None else os.getenv("RESUME_AGENT_PASSWORD", "") + ) + auth_configured = bool( + resolved_auth_username + and resolved_auth_password + and ":" not in resolved_auth_username + and not resolved_auth_username.lower().startswith("replace-with-") + and not resolved_auth_password.lower().startswith("replace-with-") + ) + + @web_app.middleware("http") + async def require_authentication(request: Request, call_next: Callable[..., Awaitable[Any]]): + if request.url.path == "/healthz": + return await call_next(request) + if not auth_configured: + return PlainTextResponse( + "Authentication is not configured. Set RESUME_AGENT_USERNAME and " + "RESUME_AGENT_PASSWORD.", + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + if not _valid_basic_credentials( + request.headers.get("Authorization"), + resolved_auth_username, + resolved_auth_password, + ): + return PlainTextResponse( + "Authentication required.", + status_code=401, + headers={ + "Cache-Control": "no-store", + "WWW-Authenticate": 'Basic realm="Resume Agent", charset="UTF-8"', + }, + ) + response = await call_next(request) + if request.url.path.startswith("/api/"): + response.headers["Cache-Control"] = "no-store" + return response + web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") editor_available = cv_dist.is_dir() if editor_available: @@ -191,6 +265,12 @@ def create_app( task_records: dict[str, TaskStatusResponse] = {} active_tasks: set[asyncio.Task[None]] = set() + @web_app.get("/healthz", include_in_schema=False) + async def healthz() -> PlainTextResponse: + if not auth_configured: + return PlainTextResponse("authentication is not configured", status_code=503) + return PlainTextResponse("ok") + async def run_task(task_id: str, operation: Awaitable[BaseModel]) -> None: TASK_LOGGER.info("task=%s event=start", task_id) try: diff --git a/tests/test_webapp.py b/tests/test_webapp.py index bdc9683..6a229c9 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -1,16 +1,37 @@ from __future__ import annotations +import base64 import time from pathlib import Path from typing import Any -from fastapi.testclient import TestClient +import pytest +from fastapi.testclient import TestClient as RawTestClient from resume_agent.agent import save_json from resume_agent.models import CareerProfile, TailoringPackage from resume_agent.render import render_ohmycv_resume from resume_agent.webapp import create_app +TEST_USERNAME = "resume-user" +TEST_PASSWORD = "test-password" +TEST_AUTHORIZATION = "Basic " + base64.b64encode( + f"{TEST_USERNAME}:{TEST_PASSWORD}".encode() +).decode() +TEST_AUTH_HEADERS = {"Authorization": TEST_AUTHORIZATION} + + +class TestClient(RawTestClient): + def __init__(self, app: Any, **kwargs: Any) -> None: + headers = {**TEST_AUTH_HEADERS, **kwargs.pop("headers", {})} + super().__init__(app, headers=headers, **kwargs) + + +@pytest.fixture(autouse=True) +def configure_authentication(monkeypatch: Any) -> None: + monkeypatch.setenv("RESUME_AGENT_USERNAME", TEST_USERNAME) + monkeypatch.setenv("RESUME_AGENT_PASSWORD", TEST_PASSWORD) + def sample_profile() -> CareerProfile: return CareerProfile.model_validate( @@ -98,6 +119,51 @@ def wait_for_task(client: TestClient, task_id: str) -> dict[str, Any]: raise AssertionError("Background task did not finish in time.") +def test_authentication_protects_ui_api_static_and_editor(tmp_path: Path) -> None: + cv_dist = tmp_path / "cv" + cv_dist.mkdir() + (cv_dist / "index.html").write_text("editor", encoding="utf-8") + (cv_dist / "200.html").write_text("editor", encoding="utf-8") + app = create_app( + profile_path=tmp_path / "profile.json", + output_dir=tmp_path / "output", + cv_dist=cv_dist, + llm_factory=lambda: FakeLLM([]), # type: ignore[arg-type] + ) + + with RawTestClient(app) as client: + assert client.get("/healthz").status_code == 200 + for path in ("/", "/api/status", "/favicon.ico", "/cv/"): + response = client.get(path) + assert response.status_code == 401 + assert response.headers["www-authenticate"].startswith("Basic ") + wrong = client.get( + "/api/status", + headers={"Authorization": "Basic " + base64.b64encode(b"wrong:wrong").decode()}, + ) + assert wrong.status_code == 401 + assert client.get("/", headers=TEST_AUTH_HEADERS).status_code == 200 + assert client.get("/api/status", headers=TEST_AUTH_HEADERS).status_code == 200 + assert client.get("/favicon.ico", headers=TEST_AUTH_HEADERS).status_code == 200 + assert client.get("/cv/", headers=TEST_AUTH_HEADERS).status_code == 200 + + +def test_missing_authentication_configuration_fails_closed( + tmp_path: Path, monkeypatch: Any +) -> None: + monkeypatch.delenv("RESUME_AGENT_USERNAME") + monkeypatch.delenv("RESUME_AGENT_PASSWORD") + app = create_app( + profile_path=tmp_path / "profile.json", + output_dir=tmp_path / "output", + llm_factory=lambda: FakeLLM([]), # type: ignore[arg-type] + ) + + with RawTestClient(app) as client: + assert client.get("/").status_code == 503 + assert client.get("/healthz").status_code == 503 + + def test_root_and_status(tmp_path: Path, monkeypatch: Any) -> None: monkeypatch.delenv("RESUME_AGENT_API_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) diff --git a/vendor/oh-my-cv/site/configs/pwa.ts b/vendor/oh-my-cv/site/configs/pwa.ts index 0fa8f3c..36c53aa 100644 --- a/vendor/oh-my-cv/site/configs/pwa.ts +++ b/vendor/oh-my-cv/site/configs/pwa.ts @@ -3,6 +3,7 @@ import type { ModuleOptions } from "@vite-pwa/nuxt"; const scope = "/"; export const pwa: ModuleOptions = { + selfDestroying: true, registerType: "autoUpdate", scope, base: scope,