From 81d816ae54841fd649dd2a7097d2fcfaf5bd05cb Mon Sep 17 00:00:00 2001 From: nfel Date: Mon, 3 Aug 2026 00:25:18 +0330 Subject: [PATCH] Replace basic auth with session login --- .env.example | 2 + README.md | 13 +- src/resume_agent/static/app.js | 9 + src/resume_agent/static/index.html | 3 + src/resume_agent/static/login.html | 220 ++++++++++++++++++ src/resume_agent/static/styles.css | 21 ++ src/resume_agent/webapp.py | 207 ++++++++++++---- tests/test_webapp.py | 71 ++++-- .../editor/toolbar/file/AiTailor.vue | 11 + .../site/src/components/shared/Header.vue | 13 ++ 10 files changed, 501 insertions(+), 69 deletions(-) create mode 100644 src/resume_agent/static/login.html diff --git a/.env.example b/.env.example index 84ad82c..b3d29cf 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # 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 +# Set true when the browser reaches the app over HTTPS. +RESUME_AGENT_SECURE_COOKIES=false 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 diff --git a/README.md b/README.md index f61c508..514cb71 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,14 @@ 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. +missing or still uses the placeholder value. The login page creates a signed, HttpOnly, +SameSite session cookie that expires after 12 hours, and both frontends provide a sign-out +button. 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. +Binding to localhost is suitable for personal use. If the app is reachable from another +machine, place it behind an HTTPS reverse proxy, set +`RESUME_AGENT_SECURE_COOKIES=true`, and do not expose the plain HTTP port. For an OpenAI-compatible provider, configure the endpoint without putting credentials in the repository: diff --git a/src/resume_agent/static/app.js b/src/resume_agent/static/app.js index 7268d89..f24bd92 100644 --- a/src/resume_agent/static/app.js +++ b/src/resume_agent/static/app.js @@ -25,6 +25,7 @@ document.addEventListener("DOMContentLoaded", async () => { async function api(path, options = {}) { const response = await fetch(path, options); + redirectToLoginIfExpired(response); if (!response.ok) { let message = "Something went wrong."; try { @@ -38,6 +39,13 @@ async function api(path, options = {}) { return response.json(); } +function redirectToLoginIfExpired(response) { + if (response.status !== 401) return; + const next = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.assign(`/login?next=${encodeURIComponent(next)}`); + throw new Error("Your session expired. Redirecting to sign in…"); +} + async function apiTask(path, options = {}) { const started = await api(`${path}/start`, options); let consecutiveNetworkErrors = 0; @@ -392,6 +400,7 @@ async function prepareOhMyCvImport(packageData) { try { const response = await fetch("/api/download/resume-ohmycv.md"); + redirectToLoginIfExpired(response); if (!response.ok) throw new Error("Could not prepare the Oh My CV export."); const markdown = await response.text(); const role = packageData.job.role_title || "Tailored resume"; diff --git a/src/resume_agent/static/index.html b/src/resume_agent/static/index.html index f7004a9..84d5db4 100644 --- a/src/resume_agent/static/index.html +++ b/src/resume_agent/static/index.html @@ -37,6 +37,9 @@ Checking provider… +
+ +
diff --git a/src/resume_agent/static/login.html b/src/resume_agent/static/login.html new file mode 100644 index 0000000..59a1998 --- /dev/null +++ b/src/resume_agent/static/login.html @@ -0,0 +1,220 @@ + + + + + + + Sign in — Signal Resume Agent + + + +
+
SSignal
+
+

Private career workspace

+

Welcome back.

+

+ Sign in to access your career profile, tailored resumes, and CV editor. +

+
+ + + + + + + +
+
+

Your session stays in an HttpOnly browser cookie and expires automatically.

+
+ + diff --git a/src/resume_agent/static/styles.css b/src/resume_agent/static/styles.css index d435387..17c7adc 100644 --- a/src/resume_agent/static/styles.css +++ b/src/resume_agent/static/styles.css @@ -176,6 +176,27 @@ button { transform: translateY(-1px); } +.topbar-actions form { + margin: 0; +} + +.logout-button { + padding: 9px 13px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: transparent; + cursor: pointer; + font-size: 10px; + font-weight: 750; +} + +.logout-button:hover { + color: var(--ink); + border-color: #aeb1a9; + background: var(--surface-2); +} + .status-dot { width: 7px; height: 7px; diff --git a/src/resume_agent/webapp.py b/src/resume_agent/webapp.py index bd1c4a9..a7ba659 100644 --- a/src/resume_agent/webapp.py +++ b/src/resume_agent/webapp.py @@ -2,19 +2,29 @@ from __future__ import annotations import asyncio import base64 -import binascii +import hashlib +import hmac +import html import logging import os import secrets +import time from collections.abc import Awaitable, Callable from pathlib import Path from typing import Annotated, Any, Literal -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse from uuid import uuid4 from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.concurrency import run_in_threadpool -from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.responses import ( + FileResponse, + HTMLResponse, + JSONResponse, + PlainTextResponse, + RedirectResponse, + Response, +) from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, ConfigDict, Field from starlette.exceptions import HTTPException as StarletteHTTPException @@ -39,6 +49,8 @@ STATIC_DIR = PACKAGE_DIR / "static" DEFAULT_CV_DIST = PROJECT_DIR / "vendor/oh-my-cv/site/.output/public" DEFAULT_PROFILE = Path(".resume-agent/profile.json") DEFAULT_OUTPUT = Path("output") +LOGIN_TEMPLATE = STATIC_DIR / "login.html" +SESSION_COOKIE = "resume_agent_session" DOWNLOADS = { "resume.md": "text/markdown", "resume-ohmycv.md": "text/markdown", @@ -177,23 +189,12 @@ 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, +def _credentials_match( + username: str, + password: str, 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") ) @@ -203,6 +204,71 @@ def _valid_basic_credentials( return username_matches and password_matches +def _session_key(username: str, password: str) -> bytes: + material = f"resume-agent-session\0{username}\0{password}".encode() + return hashlib.sha256(material).digest() + + +def _create_session_token(username: str, key: bytes, lifetime_seconds: int) -> str: + expires_at = int(time.time()) + lifetime_seconds + payload = f"{username}\0{expires_at}\0{secrets.token_urlsafe(18)}".encode() + encoded = base64.urlsafe_b64encode(payload).rstrip(b"=").decode() + signature = hmac.new(key, encoded.encode(), hashlib.sha256).hexdigest() + return f"{encoded}.{signature}" + + +def _valid_session_token(token: str | None, expected_username: str, key: bytes) -> bool: + if not token: + return False + encoded, separator, signature = token.partition(".") + if not separator or not encoded or not signature: + return False + expected_signature = hmac.new(key, encoded.encode(), hashlib.sha256).hexdigest() + if not secrets.compare_digest(signature, expected_signature): + return False + try: + padding = "=" * (-len(encoded) % 4) + decoded = base64.urlsafe_b64decode(encoded + padding).decode("utf-8") + username, expires_at, _nonce = decoded.split("\0", 2) + expires = int(expires_at) + except (ValueError, UnicodeDecodeError): + return False + return secrets.compare_digest(username, expected_username) and expires >= int(time.time()) + + +def _safe_next_path(value: str | None) -> str: + if not value or len(value) > 4_096 or not value.startswith("/") or value.startswith("//"): + return "/" + parsed = urlparse(value) + if parsed.scheme or parsed.netloc: + return "/" + return value + + +def _login_html(*, next_path: str, error: str = "") -> str: + template = LOGIN_TEMPLATE.read_text(encoding="utf-8") + return template.replace("{{NEXT}}", html.escape(next_path, quote=True)).replace( + "{{ERROR}}", html.escape(error) + ) + + +def _login_response(*, next_path: str, error: str = "", status_code: int = 200) -> HTMLResponse: + return HTMLResponse( + _login_html(next_path=next_path, error=error), + status_code=status_code, + headers={ + "Cache-Control": "no-store", + "Content-Security-Policy": ( + "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; " + "base-uri 'none'; frame-ancestors 'none'" + ), + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + ) + + def create_app( *, profile_path: Path = DEFAULT_PROFILE, @@ -227,6 +293,14 @@ def create_app( and not resolved_auth_username.lower().startswith("replace-with-") and not resolved_auth_password.lower().startswith("replace-with-") ) + session_key = _session_key(resolved_auth_username, resolved_auth_password) + session_lifetime_seconds = 12 * 60 * 60 + secure_cookies = os.getenv("RESUME_AGENT_SECURE_COOKIES", "false").lower() in { + "1", + "true", + "yes", + "on", + } @web_app.middleware("http") async def require_authentication(request: Request, call_next: Callable[..., Awaitable[Any]]): @@ -239,22 +313,30 @@ def create_app( status_code=503, headers={"Cache-Control": "no-store"}, ) - if not _valid_basic_credentials( - request.headers.get("Authorization"), - resolved_auth_username, - resolved_auth_password, + if request.url.path == "/login": + return await call_next(request) + if not _valid_session_token( + request.cookies.get(SESSION_COOKIE), resolved_auth_username, session_key ): - return PlainTextResponse( - "Authentication required.", - status_code=401, - headers={ - "Cache-Control": "no-store", - "WWW-Authenticate": 'Basic realm="Resume Agent", charset="UTF-8"', - }, + if request.url.path.startswith("/api/"): + return JSONResponse( + {"detail": "Your session has expired. Sign in again."}, + status_code=401, + headers={"Cache-Control": "no-store"}, + ) + requested_path = request.url.path + if request.url.query: + requested_path = f"{requested_path}?{request.url.query}" + return RedirectResponse( + f"/login?{urlencode({'next': requested_path})}", + status_code=303, + headers={"Cache-Control": "no-store"}, ) response = await call_next(request) - if request.url.path.startswith("/api/"): + content_type = response.headers.get("content-type", "") + if request.url.path.startswith("/api/") or content_type.startswith("text/html"): response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" return response web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @@ -271,6 +353,57 @@ def create_app( return PlainTextResponse("authentication is not configured", status_code=503) return PlainTextResponse("ok") + @web_app.get("/login", response_class=HTMLResponse, include_in_schema=False) + async def login_page(request: Request, next: str = "/") -> Response: + next_path = _safe_next_path(next) + if _valid_session_token( + request.cookies.get(SESSION_COOKIE), resolved_auth_username, session_key + ): + return RedirectResponse(next_path, status_code=303) + return _login_response(next_path=next_path) + + @web_app.post("/login", response_class=HTMLResponse, include_in_schema=False) + async def login( + username: Annotated[str, Form(max_length=256)], + password: Annotated[str, Form(max_length=4_096)], + next: Annotated[str, Form(max_length=4_096)] = "/", + ) -> Response: + next_path = _safe_next_path(next) + if not _credentials_match( + username, password, resolved_auth_username, resolved_auth_password + ): + await asyncio.sleep(0.35) + return _login_response( + next_path=next_path, + error="The username or password is incorrect.", + status_code=401, + ) + response = RedirectResponse(next_path, status_code=303) + response.set_cookie( + SESSION_COOKIE, + _create_session_token(resolved_auth_username, session_key, session_lifetime_seconds), + max_age=session_lifetime_seconds, + httponly=True, + secure=secure_cookies, + samesite="strict", + path="/", + ) + response.headers["Cache-Control"] = "no-store" + return response + + @web_app.post("/logout", include_in_schema=False) + async def logout() -> RedirectResponse: + response = RedirectResponse("/login", status_code=303) + response.delete_cookie( + SESSION_COOKIE, + httponly=True, + secure=secure_cookies, + samesite="strict", + path="/", + ) + response.headers["Cache-Control"] = "no-store" + return response + async def run_task(task_id: str, operation: Awaitable[BaseModel]) -> None: TASK_LOGGER.info("task=%s event=start", task_id) try: @@ -342,9 +475,7 @@ def create_app( api_key = os.getenv("RESUME_AGENT_API_KEY") or os.getenv("OPENAI_API_KEY") base_url = os.getenv("RESUME_AGENT_BASE_URL") or os.getenv("OPENAI_BASE_URL") model = os.getenv("RESUME_AGENT_MODEL") or (None if base_url else "gpt-5.6-terra") - configured = bool( - api_key and model and not api_key.lower().startswith("replace-with-") - ) + configured = bool(api_key and model and not api_key.lower().startswith("replace-with-")) return { "configured": configured, "provider": _provider_name(), @@ -446,9 +577,7 @@ def create_app( ) try: app_settings = _load_app_settings(resolved_settings_path) - evidence_mode = ( - request.evidence_mode if app_settings.evidence_guard else "profile" - ) + evidence_mode = request.evidence_mode if app_settings.evidence_guard else "profile" profile = load_profile(profile_path) if request.job_url: job_text = await run_in_threadpool(fetch_job_page, request.job_url) @@ -482,9 +611,7 @@ def create_app( ) try: app_settings = _load_app_settings(resolved_settings_path) - evidence_mode = ( - request.evidence_mode if app_settings.evidence_guard else "profile" - ) + evidence_mode = request.evidence_mode if app_settings.evidence_guard else "profile" llm = llm_factory() profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about) save_json(profile, profile_path) @@ -531,9 +658,7 @@ def create_app( ) try: profile = load_profile(profile_path) - current = TailoringPackage.model_validate_json( - package_path.read_text(encoding="utf-8") - ) + current = TailoringPackage.model_validate_json(package_path.read_text(encoding="utf-8")) evidence_mode = _load_evidence_mode(output_dir) if not _load_app_settings(resolved_settings_path).evidence_guard: evidence_mode = "profile" diff --git a/tests/test_webapp.py b/tests/test_webapp.py index 6a229c9..f973974 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -1,6 +1,5 @@ from __future__ import annotations -import base64 import time from pathlib import Path from typing import Any @@ -15,16 +14,18 @@ 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) + def __enter__(self) -> TestClient: + super().__enter__() + response = self.post( + "/login", + data={"username": TEST_USERNAME, "password": TEST_PASSWORD, "next": "/"}, + follow_redirects=False, + ) + assert response.status_code == 303 + return self @pytest.fixture(autouse=True) @@ -133,19 +134,47 @@ def test_authentication_protects_ui_api_static_and_editor(tmp_path: Path) -> Non 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()}, + login_page = client.get("/login") + assert login_page.status_code == 200 + assert "Welcome back" in login_page.text + + assert client.get("/", follow_redirects=False).status_code == 303 + api_response = client.get("/api/status") + assert api_response.status_code == 401 + assert api_response.json()["detail"].startswith("Your session has expired") + assert client.get("/favicon.ico", follow_redirects=False).status_code == 303 + assert client.get("/cv/", follow_redirects=False).status_code == 303 + + wrong = client.post( + "/login", + data={"username": "wrong", "password": "wrong", "next": "/"}, + follow_redirects=False, ) 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 + assert "username or password is incorrect" in wrong.text + + signed_in = client.post( + "/login", + data={ + "username": TEST_USERNAME, + "password": TEST_PASSWORD, + "next": "https://attacker.example/steal", + }, + follow_redirects=False, + ) + assert signed_in.status_code == 303 + assert signed_in.headers["location"] == "/" + assert "httponly" in signed_in.headers["set-cookie"].lower() + assert "samesite=strict" in signed_in.headers["set-cookie"].lower() + assert client.get("/").status_code == 200 + assert client.get("/api/status").status_code == 200 + assert client.get("/favicon.ico").status_code == 200 + assert client.get("/cv/").status_code == 200 + + signed_out = client.post("/logout", follow_redirects=False) + assert signed_out.status_code == 303 + assert signed_out.headers["location"] == "/login" + assert client.get("/api/status").status_code == 401 def test_missing_authentication_configuration_fails_closed( @@ -416,9 +445,7 @@ def test_revision_chat_updates_tailored_outputs(tmp_path: Path) -> None: download = client.get("/api/download/resume-ohmycv.md") assert response.status_code == 200 - assert response.json()["package"]["changes_made"] == [ - "Made the summary more direct." - ] + assert response.json()["package"]["changes_made"] == ["Made the summary more direct."] assert "Updated and re-audited" in response.json()["reply"] assert download.status_code == 200 assert "[F013]" not in download.text diff --git a/vendor/oh-my-cv/site/src/components/editor/toolbar/file/AiTailor.vue b/vendor/oh-my-cv/site/src/components/editor/toolbar/file/AiTailor.vue index 5319021..296f704 100644 --- a/vendor/oh-my-cv/site/src/components/editor/toolbar/file/AiTailor.vue +++ b/vendor/oh-my-cv/site/src/components/editor/toolbar/file/AiTailor.vue @@ -302,6 +302,7 @@ onMounted(async () => { hasBackup.value = Boolean(localStorage.getItem(backupKey.value)); try { const response = await fetch(`${apiBase.value}/api/settings`); + redirectToLoginIfExpired(response); if (!response.ok) return; const settings = (await response.json()) as { evidence_guard: boolean }; globalEvidenceGuard.value = settings.evidence_guard; @@ -324,6 +325,7 @@ const toggleGlobalEvidenceGuard = async () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ evidence_guard: next }) }); + redirectToLoginIfExpired(response); const settings = (await response.json()) as { evidence_guard?: boolean; detail?: string; @@ -348,6 +350,7 @@ async function post(path: string, payload: Record): Promise< headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); + redirectToLoginIfExpired(response); const body = (await response.json()) as T & { detail?: string }; if (!response.ok) throw new Error(body.detail || "The Signal API returned an error."); @@ -362,6 +365,7 @@ async function postTask(path: string, payload: Record): Prom await new Promise((resolve) => window.setTimeout(resolve, 1200)); try { const response = await fetch(`${apiBase.value}/api/tasks/${started.task_id}`); + redirectToLoginIfExpired(response); if (!response.ok) { const body = (await response.json()) as { detail?: string }; throw new Error(body.detail || "Could not read the model task status."); @@ -392,6 +396,13 @@ async function postTask(path: string, payload: Record): Prom } } +function redirectToLoginIfExpired(response: Response): void { + if (response.status !== 401) return; + const next = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.assign(`/login?next=${encodeURIComponent(next)}`); + throw new Error("Your session expired. Redirecting to sign in…"); +} + const jobPayload = () => ({ job_url: mode.value === "url" ? jobURL.value.trim() : null, job_text: mode.value === "text" ? jobText.value.trim() : null diff --git a/vendor/oh-my-cv/site/src/components/shared/Header.vue b/vendor/oh-my-cv/site/src/components/shared/Header.vue index d71128b..5c78adf 100644 --- a/vendor/oh-my-cv/site/src/components/shared/Header.vue +++ b/vendor/oh-my-cv/site/src/components/shared/Header.vue @@ -63,6 +63,19 @@ +
+ + + Sign out + +
+