Replace basic auth with session login

This commit is contained in:
2026-08-03 00:25:18 +03:30
parent f5f54bac66
commit 81d816ae54
10 changed files with 501 additions and 69 deletions
+9
View File
@@ -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";
+3
View File
@@ -37,6 +37,9 @@
<span class="status-dot" id="statusDot"></span>
<span id="providerText">Checking provider…</span>
</div>
<form action="/logout" method="post">
<button class="logout-button" type="submit">Sign out</button>
</form>
</div>
</header>
+220
View File
@@ -0,0 +1,220 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Sign in — Signal Resume Agent</title>
<style>
:root {
color-scheme: light;
--ink: #172019;
--muted: #667169;
--green: #214d37;
--orange: #ec6f32;
--lime: #dcebad;
--paper: #f4f1e9;
--line: #d9d8ce;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
overflow: hidden;
color: var(--ink);
background:
radial-gradient(circle at 12% 8%, rgba(220, 235, 173, 0.7), transparent 34%),
radial-gradient(circle at 90% 92%, rgba(236, 111, 50, 0.16), transparent 30%),
var(--paper);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
body::before {
position: fixed;
inset: 0;
opacity: 0.26;
pointer-events: none;
content: "";
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.12'/%3E%3C/svg%3E");
}
main {
width: min(92vw, 430px);
position: relative;
}
.brand {
margin-bottom: 24px;
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--green);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.brand-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
color: white;
background: var(--green);
font-family: Georgia, serif;
font-size: 19px;
font-style: italic;
}
.card {
padding: 38px;
border: 1px solid rgba(33, 77, 55, 0.18);
border-radius: 22px;
background: rgba(255, 255, 252, 0.9);
box-shadow: 0 24px 80px rgba(31, 48, 37, 0.14);
backdrop-filter: blur(12px);
}
.eyebrow {
margin: 0 0 12px;
color: var(--orange);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(32px, 8vw, 42px);
font-weight: 500;
line-height: 1.05;
}
.intro {
margin: 14px 0 28px;
color: var(--muted);
font-size: 13px;
line-height: 1.65;
}
label {
margin: 16px 0 7px;
display: block;
font-size: 11px;
font-weight: 750;
}
input {
width: 100%;
padding: 13px 14px;
border: 1px solid var(--line);
border-radius: 10px;
outline: none;
color: var(--ink);
background: #fff;
font: inherit;
font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input:focus {
border-color: var(--green);
box-shadow: 0 0 0 3px rgba(33, 77, 55, 0.12);
}
button {
width: 100%;
margin-top: 24px;
padding: 14px 18px;
border: 0;
border-radius: 11px;
color: white;
background: var(--green);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.03em;
}
button:hover {
background: #153a29;
}
.error {
min-height: 18px;
margin: 18px 0 -4px;
color: #a33d25;
font-size: 11px;
font-weight: 700;
}
.privacy {
margin: 18px 4px 0;
color: var(--muted);
font-size: 10px;
line-height: 1.55;
text-align: center;
}
@media (max-width: 520px) {
main {
width: min(100% - 28px, 430px);
}
.card {
padding: 28px 24px;
border-radius: 18px;
}
}
</style>
</head>
<body>
<main>
<div class="brand"><span class="brand-mark">S</span><span>Signal</span></div>
<section class="card" aria-labelledby="login-title">
<p class="eyebrow">Private career workspace</p>
<h1 id="login-title">Welcome back.</h1>
<p class="intro">
Sign in to access your career profile, tailored resumes, and CV editor.
</p>
<form action="/login" method="post">
<input type="hidden" name="next" value="{{NEXT}}" />
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
autocomplete="username"
maxlength="256"
required
autofocus
/>
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
maxlength="4096"
required
/>
<p class="error" role="alert">{{ERROR}}</p>
<button type="submit">Enter workspace</button>
</form>
</section>
<p class="privacy">Your session stays in an HttpOnly browser cookie and expires automatically.</p>
</main>
</body>
</html>
+21
View File
@@ -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;
+166 -41
View File
@@ -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"