415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
/**
|
||
* 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_<slug> = <token>
|
||
*/
|
||
|
||
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<ProtectedType, string> = {
|
||
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 `<expiryEpochMs>.<hmac>`.
|
||
*
|
||
* 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_<slug>`, 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 /<subpage>/<album>,
|
||
* 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<T extends { id: string }>(
|
||
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<string | null> {
|
||
// 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' } },
|
||
);
|
||
}
|