Add whole-app password protection
This commit is contained in:
+3
-1
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Vendored
+1
@@ -3,6 +3,7 @@ import type { ModuleOptions } from "@vite-pwa/nuxt";
|
||||
const scope = "/";
|
||||
|
||||
export const pwa: ModuleOptions = {
|
||||
selfDestroying: true,
|
||||
registerType: "autoUpdate",
|
||||
scope,
|
||||
base: scope,
|
||||
|
||||
Reference in New Issue
Block a user