mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-08 12:23:49 +08:00
feat(auth): add optional HTTP Basic Auth access gate
This commit is contained in:
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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)$).*)",
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user