feat: multi-user authentication (dual mode)
Add optional login protection for the web UI/API, intended for shared machines (e.g. AI servers). Dual mode: with no users configured the API and web UI are open (as before); once at least one user exists, every /api/* and /ws/* endpoint requires a valid session. - bcrypt password hashing: passwords stored as $2b$ hashes in /etc/nvcurve/users.json (0600, root-owned); plaintext never persisted. - 24-hour sessions: HttpOnly cookie for browsers, Authorization: Bearer token for CLI/scripts; in-memory, invalidated on server restart. - Multi-user: multiple named accounts (no shared-password mode). - New CLI: nvcurve user add|list|remove|set-password (root for mutating ops; password always prompted, never a CLI argument). - New endpoints: GET /api/ping (public), /api/auth/status|login|logout|users. - Web UI: sign-in screen when auth is enabled; status bar shows the signed-in user with sign-out; expired sessions (401) re-show sign-in. - Brute-force lockout: 10 failed logins/IP within 5 min -> 15 min lockout. - New dependency: bcrypt. Also: LSP config (pyrightconfig.json) pointing at the project .venv, and small error-handling cleanups in daemon.py/server.py.
This commit is contained in:
1 parent
af23a10f25
commit
bbd692ea2e
16 files changed
+2085
-483
No files matched your search
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { Loader, Lock, User } from "lucide-react";
|
||||
import { api, ApiError } from "../../api/client";
|
||||
|
||||
interface Props {
|
||||
onSuccess: (username: string) => void;
|
||||
}
|
||||
|
||||
export function LoginScreen({ onSuccess }: Props) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.login(username, password);
|
||||
onSuccess(res.username);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setError("Invalid username or password.");
|
||||
} else if (err instanceof ApiError && err.status === 429) {
|
||||
setError(
|
||||
"Too many failed attempts. Please wait a moment and try again.",
|
||||
);
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : "Login failed.");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img src="/logo.svg" alt="NVCurve" className="w-12 h-12 mb-3" />
|
||||
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-fuchsia-400 tracking-tight">
|
||||
NVCurve
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-500 mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 shadow-[0_8px_30px_rgb(0,0,0,0.6)] flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="text-xs font-medium text-zinc-400"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User
|
||||
size={15}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
|
||||
/>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg pl-9 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/40"
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-xs font-medium text-zinc-400"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
size={15}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
|
||||
/>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg pl-9 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/40"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !username || !password}
|
||||
className="mt-1 flex items-center justify-center gap-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg py-2.5 transition-colors"
|
||||
>
|
||||
{busy && <Loader size={15} className="animate-spin" />}
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-zinc-600 mt-4">
|
||||
Sessions last 24 hours, then you will be asked to sign in again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user