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,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE, expectedToken } from "@/lib/auth";
|
||||
|
||||
// POST /api/auth — exchange the shared password for a session cookie.
|
||||
// DELETE /api/auth — log out (clear the cookie).
|
||||
export async function POST(request: Request) {
|
||||
const token = await expectedToken();
|
||||
if (!token) {
|
||||
// No password configured — auth is disabled, nothing to log in to.
|
||||
return NextResponse.json({ ok: true, authDisabled: true });
|
||||
}
|
||||
|
||||
let password = "";
|
||||
const ctype = request.headers.get("content-type") ?? "";
|
||||
if (ctype.includes("application/json")) {
|
||||
const body = (await request.json().catch(() => ({}))) as { password?: unknown };
|
||||
password = typeof body.password === "string" ? body.password : "";
|
||||
} else {
|
||||
const form = await request.formData().catch(() => null);
|
||||
password = form ? String(form.get("password") ?? "") : "";
|
||||
}
|
||||
|
||||
if (password !== process.env.LYRA_PASSWORD) {
|
||||
return NextResponse.json({ ok: false, error: "Incorrect password" }, { status: 401 });
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(AUTH_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
// NOT `secure`: Lyra serves plain HTTP on the LAN (:8770); a Secure cookie would be
|
||||
// dropped by the browser and cause a login redirect loop. Works fine behind an HTTPS
|
||||
// proxy too (Secure is not required, only recommended).
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(AUTH_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
Reference in New Issue
Block a user