Files
immich-folio/lib/auth.ts
Ralksta a813433891 release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.

Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.

Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

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>
2026-08-25 10:08:12 +02:00

315 lines
11 KiB
TypeScript
Raw 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 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'));
}
/**
* 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' } },
);
}