feat: add authenticated password changes
CI / Format, lint & build (push) Has been cancelled

This commit is contained in:
m4
2026-07-10 17:22:13 +08:00
parent 8fc2f6a91b
commit d199f175aa
6 changed files with 344 additions and 15 deletions
+3
View File
@@ -10,6 +10,9 @@ All notable changes to EvoScientist WebUI are documented in this file.
pages and API routes are protected by a signed HTTP-only session cookie, and
successful logins record their latest timestamp and counter in the local
EvoScientist data directory.
- Added an authenticated change-password dialog. It verifies the current
password, atomically updates `.env`, rotates the session signing key, and
invalidates prior sessions.
### Fixed
+3 -1
View File
@@ -130,7 +130,9 @@ enabled, incomplete credentials fail closed. Successful logins create an
HTTP-only signed session cookie and update the most recent login timestamp and
counter in `~/.evoscientist/webui-auth.json` (or
`$EVOSCIENTIST_DATA_DIR/webui-auth.json` when that directory is configured).
Use the sign-out icon in the top bar to end the current session.
Use the key icon in the top bar to change the password. It requires the current
password, updates the local `.env`, rotates the session signing key, and signs
out all other sessions. Use the sign-out icon to end the current session.
<p align="right"><a href="#top">🔝Back to top</a></p>
+104
View File
@@ -0,0 +1,104 @@
import { type NextRequest, NextResponse } from "next/server";
import { isAuthenticationEnabled } from "@/lib/auth";
import {
AuthConfigurationError,
authCookieOptions,
createSession,
getAuthConfiguration,
recordLogin,
updateAuthPassword,
verifyCredentials,
} from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const NO_STORE = { "Cache-Control": "no-store" };
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code < 32 || code === 127) return true;
}
return false;
}
function passwordError(password: string): string | null {
if (password.length < 8) return "New password must contain at least 8 characters.";
if (password.length > 256) return "New password is too long.";
if (hasControlCharacter(password)) {
return "New password contains unsupported control characters.";
}
return null;
}
export async function POST(request: NextRequest) {
if (!isAuthenticationEnabled()) {
return NextResponse.json(
{ error: "WebUI authentication is disabled." },
{ status: 404, headers: NO_STORE }
);
}
const origin = request.headers.get("origin");
if (origin && origin !== request.nextUrl.origin) {
return NextResponse.json(
{ error: "Cross-origin password changes are not allowed." },
{ status: 403, headers: NO_STORE }
);
}
const body = (await request.json().catch(() => null)) as {
currentPassword?: unknown;
newPassword?: unknown;
confirmPassword?: unknown;
} | null;
const currentPassword =
typeof body?.currentPassword === "string" ? body.currentPassword : "";
const newPassword =
typeof body?.newPassword === "string" ? body.newPassword : "";
const confirmPassword =
typeof body?.confirmPassword === "string" ? body.confirmPassword : "";
const validationError = passwordError(newPassword);
if (!currentPassword || !newPassword || !confirmPassword || validationError) {
return NextResponse.json(
{ error: validationError ?? "Complete all password fields." },
{ status: 400, headers: NO_STORE }
);
}
if (newPassword !== confirmPassword) {
return NextResponse.json(
{ error: "New passwords do not match." },
{ status: 400, headers: NO_STORE }
);
}
try {
const config = getAuthConfiguration();
if (!verifyCredentials(config.username, currentPassword)) {
return NextResponse.json(
{ error: "Current password is incorrect." },
{ status: 401, headers: NO_STORE }
);
}
if (currentPassword === newPassword) {
return NextResponse.json(
{ error: "Choose a password different from the current one." },
{ status: 400, headers: NO_STORE }
);
}
await updateAuthPassword(newPassword);
const { token, payload } = createSession(config.username);
await recordLogin(payload);
const response = NextResponse.json({ ok: true }, { headers: NO_STORE });
response.cookies.set({ ...authCookieOptions(), value: token });
return response;
} catch (error) {
const message =
error instanceof AuthConfigurationError
? error.message
: "Unable to change the password.";
return NextResponse.json(
{ error: message },
{ status: 503, headers: NO_STORE }
);
}
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface ChangePasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ChangePasswordDialog({
open,
onOpenChange,
}: ChangePasswordDialogProps) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setError(null);
setSaving(false);
}
}, [open]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (saving) return;
setSaving(true);
setError(null);
try {
const response = await fetch("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
const body = (await response.json().catch(() => ({}))) as {
error?: string;
};
if (!response.ok) {
throw new Error(body.error || "Unable to change the password.");
}
onOpenChange(false);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Unable to change the password."
);
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Password</DialogTitle>
<DialogDescription>
Changing the password signs out every existing session except this
one.
</DialogDescription>
</DialogHeader>
<form method="post" onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="current-password">Current password</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
disabled={saving}
required
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={saving}
minLength={8}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">Confirm new password</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
disabled={saving}
minLength={8}
required
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save Password"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+24
View File
@@ -15,6 +15,7 @@ import {
PanelLeftClose,
PanelRight,
PanelRightClose,
KeyRound,
LogOut,
} from "lucide-react";
import {
@@ -31,6 +32,7 @@ import { ScheduledTasksPanel } from "@/app/components/ScheduledTasksPanel";
import { ThemeToggle } from "@/app/components/ThemeToggle";
import { HealthIndicator } from "@/app/components/HealthIndicator";
import { InspectorPanel } from "@/app/components/InspectorPanel";
import { ChangePasswordDialog } from "@/app/components/ChangePasswordDialog";
import { setThreadAutoApprove } from "@/lib/autoApprove";
import type { MainChatReporter } from "@/lib/asyncAgents";
import { cn } from "@/lib/utils";
@@ -71,6 +73,7 @@ function HomePageInner({
const [notifyMainChat, setNotifyMainChat] = useState<MainChatReporter | null>(
null
);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const fetchAssistant = useCallback(async () => {
const isUUID =
@@ -348,6 +351,20 @@ function HomePageInner({
/>
<ThemeToggle />
{authEnabled && (
<>
<Button
variant="ghost"
size="icon"
onClick={() => setChangePasswordOpen(true)}
aria-label="Change password"
title="Change password"
className="size-8"
>
<KeyRound
className="size-4"
aria-hidden="true"
/>
</Button>
<Button
variant="ghost"
size="icon"
@@ -361,6 +378,7 @@ function HomePageInner({
aria-hidden="true"
/>
</Button>
</>
)}
<Button
variant="ghost"
@@ -539,6 +557,12 @@ function HomePageInner({
</ResizablePanelGroup>
</div>
</div>
{authEnabled && (
<ChangePasswordDialog
open={changePasswordOpen}
onOpenChange={setChangePasswordOpen}
/>
)}
</>
);
}
+53 -1
View File
@@ -1,4 +1,9 @@
import { createHmac, randomUUID, timingSafeEqual } from "crypto";
import {
createHmac,
randomBytes,
randomUUID,
timingSafeEqual,
} from "crypto";
import { promises as fs } from "fs";
import { homedir } from "os";
import { join, resolve } from "path";
@@ -64,6 +69,53 @@ export function verifyCredentials(username: string, password: string): boolean {
);
}
function envFilePath(): string {
return join(process.cwd(), ".env");
}
function replaceEnvValue(source: string, key: string, value: string): string {
const expression = new RegExp(`^${key}=.*$`, "m");
if (!expression.test(source)) {
throw new AuthConfigurationError(`${key} is missing from .env.`);
}
return source.replace(expression, `${key}=${JSON.stringify(value)}`);
}
/**
* Persist the new password and rotate the signing key. Rotating the key makes
* every old session invalid; the caller immediately issues a replacement cookie
* to the account that successfully supplied the current password.
*/
export async function updateAuthPassword(password: string): Promise<void> {
getAuthConfiguration();
const target = envFilePath();
let stat;
try {
stat = await fs.lstat(target);
} catch {
throw new AuthConfigurationError("The local .env file is not available.");
}
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new AuthConfigurationError("The local .env file is not writable.");
}
const source = await fs.readFile(target, "utf8");
const nextSecret = randomBytes(32).toString("hex");
const next = replaceEnvValue(
replaceEnvValue(source, "WEBUI_AUTH_PASSWORD", password),
"WEBUI_AUTH_SECRET",
nextSecret
);
const temp = `${target}.${randomUUID()}.tmp`;
await fs.writeFile(temp, next, { encoding: "utf8", mode: 0o600 });
await fs.rename(temp, target);
// Next reloads .env on restart; update this process too so the new password
// and replacement session work immediately without waiting for a restart.
process.env.WEBUI_AUTH_PASSWORD = password;
process.env.WEBUI_AUTH_SECRET = nextSecret;
}
function encode(value: string): string {
return Buffer.from(value).toString("base64url");
}