Replace basic auth with session login
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user