Files
immich-folio/lib/auth.ts
2026-10-02 18:14:23 +02:00

415 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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' } },
);
}