From d199f175aaf5c7d80d4e69c41794eee5b090a7ce Mon Sep 17 00:00:00 2001 From: m4 Date: Fri, 10 Jul 2026 17:22:13 +0800 Subject: [PATCH] feat: add authenticated password changes --- CHANGELOG.md | 3 + README.md | 4 +- src/app/api/auth/password/route.ts | 104 ++++++++++++++ src/app/components/ChangePasswordDialog.tsx | 144 ++++++++++++++++++++ src/app/page.tsx | 50 +++++-- src/lib/server/auth.ts | 54 +++++++- 6 files changed, 344 insertions(+), 15 deletions(-) create mode 100644 src/app/api/auth/password/route.ts create mode 100644 src/app/components/ChangePasswordDialog.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de62b8..d38939d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e7b9c64..ca24966 100644 --- a/README.md +++ b/README.md @@ -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.

🔝Back to top

diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts new file mode 100644 index 0000000..768767e --- /dev/null +++ b/src/app/api/auth/password/route.ts @@ -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 } + ); + } +} diff --git a/src/app/components/ChangePasswordDialog.tsx b/src/app/components/ChangePasswordDialog.tsx new file mode 100644 index 0000000..492aada --- /dev/null +++ b/src/app/components/ChangePasswordDialog.tsx @@ -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(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) { + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setError(null); + setSaving(false); + } + }, [open]); + + const submit = async (event: FormEvent) => { + 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 ( + + + + Change Password + + Changing the password signs out every existing session except this + one. + + +
+
+ + setCurrentPassword(event.target.value)} + disabled={saving} + required + autoFocus + /> +
+
+ + setNewPassword(event.target.value)} + disabled={saving} + minLength={8} + required + /> +
+
+ + setConfirmPassword(event.target.value)} + disabled={saving} + minLength={8} + required + /> +
+ {error && ( +

+ {error} +

+ )} + + + + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 0b2c61b..e62f554 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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( null ); + const [changePasswordOpen, setChangePasswordOpen] = useState(false); const fetchAssistant = useCallback(async () => { const isUUID = @@ -348,19 +351,34 @@ function HomePageInner({ /> {authEnabled && ( - + <> + + + )}