From 0ff2130f461249eba47f664c65dbed522eaa431c Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:28:23 +0800 Subject: [PATCH] fix: recover expired Basic Auth sessions in the current tab --- README.md | 2 +- server/basic_auth.go | 8 +- server/basic_auth_test.go | 77 +++++++++++- web/src/app/(main)/system/logs/page.tsx | 4 +- web/src/components/markdown.tsx | 3 +- web/src/lib/api-auth-recovery.test.cjs | 149 ++++++++++++++++++++++++ web/src/lib/api.ts | 52 +++++++-- 7 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 web/src/lib/api-auth-recovery.test.cjs diff --git a/README.md b/README.md index 7510b0d..451d9c6 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex 从仍使用 `/api/health` 读取版本的旧版网页升级到此版本时,旧页面可能无法自动判断升级完成;服务重启后手动刷新页面即可。新版网页的版本显示和升级轮询均使用 `/api/version`。 -配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。 +配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE。验证过期时,后台请求会触发当前页面重新验证,保留 ARTEX 登录状态,无需新开标签页;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。 Docker 和内嵌前端二进制直接支持此功能。`next dev` 的认证检查仅向本机回环后端转发凭据(`localhost`、`127.0.0.1` 或 `::1`);前端与后端应使用同一主机名。远程开发后端未开启验证时仍可使用;开启后请通过内嵌前端访问远程服务,避免跨域验证 Cookie 问题。 diff --git a/server/basic_auth.go b/server/basic_auth.go index dc9c1c4..2387bd0 100644 --- a/server/basic_auth.go +++ b/server/basic_auth.go @@ -139,7 +139,13 @@ func (s *Server) requireBasicAuth(next http.Handler) http.Handler { username, password, ok := r.BasicAuth() if !ok || len(password) > maxPasswordBytes || username != cfg.Username || bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil { - w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`) + // Background requests use Bearer auth. Challenging them with Basic + // can trap the browser in repeated native dialogs. Let the client + // reload the current page once to renew only the outer session. + w.Header().Set("X-Artex-Auth", "basic") + if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/basic-auth/check" || r.URL.Path == "/api/health" { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`) + } writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证") return } diff --git a/server/basic_auth_test.go b/server/basic_auth_test.go index 8bbb16c..d9be8a6 100644 --- a/server/basic_auth_test.go +++ b/server/basic_auth_test.go @@ -279,8 +279,14 @@ func TestBasicAuthGate(t *testing.T) { if w.Code != tc.want { t.Fatalf("status=%d want %d: %s", w.Code, tc.want, w.Body.String()) } - if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="Restricted", charset="UTF-8"` { - t.Fatal("missing browser challenge") + if w.Code == 401 { + wantChallenge := !strings.HasPrefix(tc.path, "/api/") || tc.path == "/api/basic-auth/check" || tc.path == "/api/health" + if got := w.Header().Get("WWW-Authenticate"); (got != "") != wantChallenge { + t.Fatalf("browser challenge=%q, want challenge=%v", got, wantChallenge) + } + if w.Header().Get("X-Artex-Auth") != "basic" { + t.Fatal("missing gate failure marker") + } } }) } @@ -481,8 +487,8 @@ func TestBasicAuthPostgresLoginFlow(t *testing.T) { r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody)) w = httptest.NewRecorder() restarted.Handler().ServeHTTP(w, r) - if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" { - t.Fatal("login was not protected after restart") + if w.Code != 401 || w.Header().Get("X-Artex-Auth") != "basic" || w.Header().Get("WWW-Authenticate") != "" { + t.Fatal("login must fail with a recoverable gate error after restart") } r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody)) r.SetBasicAuth("entry", "entry-password") @@ -504,3 +510,66 @@ func TestBasicAuthPostgresLoginFlow(t *testing.T) { t.Fatalf("disable with gate cookie and JWT: %d %s", w.Code, w.Body.String()) } } + +// An expired gate must be recoverable without discarding the independent admin +// session. API failures must never open native Basic dialogs themselves. +func TestBasicAuthExpiredSessionRecovery(t *testing.T) { + s, _ := basicAuthTestServer(t) + w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"entry-password"}`) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + cfg := s.basicAuth.snapshot() + expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Subject: cfg.Revision, Audience: jwt.ClaimStrings{"artex-basic-auth"}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), + }).SignedString(s.basicAuthSigningKey()) + if err != nil { + t.Fatal(err) + } + adminJWT, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + h := s.Handler() + for _, path := range []string{"/api/version", "/api/auth/status", "/api/logs/stream", "/api/workspace/upload", "/api/workspace/download"} { + t.Run(strings.TrimPrefix(path, "/"), func(t *testing.T) { + r := httptest.NewRequest("GET", path, nil) + r.Header.Set("Authorization", "Bearer "+adminJWT) + r.AddCookie(&http.Cookie{Name: basicAuthCookie, Value: expired}) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 401 || w.Header().Get("X-Artex-Auth") != "basic" || w.Header().Get("WWW-Authenticate") != "" { + t.Fatalf("API must signal recovery without a browser challenge: %d %v", w.Code, w.Header()) + } + if w.Header().Get("Cache-Control") != "no-store" || len(w.Result().Cookies()) != 0 { + t.Fatal("gate failure must not cache or change the admin session") + } + }) + } + // Reloading the current document can use Basic authorization, while the + // admin JWT stays in its cookie for subsequent API requests. + r := httptest.NewRequest("GET", "/function/tasks/", nil) + r.AddCookie(&http.Cookie{Name: "artex_token", Value: adminJWT}) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" { + t.Fatal("document navigation must still prompt for Basic authentication") + } + r = httptest.NewRequest("GET", "/api/basic-auth/check", nil) + r.SetBasicAuth("entry", "entry-password") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusNoContent || len(w.Result().Cookies()) != 1 { + t.Fatalf("reauthentication failed: %d %s", w.Code, w.Body.String()) + } + renewed := w.Result().Cookies()[0] + r = httptest.NewRequest("GET", "/api/version", nil) + r.AddCookie(renewed) + r.Header.Set("Authorization", "Bearer "+adminJWT) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 || w.Header().Get("WWW-Authenticate") != "" { + t.Fatalf("original admin session did not recover: %d %s", w.Code, w.Body.String()) + } +} diff --git a/web/src/app/(main)/system/logs/page.tsx b/web/src/app/(main)/system/logs/page.tsx index 2cdf4f2..e6e7b3d 100644 --- a/web/src/app/(main)/system/logs/page.tsx +++ b/web/src/app/(main)/system/logs/page.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { openSSE } from "@/lib/api"; +import { apiFetch, openSSE } from "@/lib/api"; import { MOCK } from "@/lib/mock/enabled"; import type { LogLine } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -87,7 +87,7 @@ export default function LogsPage() { setLoadingHistory(true); try { const params = minDbId > 0 ? `?before=${minDbId}&limit=200` : `?limit=200`; - const res = await fetch(`/api/logs/history${params}`); + const res = await apiFetch(`/api/logs/history${params}`); if (!res.ok) return; const data = await res.json() as { items: LogLine[]; has_more: boolean }; if (data.items?.length) { diff --git a/web/src/components/markdown.tsx b/web/src/components/markdown.tsx index ae65d36..86ed8a7 100644 --- a/web/src/components/markdown.tsx +++ b/web/src/components/markdown.tsx @@ -7,6 +7,7 @@ import { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { apiFetch } from "@/lib/api"; import { auth } from "@/lib/auth"; function AuthenticatedImage({ @@ -39,7 +40,7 @@ function AuthenticatedImage({ const token = auth.getToken(); setCaptureTimestamp(null); - fetch(imageSrc, { + apiFetch(imageSrc, { credentials: "same-origin", headers: token ? { Authorization: `Bearer ${token}` } : undefined, }) diff --git a/web/src/lib/api-auth-recovery.test.cjs b/web/src/lib/api-auth-recovery.test.cjs new file mode 100644 index 0000000..5e1b93e --- /dev/null +++ b/web/src/lib/api-auth-recovery.test.cjs @@ -0,0 +1,149 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); +const ts = require("typescript"); + +const compiled = ts.transpileModule(fs.readFileSync(path.join(__dirname, "api.ts"), "utf8"), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, +}).outputText; + +function client(fetch) { + const exports = {}; + const state = { token: "original-admin-token", reloads: 0, requests: [], streams: [] }; + const location = { href: "http://localhost/function/tasks/?page=2", hostname: "localhost", reload: () => state.reloads++ }; + const document = { cookie: "artex_token=original-admin-token" }; + class EventSource { + constructor(url, options) { this.url = url; this.options = options; this.listeners = {}; state.streams.push(this); } + addEventListener(name, listener) { this.listeners[name] = listener; } + } + vm.runInNewContext(compiled, { + exports, URL, URLSearchParams, FormData, EventSource, + process: { env: { NODE_ENV: "production" } }, + window: { location }, document, + localStorage: { getItem: () => state.token, removeItem: () => { state.token = null; } }, + require: (id) => { + if (id === "@/lib/mock/enabled") return { MOCK: false }; + if (id === "@/lib/mock/handler") return { mockHandle: () => assert.fail("unexpected mock request") }; + throw new Error(`Unexpected import: ${id}`); + }, + fetch: async (...args) => { state.requests.push(args); return fetch(...args); }, + }); + return { ...exports, state, location, document }; +} + +const gateFailure = () => new Response('{"error":"请先完成 HTTP Basic Auth 验证"}', { + status: 401, headers: { "X-Artex-Auth": "basic" }, +}); + +function assertSessionPreserved(c) { + assert.equal(c.state.token, "original-admin-token"); + assert.equal(c.document.cookie, "artex_token=original-admin-token"); + assert.equal(c.location.href, "http://localhost/function/tasks/?page=2"); +} + +test("expired Basic gate reloads the current tab and preserves the admin session", async () => { + const c = client(async () => gateFailure()); + await assert.rejects(c.http("/version"), /访问验证已过期/); + assert.equal(c.state.reloads, 1); + assert.equal(c.state.requests[0][1].headers.Authorization, "Bearer original-admin-token"); + assertSessionPreserved(c); +}); + +test("concurrent gate failures reload once and stop new polling requests", async () => { + let release; + const pending = new Promise((resolve) => { release = resolve; }); + const c = client(async () => { await pending; return gateFailure(); }); + const requests = [c.http("/version"), c.http("/tasks"), c.http("/settings")]; + release(); + const results = await Promise.allSettled(requests); + assert.ok(results.every((r) => r.status === "rejected")); + assert.equal(c.state.reloads, 1); + await assert.rejects(c.http("/tasks"), /访问验证已过期/); + assert.equal(c.state.requests.length, 3); + assertSessionPreserved(c); +}); + +test("a late admin 401 cannot clear the token once gate recovery has started", async () => { + let release; + const late = new Promise((resolve) => { release = resolve; }); + const c = client(async (url) => { + if (url === "/api/tasks") { await late; return new Response("expired", { status: 401 }); } + return gateFailure(); + }); + const staleRequest = c.http("/tasks"); + await assert.rejects(c.http("/version"), /访问验证已过期/); + release(); + await assert.rejects(staleRequest, /访问验证已过期/); + assert.equal(c.state.reloads, 1); + assertSessionPreserved(c); +}); + +test("an ordinary expired admin JWT still clears the login and opens /login", async () => { + const c = client(async () => new Response('{"error":"token 无效或已过期"}', { status: 401 })); + await assert.rejects(c.http("/version"), /未授权/); + assert.equal(c.state.token, null); + assert.equal(c.location.href, "/login"); + assert.equal(c.state.reloads, 0); +}); + +test("business errors do not trigger gate recovery", async () => { + const c = client(async () => new Response('{"error":"禁止操作"}', { status: 403 })); + await assert.rejects(c.http("/tasks"), /禁止操作/); + assert.equal(c.state.reloads, 0); + assertSessionPreserved(c); +}); + +test("uploads and downloads also recover the Basic gate", async () => { + for (const action of [ + (c) => c.api.workspaceUpload("", [new File(["test"], "test.txt")]), + (c) => c.api.workspaceDownload("test.txt"), + (c) => c.api.report(), + (c) => c.api.interceptGetToolConfig(), + ]) { + const c = client(async () => gateFailure()); + await assert.rejects(action(c), /访问验证已过期/); + assert.equal(c.state.reloads, 1); + assertSessionPreserved(c); + } +}); + +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test("SSE failures probe the same-origin gate once for concurrent streams", async () => { + const c = client(async () => gateFailure()); + const first = c.openSSE("/api/logs/stream"); + const second = c.openSSE("/api/update/stream"); + first.listeners.error(); + second.listeners.error(); + await tick(); + assert.equal(c.state.requests.length, 1); + assert.equal(c.state.requests[0][0], "/api/auth/status"); + assert.equal(c.state.reloads, 1); + assertSessionPreserved(c); +}); + +test("ordinary SSE disconnects and network errors do not reload the page", async () => { + for (const fetch of [ + async () => new Response('{"initialized":true}'), + async () => { throw new TypeError("network unavailable"); }, + ]) { + const c = client(fetch); + const source = c.openSSE("/api/logs/stream"); + source.listeners.error(); + await tick(); + source.listeners.error(); + await tick(); + assert.equal(c.state.reloads, 0); + assert.equal(c.state.requests.length, 2); + assertSessionPreserved(c); + } +}); + +test("a fresh document resumes API requests with the original admin token", async () => { + const c = client(async () => new Response('{"version":"test"}')); + assert.equal((await c.api.version()).version, "test"); + assert.equal(c.state.requests[0][1].headers.Authorization, "Bearer original-admin-token"); + assert.equal(c.state.reloads, 0); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1fccc1b..cfe7f57 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -144,10 +144,28 @@ function getToken(): string | null { return localStorage.getItem("artex_token"); } +let basicAuthReloading = false; +let basicAuthProbe: Promise | null = null; + +// Renew the outer gate through a page navigation, where the browser can send +// Basic credentials without replacing an API request's Bearer authorization. +// Keep the admin token and collapse concurrent failures into one reload. +export async function apiFetch(input: string, init?: RequestInit): Promise { + if (basicAuthReloading) throw new Error("访问验证已过期,正在重新验证"); + const response = await fetch(input, init); + if (basicAuthReloading) throw new Error("访问验证已过期,正在重新验证"); + if (response.status === 401 && response.headers.get("X-Artex-Auth") === "basic") { + basicAuthReloading = true; + window.location.reload(); + throw new Error("访问验证已过期,正在重新验证"); + } + return response; +} + export async function http(path: string, init?: RequestInit): Promise { if (MOCK) return mockHandle(init?.method ?? "GET", path, init?.body ?? null); const token = getToken(); - const r = await fetch(`/api${path}`, { + const r = await apiFetch(`/api${path}`, { ...init, headers: { ...(init?.body ? { "Content-Type": "application/json" } : {}), @@ -236,7 +254,19 @@ export function openSSE(path: string): EventSource { // Same-origin streams already send cookies. Only opt into cross-port cookies // for the local Next/Go dev pair; keep custom remote SSE endpoints unchanged. const local = ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname); - return new EventSource(url, { withCredentials: local && target.hostname === window.location.hostname }); + const source = new EventSource(url, { withCredentials: local && target.hostname === window.location.hostname }); + source.addEventListener("error", () => { + // EventSource hides the response status. Probe a same-origin auth endpoint + // to distinguish an expired gate from an ordinary stream/network failure. + if (basicAuthProbe !== null || basicAuthReloading) return; + basicAuthProbe = apiFetch("/api/auth/status", { cache: "no-store" }) + .then(() => undefined) + .catch(() => undefined) + .finally(() => { + basicAuthProbe = null; + }); + }); + return source; } const get = (p: string) => http(p); @@ -554,7 +584,7 @@ export const api = { const fd = new FormData(); for (const f of files) fd.append("file", f); const token = getToken(); - const r = await fetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, { + const r = await apiFetch(`/api/workspace/upload?path=${encodeURIComponent(dir)}`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: fd, @@ -569,7 +599,7 @@ export const api = { blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" }); } else { const token = getToken(); - const r = await fetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, { + const r = await apiFetch(`/api/workspace/download?path=${encodeURIComponent(path)}`, { headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, }); if (!r.ok) throw new Error(`下载失败: ${r.status}`); @@ -729,7 +759,7 @@ export const api = { for (const [k, v] of findingFilterParams(opts.filters)) p.set(k, v); } const token = getToken(); - const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, { + const r = await apiFetch(`/api/exploration/findings/export?${p.toString()}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!r.ok) throw new Error(`export: ${r.status}`); @@ -807,7 +837,7 @@ export const api = { contextTask?: string, ) => { const token = getToken(); - const response = await fetch( + const response = await apiFetch( `/api/exploration/findings/${id}/traffic/${bindingId}/body?side=${side}&download=1${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }, ); @@ -1081,7 +1111,7 @@ export const api = { report: async (task?: string) => { if (MOCK) return mockReport(task); const token = getToken(); - const r = await fetch(`/api/report${tq(task)}`, { + const r = await apiFetch(`/api/report${tq(task)}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!r.ok) throw new Error(`report: ${r.status}`); @@ -1109,7 +1139,7 @@ export const api = { const fd = new FormData(); for (const f of files) fd.append("file", f); const token = getToken(); - const r = await fetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, { + const r = await apiFetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: fd, @@ -1369,7 +1399,7 @@ export const api = { const fd = new FormData(); fd.append("file", file); const token = getToken(); - const r = await fetch(`/api/skills/upload${overwrite ? "?overwrite=true" : ""}`, { + const r = await apiFetch(`/api/skills/upload${overwrite ? "?overwrite=true" : ""}`, { method: "POST", body: fd, headers: token ? { Authorization: `Bearer ${token}` } : {}, @@ -1458,7 +1488,7 @@ export const api = { interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => { if (MOCK) return { enabled_tools: ["bash"] }; const token = getToken(); - const r = await fetch("/api/intercept/tool-config", { + const r = await apiFetch("/api/intercept/tool-config", { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!r.ok) throw new Error(await r.text()); @@ -1467,7 +1497,7 @@ export const api = { interceptSetToolConfig: async (enabledTools: string[]): Promise => { if (MOCK) return; const token = getToken(); - const r = await fetch("/api/intercept/tool-config", { + const r = await apiFetch("/api/intercept/tool-config", { method: "PUT", headers: { "Content-Type": "application/json",