Compare commits

..
Author SHA1 Message Date
ji ruo 0ff2130f46 fix: recover expired Basic Auth sessions in the current tab 2026-11-10 16:28:23 +08:00
ji ruo a05a886d3d fix: support traffic ID copying over HTTP 2026-11-09 16:17:03 +08:00
RuoJi6 9fe993f460 Merge pull request #126 from RuoJi6/codex/issue-125-traffic-id-filter-counts
feat: 展示流量 ID 并让发现统计跟随筛选
2026-11-09 16:15:09 +08:00
8 changed files with 278 additions and 24 deletions

No files matched your search

+1 -1
View File
@@ -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 问题。
+7 -1
View File
@@ -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
}
+73 -4
View File
@@ -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())
}
}
+2 -2
View File
@@ -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) {
+2 -1
View File
@@ -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,
})
+3 -4
View File
@@ -48,7 +48,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { api } from "@/lib/api";
import { useStoredSortPreference } from "@/lib/sort-preference";
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
import { cn } from "@/lib/utils";
import { cn, copyText } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -92,10 +92,9 @@ function TrafficID({ id }: { id: string }) {
aria-label={`复制流量 ID ${id}`}
onClick={async (event) => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(id);
if (await copyText(id)) {
toast.success("已复制流量 ID");
} catch {
} else {
toast.error("复制失败,请手动选择流量 ID");
}
}}
+149
View File
@@ -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);
});
+41 -11
View File
@@ -144,10 +144,28 @@ function getToken(): string | null {
return localStorage.getItem("artex_token");
}
let basicAuthReloading = false;
let basicAuthProbe: Promise<void> | 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<Response> {
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<T>(path: string, init?: RequestInit): Promise<T> {
if (MOCK) return mockHandle<T>(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 = <T>(p: string) => http<T>(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<void> => {
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",