fix(webui): require token_issue_secret for LAN access with frontend auth

When host is set to 0.0.0.0, the gateway now enforces that either token
or token_issue_secret must be configured — it refuses to start otherwise.

Bootstrap endpoint behavior:
- token_issue_secret configured: always validate regardless of source IP
  (handles reverse-proxy scenarios where all connections appear as localhost)
- No secret: only localhost can bootstrap (local dev mode)

The frontend shows an authentication form when bootstrap returns 401/403,
persists the secret in localStorage, and retries automatically on reload.
This commit is contained in:
chengyongru
2026-05-06 23:51:51 +08:00
committed by Xubin Ren
parent 034bea1a44
commit 4efd904ccc
8 changed files with 265 additions and 49 deletions
+131 -36
View File
@@ -9,14 +9,23 @@ import { preloadMarkdownText } from "@/components/MarkdownText";
import { useSessions } from "@/hooks/useSessions";
import { useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import {
clearSavedSecret,
deriveWsUrl,
fetchBootstrap,
loadSavedSecret,
saveSecret,
} from "@/lib/bootstrap";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type BootState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "auth"; failed?: boolean }
| {
status: "ready";
client: NanobotClient;
@@ -28,6 +37,60 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SIDEBAR_WIDTH = 272;
type ShellView = "chat" | "settings";
function AuthForm({
failed,
onSecret,
}: {
failed: boolean;
onSecret: (secret: string) => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const secret = value.trim();
if (!secret) return;
setSubmitting(true);
onSecret(secret);
};
return (
<div className="flex h-full w-full items-center justify-center px-6">
<form
onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4"
>
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
</div>
{failed && (
<p className="text-center text-sm text-destructive">
{t("app.auth.invalid")}
</p>
)}
<Input
type="password"
placeholder={t("app.auth.placeholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={submitting}
autoFocus
/>
<Button
type="submit"
className="w-full"
disabled={!value.trim() || submitting}
>
{t("app.auth.submit")}
</Button>
</form>
</div>
);
}
function readSidebarOpen(): boolean {
if (typeof window === "undefined") return true;
try {
@@ -43,40 +106,55 @@ export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapWithSecret = useCallback(
(secret: string) => {
let cancelled = false;
(async () => {
setState({ status: "loading" });
try {
const boot = await fetchBootstrap("", secret);
if (cancelled) return;
if (secret) saveSecret(secret);
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", secret);
return deriveWsUrl(refreshed.ws_path, refreshed.token);
} catch {
return null;
}
},
});
client.connect();
setState({
status: "ready",
client,
token: boot.token,
modelName: boot.model_name ?? null,
});
} catch (e) {
if (cancelled) return;
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
setState({ status: "auth", failed: true });
} else {
setState({ status: "error", message: msg });
}
}
})();
return () => {
cancelled = true;
};
},
[],
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const boot = await fetchBootstrap();
if (cancelled) return;
const url = deriveWsUrl(boot.ws_path, boot.token);
const client = new NanobotClient({
url,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap();
return deriveWsUrl(refreshed.ws_path, refreshed.token);
} catch {
return null;
}
},
});
client.connect();
setState({
status: "ready",
client,
token: boot.token,
modelName: boot.model_name ?? null,
});
} catch (e) {
if (cancelled) return;
setState({ status: "error", message: (e as Error).message });
}
})();
return () => {
cancelled = true;
};
}, []);
const saved = loadSavedSecret();
return bootstrapWithSecret(saved);
}, [bootstrapWithSecret]);
useEffect(() => {
const warm = () => preloadMarkdownText();
@@ -110,6 +188,14 @@ export default function App() {
</div>
);
}
if (state.status === "auth") {
return (
<AuthForm
failed={!!state.failed}
onSecret={(s) => bootstrapWithSecret(s)}
/>
);
}
if (state.status === "error") {
return (
<div className="flex h-full w-full items-center justify-center px-4 text-center">
@@ -130,18 +216,26 @@ export default function App() {
);
};
const handleLogout = () => {
if (state.status === "ready") {
state.client.close();
}
clearSavedSecret();
setState({ status: "auth" });
};
return (
<ClientProvider
client={state.client}
token={state.token}
modelName={state.modelName}
>
<Shell onModelNameChange={handleModelNameChange} />
<Shell onModelNameChange={handleModelNameChange} onLogout={handleLogout} />
</ClientProvider>
);
}
function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) {
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
const { t, i18n } = useTranslation();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
@@ -319,6 +413,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
onToggleTheme={toggle}
onBackToChat={() => setView("chat")}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
/>
) : (
<ThreadShell