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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user