/** * Password gating for subpages, albums, journal entries, content pages and the * site as a whole. * Uses HMAC tokens stored in HttpOnly cookies — no database needed. * * Token = HMAC-SHA256(slug + passwordSecret, authSecret) * Cookie = lb_auth_ = */ import crypto from 'crypto'; import { getConfig, SubpageConfig } from './config'; // From the pure schema module, not the config barrel: tests that stub out // '@/lib/config' wholesale would otherwise lose this helper. import { normalizeSlug, resolveZoom } from './config/schema'; import { verifyScrypt, generateScryptHash, isScryptHash, burnScrypt } from './password'; const TOKEN_EXPIRY_HOURS = 24; /** What a password can protect. */ export type ProtectedType = 'subpage' | 'album' | 'journal' | 'page' | 'site'; /** * Key used for the site-wide gate. There is only ever one, so it is a constant * rather than a slug — but it still flows through the same token, cookie and * verification path as every other protected thing. * * This is the *public* identifier, the one the gate form posts to `/api/auth`. * The value that goes into the HMAC is `SITE_TOKEN_KEY`, see below. */ export const SITE_AUTH_KEY = 'site'; /** * Domain separator for the site token. * * A subpage may legitimately be slugged `site`, and a subpage's key is its * slug — so signing the site token as `site` would sign both gates with the * same key. With matching passwords the two tokens are then byte-identical and * either gate accepts the other's cookie. `slugify()` cannot emit an * underscore, so no slug can collide with this. */ const SITE_TOKEN_KEY = '__site__'; /** * The key that actually gets signed. The site gate is remapped (see above), and * a content page is prefixed: a page and a subpage or journal entry of the same * slug and password would otherwise sign identical tokens, and a visitor could * copy one cookie's value into the other's name. `page:` cannot be a slug — * isValidSlug() rejects the colon. */ function tokenKey(key: string, type: ProtectedType): string { if (type === 'site') return SITE_TOKEN_KEY; if (type === 'page') return `page:${key}`; return key; } const TYPE_LABELS: Record = { subpage: 'Subpage', album: 'Album', journal: 'Journal entry', page: 'Page', site: 'Site', }; /** * Check if a string looks like a legacy bcrypt hash. */ function isBcryptHash(str: string): boolean { return str.startsWith('$2a$') || str.startsWith('$2b$') || str.startsWith('$2y$'); } function hmac(data: string): string { return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex'); } /** * Build a session token of the form `.`. * * The expiry is part of the signed payload, so it cannot be extended by the * client. Without it the token was a pure function of (slug, password) and * therefore valid forever — the 24h lifetime existed only as the cookie's * Max-Age, which the client enforces and an attacker replaying a captured * token simply ignores. */ function authToken(key: string, passwordSecret: string, expiresAt: number): string { return `${expiresAt}.${hmac(`${key}:${passwordSecret}:${expiresAt}`)}`; } import nodeFs from 'fs'; import { parseFrontmatter } from './journal'; import { resolveJournalFilePath } from './admin/journal-service'; import { readPageSync } from './admin/pages-service'; function cookieName(key: string, type: ProtectedType): string { if (type === 'subpage') return `lb_auth_${key}`; if (type === 'journal') return `lb_auth_journal_${key}`; if (type === 'page') return `lb_auth_page_${key}`; if (type === 'site') return SITE_AUTH_COOKIE; return `lb_auth_album_${key}`; } /** * Cookie carrying the site-wide session. * * Deliberately *not* `lb_auth_site`: subpage cookies are `lb_auth_`, so a * subpage slugged `site` would write to the site gate's cookie and log the * visitor out of the site on every subpage unlock. */ export const SITE_AUTH_COOKIE = 'lb_site_auth'; /** * Whether this visitor may have the album at all — every gate on every route * to it, not just the album's own (#475). * * A page checks the subpage's password before it renders anything; a route * handler is reached without ever passing through that page, so it has to ask * the same question itself. Checking only the album's own password let an * album that carries none through, even when the sole way to it was a subpage * that does. * * An album listed on the home page is published there whatever else lists it, * so it stays reachable. Where the only routes are subpages, one of them has * to be open — which mirrors what the visitor could actually have clicked. A * subpage taken offline with `enabled: false` is no route at all, so an album * listed only there is not reachable, whatever its password. */ export function isAlbumReachable( albumId: string, getCookie: (name: string) => string | undefined, ): boolean { if (!isAuthenticated(albumId, getCookie, 'album')) return false; const config = getConfig(); if (config.standaloneAlbums?.includes(albumId)) return true; const routes = config.subpages.filter( (sp) => sp.enabled !== false && sp.albumIds.includes(albumId), ); if (routes.length === 0) return false; return routes.some((sp) => isAuthenticated(sp.slug, getCookie, 'subpage')); } /** * Whether this visitor may zoom into the album's photos (#467): there is a * route to the album they could have taken — every gate on it satisfied, as in * isAlbumReachable() — on which zoom resolves on. * * Per route, not per album: zoom is resolved against the subpage an album is * shown on, and an album listed on two subpages can have it on one and off on * the other. Asking "is zoom on anywhere" and "can the visitor get in * anywhere" separately would let a visitor through an open subpage that has * zoom off, on the strength of a locked one that has it on. The album entry's * own override is per route too (resolveZoom). * * Essay subpages are not excluded. The essay itself offers no zoom, but each * of its albums is still reachable as an album page at //, * which offers zoom by exactly this rule. The route answers what that page * offers; refusing it here would break zoom there. */ export function isAlbumZoomReachable( albumId: string, getCookie: (name: string) => string | undefined, ): boolean { if (!isAuthenticated(albumId, getCookie, 'album')) return false; const config = getConfig(); if (config.standaloneAlbums?.includes(albumId) && resolveZoom(config, albumId)) return true; return config.subpages.some( (sp) => sp.enabled !== false && sp.albumIds.includes(albumId) && resolveZoom(config, albumId, sp) && isAuthenticated(sp.slug, getCookie, 'subpage'), ); } /** * The albums this visitor may see the photographs of: every album without a * password of its own, plus the ones already unlocked. * * For views that gather photographs from several albums onto one page — the * essay layout — where there is no single album to put a password form in * front of. A locked album is left out rather than gating the whole page: the * visitor unlocks it at its own URL and finds it here afterwards. */ export function withoutLockedAlbums( albums: T[], getCookie: (name: string) => string | undefined, ): T[] { return albums.filter((a) => isAuthenticated(a.id, getCookie, 'album')); } /** * Find the SubpageConfig for a given slug. */ export function findSubpageBySlug(slug: string): SubpageConfig | undefined { const wanted = normalizeSlug(slug); return getConfig().subpages.find((sp) => sp.slug === wanted); } /** * Find the password secret for a given subpage slug, album ID, or journal entry. */ function findPassword(key: string, type: ProtectedType): string | undefined { const config = getConfig(); if (type === 'site') { return config.sitePassword || undefined; } if (type === 'subpage') { const wanted = normalizeSlug(key); return config.subpages.find((sp) => sp.slug === wanted)?.password; } if (type === 'album') { return config.albumPasswords[key]; } if (type === 'journal') { try { const filePath = resolveJournalFilePath(key); if (filePath && nodeFs.existsSync(filePath)) { const raw = nodeFs.readFileSync(filePath, 'utf8'); return parseFrontmatter(raw).frontmatter.password; } } catch {} return undefined; } if (type === 'page') { return readPageSync(key)?.parsed.frontmatter.password || undefined; } return undefined; } /** * Check if a subpage slug, album ID, or journal entry is password-protected. */ export function isProtected(key: string, type: ProtectedType = 'subpage'): boolean { return !!findPassword(key, type); } /** * Whether the visitor reached us over HTTPS — decides the `Secure` cookie flag. * * This used to be `NODE_ENV === 'production'`, which is always true in the * Docker image. A browser silently drops a `Secure` cookie set over plain HTTP, * so a deployment at `http://host:7211` accepted the password and then answered * every following request with 401 (#664). * * `X-Forwarded-Proto` is trusted without `TRUSTED_PROXY_HOPS`: a visitor who * forges it only changes the flag on their own cookie, in their own browser. * A proxy that omits it costs an HTTPS site the flag, which the HSTS header on * every page makes close to moot — the browser never asks over HTTP again. */ export function isHttpsRequest(request: Request): boolean { const forwarded = request.headers.get('x-forwarded-proto'); if (forwarded) return forwarded.split(',')[0].trim().toLowerCase() === 'https'; return new URL(request.url).protocol === 'https:'; } /** * Validate a password attempt and return a Set-Cookie header value on success. * Returns null if the password is wrong. * * `secure` comes from `isHttpsRequest()`; it defaults to on so that a caller * which forgets it errs towards the stricter cookie. */ export async function authenticate( key: string, password: string, type: ProtectedType = 'subpage', secure = true, ): Promise { // Every path below runs exactly one scrypt, so the reply time does not tell a // prober which keys exist, which carry a password, or how it is stored. const storedPassword = findPassword(key, type); if (!storedPassword) { await burnScrypt(password); return null; } let isValid = false; if (isBcryptHash(storedPassword)) { console.error( `\n❌ SECURITY ERROR: ${TYPE_LABELS[type]} "${key}" is using an outdated bcrypt password hash.\n` + ` Bcrypt dependency has been removed to reduce bundle size.\n` + ` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` + ` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`, ); await burnScrypt(password); return null; } if (isScryptHash(storedPassword)) { isValid = await verifyScrypt(password, storedPassword); } else { // Plaintext fallback (deprecated) // Hash both to a fixed length before constant-time comparison to prevent timing and length attacks const attemptHash = crypto .createHmac('sha256', getConfig().authSecret) .update(password) .digest(); const storedHash = crypto .createHmac('sha256', getConfig().authSecret) .update(storedPassword) .digest(); isValid = crypto.timingSafeEqual(attemptHash, storedHash); if (!isValid) { await burnScrypt(password); } else { // The success path pays its scrypt here, for the recommended hash. const recommendedHash = await generateScryptHash(storedPassword); console.warn( `\n⚠️ SECURITY WARNING: ${TYPE_LABELS[type]} "${key}" is using a plaintext password${type === 'site' ? ' in settings.yaml' : ' in gallery.yaml'}.\n` + ` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`, ); } } if (!isValid) return null; const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60; const token = authToken(tokenKey(key, type), storedPassword, Date.now() + maxAge * 1000); const secureFlag = secure ? '; Secure' : ''; return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secureFlag}`; } /** * Check if the request has a valid auth cookie for the given subpage or album. * Works with the cookies() API from Next.js server components. */ export function isAuthenticated( key: string, getCookie: (name: string) => string | undefined, type: ProtectedType = 'subpage', ): boolean { const storedPassword = findPassword(key, type); if (!storedPassword) return true; // not protected const cookie = getCookie(cookieName(key, type)); if (!cookie) return false; // Legacy tokens (bare HMAC, no expiry) do not parse here and are rejected; // the visitor simply re-enters the password. const sep = cookie.indexOf('.'); if (sep === -1) return false; const expiresAt = Number(cookie.slice(0, sep)); if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false; const expected = authToken(tokenKey(key, type), storedPassword, expiresAt); // Constant-time comparison to prevent timing attacks try { const a = Buffer.from(cookie, 'utf8'); const b = Buffer.from(expected, 'utf8'); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } catch { return false; } } /* ── Site-wide gate ───────────────────────────────────────────── * * Opt-in: with no `sitePassword` configured, `findPassword` returns undefined * and every check below answers "unlocked", so an open portfolio pays nothing * for the feature existing. */ /** True when a site-wide password is configured at all. */ export function isSiteLocked(): boolean { return isProtected(SITE_AUTH_KEY, 'site'); } /** Whether the caller may be served the public site. */ export function isSiteUnlocked(getCookie: (name: string) => string | undefined): boolean { return isAuthenticated(SITE_AUTH_KEY, getCookie, 'site'); } /** Read one cookie out of a raw `Cookie:` header. */ function readCookieHeader(header: string, name: string): string | undefined { for (const part of header.split(';')) { const eq = part.indexOf('='); if (eq === -1) continue; if (part.slice(0, eq).trim() !== name) continue; try { return decodeURIComponent(part.slice(eq + 1).trim()); } catch { return part.slice(eq + 1).trim(); } } return undefined; } /** * Route-handler guard. Returns a 401 to send back, or null to carry on. * * Route handlers do not go through the root layout, so the page-level gate * does not cover them: without this, a locked site would still stream every * photo to anyone holding an asset URL. */ export function siteLockResponse(request: Request): Response | null { const header = request.headers.get('cookie') ?? ''; if (isSiteUnlocked((name) => readCookieHeader(header, name))) return null; return Response.json( { error: 'Site is password-protected' }, // WWW-Authenticate is deliberately omitted: the credential is a form on // the site, not HTTP auth, and the header would make browsers pop their // own dialog for an image request. { status: 401, headers: { 'Cache-Control': 'no-store' } }, ); }