feat(auth): add optional HTTP Basic Auth access gate

This commit is contained in:
ji ruo committed 2026-10-09 10:12:27 +08:00
1 parent 979e1fe4eb
commit 04c8c54ff8
14 files changed
+809 -23

No files matched your search

@@ -50,7 +50,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useSideQuestions } from "@/hooks/use-side-questions";
import { api, sseUrl } from "@/lib/api";
import { api, openSSE } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import { MOCK } from "@/lib/mock/enabled";
import { isBtwCommand } from "@/lib/side-questions";
@@ -969,9 +969,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
};
});
// Open the single task SSE from the snapshot cursor.
const es = new EventSource(
sseUrl(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`),
);
const es = openSSE(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`);
esRef.current = es;
es.onopen = () => setSseLive(true);
es.onerror = () => setSseLive(false); // EventSource auto-reconnects; DB compensates the gap
+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 { sseUrl } from "@/lib/api";
import { openSSE } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type { LogLine } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -64,7 +64,7 @@ export default function LogsPage() {
setLines(MOCK_LOGS);
return;
}
const es = new EventSource(sseUrl("/api/logs/stream?since=0"));
const es = openSSE("/api/logs/stream?since=0");
es.onmessage = (e) => {
if (pausedRef.current) return;
try {
@@ -0,0 +1,132 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ShieldCheckIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api";
export function BasicAuthCard() {
const [enabled, setEnabled] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [passwordSet, setPasswordSet] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setError("");
try {
const settings = await api.basicAuthSettings();
setEnabled(settings.enabled);
setUsername(settings.username);
setPasswordSet(settings.password_set);
setLoaded(true);
} catch (e) {
setError(`读取失败:${(e as Error).message}`);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = async (event: React.FormEvent) => {
event.preventDefault();
setSaving(true);
setError("");
try {
const settings = await api.setBasicAuthSettings({ enabled, username: username.trim(), password });
setEnabled(settings.enabled);
setUsername(settings.username);
setPasswordSet(settings.password_set);
setPassword("");
toast.success(settings.enabled ? "HTTP Basic Auth 已开启,当前浏览器保持访问" : "HTTP Basic Auth 已关闭");
} catch (e) {
setError(`保存失败:${(e as Error).message}`);
} finally {
setSaving(false);
}
};
return (
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheckIcon className="size-4" />
HTTP Basic Auth
</CardTitle>
<CardDescription>
开启后,访客需先通过浏览器原生账号密码验证,再进入 ARTEX 登录页。默认关闭,保存后立即生效。
</CardDescription>
</CardHeader>
<form onSubmit={save}>
<CardContent>
<FieldGroup>
<Field orientation="horizontal">
<FieldLabel htmlFor="basic-auth-enabled">启用访问验证</FieldLabel>
<Switch
id="basic-auth-enabled"
checked={enabled}
onCheckedChange={setEnabled}
disabled={!loaded || saving}
/>
</Field>
<Field>
<FieldLabel htmlFor="basic-auth-username">验证用户名</FieldLabel>
<Input
id="basic-auth-username"
autoComplete="off"
value={username}
required={enabled}
disabled={!loaded || saving}
onChange={(e) => setUsername(e.target.value)}
placeholder="独立于 ARTEX 登录账号"
/>
<FieldDescription>不能包含冒号,最长 128 字节。</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="basic-auth-password">验证密码</FieldLabel>
<Input
id="basic-auth-password"
type="password"
autoComplete="new-password"
value={password}
required={enabled && !passwordSet}
disabled={!loaded || saving}
onChange={(e) => setPassword(e.target.value)}
placeholder={passwordSet ? "已设置,留空保持原密码" : "至少 8 位,最多 72 字节"}
/>
<FieldDescription>与 ARTEX 登录密码独立。公网访问请使用 HTTPS。</FieldDescription>
</Field>
<FieldDescription>
保存不会中断当前浏览器访问。可用无痕窗口检查验证弹窗;浏览器可能缓存凭据。关闭后保留账号密码,便于再次开启。
</FieldDescription>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
</FieldGroup>
</CardContent>
<CardFooter className="mt-4 gap-2">
<Button type="submit" disabled={!loaded || saving}>
{saving ? "保存中…" : "保存访问验证"}
</Button>
{!loaded && error ? (
<Button type="button" variant="outline" onClick={load}>
重新加载
</Button>
) : null}
</CardFooter>
</form>
</Card>
);
}
@@ -16,7 +16,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api, sseUrl } from "@/lib/api";
import { api, openSSE } from "@/lib/api";
import type { UpdateCheck, UpdateProgress } from "@/lib/types";
/** 等待新版本上线的最长时间。一次升级要经过三次进程启动(暂存 → 换装 → 新版),
@@ -100,7 +100,7 @@ export function UpdateCard() {
// 订阅更新进度。SSE 不走 Next 的 /api 重写(那层会缓冲,事件推不出来)。
const openStream = React.useCallback(
(fromVersion: string) => {
const es = new EventSource(sseUrl("/api/update/stream"));
const es = openSSE("/api/update/stream");
es.onmessage = (ev) => {
let p: UpdateProgress;
try {
@@ -16,6 +16,7 @@ import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSend
import type { Settings } from "@/lib/types";
import { UpdateCard } from "./_components/update-card";
import { BasicAuthCard } from "./_components/basic-auth-card";
export default function SystemSettingsPage() {
const [trafficCapture, setTrafficCapture] = React.useState(false);
@@ -267,6 +268,7 @@ export default function SystemSettingsPage() {
column-gap 只管列间距,行间距要由子元素自己给。 */}
<div className="columns-1 gap-4 md:gap-6 lg:columns-2">
<UpdateCard />
<BasicAuthCard />
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { sseUrl } from "@/lib/api";
import { openSSE } from "@/lib/api";
import { isBtwCommand, type SideExchange, type SideHistory, sideAPI } from "@/lib/side-questions";
// crypto.randomUUID 仅在安全上下文可用(https/localhost);经 IP+http 访问时降级。
@@ -128,7 +128,7 @@ export function useSideQuestions(parent: string | null) {
useEffect(() => {
if (!runningID) return;
const version = epoch.current;
const stream = new EventSource(sseUrl(`/api/side-questions/${runningID}/events`));
const stream = openSSE(`/api/side-questions/${runningID}/events`);
stream.addEventListener("snapshot", (event) => {
if (version !== epoch.current) return;
try {
+12
View File
@@ -197,6 +197,15 @@ export function sseUrl(path: string): string {
return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`;
}
export function openSSE(path: string): EventSource {
const url = sseUrl(path);
const target = new URL(url, window.location.href);
// 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 get = <T>(p: string) => http<T>(p);
const post = <T>(p: string, body?: unknown) =>
http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
@@ -812,6 +821,9 @@ export const api = {
// ---- app settings (runtime toggles) ----
settings: () => get<Settings>(`/settings`),
basicAuthSettings: () => get<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth"),
setBasicAuthSettings: (settings: { enabled: boolean; username: string; password: string }) =>
put<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth", settings),
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
// Run a real "test" search with the given (or saved) config to verify it works.
testWebSearch: (patch: {
+2
View File
@@ -2220,6 +2220,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/traffic/exchange") return D.trafficDetail;
if (path === "/settings" && m === "GET") return D.settings;
if (path === "/settings" && m === "PUT") return { ...D.settings, ...b };
if (path === "/settings/basic-auth" && m === "GET") return { enabled: false, username: "", password_set: false };
if (path === "/settings/basic-auth" && m === "PUT") throw new Error("演示模式不支持修改 HTTP Basic Auth,请在实际部署中配置");
if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend };
if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" };
if (path === "/chat")
+53 -6
View File
@@ -1,30 +1,77 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
const AUTH_PAGES = ["/login", "/setup"];
export function proxy(request: NextRequest) {
export async function proxy(request: NextRequest) {
// Mock demo:无真实登录,放行所有页面(客户端 auth 守卫也会放行)。
if (process.env.NEXT_PUBLIC_MOCK === "1") return NextResponse.next();
// Pages served by Next dev must pass the same gate as the embedded UI.
// Only send credentials to a loopback backend; never follow redirects.
// Remote dev backends remain usable when their optional gate is disabled.
let gate: Response;
try {
const backend = new URL(process.env.AUTOPENTEST_API ?? "http://localhost:8787");
if (!["http:", "https:"].includes(backend.protocol) || backend.username || backend.password) {
return new NextResponse("认证后端地址配置无效", { status: 503 });
}
const local = ["localhost", "127.0.0.1", "[::1]"].includes(backend.hostname);
gate = await fetch(new URL("/api/basic-auth/check", backend), {
headers: local
? {
cookie: request.headers.get("cookie") ?? "",
authorization: request.headers.get("authorization") ?? "",
"x-forwarded-proto": request.nextUrl.protocol.replace(":", ""),
}
: {},
credentials: "omit",
cache: "no-store",
redirect: "error",
signal: AbortSignal.timeout(5000),
});
if (!local && gate.status === 401) {
return new NextResponse("远程后端已启用 HTTP Basic Auth,请通过内嵌前端访问该服务", { status: 503 });
}
} catch {
return new NextResponse("认证服务暂时不可用,请稍后重试", { status: 503 });
}
if (!gate.ok) {
const headers = new Headers({ "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8" });
const challenge = gate.headers.get("www-authenticate");
if (challenge) headers.set("WWW-Authenticate", challenge);
return new NextResponse(gate.status === 401 ? "请完成 HTTP Basic Auth 验证后刷新页面" : "认证服务暂时不可用", {
status: gate.status,
headers,
});
}
const finish = (response: NextResponse) => {
const cookie = gate.headers.get("set-cookie");
if (cookie) response.headers.append("Set-Cookie", cookie);
response.headers.set("Cache-Control", "no-store");
return response;
};
const { pathname } = request.nextUrl;
const token = request.cookies.get("artex_token")?.value;
const isAuthPage = AUTH_PAGES.some((p) => pathname === p || pathname.startsWith(`${p}/`));
// 未登录 → 跳转登录页
if (!token && !isAuthPage) {
return NextResponse.redirect(new URL("/login", request.url));
return finish(NextResponse.redirect(new URL("/login", request.url)));
}
// 已登录时访问登录/初始化页 → 跳转主界面
if (token && isAuthPage) {
return NextResponse.redirect(new URL("/function/tasks", request.url));
return finish(NextResponse.redirect(new URL("/function/tasks", request.url)));
}
return NextResponse.next();
return finish(NextResponse.next());
}
export const config = {
// 跳过 Next.js 内部路由、API 路由、favicon 及 public/ 下的静态文件(含图片、字体等)
matcher: ["/((?!_next/static|_next/image|favicon\\.ico|api/|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf)$).*)"],
matcher: [
"/((?!_next/static|_next/image|favicon\\.ico|api/|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf)$).*)",
],
};