See CHANGELOG.md for the full v0.16.0 entry. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
/**
|
||
* Password gating for subpages, albums, journal entries 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 } from './config/schema';
|
||
import { verifyScrypt, generateScryptHash, isScryptHash } from './password';
|
||
|
||
const TOKEN_EXPIRY_HOURS = 24;
|
||
|
||
/** What a password can protect. */
|
||
export type ProtectedType = 'subpage' | 'album' | 'journal' | '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: only the site gate is remapped. */
|
||
function tokenKey(key: string, type: ProtectedType): string {
|
||
return type === 'site' ? SITE_TOKEN_KEY : key;
|
||
}
|
||
|
||
const TYPE_LABELS: Record<ProtectedType, string> = {
|
||
subpage: 'Subpage',
|
||
album: 'Album',
|
||
journal: 'Journal entry',
|
||
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';
|
||
|
||
function cookieName(key: string, type: ProtectedType): string {
|
||
if (type === 'subpage') return `lb_auth_${key}`;
|
||
if (type === 'journal') return `lb_auth_journal_${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.
|
||
*/
|
||
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 true;
|
||
|
||
return routes.some((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;
|
||
}
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Validate a password attempt and return a Set-Cookie header value on success.
|
||
* Returns null if the password is wrong.
|
||
*/
|
||
export async function authenticate(
|
||
key: string,
|
||
password: string,
|
||
type: ProtectedType = 'subpage',
|
||
): Promise<string | null> {
|
||
const storedPassword = findPassword(key, type);
|
||
if (!storedPassword) 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`,
|
||
);
|
||
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) {
|
||
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 secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
||
|
||
return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`;
|
||
}
|
||
|
||
/**
|
||
* 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' } },
|
||
);
|
||
}
|