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:
@@ -6,6 +6,12 @@ DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra
|
||||
# 32 random bytes, base64. Generate with: openssl rand -base64 32
|
||||
LYRA_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
|
||||
|
||||
# Shared password protecting the whole web UI + API. When set, every request must sign in
|
||||
# (one shared password, no per-user accounts). LEAVE UNSET AND THE APP IS OPEN to anyone
|
||||
# who can reach the host — only safe on a trusted LAN or behind a VPN/auth proxy. Strongly
|
||||
# recommended for any exposed deployment. See README "Security".
|
||||
# LYRA_PASSWORD=change-me
|
||||
|
||||
# Host directory for the downloaded music library (bind-mounted into the worker at /music)
|
||||
MUSIC_DIR=./music
|
||||
|
||||
|
||||
@@ -124,3 +124,20 @@ docker compose start web worker
|
||||
`--clean --if-exists` drops and recreates each object, so restoring over an existing DB
|
||||
is safe. To restore onto a brand-new host, bring up just `db` first
|
||||
(`docker compose up -d db`), restore, then `up -d --build` the rest.
|
||||
|
||||
## Security
|
||||
|
||||
Lyra has **no per-user accounts**. Two layers protect it:
|
||||
|
||||
- **Shared-password gate.** Set `LYRA_PASSWORD` in `.env` to require a single shared
|
||||
password for the entire UI and API. A request without a valid session cookie is
|
||||
redirected to `/login` (or gets `401` for `/api/*`). The cookie is httpOnly and carries
|
||||
an HMAC of the password keyed by `LYRA_SECRET_KEY`, so it can't be forged. **If
|
||||
`LYRA_PASSWORD` is unset, the app is completely open** — anyone who can reach the host
|
||||
can browse the library, trigger downloads, and read/overwrite the stored credentials in
|
||||
Settings. Set it for any deployment that isn't on a fully trusted, isolated network.
|
||||
|
||||
- **Network.** The shared password is deliberately minimal (single password, bearer
|
||||
cookie, plain HTTP on the LAN). For anything internet-facing, also put Lyra behind a
|
||||
reverse proxy with TLS and/or a VPN or auth gateway — don't rely on the shared password
|
||||
alone.
|
||||
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
|
||||
# Optional shared-password gate for the whole UI/API. Unset ⇒ app is open. See README.
|
||||
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
|
||||
ports:
|
||||
# host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686)
|
||||
- "8770:3000"
|
||||
|
||||
@@ -13,6 +13,7 @@ const LINKS: { href: string; label: string }[] = [
|
||||
|
||||
export function ContentsNav() {
|
||||
const pathname = usePathname();
|
||||
if (pathname === "/login") return null; // no section nav on the login screen
|
||||
const active = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
|
||||
return (
|
||||
<nav className="contents">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { WorkerStatus } from "./worker-status";
|
||||
import { SignOut } from "./sign-out";
|
||||
|
||||
export function Masthead() {
|
||||
const authEnabled = !!process.env.LYRA_PASSWORD;
|
||||
return (
|
||||
<header className="masthead">
|
||||
<div className="wordmark">
|
||||
@@ -13,6 +15,7 @@ export function Masthead() {
|
||||
<div className="press-status">
|
||||
<WorkerStatus />
|
||||
<ThemeToggle />
|
||||
{authEnabled ? <SignOut /> : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/** Clears the auth cookie and returns to /login. Rendered only when auth is enabled. */
|
||||
export function SignOut() {
|
||||
const pathname = usePathname();
|
||||
if (pathname === "/login") return null;
|
||||
|
||||
async function signOut() {
|
||||
await fetch("/api/auth", { method: "DELETE" }).catch(() => {});
|
||||
window.location.assign("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="sign-out" onClick={signOut} aria-label="sign out">
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/** Live worker liveness in the masthead — polls /api/worker (heartbeat recency). */
|
||||
export function WorkerStatus() {
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const pathname = usePathname();
|
||||
const onLogin = pathname === "/login";
|
||||
|
||||
useEffect(() => {
|
||||
if (onLogin) return; // don't poll (would 401) on the login screen
|
||||
let alive = true;
|
||||
async function check() {
|
||||
try {
|
||||
@@ -22,8 +26,9 @@ export function WorkerStatus() {
|
||||
alive = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
}, [onLogin]);
|
||||
|
||||
if (onLogin) return null;
|
||||
const label = online === null ? "…" : online ? "listening" : "offline";
|
||||
return (
|
||||
<div className={`worker-status${online === false ? " off" : ""}`}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -172,6 +172,29 @@ nav.contents .sep { flex: 1; }
|
||||
.job .chip { grid-column: 2 / 3; justify-self: start; margin-top: 12px; }
|
||||
}
|
||||
|
||||
/* ── Login ────────────────────────────────────────────── */
|
||||
.login { display: flex; justify-content: center; padding: 48px 0; }
|
||||
.login-panel {
|
||||
border: 1.5px solid var(--rule-2); padding: 34px 32px; max-width: 380px; width: 100%;
|
||||
}
|
||||
.login-panel .eyebrow {
|
||||
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--accent); margin: 0 0 8px;
|
||||
}
|
||||
.login-panel h1 { font-family: var(--serif); font-size: 1.9rem; margin: 0 0 6px; }
|
||||
.login-lede { color: var(--graphite); margin: 0 0 22px; font-size: 0.9rem; }
|
||||
.login-form { display: flex; flex-direction: column; gap: 18px; align-items: stretch; }
|
||||
.login-form .field input { min-width: 0; }
|
||||
.login-form .btn { align-self: flex-start; }
|
||||
.login-error {
|
||||
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.04em; color: var(--accent); margin: 0;
|
||||
}
|
||||
.sign-out {
|
||||
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--graphite); background: transparent; border: 0; cursor: pointer; padding: 0;
|
||||
}
|
||||
.sign-out:hover { color: var(--accent); }
|
||||
|
||||
/* ── Phase 2: page headers, list rows, tools ──────────── */
|
||||
.page-head { margin: 6px 0 26px; }
|
||||
.page-head .eyebrow {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export const metadata = { title: "Sign in · Lyra" };
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="login">
|
||||
<div className="login-panel">
|
||||
<p className="eyebrow">Restricted pressing</p>
|
||||
<h1>Sign in</h1>
|
||||
<p className="login-lede">This Lyra instance is password-protected.</p>
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { expectedToken, safeEqual } from "./auth";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
|
||||
delete process.env.LYRA_PASSWORD;
|
||||
});
|
||||
|
||||
describe("auth token", () => {
|
||||
it("returns '' when no password is configured (auth disabled)", async () => {
|
||||
expect(await expectedToken()).toBe("");
|
||||
});
|
||||
|
||||
it("derives a stable, non-empty token from the password", async () => {
|
||||
process.env.LYRA_PASSWORD = "hunter2";
|
||||
const a = await expectedToken();
|
||||
const b = await expectedToken();
|
||||
expect(a).not.toBe("");
|
||||
expect(a).toBe(b); // deterministic
|
||||
});
|
||||
|
||||
it("changes with the password and with the secret key", async () => {
|
||||
process.env.LYRA_PASSWORD = "hunter2";
|
||||
const base = await expectedToken();
|
||||
process.env.LYRA_PASSWORD = "different";
|
||||
expect(await expectedToken()).not.toBe(base);
|
||||
process.env.LYRA_PASSWORD = "hunter2";
|
||||
process.env.LYRA_SECRET_KEY = "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmY=";
|
||||
expect(await expectedToken()).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeEqual", () => {
|
||||
it("matches equal strings and rejects others", () => {
|
||||
expect(safeEqual("abc", "abc")).toBe(true);
|
||||
expect(safeEqual("abc", "abd")).toBe(false);
|
||||
expect(safeEqual("abc", "ab")).toBe(false);
|
||||
expect(safeEqual("", "")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Minimal shared-password auth. Enabled ONLY when LYRA_PASSWORD is set; otherwise the
|
||||
// app stays open (unchanged behavior) — see README "Security". The auth cookie holds a
|
||||
// token derived from the password via HMAC-SHA256 keyed by LYRA_SECRET_KEY, so it can't
|
||||
// be forged without the secret and the plaintext password is never stored client-side.
|
||||
// Edge-safe: uses global Web Crypto + btoa (no node:crypto / Buffer), so the same code
|
||||
// runs in middleware (Edge runtime) and in the API route (Node runtime).
|
||||
|
||||
export const AUTH_COOKIE = "lyra_auth";
|
||||
|
||||
function toB64(bytes: Uint8Array): string {
|
||||
let s = "";
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
/** The token a valid session cookie must carry, or "" when auth is disabled (no password). */
|
||||
export async function expectedToken(): Promise<string> {
|
||||
const password = process.env.LYRA_PASSWORD;
|
||||
if (!password) return "";
|
||||
const secret = process.env.LYRA_SECRET_KEY ?? "lyra";
|
||||
const enc = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(password));
|
||||
return toB64(new Uint8Array(sig));
|
||||
}
|
||||
|
||||
/** Constant-time string compare (both operands are our own derived tokens). */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { AUTH_COOKIE, expectedToken, safeEqual } from "@/lib/auth";
|
||||
|
||||
// Gate every request behind the shared password when LYRA_PASSWORD is set. The /login
|
||||
// page and the auth API are always reachable; static assets are excluded via `matcher`.
|
||||
export async function middleware(req: NextRequest) {
|
||||
const token = await expectedToken();
|
||||
if (!token) return NextResponse.next(); // auth disabled (no LYRA_PASSWORD)
|
||||
|
||||
const { pathname } = req.nextUrl;
|
||||
if (pathname === "/login" || pathname === "/api/auth") return NextResponse.next();
|
||||
|
||||
const cookie = req.cookies.get(AUTH_COOKIE)?.value ?? "";
|
||||
if (safeEqual(cookie, token)) return NextResponse.next();
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("next", pathname + req.nextUrl.search);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Run on everything except Next internals and static assets (which carry no secrets).
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg|fonts/).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user