Add whole-app password protection

This commit is contained in:
2026-08-03 00:06:07 +03:30
parent 4eb9d7fb37
commit f5f54bac66
7 changed files with 178 additions and 6 deletions
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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"]
+24 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+81 -1
View File
@@ -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:
+67 -1
View File
@@ -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)
+1
View File
@@ -3,6 +3,7 @@ import type { ModuleOptions } from "@vite-pwa/nuxt";
const scope = "/";
export const pwa: ModuleOptions = {
selfDestroying: true,
registerType: "autoUpdate",
scope,
base: scope,