Replace basic auth with session login
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
# 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_USERNAME=resume-user
|
||||||
RESUME_AGENT_PASSWORD=replace-with-a-long-random-password
|
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_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
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
missing or still uses the placeholder value. The login page creates a signed, HttpOnly,
|
||||||
username/password prompt. Oh My CV offline caching is disabled so its app shell cannot
|
SameSite session cookie that expires after 12 hours, and both frontends provide a sign-out
|
||||||
bypass the server authentication gate.
|
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
|
||||||
Binding to localhost is suitable for personal use; if the app is reachable from another
|
machine, place it behind an HTTPS reverse proxy, set
|
||||||
machine, place it behind an HTTPS reverse proxy and do not expose the plain HTTP port.
|
`RESUME_AGENT_SECURE_COOKIES=true`, 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:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const response = await fetch(path, options);
|
const response = await fetch(path, options);
|
||||||
|
redirectToLoginIfExpired(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = "Something went wrong.";
|
let message = "Something went wrong.";
|
||||||
try {
|
try {
|
||||||
@@ -38,6 +39,13 @@ async function api(path, options = {}) {
|
|||||||
return response.json();
|
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 = {}) {
|
async function apiTask(path, options = {}) {
|
||||||
const started = await api(`${path}/start`, options);
|
const started = await api(`${path}/start`, options);
|
||||||
let consecutiveNetworkErrors = 0;
|
let consecutiveNetworkErrors = 0;
|
||||||
@@ -392,6 +400,7 @@ async function prepareOhMyCvImport(packageData) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/download/resume-ohmycv.md");
|
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.");
|
if (!response.ok) throw new Error("Could not prepare the Oh My CV export.");
|
||||||
const markdown = await response.text();
|
const markdown = await response.text();
|
||||||
const role = packageData.job.role_title || "Tailored resume";
|
const role = packageData.job.role_title || "Tailored resume";
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
<span class="status-dot" id="statusDot"></span>
|
<span class="status-dot" id="statusDot"></span>
|
||||||
<span id="providerText">Checking provider…</span>
|
<span id="providerText">Checking provider…</span>
|
||||||
</div>
|
</div>
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<button class="logout-button" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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);
|
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 {
|
.status-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
|
|||||||
+165
-40
@@ -2,19 +2,29 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
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 urlencode, urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, Request, 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,
|
||||||
|
HTMLResponse,
|
||||||
|
JSONResponse,
|
||||||
|
PlainTextResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
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_CV_DIST = PROJECT_DIR / "vendor/oh-my-cv/site/.output/public"
|
||||||
DEFAULT_PROFILE = Path(".resume-agent/profile.json")
|
DEFAULT_PROFILE = Path(".resume-agent/profile.json")
|
||||||
DEFAULT_OUTPUT = Path("output")
|
DEFAULT_OUTPUT = Path("output")
|
||||||
|
LOGIN_TEMPLATE = STATIC_DIR / "login.html"
|
||||||
|
SESSION_COOKIE = "resume_agent_session"
|
||||||
DOWNLOADS = {
|
DOWNLOADS = {
|
||||||
"resume.md": "text/markdown",
|
"resume.md": "text/markdown",
|
||||||
"resume-ohmycv.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")
|
settings_path.write_text(settings.model_dump_json(indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _valid_basic_credentials(
|
def _credentials_match(
|
||||||
authorization: str | None,
|
username: str,
|
||||||
|
password: str,
|
||||||
expected_username: str,
|
expected_username: str,
|
||||||
expected_password: str,
|
expected_password: str,
|
||||||
) -> bool:
|
) -> 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_matches = secrets.compare_digest(
|
||||||
username.encode("utf-8"), expected_username.encode("utf-8")
|
username.encode("utf-8"), expected_username.encode("utf-8")
|
||||||
)
|
)
|
||||||
@@ -203,6 +204,71 @@ def _valid_basic_credentials(
|
|||||||
return username_matches and password_matches
|
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(
|
def create_app(
|
||||||
*,
|
*,
|
||||||
profile_path: Path = DEFAULT_PROFILE,
|
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_username.lower().startswith("replace-with-")
|
||||||
and not resolved_auth_password.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")
|
@web_app.middleware("http")
|
||||||
async def require_authentication(request: Request, call_next: Callable[..., Awaitable[Any]]):
|
async def require_authentication(request: Request, call_next: Callable[..., Awaitable[Any]]):
|
||||||
@@ -239,22 +313,30 @@ def create_app(
|
|||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
if not _valid_basic_credentials(
|
if request.url.path == "/login":
|
||||||
request.headers.get("Authorization"),
|
return await call_next(request)
|
||||||
resolved_auth_username,
|
if not _valid_session_token(
|
||||||
resolved_auth_password,
|
request.cookies.get(SESSION_COOKIE), resolved_auth_username, session_key
|
||||||
):
|
):
|
||||||
return PlainTextResponse(
|
if request.url.path.startswith("/api/"):
|
||||||
"Authentication required.",
|
return JSONResponse(
|
||||||
|
{"detail": "Your session has expired. Sign in again."},
|
||||||
status_code=401,
|
status_code=401,
|
||||||
headers={
|
headers={"Cache-Control": "no-store"},
|
||||||
"Cache-Control": "no-store",
|
)
|
||||||
"WWW-Authenticate": 'Basic realm="Resume Agent", charset="UTF-8"',
|
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)
|
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["Cache-Control"] = "no-store"
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
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("authentication is not configured", status_code=503)
|
||||||
return PlainTextResponse("ok")
|
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:
|
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:
|
||||||
@@ -342,9 +475,7 @@ def create_app(
|
|||||||
api_key = os.getenv("RESUME_AGENT_API_KEY") or os.getenv("OPENAI_API_KEY")
|
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")
|
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")
|
model = os.getenv("RESUME_AGENT_MODEL") or (None if base_url else "gpt-5.6-terra")
|
||||||
configured = bool(
|
configured = bool(api_key and model and not api_key.lower().startswith("replace-with-"))
|
||||||
api_key and model and not api_key.lower().startswith("replace-with-")
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
"provider": _provider_name(),
|
"provider": _provider_name(),
|
||||||
@@ -446,9 +577,7 @@ def create_app(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
app_settings = _load_app_settings(resolved_settings_path)
|
app_settings = _load_app_settings(resolved_settings_path)
|
||||||
evidence_mode = (
|
evidence_mode = request.evidence_mode if app_settings.evidence_guard else "profile"
|
||||||
request.evidence_mode if app_settings.evidence_guard else "profile"
|
|
||||||
)
|
|
||||||
profile = load_profile(profile_path)
|
profile = load_profile(profile_path)
|
||||||
if request.job_url:
|
if request.job_url:
|
||||||
job_text = await run_in_threadpool(fetch_job_page, request.job_url)
|
job_text = await run_in_threadpool(fetch_job_page, request.job_url)
|
||||||
@@ -482,9 +611,7 @@ def create_app(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
app_settings = _load_app_settings(resolved_settings_path)
|
app_settings = _load_app_settings(resolved_settings_path)
|
||||||
evidence_mode = (
|
evidence_mode = request.evidence_mode if app_settings.evidence_guard else "profile"
|
||||||
request.evidence_mode if app_settings.evidence_guard else "profile"
|
|
||||||
)
|
|
||||||
llm = llm_factory()
|
llm = llm_factory()
|
||||||
profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about)
|
profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about)
|
||||||
save_json(profile, profile_path)
|
save_json(profile, profile_path)
|
||||||
@@ -531,9 +658,7 @@ def create_app(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
profile = load_profile(profile_path)
|
profile = load_profile(profile_path)
|
||||||
current = TailoringPackage.model_validate_json(
|
current = TailoringPackage.model_validate_json(package_path.read_text(encoding="utf-8"))
|
||||||
package_path.read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
evidence_mode = _load_evidence_mode(output_dir)
|
evidence_mode = _load_evidence_mode(output_dir)
|
||||||
if not _load_app_settings(resolved_settings_path).evidence_guard:
|
if not _load_app_settings(resolved_settings_path).evidence_guard:
|
||||||
evidence_mode = "profile"
|
evidence_mode = "profile"
|
||||||
|
|||||||
+49
-22
@@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -15,16 +14,18 @@ from resume_agent.webapp import create_app
|
|||||||
|
|
||||||
TEST_USERNAME = "resume-user"
|
TEST_USERNAME = "resume-user"
|
||||||
TEST_PASSWORD = "test-password"
|
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):
|
class TestClient(RawTestClient):
|
||||||
def __init__(self, app: Any, **kwargs: Any) -> None:
|
def __enter__(self) -> TestClient:
|
||||||
headers = {**TEST_AUTH_HEADERS, **kwargs.pop("headers", {})}
|
super().__enter__()
|
||||||
super().__init__(app, headers=headers, **kwargs)
|
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)
|
@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:
|
with RawTestClient(app) as client:
|
||||||
assert client.get("/healthz").status_code == 200
|
assert client.get("/healthz").status_code == 200
|
||||||
for path in ("/", "/api/status", "/favicon.ico", "/cv/"):
|
login_page = client.get("/login")
|
||||||
response = client.get(path)
|
assert login_page.status_code == 200
|
||||||
assert response.status_code == 401
|
assert "Welcome back" in login_page.text
|
||||||
assert response.headers["www-authenticate"].startswith("Basic ")
|
|
||||||
wrong = client.get(
|
assert client.get("/", follow_redirects=False).status_code == 303
|
||||||
"/api/status",
|
api_response = client.get("/api/status")
|
||||||
headers={"Authorization": "Basic " + base64.b64encode(b"wrong:wrong").decode()},
|
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 wrong.status_code == 401
|
||||||
assert client.get("/", headers=TEST_AUTH_HEADERS).status_code == 200
|
assert "username or password is incorrect" in wrong.text
|
||||||
assert client.get("/api/status", headers=TEST_AUTH_HEADERS).status_code == 200
|
|
||||||
assert client.get("/favicon.ico", headers=TEST_AUTH_HEADERS).status_code == 200
|
signed_in = client.post(
|
||||||
assert client.get("/cv/", headers=TEST_AUTH_HEADERS).status_code == 200
|
"/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(
|
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")
|
download = client.get("/api/download/resume-ohmycv.md")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["package"]["changes_made"] == [
|
assert response.json()["package"]["changes_made"] == ["Made the summary more direct."]
|
||||||
"Made the summary more direct."
|
|
||||||
]
|
|
||||||
assert "Updated and re-audited" in response.json()["reply"]
|
assert "Updated and re-audited" in response.json()["reply"]
|
||||||
assert download.status_code == 200
|
assert download.status_code == 200
|
||||||
assert "[F013]" not in download.text
|
assert "[F013]" not in download.text
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ onMounted(async () => {
|
|||||||
hasBackup.value = Boolean(localStorage.getItem(backupKey.value));
|
hasBackup.value = Boolean(localStorage.getItem(backupKey.value));
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBase.value}/api/settings`);
|
const response = await fetch(`${apiBase.value}/api/settings`);
|
||||||
|
redirectToLoginIfExpired(response);
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const settings = (await response.json()) as { evidence_guard: boolean };
|
const settings = (await response.json()) as { evidence_guard: boolean };
|
||||||
globalEvidenceGuard.value = settings.evidence_guard;
|
globalEvidenceGuard.value = settings.evidence_guard;
|
||||||
@@ -324,6 +325,7 @@ const toggleGlobalEvidenceGuard = async () => {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ evidence_guard: next })
|
body: JSON.stringify({ evidence_guard: next })
|
||||||
});
|
});
|
||||||
|
redirectToLoginIfExpired(response);
|
||||||
const settings = (await response.json()) as {
|
const settings = (await response.json()) as {
|
||||||
evidence_guard?: boolean;
|
evidence_guard?: boolean;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
@@ -348,6 +350,7 @@ async function post<T>(path: string, payload: Record<string, unknown>): Promise<
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
redirectToLoginIfExpired(response);
|
||||||
|
|
||||||
const body = (await response.json()) as T & { detail?: string };
|
const body = (await response.json()) as T & { detail?: string };
|
||||||
if (!response.ok) throw new Error(body.detail || "The Signal API returned an error.");
|
if (!response.ok) throw new Error(body.detail || "The Signal API returned an error.");
|
||||||
@@ -362,6 +365,7 @@ async function postTask<T>(path: string, payload: Record<string, unknown>): Prom
|
|||||||
await new Promise((resolve) => window.setTimeout(resolve, 1200));
|
await new Promise((resolve) => window.setTimeout(resolve, 1200));
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBase.value}/api/tasks/${started.task_id}`);
|
const response = await fetch(`${apiBase.value}/api/tasks/${started.task_id}`);
|
||||||
|
redirectToLoginIfExpired(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json()) as { detail?: string };
|
const body = (await response.json()) as { detail?: string };
|
||||||
throw new Error(body.detail || "Could not read the model task status.");
|
throw new Error(body.detail || "Could not read the model task status.");
|
||||||
@@ -392,6 +396,13 @@ async function postTask<T>(path: string, payload: Record<string, unknown>): 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 = () => ({
|
const jobPayload = () => ({
|
||||||
job_url: mode.value === "url" ? jobURL.value.trim() : null,
|
job_url: mode.value === "url" ? jobURL.value.trim() : null,
|
||||||
job_text: mode.value === "text" ? jobText.value.trim() : null
|
job_text: mode.value === "text" ? jobText.value.trim() : null
|
||||||
|
|||||||
@@ -63,6 +63,19 @@
|
|||||||
|
|
||||||
<SharedToggleDark />
|
<SharedToggleDark />
|
||||||
|
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<UiButton
|
||||||
|
type="submit"
|
||||||
|
variant="ghost-secondary"
|
||||||
|
size="xs"
|
||||||
|
class="h-8 gap-x-1"
|
||||||
|
aria-label="Sign out of Signal"
|
||||||
|
>
|
||||||
|
<span class="i-material-symbols:logout text-lg" />
|
||||||
|
<span class="hide-on-mobile text-base">Sign out</span>
|
||||||
|
</UiButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
<UiButton
|
<UiButton
|
||||||
as="a"
|
as="a"
|
||||||
variant="ghost-secondary"
|
variant="ghost-secondary"
|
||||||
|
|||||||
Reference in New Issue
Block a user