feat(web): minimal shared-password auth gate
The web app had no auth — anyone reaching the host could browse the library, trigger downloads, and read/overwrite the stored credentials in Settings. Adds an opt-in single-shared-password gate: - Next.js middleware redirects unauthenticated page requests to /login and returns 401 for /api/*; static assets are excluded via matcher. - Auth is enabled ONLY when LYRA_PASSWORD is set (unset ⇒ app stays open, so existing deployments are unchanged). Documented in README "Security". - The session cookie is httpOnly and carries HMAC-SHA256(password) keyed by LYRA_SECRET_KEY — unforgeable, and the plaintext password never touches the client. Edge-safe Web Crypto so one lib/auth.ts serves middleware + route. - Cookie is NOT Secure: Lyra serves plain HTTP on the LAN; a Secure cookie would loop. Works behind an HTTPS proxy too. - /login page + form, a Sign out button (shown only when auth is enabled), and chrome (nav + worker-status poll) suppressed on /login. - docker-compose passes LYRA_PASSWORD to web; .env.example documents it. Verified live end-to-end (built + next start): no-cookie page→307 /login, API→401, wrong password→401, correct→200+cookie, authed page/API→200, forged cookie→401, and the auth-disabled (no LYRA_PASSWORD) path stays fully open. web 133 tests green, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const next = params.get("next") || "/";
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
// full navigation so middleware re-evaluates with the new cookie
|
||||
window.location.assign(next);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error || "Login failed");
|
||||
setBusy(false);
|
||||
} catch {
|
||||
setError("Login failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="login-form" onSubmit={submit}>
|
||||
<label className="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="shared password"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="login-error">{error}</p> : null}
|
||||
<button className="btn accent" type="submit" disabled={busy || !password}>
|
||||
{busy ? "Entering…" : "Enter"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user