From 190e0ef4b67ab2ee5189e907aa3feebd92a9aed6 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 10:12:51 +0200 Subject: [PATCH] feat(web): minimal shared-password auth gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 6 ++++ README.md | 17 ++++++++++ docker-compose.yml | 2 ++ web/src/app/_ui/contents-nav.tsx | 1 + web/src/app/_ui/masthead.tsx | 3 ++ web/src/app/_ui/sign-out.tsx | 20 +++++++++++ web/src/app/_ui/worker-status.tsx | 7 +++- web/src/app/api/auth/route.ts | 44 ++++++++++++++++++++++++ web/src/app/design.css | 23 +++++++++++++ web/src/app/login/login-form.tsx | 56 +++++++++++++++++++++++++++++++ web/src/app/login/page.tsx | 19 +++++++++++ web/src/lib/auth.test.ts | 40 ++++++++++++++++++++++ web/src/lib/auth.ts | 39 +++++++++++++++++++++ web/src/middleware.ts | 28 ++++++++++++++++ 14 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 web/src/app/_ui/sign-out.tsx create mode 100644 web/src/app/api/auth/route.ts create mode 100644 web/src/app/login/login-form.tsx create mode 100644 web/src/app/login/page.tsx create mode 100644 web/src/lib/auth.test.ts create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/middleware.ts diff --git a/.env.example b/.env.example index 5e6fab3..121dff4 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index e785ae3..ea98ea1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index afe68db..8d415aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/web/src/app/_ui/contents-nav.tsx b/web/src/app/_ui/contents-nav.tsx index a6c88b3..3c31c38 100644 --- a/web/src/app/_ui/contents-nav.tsx +++ b/web/src/app/_ui/contents-nav.tsx @@ -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 (