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.
|
# 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_API_KEY=replace-with-your-token
|
||||||
RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1
|
RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1
|
||||||
RESUME_AGENT_MODEL=replace-with-a-model-id-from-resume-agent-models
|
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
|
RESUME_AGENT_LOG_MODEL_PAYLOADS=true
|
||||||
|
|
||||||
# Optional host port used by Docker Compose.
|
# 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
|
EXPOSE 8000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=5 \
|
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"]
|
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`;
|
The app loads `.env` automatically. The default OpenAI model is `gpt-5.6-terra`;
|
||||||
override it with `RESUME_AGENT_MODEL` or `--model`.
|
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
|
For an OpenAI-compatible provider, configure the endpoint without putting credentials
|
||||||
in the repository:
|
in the repository:
|
||||||
|
|
||||||
@@ -87,7 +110,7 @@ After configuring `.env`, build and start the complete app with:
|
|||||||
docker compose up --build
|
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
|
the same FastAPI process. Career-profile state and generated resumes persist in the
|
||||||
host's `.resume-agent/` and `output/` directories.
|
host's `.resume-agent/` and `output/` directories.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
- "${RESUME_AGENT_PORT:-8000}:8000"
|
- "${RESUME_AGENT_PORT:-8900}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./.resume-agent:/app/.resume-agent
|
- ./.resume-agent:/app/.resume-agent
|
||||||
- ./output:/app/output
|
- ./output:/app/output
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import uuid4
|
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.concurrency import run_in_threadpool
|
||||||
from fastapi.responses import FileResponse, PlainTextResponse
|
from fastapi.responses import FileResponse, PlainTextResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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")
|
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(
|
def create_app(
|
||||||
*,
|
*,
|
||||||
profile_path: Path = DEFAULT_PROFILE,
|
profile_path: Path = DEFAULT_PROFILE,
|
||||||
@@ -181,8 +210,53 @@ def create_app(
|
|||||||
llm_factory: Callable[[], OpenAILLM] = OpenAILLM,
|
llm_factory: Callable[[], OpenAILLM] = OpenAILLM,
|
||||||
cv_dist: Path = DEFAULT_CV_DIST,
|
cv_dist: Path = DEFAULT_CV_DIST,
|
||||||
settings_path: Path | None = None,
|
settings_path: Path | None = None,
|
||||||
|
auth_username: str | None = None,
|
||||||
|
auth_password: str | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
web_app = FastAPI(title="Resume Agent", version="0.1.0")
|
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")
|
web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
editor_available = cv_dist.is_dir()
|
editor_available = cv_dist.is_dir()
|
||||||
if editor_available:
|
if editor_available:
|
||||||
@@ -191,6 +265,12 @@ def create_app(
|
|||||||
task_records: dict[str, TaskStatusResponse] = {}
|
task_records: dict[str, TaskStatusResponse] = {}
|
||||||
active_tasks: set[asyncio.Task[None]] = set()
|
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:
|
async def run_task(task_id: str, operation: Awaitable[BaseModel]) -> None:
|
||||||
TASK_LOGGER.info("task=%s event=start", task_id)
|
TASK_LOGGER.info("task=%s event=start", task_id)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+67
-1
@@ -1,16 +1,37 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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.agent import save_json
|
||||||
from resume_agent.models import CareerProfile, TailoringPackage
|
from resume_agent.models import CareerProfile, TailoringPackage
|
||||||
from resume_agent.render import render_ohmycv_resume
|
from resume_agent.render import render_ohmycv_resume
|
||||||
from resume_agent.webapp import create_app
|
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:
|
def sample_profile() -> CareerProfile:
|
||||||
return CareerProfile.model_validate(
|
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.")
|
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:
|
def test_root_and_status(tmp_path: Path, monkeypatch: Any) -> None:
|
||||||
monkeypatch.delenv("RESUME_AGENT_API_KEY", raising=False)
|
monkeypatch.delenv("RESUME_AGENT_API_KEY", raising=False)
|
||||||
monkeypatch.delenv("OPENAI_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 = "/";
|
const scope = "/";
|
||||||
|
|
||||||
export const pwa: ModuleOptions = {
|
export const pwa: ModuleOptions = {
|
||||||
|
selfDestroying: true,
|
||||||
registerType: "autoUpdate",
|
registerType: "autoUpdate",
|
||||||
scope,
|
scope,
|
||||||
base: scope,
|
base: scope,
|
||||||
|
|||||||
Reference in New Issue
Block a user